@interactive-inc/claude-funnel 0.4.1 → 0.8.0

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/README.md +233 -111
  2. package/dist/bin.js +1417 -0
  3. package/dist/gateway/daemon.js +513 -0
  4. package/dist/highlights-eq9cgrbb.scm +604 -0
  5. package/dist/highlights-ghv9g403.scm +205 -0
  6. package/dist/highlights-hk7bwhj4.scm +284 -0
  7. package/dist/highlights-r812a2qc.scm +150 -0
  8. package/dist/highlights-x6tmsnaa.scm +115 -0
  9. package/dist/injections-73j83es3.scm +27 -0
  10. package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  11. package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
  12. package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  13. package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  14. package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
  15. package/lib/bin.ts +78 -0
  16. package/lib/{modules → cli}/router/to-request.ts +13 -20
  17. package/lib/cli/routes/channels.$channel.connectors.$connector.rename.$newName.ts +27 -0
  18. package/lib/cli/routes/channels.$channel.connectors.$connector.request.ts +40 -0
  19. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.add.$id.ts +41 -0
  20. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.remove.$id.ts +22 -0
  21. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.ts +23 -0
  22. package/lib/cli/routes/channels.$channel.connectors.$connector.ts +26 -0
  23. package/lib/cli/routes/channels.$channel.connectors.add.$connector.ts +92 -0
  24. package/lib/cli/routes/channels.$channel.connectors.remove.$connector.ts +22 -0
  25. package/lib/cli/routes/channels.$channel.connectors.set.$connector.ts +63 -0
  26. package/lib/cli/routes/channels.$channel.connectors.ts +26 -0
  27. package/lib/cli/routes/channels.$channel.rename.$newName.ts +22 -0
  28. package/lib/cli/routes/channels.$channel.set.delivery.$mode.ts +34 -0
  29. package/lib/cli/routes/channels.$channel.ts +34 -0
  30. package/lib/cli/routes/channels.add.$channel.ts +33 -0
  31. package/lib/cli/routes/channels.remove.$channel.ts +20 -0
  32. package/lib/cli/routes/channels.ts +39 -0
  33. package/lib/cli/routes/claude.ts +69 -0
  34. package/lib/cli/routes/gateway.listeners.ts +41 -0
  35. package/lib/cli/routes/gateway.logs.ts +123 -0
  36. package/lib/{routes/gateway/restart.ts → cli/routes/gateway.restart.ts} +20 -5
  37. package/lib/cli/routes/gateway.run.ts +41 -0
  38. package/lib/cli/routes/gateway.start.ts +50 -0
  39. package/lib/cli/routes/gateway.status.ts +19 -0
  40. package/lib/cli/routes/gateway.stop.ts +32 -0
  41. package/lib/cli/routes/gateway.ts +55 -0
  42. package/lib/cli/routes/index.ts +202 -0
  43. package/lib/cli/routes/profiles.$profile.as-default.ts +22 -0
  44. package/lib/cli/routes/profiles.$profile.rename.$newName.ts +22 -0
  45. package/lib/cli/routes/profiles.$profile.run.ts +36 -0
  46. package/lib/cli/routes/profiles.add.$profile.ts +46 -0
  47. package/lib/cli/routes/profiles.remove.$profile.ts +20 -0
  48. package/lib/cli/routes/profiles.set.$profile.ts +46 -0
  49. package/lib/cli/routes/profiles.ts +40 -0
  50. package/lib/cli/routes/status.ts +93 -0
  51. package/lib/cli/routes/update.ts +27 -0
  52. package/lib/connectors/connector-config-schema.ts +16 -0
  53. package/lib/connectors/connector-factory.ts +94 -0
  54. package/lib/connectors/connector-listener.ts +20 -0
  55. package/lib/{modules/connectors/funnel-discord-adapter.ts → connectors/discord-adapter.ts} +6 -11
  56. package/lib/{modules/connectors → connectors}/discord-connector-schema.ts +4 -1
  57. package/lib/connectors/discord-listener.ts +111 -0
  58. package/lib/{modules/connectors/funnel-gh-adapter.ts → connectors/gh-adapter.ts} +3 -6
  59. package/lib/{modules/connectors → connectors}/gh-connector-schema.ts +4 -1
  60. package/lib/{modules/connectors/funnel-gh-listener.ts → connectors/gh-listener.ts} +57 -22
  61. package/lib/{modules/connectors → connectors}/match-cron.ts +10 -4
  62. package/lib/connectors/schedule-connector-schema.ts +33 -0
  63. package/lib/connectors/schedule-listener.ts +207 -0
  64. package/lib/connectors/schedule-state-store.ts +54 -0
  65. package/lib/connectors/slack-adapter.ts +36 -0
  66. package/lib/{modules/connectors → connectors}/slack-connector-schema.ts +4 -1
  67. package/lib/{modules/connectors/funnel-slack-event-processor.ts → connectors/slack-event-processor.ts} +15 -9
  68. package/lib/{modules/connectors/funnel-slack-listener.ts → connectors/slack-listener.ts} +39 -14
  69. package/lib/engine/channels/channels.ts +520 -0
  70. package/lib/{modules/claude/funnel-claude.ts → engine/claude/claude.ts} +47 -62
  71. package/lib/engine/claude/gateway-controller.ts +4 -0
  72. package/lib/{modules/fs/funnel-file-system.ts → engine/fs/file-system.ts} +9 -0
  73. package/lib/{modules/fs/memory-funnel-file-system.ts → engine/fs/memory-file-system.ts} +20 -3
  74. package/lib/{modules/fs/node-funnel-file-system.ts → engine/fs/node-file-system.ts} +14 -2
  75. package/lib/{modules/http/memory-funnel-http-client.ts → engine/http/memory-http-client.ts} +1 -5
  76. package/lib/{modules/http/node-funnel-http-client.ts → engine/http/node-http-client.ts} +1 -5
  77. package/lib/engine/id/id-generator.ts +7 -0
  78. package/lib/engine/id/memory-id-generator.ts +20 -0
  79. package/lib/engine/id/node-id-generator.ts +7 -0
  80. package/lib/engine/logger/logger.ts +11 -0
  81. package/lib/engine/logger/memory-logger.ts +28 -0
  82. package/lib/engine/logger/node-logger.ts +49 -0
  83. package/lib/engine/logger/noop-logger.ts +9 -0
  84. package/lib/engine/mcp/channel-server.ts +204 -0
  85. package/lib/{modules/mcp/funnel-mcp.ts → engine/mcp/mcp.ts} +29 -10
  86. package/lib/{modules/process/memory-funnel-process-runner.ts → engine/process/memory-process-runner.ts} +1 -1
  87. package/lib/{modules/process/node-funnel-process-runner.ts → engine/process/node-process-runner.ts} +12 -21
  88. package/lib/{modules/process/funnel-process-runner.ts → engine/process/process-runner.ts} +5 -0
  89. package/lib/engine/profiles/profile-channel-checker.ts +7 -0
  90. package/lib/engine/profiles/profiles.ts +126 -0
  91. package/lib/{modules/settings/mock-funnel-settings-reader.ts → engine/settings/mock-settings-reader.ts} +4 -3
  92. package/lib/{modules/settings/funnel-settings-reader.ts → engine/settings/settings-reader.ts} +1 -1
  93. package/lib/engine/settings/settings-schema.ts +46 -0
  94. package/lib/engine/settings/settings-store.ts +110 -0
  95. package/lib/engine/time/clock.ts +15 -0
  96. package/lib/engine/time/memory-clock.ts +26 -0
  97. package/lib/engine/time/node-clock.ts +7 -0
  98. package/lib/funnel.ts +148 -56
  99. package/lib/gateway/auth-middleware.ts +44 -0
  100. package/lib/gateway/broadcaster.ts +319 -0
  101. package/lib/gateway/daemon.ts +47 -0
  102. package/lib/gateway/factory.ts +10 -0
  103. package/lib/gateway/funnel-event-store.ts +155 -0
  104. package/lib/gateway/gateway-server.ts +414 -0
  105. package/lib/gateway/gateway-token.ts +79 -0
  106. package/lib/{modules/gateway/funnel-gateway.ts → gateway/gateway.ts} +70 -27
  107. package/lib/{modules/gateway → gateway}/kill-competing-slack-gateways.ts +7 -3
  108. package/lib/gateway/listener-supervisor.ts +339 -0
  109. package/lib/gateway/listeners-client.ts +128 -0
  110. package/lib/gateway/resolve-daemon-script.ts +26 -0
  111. package/lib/gateway/routes/channels.connectors.call.ts +39 -0
  112. package/lib/gateway/routes/health.ts +13 -0
  113. package/lib/gateway/routes/index.ts +24 -0
  114. package/lib/gateway/routes/listeners.list.ts +6 -0
  115. package/lib/gateway/routes/listeners.restart.ts +15 -0
  116. package/lib/gateway/routes/listeners.start.ts +15 -0
  117. package/lib/gateway/routes/listeners.stop.ts +15 -0
  118. package/lib/gateway/routes/route-deps.ts +11 -0
  119. package/lib/gateway/routes/status.ts +15 -0
  120. package/lib/gateway/routes/validator.ts +17 -0
  121. package/lib/index.ts +50 -92
  122. package/lib/logger/leuco-human-file-writer.ts +65 -0
  123. package/lib/logger/leuco-human-logger.ts +98 -0
  124. package/lib/logger/leuco-human-record.ts +16 -0
  125. package/lib/logger/leuco-human-stdout-writer.ts +26 -0
  126. package/lib/logger/leuco-human-writer.ts +14 -0
  127. package/lib/logger/leuco-logger-memory-sink.ts +67 -0
  128. package/lib/logger/leuco-logger-record.ts +13 -0
  129. package/lib/logger/leuco-logger-sink.ts +33 -0
  130. package/lib/logger/leuco-logger-sqlite-sink.ts +355 -0
  131. package/lib/logger/leuco-logger.ts +135 -0
  132. package/lib/tui/app.tsx +357 -0
  133. package/lib/tui/components/add-row.tsx +18 -0
  134. package/lib/tui/components/brand.tsx +27 -0
  135. package/lib/tui/components/card.tsx +44 -0
  136. package/lib/tui/components/detail-bar.tsx +46 -0
  137. package/lib/tui/components/editable-field.tsx +33 -0
  138. package/lib/tui/components/empty-state.tsx +11 -0
  139. package/lib/tui/components/gateway-status.tsx +66 -0
  140. package/lib/tui/components/keymap.tsx +29 -0
  141. package/lib/tui/components/menu-item.tsx +73 -0
  142. package/lib/tui/components/menu.tsx +26 -0
  143. package/lib/tui/components/panel-header.tsx +22 -0
  144. package/lib/tui/components/readonly-field.tsx +18 -0
  145. package/lib/tui/components/section-header.tsx +25 -0
  146. package/lib/tui/components/selection-accent.tsx +32 -0
  147. package/lib/tui/components/session-item.tsx +33 -0
  148. package/lib/tui/components/session-list.tsx +33 -0
  149. package/lib/tui/components/ui/hascii/accordion-item.tsx +88 -0
  150. package/lib/tui/components/ui/hascii/accordion.tsx +96 -0
  151. package/lib/tui/components/ui/hascii/alert-dialog.tsx +43 -0
  152. package/lib/tui/components/ui/hascii/badge.tsx +51 -0
  153. package/lib/tui/components/ui/hascii/breadcrumb.tsx +58 -0
  154. package/lib/tui/components/ui/hascii/button.tsx +194 -0
  155. package/lib/tui/components/ui/hascii/card-content.tsx +14 -0
  156. package/lib/tui/components/ui/hascii/card-description.tsx +13 -0
  157. package/lib/tui/components/ui/hascii/card-footer.tsx +14 -0
  158. package/lib/tui/components/ui/hascii/card-header.tsx +14 -0
  159. package/lib/tui/components/ui/hascii/card-title.tsx +13 -0
  160. package/lib/tui/components/ui/hascii/card.tsx +27 -0
  161. package/lib/tui/components/ui/hascii/checkbox.tsx +65 -0
  162. package/lib/tui/components/ui/hascii/command.tsx +159 -0
  163. package/lib/tui/components/ui/hascii/dialog-content.tsx +14 -0
  164. package/lib/tui/components/ui/hascii/dialog-description.tsx +13 -0
  165. package/lib/tui/components/ui/hascii/dialog-footer.tsx +14 -0
  166. package/lib/tui/components/ui/hascii/dialog-header.tsx +14 -0
  167. package/lib/tui/components/ui/hascii/dialog-title.tsx +13 -0
  168. package/lib/tui/components/ui/hascii/dialog.tsx +27 -0
  169. package/lib/tui/components/ui/hascii/file-tree.tsx +142 -0
  170. package/lib/tui/components/ui/hascii/focus-group.tsx +62 -0
  171. package/lib/tui/components/ui/hascii/form-item.tsx +43 -0
  172. package/lib/tui/components/ui/hascii/input-otp.tsx +86 -0
  173. package/lib/tui/components/ui/hascii/input.tsx +130 -0
  174. package/lib/tui/components/ui/hascii/pagination.tsx +105 -0
  175. package/lib/tui/components/ui/hascii/progress.tsx +28 -0
  176. package/lib/tui/components/ui/hascii/select.tsx +131 -0
  177. package/lib/tui/components/ui/hascii/separator.tsx +35 -0
  178. package/lib/tui/components/ui/hascii/sidebar-content.tsx +23 -0
  179. package/lib/tui/components/ui/hascii/sidebar-header.tsx +14 -0
  180. package/lib/tui/components/ui/hascii/sidebar-menu-item.tsx +67 -0
  181. package/lib/tui/components/ui/hascii/sidebar.tsx +24 -0
  182. package/lib/tui/components/ui/hascii/skeleton.tsx +60 -0
  183. package/lib/tui/components/ui/hascii/slider.tsx +91 -0
  184. package/lib/tui/components/ui/hascii/snackbar.tsx +75 -0
  185. package/lib/tui/components/ui/hascii/sparkline.tsx +53 -0
  186. package/lib/tui/components/ui/hascii/spinner.tsx +47 -0
  187. package/lib/tui/components/ui/hascii/stepper.tsx +54 -0
  188. package/lib/tui/components/ui/hascii/switch.tsx +66 -0
  189. package/lib/tui/components/ui/hascii/table.tsx +95 -0
  190. package/lib/tui/components/ui/hascii/tabs.tsx +59 -0
  191. package/lib/tui/components/ui/hascii/toggle-group-item.tsx +45 -0
  192. package/lib/tui/components/ui/hascii/toggle-group.tsx +99 -0
  193. package/lib/tui/components/ui/hascii/tree.tsx +104 -0
  194. package/lib/tui/components/view-shell.tsx +44 -0
  195. package/lib/tui/filter-input.tsx +33 -0
  196. package/lib/tui/hooks/hascii/use-pressable.ts +54 -0
  197. package/lib/tui/parse-comma-list.ts +14 -0
  198. package/lib/tui/profile-launcher.tsx +61 -0
  199. package/lib/tui/scrollbar-options.ts +19 -0
  200. package/lib/tui/sidebar.tsx +50 -0
  201. package/lib/tui/theme.ts +40 -0
  202. package/lib/tui/tui.tsx +20 -0
  203. package/lib/tui/types.ts +38 -0
  204. package/lib/tui/unique-name.ts +18 -0
  205. package/lib/tui/use-event-stream.ts +133 -0
  206. package/lib/tui/use-snapshot.ts +99 -0
  207. package/lib/tui/utils/hascii/form-item-context.tsx +23 -0
  208. package/lib/tui/utils/hascii/input-focus-context.tsx +31 -0
  209. package/lib/tui/utils/hascii/theme-context.tsx +26 -0
  210. package/lib/tui/utils/hascii/theme.ts +176 -0
  211. package/lib/tui/views/channels-view.tsx +108 -0
  212. package/lib/tui/views/connectors-view.tsx +164 -0
  213. package/lib/tui/views/events-view.tsx +160 -0
  214. package/lib/tui/views/listeners-view.tsx +80 -0
  215. package/lib/tui/views/profiles-view.tsx +152 -0
  216. package/package.json +51 -34
  217. package/lib/modules/channels/channel-connector-ref-updater.ts +0 -4
  218. package/lib/modules/channels/funnel-channels.ts +0 -155
  219. package/lib/modules/connectors/connector-config-schema.ts +0 -16
  220. package/lib/modules/connectors/connector-existence-checker.ts +0 -3
  221. package/lib/modules/connectors/funnel-callable-connector-store.ts +0 -9
  222. package/lib/modules/connectors/funnel-connector-listener.ts +0 -5
  223. package/lib/modules/connectors/funnel-connector-stores.ts +0 -24
  224. package/lib/modules/connectors/funnel-connector-type-store.ts +0 -24
  225. package/lib/modules/connectors/funnel-connectors.ts +0 -145
  226. package/lib/modules/connectors/funnel-discord-listener.ts +0 -65
  227. package/lib/modules/connectors/funnel-discord-store.ts +0 -84
  228. package/lib/modules/connectors/funnel-gh-store.ts +0 -84
  229. package/lib/modules/connectors/funnel-json-connector-store.ts +0 -100
  230. package/lib/modules/connectors/funnel-schedule-listener.ts +0 -124
  231. package/lib/modules/connectors/funnel-schedule-store.ts +0 -178
  232. package/lib/modules/connectors/funnel-slack-adapter.ts +0 -31
  233. package/lib/modules/connectors/funnel-slack-store.ts +0 -86
  234. package/lib/modules/connectors/migrate-legacy-connectors.ts +0 -77
  235. package/lib/modules/connectors/schedule-connector-schema.ts +0 -18
  236. package/lib/modules/connectors/schedule-last-fired-store.ts +0 -48
  237. package/lib/modules/gateway/daemon.ts +0 -207
  238. package/lib/modules/gateway/funnel-broadcaster.ts +0 -37
  239. package/lib/modules/gateway/funnel-event-logger.ts +0 -59
  240. package/lib/modules/logger.ts +0 -26
  241. package/lib/modules/mcp/channel-server.ts +0 -76
  242. package/lib/modules/profiles/funnel-profiles.ts +0 -123
  243. package/lib/modules/profiles/profile-channel-checker.ts +0 -3
  244. package/lib/modules/profiles/profile-channel-ref-updater.ts +0 -3
  245. package/lib/modules/repos/funnel-repositories.ts +0 -107
  246. package/lib/modules/schedule/funnel-schedule.ts +0 -34
  247. package/lib/modules/settings/funnel-settings-store.ts +0 -56
  248. package/lib/modules/settings/settings-schema.ts +0 -33
  249. package/lib/modules/tui/app.tsx +0 -44
  250. package/lib/modules/tui/tui.tsx +0 -13
  251. package/lib/routes/channels/add.help.ts +0 -3
  252. package/lib/routes/channels/add.ts +0 -21
  253. package/lib/routes/channels/connectors-attach.help.ts +0 -3
  254. package/lib/routes/channels/connectors-attach.ts +0 -17
  255. package/lib/routes/channels/connectors-detach.help.ts +0 -3
  256. package/lib/routes/channels/connectors-detach.ts +0 -17
  257. package/lib/routes/channels/group.help.ts +0 -16
  258. package/lib/routes/channels/group.ts +0 -22
  259. package/lib/routes/channels/remove.help.ts +0 -3
  260. package/lib/routes/channels/remove.ts +0 -17
  261. package/lib/routes/channels/rename.help.ts +0 -5
  262. package/lib/routes/channels/rename.ts +0 -17
  263. package/lib/routes/channels/routes.ts +0 -19
  264. package/lib/routes/channels/show.help.ts +0 -1
  265. package/lib/routes/channels/show.ts +0 -26
  266. package/lib/routes/claude/claude.help.ts +0 -16
  267. package/lib/routes/claude/claude.ts +0 -76
  268. package/lib/routes/claude/routes.ts +0 -4
  269. package/lib/routes/connectors/add.help.ts +0 -28
  270. package/lib/routes/connectors/add.ts +0 -64
  271. package/lib/routes/connectors/group.help.ts +0 -14
  272. package/lib/routes/connectors/group.ts +0 -18
  273. package/lib/routes/connectors/remove.help.ts +0 -3
  274. package/lib/routes/connectors/remove.ts +0 -17
  275. package/lib/routes/connectors/rename.help.ts +0 -5
  276. package/lib/routes/connectors/rename.ts +0 -17
  277. package/lib/routes/connectors/routes.ts +0 -23
  278. package/lib/routes/connectors/schedules-add.help.ts +0 -11
  279. package/lib/routes/connectors/schedules-add.ts +0 -33
  280. package/lib/routes/connectors/schedules-group.help.ts +0 -1
  281. package/lib/routes/connectors/schedules-group.ts +0 -38
  282. package/lib/routes/connectors/schedules-remove.help.ts +0 -3
  283. package/lib/routes/connectors/schedules-remove.ts +0 -17
  284. package/lib/routes/connectors/set.help.ts +0 -8
  285. package/lib/routes/connectors/set.ts +0 -72
  286. package/lib/routes/connectors/show.help.ts +0 -1
  287. package/lib/routes/connectors/show.ts +0 -41
  288. package/lib/routes/gateway/group.help.ts +0 -15
  289. package/lib/routes/gateway/group.ts +0 -28
  290. package/lib/routes/gateway/logs.help.ts +0 -13
  291. package/lib/routes/gateway/logs.ts +0 -100
  292. package/lib/routes/gateway/restart.help.ts +0 -10
  293. package/lib/routes/gateway/routes.ts +0 -18
  294. package/lib/routes/gateway/run.help.ts +0 -12
  295. package/lib/routes/gateway/run.ts +0 -35
  296. package/lib/routes/gateway/start.help.ts +0 -15
  297. package/lib/routes/gateway/start.ts +0 -32
  298. package/lib/routes/gateway/status.help.ts +0 -9
  299. package/lib/routes/gateway/status.ts +0 -28
  300. package/lib/routes/gateway/stop.help.ts +0 -8
  301. package/lib/routes/gateway/stop.ts +0 -21
  302. package/lib/routes/profiles/add.help.ts +0 -3
  303. package/lib/routes/profiles/add.ts +0 -33
  304. package/lib/routes/profiles/group.help.ts +0 -16
  305. package/lib/routes/profiles/group.ts +0 -25
  306. package/lib/routes/profiles/launch.help.ts +0 -4
  307. package/lib/routes/profiles/launch.ts +0 -36
  308. package/lib/routes/profiles/remove.help.ts +0 -3
  309. package/lib/routes/profiles/remove.ts +0 -17
  310. package/lib/routes/profiles/rename.help.ts +0 -5
  311. package/lib/routes/profiles/rename.ts +0 -17
  312. package/lib/routes/profiles/routes.ts +0 -18
  313. package/lib/routes/profiles/set.help.ts +0 -5
  314. package/lib/routes/profiles/set.ts +0 -32
  315. package/lib/routes/repos/add.help.ts +0 -6
  316. package/lib/routes/repos/add.ts +0 -20
  317. package/lib/routes/repos/group.help.ts +0 -11
  318. package/lib/routes/repos/group.ts +0 -18
  319. package/lib/routes/repos/remove.help.ts +0 -3
  320. package/lib/routes/repos/remove.ts +0 -17
  321. package/lib/routes/repos/rename.help.ts +0 -5
  322. package/lib/routes/repos/rename.ts +0 -17
  323. package/lib/routes/repos/routes.ts +0 -17
  324. package/lib/routes/repos/set.help.ts +0 -5
  325. package/lib/routes/repos/set.ts +0 -21
  326. package/lib/routes/repos/show.help.ts +0 -1
  327. package/lib/routes/repos/show.ts +0 -19
  328. package/lib/routes/request/discord-help.ts +0 -9
  329. package/lib/routes/request/discord.help.ts +0 -19
  330. package/lib/routes/request/discord.ts +0 -65
  331. package/lib/routes/request/group.help.ts +0 -15
  332. package/lib/routes/request/group.ts +0 -9
  333. package/lib/routes/request/routes.ts +0 -14
  334. package/lib/routes/request/slack-help.ts +0 -9
  335. package/lib/routes/request/slack.help.ts +0 -19
  336. package/lib/routes/request/slack.ts +0 -61
  337. package/lib/routes/status/routes.ts +0 -4
  338. package/lib/routes/status/status.help.ts +0 -6
  339. package/lib/routes/status/status.ts +0 -77
  340. package/lib/routes/update/routes.ts +0 -4
  341. package/lib/routes/update/update.help.ts +0 -5
  342. package/lib/routes/update/update.ts +0 -21
  343. package/lib/routes.ts +0 -40
  344. /package/lib/{factory.ts → cli/factory.ts} +0 -0
  345. /package/lib/{modules → cli}/router/query-to-cli-args.ts +0 -0
  346. /package/lib/{modules → cli}/router/validator.ts +0 -0
  347. /package/lib/{modules/connectors/funnel-connector-adapter.ts → connectors/connector-adapter.ts} +0 -0
  348. /package/lib/{modules/connectors/funnel-discord-event-processor.ts → connectors/discord-event-processor.ts} +0 -0
  349. /package/lib/{modules/http/funnel-http-client.ts → engine/http/http-client.ts} +0 -0
