dexterm-cli 0.0.0-stage → 0.1.1

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 (349) hide show
  1. package/LICENSE +661 -0
  2. package/dist/packages/agents/src/audit.js +122 -0
  3. package/dist/packages/agents/src/bus.js +379 -0
  4. package/dist/packages/agents/src/executor.js +177 -0
  5. package/dist/packages/agents/src/gate.js +176 -0
  6. package/dist/packages/agents/src/identity.js +179 -0
  7. package/dist/packages/agents/src/index.js +15 -0
  8. package/dist/packages/agents/src/proposals.js +153 -0
  9. package/dist/packages/agents/src/schema.js +87 -0
  10. package/dist/packages/agents/src/sdk.js +140 -0
  11. package/dist/packages/agents/src/tiers.js +114 -0
  12. package/dist/packages/agents/src/tools.js +69 -0
  13. package/dist/packages/agents/src/ws.js +158 -0
  14. package/dist/packages/cli/assets/claude/analyst-system.md +76 -0
  15. package/dist/packages/cli/assets/claude/plugin/.claude-plugin/plugin.json +5 -0
  16. package/dist/packages/cli/assets/claude/plugin/skills/REGISTRY.json +16 -0
  17. package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +67 -0
  18. package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +64 -0
  19. package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +63 -0
  20. package/dist/packages/cli/assets/claude/plugin/skills/trench-scan/SKILL.md +44 -0
  21. package/dist/packages/cli/assets/claude/schemas/larp.schema.json +117 -0
  22. package/dist/packages/cli/assets/claude/schemas/trench-scan.schema.json +71 -0
  23. package/dist/packages/cli/assets/claude/schemas/verdict.schema.json +115 -0
  24. package/dist/packages/cli/bin/dexterm.mjs +11 -0
  25. package/dist/packages/cli/src/agent-cmd.js +303 -0
  26. package/dist/packages/cli/src/agent-roster.js +26 -0
  27. package/dist/packages/cli/src/analyst/backend.js +526 -0
  28. package/dist/packages/cli/src/analyst/results.js +38 -0
  29. package/dist/packages/cli/src/analyst/session.js +319 -0
  30. package/dist/packages/cli/src/audit.js +51 -0
  31. package/dist/packages/cli/src/backtest-cmd.js +149 -0
  32. package/dist/packages/cli/src/boot-state.js +62 -0
  33. package/dist/packages/cli/src/custom.js +261 -0
  34. package/dist/packages/cli/src/display.js +191 -0
  35. package/dist/packages/cli/src/doctor.js +361 -0
  36. package/dist/packages/cli/src/feed.js +634 -0
  37. package/dist/packages/cli/src/fixtures.js +275 -0
  38. package/dist/packages/cli/src/follow-cmd.js +221 -0
  39. package/dist/packages/cli/src/images-config.js +59 -0
  40. package/dist/packages/cli/src/index.js +31 -0
  41. package/dist/packages/cli/src/kb-skill-cmd.js +240 -0
  42. package/dist/packages/cli/src/larp-record.js +21 -0
  43. package/dist/packages/cli/src/live/copy.js +80 -0
  44. package/dist/packages/cli/src/live/flow.js +233 -0
  45. package/dist/packages/cli/src/live/index.js +509 -0
  46. package/dist/packages/cli/src/live/intel.js +227 -0
  47. package/dist/packages/cli/src/live/newpairs.js +334 -0
  48. package/dist/packages/cli/src/live/screens/newpairs-live.js +219 -0
  49. package/dist/packages/cli/src/live/screens/token-live.js +459 -0
  50. package/dist/packages/cli/src/live/screens/watchlist-live.js +149 -0
  51. package/dist/packages/cli/src/live/token.js +585 -0
  52. package/dist/packages/cli/src/live/trade.js +929 -0
  53. package/dist/packages/cli/src/live/watchlist.js +178 -0
  54. package/dist/packages/cli/src/main.js +5583 -0
  55. package/dist/packages/cli/src/narrative-cmd.js +76 -0
  56. package/dist/packages/cli/src/pack-cmd.js +323 -0
  57. package/dist/packages/cli/src/plan-cmd.js +278 -0
  58. package/dist/packages/cli/src/popup.js +810 -0
  59. package/dist/packages/cli/src/rules-cmd.js +131 -0
  60. package/dist/packages/cli/src/rules.js +339 -0
  61. package/dist/packages/cli/src/scan/command.js +57 -0
  62. package/dist/packages/cli/src/scan/engine.js +419 -0
  63. package/dist/packages/cli/src/scan/observe.js +64 -0
  64. package/dist/packages/cli/src/scan/ui.js +98 -0
  65. package/dist/packages/cli/src/screener/filter.js +241 -0
  66. package/dist/packages/cli/src/screener/presets.js +80 -0
  67. package/dist/packages/cli/src/screener/rows.js +146 -0
  68. package/dist/packages/cli/src/screener/rug.js +50 -0
  69. package/dist/packages/cli/src/screener/sort.js +52 -0
  70. package/dist/packages/cli/src/screener/view.js +183 -0
  71. package/dist/packages/cli/src/screens/analyst-session.js +343 -0
  72. package/dist/packages/cli/src/screens/analyst.js +326 -0
  73. package/dist/packages/cli/src/screens/boot.js +74 -0
  74. package/dist/packages/cli/src/screens/bubbles.js +255 -0
  75. package/dist/packages/cli/src/screens/coin.js +33 -0
  76. package/dist/packages/cli/src/screens/follows.js +201 -0
  77. package/dist/packages/cli/src/screens/kol.js +90 -0
  78. package/dist/packages/cli/src/screens/narrative.js +90 -0
  79. package/dist/packages/cli/src/screens/newpairs.js +150 -0
  80. package/dist/packages/cli/src/screens/overlay.js +257 -0
  81. package/dist/packages/cli/src/screens/plans.js +126 -0
  82. package/dist/packages/cli/src/screens/positions.js +176 -0
  83. package/dist/packages/cli/src/screens/rules.js +170 -0
  84. package/dist/packages/cli/src/screens/screener.js +459 -0
  85. package/dist/packages/cli/src/screens/social-panel.js +107 -0
  86. package/dist/packages/cli/src/screens/ticket.js +342 -0
  87. package/dist/packages/cli/src/screens/token.js +312 -0
  88. package/dist/packages/cli/src/screens/trending.js +181 -0
  89. package/dist/packages/cli/src/screens/watchlist.js +104 -0
  90. package/dist/packages/cli/src/social/demo.js +96 -0
  91. package/dist/packages/cli/src/social/view.js +250 -0
  92. package/dist/packages/cli/src/state.js +463 -0
  93. package/dist/packages/cli/src/theme-cmd.js +126 -0
  94. package/dist/packages/cli/src/trade.js +319 -0
  95. package/dist/packages/cli/src/voice-hud.js +42 -0
  96. package/dist/packages/cli/src/wallet-cmd.js +440 -0
  97. package/dist/packages/cli/src/wallet-command.js +31 -0
  98. package/dist/packages/cli/src/widgets.js +208 -0
  99. package/dist/packages/cli/src/workspace-cmd.js +68 -0
  100. package/dist/packages/cli/src/x-cmd.js +154 -0
  101. package/dist/packages/custom/src/config.js +213 -0
  102. package/dist/packages/custom/src/index.js +10 -0
  103. package/dist/packages/custom/src/keymap.js +107 -0
  104. package/dist/packages/custom/src/layout.js +235 -0
  105. package/dist/packages/custom/src/packs.js +633 -0
  106. package/dist/packages/custom/src/rules.js +704 -0
  107. package/dist/packages/custom/src/schema.js +515 -0
  108. package/dist/packages/custom/src/themes.js +176 -0
  109. package/dist/packages/custom/src/widget-html.js +222 -0
  110. package/dist/packages/custom/src/workspace.js +141 -0
  111. package/dist/packages/data/src/analyst-actions.js +133 -0
  112. package/dist/packages/data/src/budget.js +88 -0
  113. package/dist/packages/data/src/cache.js +142 -0
  114. package/dist/packages/data/src/candles.js +168 -0
  115. package/dist/packages/data/src/clock.js +64 -0
  116. package/dist/packages/data/src/credits.js +137 -0
  117. package/dist/packages/data/src/detail.js +97 -0
  118. package/dist/packages/data/src/fast-lane.js +321 -0
  119. package/dist/packages/data/src/fixtures.js +135 -0
  120. package/dist/packages/data/src/health.js +245 -0
  121. package/dist/packages/data/src/http.js +277 -0
  122. package/dist/packages/data/src/images/cache.js +141 -0
  123. package/dist/packages/data/src/images/decode-worker.js +28 -0
  124. package/dist/packages/data/src/images/decode.js +81 -0
  125. package/dist/packages/data/src/images/fetch.js +162 -0
  126. package/dist/packages/data/src/images/gif.js +214 -0
  127. package/dist/packages/data/src/images/index.js +10 -0
  128. package/dist/packages/data/src/images/jpeg.js +544 -0
  129. package/dist/packages/data/src/images/png.js +310 -0
  130. package/dist/packages/data/src/images/service.js +334 -0
  131. package/dist/packages/data/src/images/types.js +27 -0
  132. package/dist/packages/data/src/index.js +208 -0
  133. package/dist/packages/data/src/intel/helius-history.js +59 -0
  134. package/dist/packages/data/src/intel/holder-class.js +125 -0
  135. package/dist/packages/data/src/intel/rules.js +421 -0
  136. package/dist/packages/data/src/intel/scan.js +170 -0
  137. package/dist/packages/data/src/larp/evidence.js +288 -0
  138. package/dist/packages/data/src/larp/links.js +137 -0
  139. package/dist/packages/data/src/larp/robots.js +90 -0
  140. package/dist/packages/data/src/larp/safe-fetch.js +389 -0
  141. package/dist/packages/data/src/limiter.js +341 -0
  142. package/dist/packages/data/src/model.js +12 -0
  143. package/dist/packages/data/src/paper.js +331 -0
  144. package/dist/packages/data/src/rpc-config.js +126 -0
  145. package/dist/packages/data/src/rpc-pool.js +166 -0
  146. package/dist/packages/data/src/social/chain.js +83 -0
  147. package/dist/packages/data/src/social/dedupe.js +205 -0
  148. package/dist/packages/data/src/social/feed.js +462 -0
  149. package/dist/packages/data/src/social/firewall.js +110 -0
  150. package/dist/packages/data/src/social/index.js +11 -0
  151. package/dist/packages/data/src/social/kol.js +269 -0
  152. package/dist/packages/data/src/social/model.js +11 -0
  153. package/dist/packages/data/src/social/rank.js +184 -0
  154. package/dist/packages/data/src/social/rss.js +113 -0
  155. package/dist/packages/data/src/social/summary.js +104 -0
  156. package/dist/packages/data/src/social/venues.js +186 -0
  157. package/dist/packages/data/src/sources/dexscreener.js +170 -0
  158. package/dist/packages/data/src/sources/geckoterminal.js +133 -0
  159. package/dist/packages/data/src/sources/goplus.js +161 -0
  160. package/dist/packages/data/src/sources/jupiter-swap.js +236 -0
  161. package/dist/packages/data/src/sources/jupiter.js +198 -0
  162. package/dist/packages/data/src/sources/pda.js +113 -0
  163. package/dist/packages/data/src/sources/pump-curve.js +58 -0
  164. package/dist/packages/data/src/sources/pumpportal.js +247 -0
  165. package/dist/packages/data/src/sources/rugcheck.js +71 -0
  166. package/dist/packages/data/src/sources/solana-rpc.js +299 -0
  167. package/dist/packages/data/src/sources/solana-ws.js +287 -0
  168. package/dist/packages/data/src/store.js +289 -0
  169. package/dist/packages/data/src/trade/bridge.js +464 -0
  170. package/dist/packages/data/src/trade/caps.js +323 -0
  171. package/dist/packages/data/src/trade/copy/auto.js +385 -0
  172. package/dist/packages/data/src/trade/copy/follows.js +103 -0
  173. package/dist/packages/data/src/trade/copy/index.js +9 -0
  174. package/dist/packages/data/src/trade/copy/mirror.js +91 -0
  175. package/dist/packages/data/src/trade/copy/stats.js +120 -0
  176. package/dist/packages/data/src/trade/copy/tracker.js +158 -0
  177. package/dist/packages/data/src/trade/copy/trades.js +87 -0
  178. package/dist/packages/data/src/trade/copy/types.js +6 -0
  179. package/dist/packages/data/src/trade/executor.js +933 -0
  180. package/dist/packages/data/src/trade/kill-switch.js +47 -0
  181. package/dist/packages/data/src/trade/link-store.js +52 -0
  182. package/dist/packages/data/src/trade/plans/confirm.js +157 -0
  183. package/dist/packages/data/src/trade/plans/engine.js +784 -0
  184. package/dist/packages/data/src/trade/plans/executor-live.js +220 -0
  185. package/dist/packages/data/src/trade/plans/executor-paper.js +44 -0
  186. package/dist/packages/data/src/trade/plans/executor.js +11 -0
  187. package/dist/packages/data/src/trade/plans/index.js +12 -0
  188. package/dist/packages/data/src/trade/plans/journal.js +77 -0
  189. package/dist/packages/data/src/trade/plans/mandate.js +129 -0
  190. package/dist/packages/data/src/trade/plans/plan.js +179 -0
  191. package/dist/packages/data/src/trade/plans/service.js +168 -0
  192. package/dist/packages/data/src/trade/plans/types.js +6 -0
  193. package/dist/packages/data/src/trade/rpc-exec.js +229 -0
  194. package/dist/packages/data/src/trade/signer/client.js +197 -0
  195. package/dist/packages/data/src/trade/signer/fast-wallet.js +154 -0
  196. package/dist/packages/data/src/trade/signer/host.js +97 -0
  197. package/dist/packages/data/src/trade/signer/main.js +121 -0
  198. package/dist/packages/data/src/trade/signer/probe-builder.js +85 -0
  199. package/dist/packages/data/src/trade/signer/protocol.js +92 -0
  200. package/dist/packages/data/src/trade/signer/service.js +400 -0
  201. package/dist/packages/data/src/trade/signer/sol-usd-feed.js +98 -0
  202. package/dist/packages/data/src/trade/sol-usd.js +86 -0
  203. package/dist/packages/data/src/trade/swap-build.js +108 -0
  204. package/dist/packages/data/src/trade/token-delta.js +201 -0
  205. package/dist/packages/data/src/trade/tx-decode.js +632 -0
  206. package/dist/packages/data/src/util.js +24 -0
  207. package/dist/packages/data/src/watchlist.js +176 -0
  208. package/dist/packages/data/src/x/api.js +256 -0
  209. package/dist/packages/data/src/x/budget.js +66 -0
  210. package/dist/packages/data/src/x/config.js +49 -0
  211. package/dist/packages/data/src/x/index.js +10 -0
  212. package/dist/packages/data/src/x/mock-server.js +217 -0
  213. package/dist/packages/data/src/x/oauth.js +242 -0
  214. package/dist/packages/data/src/x/pkce.js +21 -0
  215. package/dist/packages/data/src/x/source.js +285 -0
  216. package/dist/packages/data/src/x/token-store.js +134 -0
  217. package/dist/packages/data/src/x/transport.js +71 -0
  218. package/dist/packages/data/vendor/solana-web3/LICENSE +20 -0
  219. package/dist/packages/data/vendor/solana-web3/NOTICE.md +13 -0
  220. package/dist/packages/data/vendor/solana-web3/index.iife.min.js +20 -0
  221. package/dist/packages/mcp/assets/cases.json +1194 -0
  222. package/dist/packages/mcp/assets/kb/entries/cabal-and-coordinated-launches.md +28 -0
  223. package/dist/packages/mcp/assets/kb/entries/common-rug-patterns.md +40 -0
  224. package/dist/packages/mcp/assets/kb/entries/dbc-mechanics.md +30 -0
  225. package/dist/packages/mcp/assets/kb/entries/dev-wallet-behaviour.md +33 -0
  226. package/dist/packages/mcp/assets/kb/entries/glossary.md +35 -0
  227. package/dist/packages/mcp/assets/kb/entries/holder-concentration.md +30 -0
  228. package/dist/packages/mcp/assets/kb/entries/jito-bundles.md +29 -0
  229. package/dist/packages/mcp/assets/kb/entries/launchlab-curve.md +31 -0
  230. package/dist/packages/mcp/assets/kb/entries/lp-burn-vs-lock.md +31 -0
  231. package/dist/packages/mcp/assets/kb/entries/metadata-and-copycats.md +29 -0
  232. package/dist/packages/mcp/assets/kb/entries/mint-freeze-authority.md +31 -0
  233. package/dist/packages/mcp/assets/kb/entries/narrative-lifecycle.md +32 -0
  234. package/dist/packages/mcp/assets/kb/entries/pump-bonding-curve.md +33 -0
  235. package/dist/packages/mcp/assets/kb/entries/pumpswap-migration.md +29 -0
  236. package/dist/packages/mcp/assets/kb/entries/sniping-and-snipers.md +29 -0
  237. package/dist/packages/mcp/assets/kb/entries/token2022-extensions-risk.md +32 -0
  238. package/dist/packages/mcp/assets/kb/entries/wash-trading-fake-volume.md +33 -0
  239. package/dist/packages/mcp/assets/skills/rug-check.md +52 -0
  240. package/dist/packages/mcp/assets/skills/token-dossier.md +57 -0
  241. package/dist/packages/mcp/assets/skills/trench-scan.md +40 -0
  242. package/dist/packages/mcp/bin/dexterm-mcp.mjs +16 -0
  243. package/dist/packages/mcp/src/dossier.js +78 -0
  244. package/dist/packages/mcp/src/firewall.js +21 -0
  245. package/dist/packages/mcp/src/index.js +22 -0
  246. package/dist/packages/mcp/src/server.js +849 -0
  247. package/dist/packages/mcp/src/user-content.js +434 -0
  248. package/dist/packages/packs/src/backtest.js +183 -0
  249. package/dist/packages/packs/src/digest.js +95 -0
  250. package/dist/packages/packs/src/firewall.js +57 -0
  251. package/dist/packages/packs/src/index.js +19 -0
  252. package/dist/packages/packs/src/larp-eval.js +100 -0
  253. package/dist/packages/packs/src/narrative.js +309 -0
  254. package/dist/packages/packs/src/reason.js +135 -0
  255. package/dist/packages/packs/src/rug-model.js +137 -0
  256. package/dist/packages/packs/src/schema.js +54 -0
  257. package/dist/packages/packs/src/token-dossier.js +174 -0
  258. package/dist/packages/packs/src/verdicts.js +17 -0
  259. package/dist/packages/packs/src/wallet-dossier.js +105 -0
  260. package/dist/packages/popup/src/config.js +71 -0
  261. package/dist/packages/popup/src/dock.js +271 -0
  262. package/dist/packages/popup/src/host.js +210 -0
  263. package/dist/packages/popup/src/index.js +12 -0
  264. package/dist/packages/popup/src/loopback.js +53 -0
  265. package/dist/packages/popup/src/open.js +183 -0
  266. package/dist/packages/popup/src/pages/bubbles-lib.js +160 -0
  267. package/dist/packages/popup/src/pages/bubbles.css +23 -0
  268. package/dist/packages/popup/src/pages/bubbles.html +36 -0
  269. package/dist/packages/popup/src/pages/bubbles.js +214 -0
  270. package/dist/packages/popup/src/pages/chart-lib.js +262 -0
  271. package/dist/packages/popup/src/pages/chart.css +50 -0
  272. package/dist/packages/popup/src/pages/chart.html +56 -0
  273. package/dist/packages/popup/src/pages/chart.js +554 -0
  274. package/dist/packages/popup/src/pages/common.css +34 -0
  275. package/dist/packages/popup/src/pages/common.js +187 -0
  276. package/dist/packages/popup/src/pages/feeds.html +20 -0
  277. package/dist/packages/popup/src/pages/feeds.js +77 -0
  278. package/dist/packages/popup/src/pages/narrative.html +20 -0
  279. package/dist/packages/popup/src/pages/narrative.js +61 -0
  280. package/dist/packages/popup/src/pages/plans.css +30 -0
  281. package/dist/packages/popup/src/pages/plans.html +26 -0
  282. package/dist/packages/popup/src/pages/plans.js +82 -0
  283. package/dist/packages/popup/src/pages/roster.css +41 -0
  284. package/dist/packages/popup/src/pages/roster.html +25 -0
  285. package/dist/packages/popup/src/pages/roster.js +75 -0
  286. package/dist/packages/popup/src/pages/token.html +20 -0
  287. package/dist/packages/popup/src/pages/token.js +112 -0
  288. package/dist/packages/popup/src/pages/widget-sdk.js +224 -0
  289. package/dist/packages/popup/src/pages/widget.css +5 -0
  290. package/dist/packages/popup/src/pages/widget.html +21 -0
  291. package/dist/packages/popup/src/pages/widget.js +85 -0
  292. package/dist/packages/popup/src/protocol.js +31 -0
  293. package/dist/packages/popup/src/selftest.js +156 -0
  294. package/dist/packages/popup/src/server.js +582 -0
  295. package/dist/packages/popup/src/sinkproxy.js +167 -0
  296. package/dist/packages/popup/src/widget-html.js +231 -0
  297. package/dist/packages/popup/vendor/lightweight-charts/LICENSE +201 -0
  298. package/dist/packages/popup/vendor/lightweight-charts/NOTICE +2 -0
  299. package/dist/packages/popup/vendor/lightweight-charts/README.md +30 -0
  300. package/dist/packages/popup/vendor/lightweight-charts/VERSION +1 -0
  301. package/dist/packages/popup/vendor/lightweight-charts/lightweight-charts.standalone.production.js +7 -0
  302. package/dist/packages/recorder/bin/dexterm-recorder.mjs +11 -0
  303. package/dist/packages/recorder/src/cli.js +113 -0
  304. package/dist/packages/recorder/src/export.js +71 -0
  305. package/dist/packages/recorder/src/index.js +20 -0
  306. package/dist/packages/recorder/src/labels.js +151 -0
  307. package/dist/packages/recorder/src/open.js +29 -0
  308. package/dist/packages/recorder/src/recorder.js +553 -0
  309. package/dist/packages/recorder/src/schedule.js +43 -0
  310. package/dist/packages/recorder/src/schema.js +135 -0
  311. package/dist/packages/recorder/src/snapshot.js +191 -0
  312. package/dist/packages/recorder/src/sweep.js +52 -0
  313. package/dist/packages/recorder/src/verdicts.js +52 -0
  314. package/dist/packages/tui/src/bubbles.js +415 -0
  315. package/dist/packages/tui/src/canvas.js +370 -0
  316. package/dist/packages/tui/src/chrome.js +85 -0
  317. package/dist/packages/tui/src/color.js +133 -0
  318. package/dist/packages/tui/src/flush.js +62 -0
  319. package/dist/packages/tui/src/fmt.js +188 -0
  320. package/dist/packages/tui/src/fx-trade.js +278 -0
  321. package/dist/packages/tui/src/fx.js +527 -0
  322. package/dist/packages/tui/src/images/blocks.js +141 -0
  323. package/dist/packages/tui/src/images/coin.js +22 -0
  324. package/dist/packages/tui/src/images/detect.js +140 -0
  325. package/dist/packages/tui/src/images/index.js +7 -0
  326. package/dist/packages/tui/src/images/layer.js +146 -0
  327. package/dist/packages/tui/src/images/pixels.js +157 -0
  328. package/dist/packages/tui/src/images/protocols.js +87 -0
  329. package/dist/packages/tui/src/index.js +15 -0
  330. package/dist/packages/tui/src/keymap.js +355 -0
  331. package/dist/packages/tui/src/layout.js +11 -0
  332. package/dist/packages/tui/src/sound.js +177 -0
  333. package/dist/packages/tui/src/theme.js +182 -0
  334. package/dist/packages/tui/src/widgets.js +598 -0
  335. package/dist/packages/voice/src/controller.js +395 -0
  336. package/dist/packages/voice/src/doctor.js +63 -0
  337. package/dist/packages/voice/src/engines.js +44 -0
  338. package/dist/packages/voice/src/index.js +16 -0
  339. package/dist/packages/voice/src/router.js +502 -0
  340. package/dist/packages/voice/src/stt/apple-helper.swift.js +103 -0
  341. package/dist/packages/voice/src/stt/apple.js +179 -0
  342. package/dist/packages/voice/src/stt/fake.js +34 -0
  343. package/dist/packages/voice/src/stt/whisper.js +102 -0
  344. package/dist/packages/voice/src/summary.js +44 -0
  345. package/dist/packages/voice/src/tts/fake.js +30 -0
  346. package/dist/packages/voice/src/tts/say.js +96 -0
  347. package/dist/packages/voice/src/types.js +3 -0
  348. package/package.json +42 -4
  349. package/README.md +0 -3
@@ -0,0 +1,217 @@
1
+ // @dexterm/data — a loopback MOCK of X's OAuth2 + the v2 read endpoints dexterm uses (task-1357). Test
2
+ // support only: the default test suites and `dexterm x` tests run the whole login + feed flow against this
3
+ // server and never touch x.com. It validates what the real X validates (client id, redirect uri, PKCE S256,
4
+ // single-use 30-s codes, bearer, refresh rotation, revoke) and can simulate rate limits (429 + x-rate-limit-*),
5
+ // expired tokens (401) and a Free-tier app (403 on search). Zero dependencies.
6
+ import { createServer } from 'node:http';
7
+ import { randomBytes } from 'node:crypto';
8
+ import { pkceMatches } from "./pkce.js";
9
+ /** Start the mock on an ephemeral loopback port. */
10
+ export function startMockX(options = {}) {
11
+ const now = options.now ?? (() => Date.now());
12
+ const opts = {
13
+ clientId: 'mock-client-id-1357',
14
+ me: { id: '1001', username: 'dexterm_tester', name: 'dexterm tester', created_at: '2020-01-01T00:00:00.000Z' },
15
+ tier: 'basic',
16
+ consent: 'approve',
17
+ expiresIn: 7200,
18
+ ...options,
19
+ };
20
+ const users = new Map([[opts.me.id, opts.me], ...(opts.users ?? []).map((u) => [u.id, u])]);
21
+ const tweets = [...(opts.tweets ?? [])];
22
+ const lists = [...(opts.lists ?? [])];
23
+ const state = { access: new Map(), refresh: new Map(), codes: new Map(), log: [], calls: {}, rateLimitNext: null, unauthorized: false, windows: {} };
24
+ const rid = () => randomBytes(12).toString('base64url');
25
+ const windowFor = (key, limit) => {
26
+ const w = state.windows[key] ?? (state.windows[key] = { limit, remaining: limit, reset: Math.floor(now() / 1000) + 900 });
27
+ if (Math.floor(now() / 1000) >= w.reset) {
28
+ w.remaining = w.limit;
29
+ w.reset = Math.floor(now() / 1000) + 900;
30
+ }
31
+ return w;
32
+ };
33
+ return new Promise((resolve) => {
34
+ const server = createServer((req, res) => {
35
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
36
+ const key = `${req.method} ${url.pathname}`;
37
+ state.log.push(key);
38
+ state.calls[url.pathname] = (state.calls[url.pathname] ?? 0) + 1;
39
+ const chunks = [];
40
+ req.on('data', (c) => chunks.push(c));
41
+ req.on('end', () => {
42
+ const body = Buffer.concat(chunks).toString('utf8');
43
+ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'content-type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };
44
+ // ---- authorize: the "consent screen". Redirects to the app's callback with code+state (or error).
45
+ if (req.method === 'GET' && url.pathname === '/i/oauth2/authorize') {
46
+ const q = url.searchParams;
47
+ const bad = (reason) => json(400, { error: 'invalid_request', error_description: reason });
48
+ if (q.get('response_type') !== 'code')
49
+ return bad('response_type');
50
+ if (q.get('client_id') !== opts.clientId)
51
+ return bad('client_id');
52
+ if (q.get('code_challenge_method') !== 'S256' || !q.get('code_challenge'))
53
+ return bad('pkce');
54
+ const redirect = q.get('redirect_uri') ?? '';
55
+ if (opts.redirectUri && redirect !== opts.redirectUri)
56
+ return bad('redirect_uri');
57
+ if (!/^http:\/\/127\.0\.0\.1:\d+\/callback$/.test(redirect))
58
+ return bad('redirect_uri must be http://127.0.0.1:<port>/callback');
59
+ const scope = q.get('scope') ?? '';
60
+ if (/\.write\b|\bdm\./.test(scope))
61
+ return bad('scope: write/dm scopes are refused by this mock (dexterm never asks for them)');
62
+ const back = new URL(redirect);
63
+ back.searchParams.set('state', q.get('state') ?? '');
64
+ if (opts.consent === 'deny')
65
+ back.searchParams.set('error', 'access_denied');
66
+ else {
67
+ const code = rid();
68
+ state.codes.set(code, { challenge: q.get('code_challenge'), redirectUri: redirect, scope, issuedAt: now(), used: false });
69
+ back.searchParams.set('code', code);
70
+ }
71
+ res.writeHead(302, { location: back.toString() });
72
+ res.end();
73
+ return;
74
+ }
75
+ // ---- token: authorization_code (PKCE) and refresh_token grants, public client
76
+ if (req.method === 'POST' && url.pathname === '/2/oauth2/token') {
77
+ const f = new URLSearchParams(body);
78
+ if (f.get('client_id') !== opts.clientId)
79
+ return json(400, { error: 'invalid_client', error_description: 'Value passed for the client id was invalid.' });
80
+ const issue = (user, scope) => {
81
+ const access = `at-${rid()}`, refresh = scope.includes('offline.access') ? `rt-${rid()}` : null;
82
+ state.access.set(access, { user, scope, expiresAt: now() + opts.expiresIn * 1000, revoked: false });
83
+ if (refresh)
84
+ state.refresh.set(refresh, { user, scope, revoked: false });
85
+ return json(200, { token_type: 'bearer', expires_in: opts.expiresIn, access_token: access, scope, ...(refresh ? { refresh_token: refresh } : {}) });
86
+ };
87
+ if (f.get('grant_type') === 'authorization_code') {
88
+ const c = state.codes.get(f.get('code') ?? '');
89
+ if (!c || c.used)
90
+ return json(400, { error: 'invalid_grant', error_description: 'Value passed for the authorization code was invalid.' });
91
+ if (now() - c.issuedAt > 30_000)
92
+ return json(400, { error: 'invalid_grant', error_description: 'authorization code expired' });
93
+ if (f.get('redirect_uri') !== c.redirectUri)
94
+ return json(400, { error: 'invalid_grant', error_description: 'redirect_uri mismatch' });
95
+ if (!pkceMatches(f.get('code_verifier') ?? '', c.challenge))
96
+ return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
97
+ c.used = true;
98
+ return issue(opts.me, c.scope);
99
+ }
100
+ if (f.get('grant_type') === 'refresh_token') {
101
+ const r = state.refresh.get(f.get('refresh_token') ?? '');
102
+ if (!r || r.revoked)
103
+ return json(400, { error: 'invalid_request', error_description: 'Value passed for the token was invalid.' });
104
+ r.revoked = true; // X rotates refresh tokens
105
+ return issue(r.user, r.scope);
106
+ }
107
+ return json(400, { error: 'unsupported_grant_type' });
108
+ }
109
+ if (req.method === 'POST' && url.pathname === '/2/oauth2/revoke') {
110
+ const f = new URLSearchParams(body);
111
+ if (f.get('client_id') !== opts.clientId)
112
+ return json(400, { error: 'invalid_client' });
113
+ const t = f.get('token') ?? '';
114
+ const a = state.access.get(t);
115
+ if (a)
116
+ a.revoked = true;
117
+ const r = state.refresh.get(t);
118
+ if (r)
119
+ r.revoked = true;
120
+ return json(200, { revoked: true });
121
+ }
122
+ // ---- v2 reads: bearer required
123
+ const auth = req.headers.authorization ?? '';
124
+ const bearer = auth.startsWith('Bearer ') ? auth.slice(7) : '';
125
+ const a = state.access.get(bearer);
126
+ if (!a || a.revoked || a.expiresAt <= now() || state.unauthorized)
127
+ return json(401, { title: 'Unauthorized', type: 'about:blank', status: 401, detail: 'Unauthorized' });
128
+ if (state.rateLimitNext && state.rateLimitNext.n > 0) {
129
+ state.rateLimitNext.n -= 1;
130
+ return json(429, { title: 'Too Many Requests', detail: 'Too Many Requests', type: 'about:blank', status: 429 }, { 'x-rate-limit-limit': '1', 'x-rate-limit-remaining': '0', 'x-rate-limit-reset': String(state.rateLimitNext.resetAt) });
131
+ }
132
+ const limited = (key, limit, fn) => {
133
+ const w = windowFor(key, limit);
134
+ if (w.remaining <= 0)
135
+ return json(429, { title: 'Too Many Requests', status: 429 }, { 'x-rate-limit-limit': String(w.limit), 'x-rate-limit-remaining': '0', 'x-rate-limit-reset': String(w.reset) });
136
+ w.remaining -= 1;
137
+ res.setHeader('x-rate-limit-limit', String(w.limit));
138
+ res.setHeader('x-rate-limit-remaining', String(w.remaining));
139
+ res.setHeader('x-rate-limit-reset', String(w.reset));
140
+ fn();
141
+ };
142
+ const userObj = (u) => ({ id: u.id, username: u.username, name: u.name ?? u.username, created_at: u.created_at ?? '2021-01-01T00:00:00.000Z' });
143
+ const tweetsPayload = (rows, max) => {
144
+ const data = rows.slice(0, max).map((t) => ({ id: t.id, text: t.text, author_id: t.author_id, created_at: t.created_at, lang: t.lang ?? 'en', public_metrics: t.public_metrics ?? { like_count: 0, retweet_count: 0, reply_count: 0 } }));
145
+ const ids = [...new Set(data.map((t) => t.author_id))];
146
+ return { data, includes: { users: ids.map((id) => users.get(id)).filter((u) => !!u).map(userObj) }, meta: { result_count: data.length, ...(data.length ? { newest_id: data[0].id, oldest_id: data[data.length - 1].id } : {}) } };
147
+ };
148
+ const notEnrolled = () => json(403, { client_id: opts.clientId, detail: 'When authenticating requests to the Twitter API v2 endpoints, you must use keys and tokens from a Twitter developer App that is attached to a Project. You can create a project via the Developer Portal.', registration_url: 'https://developer.twitter.com/en/docs/projects/overview', title: 'Client Forbidden', required_enrollment: 'Appropriate Level of API Access', reason: 'client-not-enrolled', type: 'https://api.twitter.com/2/problems/client-forbidden' });
149
+ if (req.method === 'GET' && url.pathname === '/2/users/me')
150
+ return limited('me', 75, () => json(200, { data: userObj(a.user) }));
151
+ if (req.method === 'GET' && url.pathname === '/2/tweets/search/recent') {
152
+ if (opts.tier === 'free')
153
+ return notEnrolled();
154
+ return limited('search', 300, () => {
155
+ const q = (url.searchParams.get('query') ?? '').toLowerCase();
156
+ const terms = q.replace(/-is:\w+|lang:\w+|[()]/g, ' ').split(/\s+or\s+|\s+/).map((s) => s.trim()).filter((s) => s && s !== 'or');
157
+ const sinceId = url.searchParams.get('since_id');
158
+ const rows = tweets.filter((t) => terms.some((term) => t.text.toLowerCase().includes(term.replace(/^\$/, '').replace(/^from:/, '')) || (term.startsWith('from:') && users.get(t.author_id)?.username.toLowerCase() === term.slice(5))))
159
+ .filter((t) => !sinceId || BigInt(t.id) > BigInt(sinceId))
160
+ .sort((x, y) => (BigInt(y.id) > BigInt(x.id) ? 1 : -1));
161
+ json(200, tweetsPayload(rows, Math.min(100, Number(url.searchParams.get('max_results') ?? 10))));
162
+ });
163
+ }
164
+ const listTweets = url.pathname.match(/^\/2\/lists\/(\d+)\/tweets$/);
165
+ if (req.method === 'GET' && listTweets) {
166
+ if (opts.tier === 'free')
167
+ return notEnrolled();
168
+ return limited('list-tweets', 900, () => {
169
+ const l = lists.find((x) => x.id === listTweets[1]);
170
+ if (!l)
171
+ return json(404, { errors: [{ detail: 'Could not find list' }] });
172
+ const members = new Set(l.member_ids);
173
+ const rows = tweets.filter((t) => members.has(t.author_id)).sort((x, y) => (BigInt(y.id) > BigInt(x.id) ? 1 : -1));
174
+ json(200, tweetsPayload(rows, Math.min(100, Number(url.searchParams.get('max_results') ?? 10))));
175
+ });
176
+ }
177
+ const owned = url.pathname.match(/^\/2\/users\/(\d+)\/owned_lists$/);
178
+ if (req.method === 'GET' && owned)
179
+ return limited('owned-lists', 15, () => json(200, { data: lists.filter((l) => l.owner_id === owned[1]).map((l) => ({ id: l.id, name: l.name, member_count: l.member_ids.length })), meta: { result_count: lists.filter((l) => l.owner_id === owned[1]).length } }));
180
+ const members = url.pathname.match(/^\/2\/lists\/(\d+)\/members$/);
181
+ if (req.method === 'GET' && members)
182
+ return limited('list-members', 900, () => {
183
+ const l = lists.find((x) => x.id === members[1]);
184
+ if (!l)
185
+ return json(404, { errors: [{ detail: 'Could not find list' }] });
186
+ json(200, { data: l.member_ids.map((id) => users.get(id)).filter((u) => !!u).map(userObj), meta: { result_count: l.member_ids.length } });
187
+ });
188
+ json(404, { title: 'Not Found', status: 404 });
189
+ });
190
+ });
191
+ server.listen(0, '127.0.0.1', () => {
192
+ const addr = server.address();
193
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
194
+ const base = `http://127.0.0.1:${port}`;
195
+ resolve({
196
+ url: base, port, state, opts,
197
+ endpoints: { authorize: `${base}/i/oauth2/authorize`, token: `${base}/2/oauth2/token`, revoke: `${base}/2/oauth2/revoke`, api: base },
198
+ close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); }),
199
+ });
200
+ });
201
+ });
202
+ }
203
+ /**
204
+ * A browser stand-in: GET the authorize URL and follow the mock's 302 to the app's loopback callback.
205
+ * Uses node:http (not fetch) so test files that block `globalThis.fetch` can still drive the flow.
206
+ */
207
+ export async function mockBrowser(authorizeUrl) {
208
+ const { request } = await import('node:http');
209
+ const get = (u) => new Promise((resolve, reject) => {
210
+ const r = request(u, { method: 'GET' }, (res) => { res.resume(); res.on('end', () => resolve({ status: res.statusCode ?? 0, location: typeof res.headers.location === 'string' ? res.headers.location : null })); });
211
+ r.on('error', reject);
212
+ r.end();
213
+ });
214
+ const first = await get(authorizeUrl);
215
+ if (first.status === 302 && first.location)
216
+ await get(first.location);
217
+ }
@@ -0,0 +1,242 @@
1
+ // @dexterm/data — Login with X: OAuth 2.0 Authorization Code + PKCE on a loopback callback (task-1357).
2
+ //
3
+ // login(): pkce + state → one-shot listener on http://127.0.0.1:41287/callback (binds 127.0.0.1 only, checks
4
+ // Host, ignores wrong-state hits, 404s everything else) → opens the authorize URL in the user's
5
+ // browser through a mode-0600 launcher file (never `open <url>` on argv) → exchanges the code at once
6
+ // (X's code expires in 30 s) → stores the record → `/2/users/me` for the handle.
7
+ // refresh(): grant_type=refresh_token (public client: client_id, no secret); the whole record is rewritten
8
+ // because X may rotate the refresh token.
9
+ // revoke(): POST /2/oauth2/revoke for both tokens, then the file is deleted even if X answered an error
10
+ // (the user can also revoke at x.com/settings/connected_apps).
11
+ //
12
+ // Nothing here logs a token. Errors carry redacted text only. No `.write` / `dm.*` scope can be requested.
13
+ import { createServer } from 'node:http';
14
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { spawn } from 'node:child_process';
18
+ import { X_CALLBACK_PATH, X_CALLBACK_PORT, X_FORBIDDEN_SCOPE_RE, X_LIVE_ENDPOINTS, X_SCOPES, callbackUri, redactXSecrets } from "./config.js";
19
+ import { oauthState, pkce, safeEqual } from "./pkce.js";
20
+ import { XTokenStore } from "./token-store.js";
21
+ import { formBody, nodeXTransport } from "./transport.js";
22
+ const LOOPBACK = new Set(['127.0.0.1', '::ffff:127.0.0.1', '::1']);
23
+ export class XAuthError extends Error {
24
+ kind;
25
+ constructor(kind, message) {
26
+ super(redactXSecrets(message));
27
+ this.name = 'XAuthError';
28
+ this.kind = kind;
29
+ }
30
+ }
31
+ /** The one-shot callback listener. Resolves with the code; everything else is ignored or 404. */
32
+ export function waitForCallback(opts) {
33
+ return new Promise((resolve, reject) => {
34
+ let settled = false;
35
+ let server;
36
+ // closeAllConnections: a keep-alive socket from this login must never be reused by the browser for the
37
+ // NEXT login's redirect (it would land on this dead listener and be ignored as a state mismatch).
38
+ const finish = (fn) => { if (settled)
39
+ return; settled = true; clearTimeout(timer); try {
40
+ server.close();
41
+ server.closeAllConnections?.();
42
+ }
43
+ catch { /* ignore */ } fn(); };
44
+ const host = `127.0.0.1:${opts.port}`;
45
+ server = createServer((req, res) => {
46
+ const remote = req.socket.remoteAddress ?? '';
47
+ if (!LOOPBACK.has(remote) || req.headers.host !== host) {
48
+ res.writeHead(404);
49
+ res.end();
50
+ return;
51
+ }
52
+ const url = new URL(req.url ?? '/', `http://${host}`);
53
+ if (req.method !== 'GET' || url.pathname !== X_CALLBACK_PATH) {
54
+ res.writeHead(404);
55
+ res.end();
56
+ return;
57
+ }
58
+ const st = url.searchParams.get('state') ?? '';
59
+ const code = url.searchParams.get('code') ?? '';
60
+ const err = url.searchParams.get('error');
61
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', connection: 'close', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'" });
62
+ if (!safeEqual(st, opts.state)) {
63
+ res.end(page('✗ state mismatch — this response was not for this login. Close the tab.'));
64
+ return;
65
+ } // not consumed; keep waiting
66
+ if (err) {
67
+ res.end(page(`✗ X returned: ${esc(err)}. Close the tab.`));
68
+ finish(() => reject(new XAuthError('denied', `authorization denied: ${err}`)));
69
+ return;
70
+ }
71
+ if (!code) {
72
+ res.end(page('✗ no code in the redirect. Close the tab.'));
73
+ return;
74
+ }
75
+ res.end(page('✓ dexterm is linked to your X account. You can close this tab.'));
76
+ finish(() => resolve({ code }));
77
+ });
78
+ server.headersTimeout = 5000;
79
+ server.requestTimeout = 10_000;
80
+ server.on('error', (e) => finish(() => reject(e.code === 'EADDRINUSE' ? new XAuthError('port-busy', `port ${opts.port} is busy (another login in progress, or another process squats it): retry in a moment`) : e)));
81
+ const timer = setTimeout(() => finish(() => reject(new XAuthError('timeout', `login timed out after ${Math.round(opts.timeoutMs / 1000)} s (no callback from the browser)`))), opts.timeoutMs);
82
+ timer.unref?.();
83
+ server.listen(opts.port, '127.0.0.1', () => opts.onListening?.());
84
+ });
85
+ }
86
+ function esc(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
87
+ function page(msg) { return `<!doctype html><meta charset="utf-8"><title>dexterm · X</title><body style="font:15px system-ui;background:#07090d;color:#d7dbe0;padding:2rem"><p>${msg}</p>`; }
88
+ /** Default opener: a mode-0600 redirect page handed to `open`/`xdg-open` so the URL never sits on an argv. */
89
+ export function defaultBrowserOpener(url) {
90
+ let dir = null;
91
+ try {
92
+ dir = mkdtempSync(join(tmpdir(), 'dexterm-x-'));
93
+ writeFileSync(join(dir, 'open.html'), `<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${esc(url)}">`, { mode: 0o600 });
94
+ }
95
+ catch {
96
+ dir = null;
97
+ }
98
+ const child = spawn(process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open', process.platform === 'win32' ? ['/c', 'start', '', dir ? join(dir, 'open.html') : url] : [dir ? join(dir, 'open.html') : url], { stdio: 'ignore', detached: true });
99
+ child.on('error', () => { });
100
+ child.unref();
101
+ if (dir) {
102
+ const d = dir;
103
+ const t = setTimeout(() => { try {
104
+ rmSync(d, { recursive: true, force: true });
105
+ }
106
+ catch { /* ignore */ } }, 20_000);
107
+ t.unref?.();
108
+ }
109
+ }
110
+ export class XOAuth {
111
+ store;
112
+ endpoints;
113
+ port;
114
+ transport;
115
+ open;
116
+ now;
117
+ loginTimeoutMs;
118
+ /** The authorize URL of the login in progress (the CLI prints it when the browser could not be opened). */
119
+ lastAuthorizeUrl = null;
120
+ constructor(opts) {
121
+ this.store = opts.store;
122
+ this.endpoints = opts.endpoints ?? X_LIVE_ENDPOINTS;
123
+ this.port = opts.port ?? X_CALLBACK_PORT;
124
+ this.transport = opts.transport ?? nodeXTransport();
125
+ this.open = opts.open ?? defaultBrowserOpener;
126
+ this.now = opts.now ?? (() => Date.now());
127
+ this.loginTimeoutMs = opts.loginTimeoutMs ?? 180_000;
128
+ }
129
+ get redirectUri() { return callbackUri(this.port); }
130
+ /** Build the authorize URL (public values only: client id, challenge, state). */
131
+ authorizeUrl(clientId, challenge, state, scopes = X_SCOPES) {
132
+ for (const s of scopes)
133
+ if (X_FORBIDDEN_SCOPE_RE.test(s))
134
+ throw new XAuthError('scope', `scope ${s} is never requested by dexterm`);
135
+ const url = new URL(this.endpoints.authorize);
136
+ url.search = new URLSearchParams({ response_type: 'code', client_id: clientId, redirect_uri: this.redirectUri, scope: scopes.join(' '), state, code_challenge: challenge, code_challenge_method: 'S256' }).toString();
137
+ return url.toString();
138
+ }
139
+ async tokenRequest(form) {
140
+ const res = await this.transport({ method: 'POST', url: this.endpoints.token, headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: formBody(form) });
141
+ let j = null;
142
+ try {
143
+ j = JSON.parse(res.body);
144
+ }
145
+ catch {
146
+ j = null;
147
+ }
148
+ if (res.status < 200 || res.status >= 300) {
149
+ throw new Error(`token endpoint ${res.status}: ${String(j?.error ?? '')} ${String(j?.error_description ?? '')}`.trim());
150
+ }
151
+ if (!j || typeof j.access_token !== 'string')
152
+ throw new Error('token endpoint answered without an access_token');
153
+ return j;
154
+ }
155
+ /** Run the whole login. Requires a stored client id. */
156
+ async login(opts = {}) {
157
+ const clientId = this.store.clientId(opts.env ?? process.env);
158
+ if (!clientId)
159
+ throw new XAuthError('no-client-id', 'no X app Client ID stored: run `dexterm x set-client-id` first (see docs: Login with X)');
160
+ const { verifier, challenge } = pkce();
161
+ const state = oauthState();
162
+ const scopes = opts.scopes ?? X_SCOPES;
163
+ const url = this.authorizeUrl(clientId, challenge, state, scopes);
164
+ this.lastAuthorizeUrl = url;
165
+ const t0 = this.now();
166
+ const pending = waitForCallback({ state, port: this.port, timeoutMs: this.loginTimeoutMs, onListening: () => this.open(url) });
167
+ const { code } = await pending;
168
+ const tCallback = this.now();
169
+ let j;
170
+ try {
171
+ j = await this.tokenRequest({ code, grant_type: 'authorization_code', client_id: clientId, redirect_uri: this.redirectUri, code_verifier: verifier });
172
+ }
173
+ catch (e) {
174
+ throw new XAuthError('exchange', `code exchange failed: ${e instanceof Error ? e.message : String(e)}`);
175
+ }
176
+ const rec = this.store.setTokens(j, null);
177
+ const user = await this.fetchMe(rec).catch(() => null);
178
+ if (user)
179
+ this.store.setUser(user);
180
+ return { user, scopes: rec.scope.split(/\s+/).filter(Boolean), expiresAt: rec.expires_at, hasRefresh: !!rec.refresh_token, callbackMs: tCallback - t0, exchangeMs: this.now() - tCallback };
181
+ }
182
+ /** Rotate the access token. Throws XAuthError('refresh') and clears nothing on failure (the caller decides). */
183
+ async refresh(env = process.env) {
184
+ const clientId = this.store.clientId(env);
185
+ const t = this.store.tokens();
186
+ if (!clientId || !t)
187
+ throw new XAuthError('not-logged-in', 'not logged in');
188
+ if (!t.refresh_token)
189
+ throw new XAuthError('refresh', 'no refresh token (login again with offline.access)');
190
+ let j;
191
+ try {
192
+ j = await this.tokenRequest({ grant_type: 'refresh_token', refresh_token: t.refresh_token, client_id: clientId });
193
+ }
194
+ catch (e) {
195
+ throw new XAuthError('refresh', `refresh failed: ${e instanceof Error ? e.message : String(e)}`);
196
+ }
197
+ return this.store.setTokens(j, t.user);
198
+ }
199
+ /** A valid bearer, refreshing when within `skewMs` of expiry. Never logged by callers. */
200
+ async bearer(env = process.env, skewMs = 60_000) {
201
+ let t = this.store.tokens();
202
+ if (!t)
203
+ throw new XAuthError('not-logged-in', 'not logged in');
204
+ if (t.expires_at <= this.now() + skewMs)
205
+ t = await this.refresh(env);
206
+ return t.access_token;
207
+ }
208
+ /** Revoke both tokens at X (best effort) and delete the local record. Returns the HTTP statuses seen. */
209
+ async logout(env = process.env) {
210
+ const clientId = this.store.clientId(env);
211
+ const t = this.store.tokens();
212
+ const revoked = {};
213
+ if (t && clientId) {
214
+ for (const [k, v] of [['access_token', t.access_token], ['refresh_token', t.refresh_token]]) {
215
+ if (!v)
216
+ continue;
217
+ try {
218
+ const res = await this.transport({ method: 'POST', url: this.endpoints.revoke, headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: formBody({ token: v, client_id: clientId, token_type_hint: k }) });
219
+ revoked[k] = res.status;
220
+ }
221
+ catch {
222
+ revoked[k] = 0;
223
+ }
224
+ }
225
+ }
226
+ const hadTokens = this.store.clearTokens();
227
+ return { revoked, hadTokens };
228
+ }
229
+ /** GET /2/users/me with a given record (login uses it before the adapter exists). */
230
+ async fetchMe(rec) {
231
+ const res = await this.transport({ method: 'GET', url: `${this.endpoints.api}/2/users/me`, headers: { authorization: `Bearer ${rec.access_token}` } });
232
+ if (res.status !== 200)
233
+ return null;
234
+ try {
235
+ const j = JSON.parse(res.body);
236
+ return j.data && typeof j.data.id === 'string' && typeof j.data.username === 'string' ? { id: j.data.id, username: j.data.username } : null;
237
+ }
238
+ catch {
239
+ return null;
240
+ }
241
+ }
242
+ }
@@ -0,0 +1,21 @@
1
+ // @dexterm/data — PKCE (RFC 7636, S256) + opaque state for the Login with X flow (task-1357).
2
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
3
+ const b64url = (b) => b.toString('base64url');
4
+ /** 64-char verifier (48 random bytes) and its S256 challenge (43 chars). */
5
+ export function pkce(rand = randomBytes) {
6
+ const verifier = b64url(rand(48));
7
+ return { verifier, challenge: b64url(createHash('sha256').update(verifier).digest()), method: 'S256' };
8
+ }
9
+ /** 32-char opaque state (24 random bytes). */
10
+ export function oauthState(rand = randomBytes) {
11
+ return b64url(rand(24));
12
+ }
13
+ /** Constant-time string equality (length leak is fine: the lengths are public). */
14
+ export function safeEqual(a, b) {
15
+ const x = Buffer.from(a), y = Buffer.from(b);
16
+ return x.length === y.length && timingSafeEqual(x, y);
17
+ }
18
+ /** What the mock server (and any PKCE verifier) checks: S256(verifier) === challenge. */
19
+ export function pkceMatches(verifier, challenge) {
20
+ return safeEqual(b64url(createHash('sha256').update(verifier).digest()), challenge);
21
+ }