@@ -0,0 +1,10 @@
1
+ import { createFactory } from "hono/factory"
2
+ import type { GatewayRouteDeps } from "@/gateway/routes/route-deps"
3
+
4
+ export type Env = {
5
+ Variables: {
6
+ deps: GatewayRouteDeps
7
+ }
8
+ }
9
+
10
+ export const factory = createFactory<Env>()
@@ -0,0 +1,155 @@
1
+ import { z } from "zod"
2
+ import type { ReplayableEvent } from "@/gateway/broadcaster"
3
+ import { LeucoLoggerSqliteSink } from "@/logger/leuco-logger-sqlite-sink"
4
+
5
+ const MAX_CONTENT_CHARS = 2000
6
+
7
+ /**
8
+ * Replayable event payload persisted by the gateway. Domain events the
9
+ * broadcaster emits to WS clients land here so reconnects across daemon
10
+ * restarts can be served from disk. System events (gateway start, channel
11
+ * connected, etc.) are routed to `FunnelLogger` instead — they never go
12
+ * through this store, which keeps the seq space clean for replay.
13
+ */
14
+ export const funnelEventSchema = z.object({
15
+ type: z.string(),
16
+ content: z.string(),
17
+ channel_id: z.string().nullable(),
18
+ connector_id: z.string().nullable(),
19
+ meta: z.record(z.string(), z.string()).nullable(),
20
+ })
21
+
22
+ export type FunnelEvent = z.infer<typeof funnelEventSchema>
23
+
24
+ type Props = {
25
+ /** SQLite database file path. Created on first write. ":memory:" for tests. */
26
+ path: string
27
+ /** Override for tests. Defaults to `Date.now`. */
28
+ now?: () => number
29
+ /** Optional row cap. Pruned on every insert. */
30
+ maxRows?: number
31
+ /** Optional age cap in ms. Pruned on every insert. */
32
+ maxAgeMs?: number
33
+ }
34
+
35
+ /**
36
+ * SQLite-backed event store. One indexed table holds every broadcaster
37
+ * event with `channel_id` and `connector_id` as dedicated columns, so
38
+ * per-channel and per-connector replay is an indexed range scan.
39
+ *
40
+ * Concurrency: `seq` is `INTEGER PRIMARY KEY`, so SQLite assigns it
41
+ * atomically. The broadcaster owns its own offset counter at runtime
42
+ * (seeded from `findMaxOffset()` at startup); each broadcaster event
43
+ * flows in here via `record()` with that pre-assigned offset, which the
44
+ * sink stores via `write()` — PK uniqueness catches double-emit bugs.
45
+ *
46
+ * System events (gateway lifecycle, channel connect/disconnect, etc.) do
47
+ * NOT go through this store. They are diagnostic only and live in
48
+ * `FunnelLogger`'s file so the seq space here stays exclusive to
49
+ * broadcaster traffic. This is what makes the broadcaster's seq seeding
50
+ * (`getMaxSeq()` at startup) correct without per-event coordination.
51
+ */
52
+ export class FunnelEventStore {
53
+ private readonly sink: LeucoLoggerSqliteSink<FunnelEvent, ["channel_id", "connector_id"]>
54
+ private readonly now: () => number
55
+
56
+ constructor(props: Props) {
57
+ this.now = props.now ?? (() => Date.now())
58
+ this.sink = new LeucoLoggerSqliteSink<FunnelEvent, ["channel_id", "connector_id"]>({
59
+ path: props.path,
60
+ indexes: ["channel_id", "connector_id"],
61
+ extractIndexes: (event) => ({
62
+ channel_id: event.channel_id,
63
+ connector_id: event.connector_id,
64
+ }),
65
+ now: this.now,
66
+ ...(props.maxRows !== undefined ? { maxRows: props.maxRows } : {}),
67
+ ...(props.maxAgeMs !== undefined ? { maxAgeMs: props.maxAgeMs } : {}),
68
+ })
69
+ }
70
+
71
+ /**
72
+ * Persist a broadcaster-driven event with its assigned offset. Caller
73
+ * (the gateway-server) supplies the offset from `broadcaster.broadcast()`
74
+ * so this store and the broadcaster's in-memory ring stay aligned.
75
+ */
76
+ record(props: {
77
+ content: string
78
+ channelId: string | null
79
+ connectorId: string | null
80
+ meta: Record<string, string> | null
81
+ offset: number
82
+ }): void {
83
+ const event: FunnelEvent = {
84
+ type: props.meta?.event_type ?? "unknown",
85
+ content: truncate(props.content),
86
+ channel_id: props.channelId,
87
+ connector_id: props.connectorId,
88
+ meta: props.meta,
89
+ }
90
+ this.sink.write({ seq: props.offset, ts: this.now(), event })
91
+ }
92
+
93
+ /**
94
+ * Returns events with offset > since. Filtering by channel/connector is
95
+ * the broadcaster's responsibility (it knows the client's subscription),
96
+ * so this returns the full slice and lets the caller filter.
97
+ */
98
+ loadSince(since: number): ReplayableEvent[] {
99
+ const records = this.sink.getRecords({ sinceSeq: since })
100
+ const out: ReplayableEvent[] = []
101
+ for (const record of records) {
102
+ out.push({
103
+ content: record.event.content,
104
+ meta: record.event.meta ?? undefined,
105
+ offset: record.seq,
106
+ })
107
+ }
108
+ return out
109
+ }
110
+
111
+ /**
112
+ * Returns events for one channel (and optionally one connector). Used
113
+ * by the gateway logs CLI for scoped queries. Channel/connector filters
114
+ * are indexed columns, so this is an indexed range scan.
115
+ */
116
+ loadForChannel(props: {
117
+ channelId: string
118
+ connectorId?: string
119
+ sinceSeq?: number
120
+ limit?: number
121
+ }): ReplayableEvent[] {
122
+ const where: { channel_id: string; connector_id?: string } = {
123
+ channel_id: props.channelId,
124
+ }
125
+ if (props.connectorId !== undefined) where.connector_id = props.connectorId
126
+
127
+ const records = this.sink.getRecords({
128
+ where,
129
+ ...(props.sinceSeq !== undefined ? { sinceSeq: props.sinceSeq } : {}),
130
+ ...(props.limit !== undefined ? { limit: props.limit } : {}),
131
+ })
132
+ const out: ReplayableEvent[] = []
133
+ for (const record of records) {
134
+ out.push({
135
+ content: record.event.content,
136
+ meta: record.event.meta ?? undefined,
137
+ offset: record.seq,
138
+ })
139
+ }
140
+ return out
141
+ }
142
+
143
+ findMaxOffset(): number {
144
+ return this.sink.getMaxSeq()
145
+ }
146
+
147
+ close(): void {
148
+ this.sink.close()
149
+ }
150
+ }
151
+
152
+ function truncate(content: string): string {
153
+ if (content.length <= MAX_CONTENT_CHARS) return content
154
+ return `${content.slice(0, MAX_CONTENT_CHARS)}...`
155
+ }
@@ -0,0 +1,414 @@
1
+ import { existsSync, mkdirSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import type { Server, ServerWebSocket } from "bun"
4
+ import type { Hono } from "hono"
5
+ import type { FunnelChannels } from "@/engine/channels/channels"
6
+ import { constantTimeEqual, requireBearerToken } from "@/gateway/auth-middleware"
7
+ import { type Env, factory } from "@/gateway/factory"
8
+ import { FunnelBroadcaster } from "@/gateway/broadcaster"
9
+ import { FunnelEventStore } from "@/gateway/funnel-event-store"
10
+ import { FunnelListenerSupervisor } from "@/gateway/listener-supervisor"
11
+ import { killCompetingSlackGateways } from "@/gateway/kill-competing-slack-gateways"
12
+ import { gatewayRoutes } from "@/gateway/routes"
13
+ import { FunnelLogger } from "@/engine/logger/logger"
14
+ import { NodeFunnelLogger } from "@/engine/logger/node-logger"
15
+ import type { FunnelProcessRunner } from "@/engine/process/process-runner"
16
+ import type { FunnelSettingsReader } from "@/engine/settings/settings-reader"
17
+ import type { FunnelClock } from "@/engine/time/clock"
18
+
19
+ const DEFAULT_PORT = 9742
20
+ const DEFAULT_LOG_DIR = "/tmp/funnel/events"
21
+ const DB_FILENAME = "events.db"
22
+
23
+ type Deps = {
24
+ channels: FunnelChannels
25
+ settings: FunnelSettingsReader
26
+ port?: number
27
+ /** Directory holding the SQLite event store. The DB file lives at `<logDir>/events.db`. */
28
+ logDir?: string
29
+ process?: FunnelProcessRunner
30
+ clock?: FunnelClock
31
+ logger?: FunnelLogger
32
+ selfPid?: number
33
+ killCompetingSlack?: boolean
34
+ /** Bearer token required for `/listeners*`, `/status`, and `/ws`. Empty string disables auth (tests only). */
35
+ token?: string
36
+ }
37
+
38
+ type WsData = {
39
+ /** Stable channel id (uuid) the client subscribed to. "" for tap-all clients. */
40
+ channel: string
41
+ /** Resolved channel name (for log readability). null for tap-all or unknown. */
42
+ channelName: string | null
43
+ /** Connector names belonging to that channel; used by tap-all replay filtering. */
44
+ connectors: string[]
45
+ tapAll?: boolean
46
+ /** Routing mode for this channel; resolved at upgrade time from settings. */
47
+ delivery: "fanout" | "exclusive"
48
+ /** Replay any events with offset strictly greater than this on open, then resume the live stream. */
49
+ since?: number
50
+ }
51
+
52
+ const defaultLogger = new NodeFunnelLogger()
53
+
54
+ /**
55
+ * In-process gateway: runs `Bun.serve` (HTTP + WebSocket /ws), boots connector
56
+ * listeners through `FunnelListenerSupervisor`, fans events out via
57
+ * `FunnelBroadcaster`, and persists them via `FunnelEventStore` (SQLite).
58
+ * System events (gateway lifecycle, connect/disconnect) flow to `FunnelLogger`
59
+ * instead — keeping the SQLite seq space exclusive to broadcaster traffic so
60
+ * the broadcaster's offset counter and `getMaxSeq()` stay aligned without
61
+ * per-event coordination. Exposes `/listeners` HTTP for runtime
62
+ * start/stop/restart of individual connectors.
63
+ */
64
+ export class FunnelGatewayServer {
65
+ private readonly channels: FunnelChannels
66
+ private readonly settings: FunnelSettingsReader
67
+ private readonly port: number
68
+ private readonly logDir: string
69
+ private readonly process?: FunnelProcessRunner
70
+ private readonly logger: FunnelLogger
71
+ private readonly selfPid: number
72
+ private readonly killCompetingSlack: boolean
73
+ private readonly token: string
74
+ private readonly broadcaster: FunnelBroadcaster
75
+ private readonly eventStore: FunnelEventStore
76
+ private readonly supervisor: FunnelListenerSupervisor
77
+ private readonly nowMs: () => number
78
+ private startedAt: number | null = null
79
+ private server: Server<WsData> | null = null
80
+
81
+ constructor(deps: Deps) {
82
+ this.channels = deps.channels
83
+ this.settings = deps.settings
84
+ this.port = deps.port ?? DEFAULT_PORT
85
+ this.logDir = deps.logDir ?? DEFAULT_LOG_DIR
86
+ this.process = deps.process
87
+ this.logger = deps.logger ?? defaultLogger
88
+ this.selfPid = deps.selfPid ?? globalThis.process.pid
89
+ this.killCompetingSlack = deps.killCompetingSlack ?? true
90
+ this.token = deps.token ?? ""
91
+ const clock = deps.clock
92
+ this.nowMs = clock ? () => clock.millis() : () => Date.now()
93
+ if (!existsSync(this.logDir)) mkdirSync(this.logDir, { recursive: true })
94
+ this.eventStore = new FunnelEventStore({
95
+ path: join(this.logDir, DB_FILENAME),
96
+ now: this.nowMs,
97
+ })
98
+ this.broadcaster = new FunnelBroadcaster({
99
+ logger: this.logger,
100
+ now: this.nowMs,
101
+ persistentReplay: this.eventStore,
102
+ })
103
+ this.broadcaster.seedLatestOffset(this.eventStore.findMaxOffset())
104
+ this.supervisor = new FunnelListenerSupervisor({
105
+ channels: this.channels,
106
+ logger: this.logger,
107
+ notify: (channelName, connectorName, content, meta) =>
108
+ this.notify(channelName, connectorName, content, meta),
109
+ now: this.nowMs,
110
+ })
111
+ }
112
+
113
+ async start(): Promise<Server<WsData>> {
114
+ if (this.server) return this.server
115
+
116
+ const app = this.buildApp()
117
+
118
+ this.startedAt = this.nowMs()
119
+ this.server = Bun.serve<WsData>({
120
+ port: this.port,
121
+ development: false,
122
+ fetch: (request, server) => this.handleFetch(request, server, app),
123
+ websocket: {
124
+ open: (ws) => this.handleWsOpen(ws),
125
+ close: (ws) => this.handleWsClose(ws),
126
+ message() {
127
+ // required by Bun's websocket interface; no client → gateway messages today
128
+ },
129
+ },
130
+ })
131
+
132
+ this.logServerStarted()
133
+ await this.bootListeners()
134
+
135
+ return this.server
136
+ }
137
+
138
+ async stop(): Promise<void> {
139
+ await this.supervisor.stopAll()
140
+
141
+ if (this.server) {
142
+ this.server.stop()
143
+ this.server = null
144
+ }
145
+ }
146
+
147
+ getStatus(): { clients: number; channels: { channel: string; connectors: string[] }[] } {
148
+ return {
149
+ clients: this.broadcaster.getClientCount(),
150
+ channels: this.broadcaster.listChannels(),
151
+ }
152
+ }
153
+
154
+ getBroadcaster(): FunnelBroadcaster {
155
+ return this.broadcaster
156
+ }
157
+
158
+ getSupervisor(): FunnelListenerSupervisor {
159
+ return this.supervisor
160
+ }
161
+
162
+ getEventStore(): FunnelEventStore {
163
+ return this.eventStore
164
+ }
165
+
166
+ private handleFetch(
167
+ request: Request,
168
+ server: Server<WsData>,
169
+ app: Hono<Env>,
170
+ ): Response | Promise<Response> | undefined {
171
+ const url = new URL(request.url)
172
+
173
+ if (url.pathname === "/ws" && request.headers.get("upgrade") === "websocket") {
174
+ if (this.token && !this.tokenMatchesUpgrade(request)) {
175
+ return new Response("unauthorized", { status: 401 })
176
+ }
177
+
178
+ const tapAll = url.searchParams.get("tap") === "all"
179
+ const requestedChannel = tapAll ? "" : (url.searchParams.get("channel") ?? "")
180
+ const channel = !tapAll && requestedChannel ? this.resolveChannel(requestedChannel) : null
181
+ const channelId = tapAll ? "" : (channel?.id ?? requestedChannel)
182
+ const channelName = tapAll ? null : (channel?.name ?? null)
183
+ const connectors = channel?.connectors ?? []
184
+ const delivery = channel?.delivery ?? "fanout"
185
+ const sinceRaw = url.searchParams.get("since")
186
+ const sinceParsed = sinceRaw === null ? Number.NaN : Number.parseInt(sinceRaw, 10)
187
+ const since = Number.isFinite(sinceParsed) && sinceParsed >= 0 ? sinceParsed : undefined
188
+ const upgraded = server.upgrade(request, {
189
+ data: {
190
+ channel: channelId,
191
+ channelName,
192
+ connectors,
193
+ tapAll,
194
+ delivery,
195
+ since,
196
+ },
197
+ })
198
+
199
+ if (upgraded) return undefined
200
+
201
+ return new Response("WebSocket upgrade failed", { status: 400 })
202
+ }
203
+
204
+ return app.fetch(request)
205
+ }
206
+
207
+ private handleWsOpen(ws: ServerWebSocket<WsData>): void {
208
+ if (typeof ws.data.since === "number") {
209
+ const replay = this.broadcaster.replaySince(ws.data.since, ws.data)
210
+
211
+ for (const event of replay) ws.send(JSON.stringify(event))
212
+ }
213
+
214
+ this.broadcaster.addClient(ws, ws.data)
215
+
216
+ if (ws.data.channelName) {
217
+ const meta: Record<string, string> = {
218
+ event_type: "system",
219
+ action: "channel_connect",
220
+ channel: ws.data.channelName,
221
+ channelId: ws.data.channel,
222
+ connectors: ws.data.connectors.join(","),
223
+ total: String(this.broadcaster.getClientCount()),
224
+ }
225
+
226
+ this.logger.info("channel connected", meta)
227
+ } else {
228
+ this.logger.info("tap-all client connected", {
229
+ event_type: "system",
230
+ action: "tap_connect",
231
+ total: String(this.broadcaster.getClientCount()),
232
+ })
233
+ }
234
+ }
235
+
236
+ private handleWsClose(ws: ServerWebSocket<WsData>): void {
237
+ this.broadcaster.removeClient(ws)
238
+
239
+ if (ws.data.channelName) {
240
+ this.logger.info("channel disconnected", {
241
+ event_type: "system",
242
+ action: "channel_disconnect",
243
+ channel: ws.data.channelName,
244
+ channelId: ws.data.channel,
245
+ total: String(this.broadcaster.getClientCount()),
246
+ })
247
+ } else {
248
+ this.logger.info("tap-all client disconnected", {
249
+ event_type: "system",
250
+ action: "tap_disconnect",
251
+ total: String(this.broadcaster.getClientCount()),
252
+ })
253
+ }
254
+ }
255
+
256
+ private logServerStarted(): void {
257
+ this.logger.info("gateway started", {
258
+ event_type: "system",
259
+ action: "gateway_start",
260
+ port: String(this.port),
261
+ pid: String(this.selfPid),
262
+ })
263
+
264
+ this.logger.info("funnel gateway listening", {
265
+ url: `http://localhost:${this.port}`,
266
+ websocket: `ws://localhost:${this.port}/ws`,
267
+ health: `http://localhost:${this.port}/health`,
268
+ })
269
+ }
270
+
271
+ private buildApp(): Hono<Env> {
272
+ const base = factory.createApp()
273
+
274
+ base.use((c, next) => {
275
+ c.set("deps", {
276
+ selfPid: this.selfPid,
277
+ broadcaster: this.broadcaster,
278
+ supervisor: this.supervisor,
279
+ channels: this.channels,
280
+ uptimeMs: () => (this.startedAt ? this.nowMs() - this.startedAt : 0),
281
+ })
282
+
283
+ return next()
284
+ })
285
+
286
+ if (this.token) {
287
+ base.use("/listeners/*", requireBearerToken({ expected: this.token }))
288
+ base.use("/status", requireBearerToken({ expected: this.token }))
289
+ base.use("/channels/*", requireBearerToken({ expected: this.token }))
290
+ }
291
+
292
+ return base.route("/", gatewayRoutes)
293
+ }
294
+
295
+ /**
296
+ * Reads the bearer token from the WebSocket upgrade request. Accepts:
297
+ * - `Sec-WebSocket-Protocol: funnel.token.<value>` (preferred — header, never logged in URLs)
298
+ * - `Authorization: Bearer <value>` (also header-based)
299
+ * Returns true on a constant-time match against the daemon token.
300
+ */
301
+ private tokenMatchesUpgrade(request: Request): boolean {
302
+ const protocols = (request.headers.get("sec-websocket-protocol") ?? "")
303
+ .split(",")
304
+ .map((p) => p.trim())
305
+ .filter((p) => p.length > 0)
306
+
307
+ for (const proto of protocols) {
308
+ if (
309
+ proto.startsWith("funnel.token.") &&
310
+ constantTimeEqual(proto.slice("funnel.token.".length), this.token)
311
+ ) {
312
+ return true
313
+ }
314
+ }
315
+
316
+ const auth = request.headers.get("authorization") ?? ""
317
+ const match = auth.match(/^Bearer\s+(.+)$/i)
318
+
319
+ if (match && constantTimeEqual(match[1] ?? "", this.token)) return true
320
+
321
+ return false
322
+ }
323
+
324
+ private resolveChannel(
325
+ requested: string,
326
+ ): { id: string; name: string; connectors: string[]; delivery: "fanout" | "exclusive" } | null {
327
+ const settings = this.settings.read()
328
+ const channel = settings?.channels.find((c) => c.id === requested || c.name === requested)
329
+
330
+ if (!channel) return null
331
+
332
+ return {
333
+ id: channel.id,
334
+ name: channel.name,
335
+ connectors: channel.connectors.map((c) => c.name),
336
+ delivery: channel.delivery,
337
+ }
338
+ }
339
+
340
+ private async bootListeners(): Promise<void> {
341
+ const allConnectors = this.channels.listAllConnectors()
342
+
343
+ if (this.killCompetingSlack && allConnectors.some((c) => c.type === "slack")) {
344
+ const killed = await killCompetingSlackGateways({
345
+ selfPid: this.selfPid,
346
+ process: this.process,
347
+ logger: this.logger,
348
+ })
349
+
350
+ if (killed.length > 0) {
351
+ this.logger.info("killed competing Slack gateway processes", {
352
+ event_type: "system",
353
+ action: "kill_competing",
354
+ pids: killed.join(","),
355
+ })
356
+ }
357
+ }
358
+
359
+ await this.supervisor.startAll()
360
+
361
+ for (const entry of this.supervisor.list()) {
362
+ this.logger.info(`${entry.type} listener started: ${entry.name}`, {
363
+ event_type: "system",
364
+ action: `${entry.type}_connect`,
365
+ channel: entry.channelName,
366
+ connector: entry.name,
367
+ })
368
+ }
369
+
370
+ this.logger.info(`event store: ${join(this.logDir, DB_FILENAME)}`)
371
+ this.logger.info("funnel gateway running")
372
+ }
373
+
374
+ private async notify(
375
+ channelName: string,
376
+ connectorName: string,
377
+ content: string,
378
+ meta?: Record<string, string>,
379
+ ): Promise<void> {
380
+ const channelId = this.lookupChannelId(channelName)
381
+ const connectorId = channelId ? this.lookupConnectorId(channelId, connectorName) : null
382
+ const enriched: Record<string, string> = {
383
+ ...meta,
384
+ channel: channelName,
385
+ connector: connectorName,
386
+ }
387
+
388
+ if (channelId) enriched.channelId = channelId
389
+ if (connectorId) enriched.connectorId = connectorId
390
+
391
+ const event = this.broadcaster.broadcast(content, enriched)
392
+
393
+ this.eventStore.record({
394
+ content,
395
+ channelId: channelId ?? null,
396
+ connectorId: connectorId ?? null,
397
+ meta: enriched,
398
+ offset: event.offset,
399
+ })
400
+ }
401
+
402
+ private lookupChannelId(channelName: string): string | null {
403
+ const channel = this.settings.read().channels.find((c) => c.name === channelName)
404
+
405
+ return channel?.id ?? null
406
+ }
407
+
408
+ private lookupConnectorId(channelId: string, connectorName: string): string | null {
409
+ const channel = this.settings.read().channels.find((c) => c.id === channelId)
410
+ const connector = channel?.connectors.find((c) => c.name === connectorName)
411
+
412
+ return connector?.id ?? null
413
+ }
414
+ }
@@ -0,0 +1,79 @@
1
+ import { homedir } from "node:os"
2
+ import { dirname, join } from "node:path"
3
+ import { FunnelFileSystem } from "@/engine/fs/file-system"
4
+ import { NodeFunnelFileSystem } from "@/engine/fs/node-file-system"
5
+ import { FUNNEL_DIR } from "@/engine/settings/settings-store"
6
+
7
+ const TOKEN_FILE_NAME = "gateway.token"
8
+ const TOKEN_BYTES = 32
9
+
10
+ type Deps = {
11
+ fs?: FunnelFileSystem
12
+ dir?: string
13
+ generate?: () => string
14
+ }
15
+
16
+ const defaultFs = new NodeFunnelFileSystem()
17
+
18
+ const defaultGenerate = (): string => {
19
+ const buf = new Uint8Array(TOKEN_BYTES)
20
+ crypto.getRandomValues(buf)
21
+
22
+ return [...buf].map((b) => b.toString(16).padStart(2, "0")).join("")
23
+ }
24
+
25
+ /**
26
+ * Reads / generates the gateway daemon token used to authenticate
27
+ * `/listeners*`, `/status`, and `/ws` connections.
28
+ *
29
+ * Token file: `<dir>/gateway.token` (default `~/.funnel/gateway.token`),
30
+ * written with mode 0600. Clients on the same machine as the daemon read
31
+ * the file directly; the token never leaves the user's home directory.
32
+ */
33
+ export class FunnelGatewayToken {
34
+ private readonly fs: FunnelFileSystem
35
+ private readonly path: string
36
+ private readonly generate: () => string
37
+
38
+ constructor(deps: Deps = {}) {
39
+ this.fs = deps.fs ?? defaultFs
40
+ this.path = join(deps.dir ?? FUNNEL_DIR, TOKEN_FILE_NAME)
41
+ this.generate = deps.generate ?? defaultGenerate
42
+ Object.freeze(this)
43
+ }
44
+
45
+ read(): string | null {
46
+ if (!this.fs.existsSync(this.path)) return null
47
+
48
+ const value = this.fs.readFileSync(this.path).trim()
49
+
50
+ return value.length > 0 ? value : null
51
+ }
52
+
53
+ /**
54
+ * Returns the existing token or, if missing, generates one and writes it with mode 0600.
55
+ *
56
+ * NOTE: not atomic — two concurrent `ensure()` calls (e.g., `fnl gateway start` racing
57
+ * itself before the PID lock is acquired) could each generate independent tokens. The
58
+ * gateway PID file makes this practically a non-issue; if you need stronger guarantees,
59
+ * take a file lock around this call externally.
60
+ */
61
+ ensure(): string {
62
+ const existing = this.read()
63
+
64
+ if (existing) return existing
65
+
66
+ const token = this.generate()
67
+
68
+ this.fs.mkdirSync(dirname(this.path), { recursive: true })
69
+ this.fs.writeSecretFileSync(this.path, `${token}\n`)
70
+
71
+ return token
72
+ }
73
+
74
+ getPath(): string {
75
+ return this.path
76
+ }
77
+ }
78
+
79
+ export const DEFAULT_GATEWAY_TOKEN_PATH = join(homedir(), ".funnel", TOKEN_FILE_NAME)