@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.
- package/README.md +233 -111
- package/dist/bin.js +1417 -0
- package/dist/gateway/daemon.js +513 -0
- package/dist/highlights-eq9cgrbb.scm +604 -0
- package/dist/highlights-ghv9g403.scm +205 -0
- package/dist/highlights-hk7bwhj4.scm +284 -0
- package/dist/highlights-r812a2qc.scm +150 -0
- package/dist/highlights-x6tmsnaa.scm +115 -0
- package/dist/injections-73j83es3.scm +27 -0
- package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
- package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
- package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
- package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
- package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
- package/lib/bin.ts +78 -0
- package/lib/{modules → cli}/router/to-request.ts +13 -20
- package/lib/cli/routes/channels.$channel.connectors.$connector.rename.$newName.ts +27 -0
- package/lib/cli/routes/channels.$channel.connectors.$connector.request.ts +40 -0
- package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.add.$id.ts +41 -0
- package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.remove.$id.ts +22 -0
- package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.ts +23 -0
- package/lib/cli/routes/channels.$channel.connectors.$connector.ts +26 -0
- package/lib/cli/routes/channels.$channel.connectors.add.$connector.ts +92 -0
- package/lib/cli/routes/channels.$channel.connectors.remove.$connector.ts +22 -0
- package/lib/cli/routes/channels.$channel.connectors.set.$connector.ts +63 -0
- package/lib/cli/routes/channels.$channel.connectors.ts +26 -0
- package/lib/cli/routes/channels.$channel.rename.$newName.ts +22 -0
- package/lib/cli/routes/channels.$channel.set.delivery.$mode.ts +34 -0
- package/lib/cli/routes/channels.$channel.ts +34 -0
- package/lib/cli/routes/channels.add.$channel.ts +33 -0
- package/lib/cli/routes/channels.remove.$channel.ts +20 -0
- package/lib/cli/routes/channels.ts +39 -0
- package/lib/cli/routes/claude.ts +69 -0
- package/lib/cli/routes/gateway.listeners.ts +41 -0
- package/lib/cli/routes/gateway.logs.ts +123 -0
- package/lib/{routes/gateway/restart.ts → cli/routes/gateway.restart.ts} +20 -5
- package/lib/cli/routes/gateway.run.ts +41 -0
- package/lib/cli/routes/gateway.start.ts +50 -0
- package/lib/cli/routes/gateway.status.ts +19 -0
- package/lib/cli/routes/gateway.stop.ts +32 -0
- package/lib/cli/routes/gateway.ts +55 -0
- package/lib/cli/routes/index.ts +202 -0
- package/lib/cli/routes/profiles.$profile.as-default.ts +22 -0
- package/lib/cli/routes/profiles.$profile.rename.$newName.ts +22 -0
- package/lib/cli/routes/profiles.$profile.run.ts +36 -0
- package/lib/cli/routes/profiles.add.$profile.ts +46 -0
- package/lib/cli/routes/profiles.remove.$profile.ts +20 -0
- package/lib/cli/routes/profiles.set.$profile.ts +46 -0
- package/lib/cli/routes/profiles.ts +40 -0
- package/lib/cli/routes/status.ts +93 -0
- package/lib/cli/routes/update.ts +27 -0
- package/lib/connectors/connector-config-schema.ts +16 -0
- package/lib/connectors/connector-factory.ts +94 -0
- package/lib/connectors/connector-listener.ts +20 -0
- package/lib/{modules/connectors/funnel-discord-adapter.ts → connectors/discord-adapter.ts} +6 -11
- package/lib/{modules/connectors → connectors}/discord-connector-schema.ts +4 -1
- package/lib/connectors/discord-listener.ts +111 -0
- package/lib/{modules/connectors/funnel-gh-adapter.ts → connectors/gh-adapter.ts} +3 -6
- package/lib/{modules/connectors → connectors}/gh-connector-schema.ts +4 -1
- package/lib/{modules/connectors/funnel-gh-listener.ts → connectors/gh-listener.ts} +57 -22
- package/lib/{modules/connectors → connectors}/match-cron.ts +10 -4
- package/lib/connectors/schedule-connector-schema.ts +33 -0
- package/lib/connectors/schedule-listener.ts +207 -0
- package/lib/connectors/schedule-state-store.ts +54 -0
- package/lib/connectors/slack-adapter.ts +36 -0
- package/lib/{modules/connectors → connectors}/slack-connector-schema.ts +4 -1
- package/lib/{modules/connectors/funnel-slack-event-processor.ts → connectors/slack-event-processor.ts} +15 -9
- package/lib/{modules/connectors/funnel-slack-listener.ts → connectors/slack-listener.ts} +39 -14
- package/lib/engine/channels/channels.ts +520 -0
- package/lib/{modules/claude/funnel-claude.ts → engine/claude/claude.ts} +47 -62
- package/lib/engine/claude/gateway-controller.ts +4 -0
- package/lib/{modules/fs/funnel-file-system.ts → engine/fs/file-system.ts} +9 -0
- package/lib/{modules/fs/memory-funnel-file-system.ts → engine/fs/memory-file-system.ts} +20 -3
- package/lib/{modules/fs/node-funnel-file-system.ts → engine/fs/node-file-system.ts} +14 -2
- package/lib/{modules/http/memory-funnel-http-client.ts → engine/http/memory-http-client.ts} +1 -5
- package/lib/{modules/http/node-funnel-http-client.ts → engine/http/node-http-client.ts} +1 -5
- package/lib/engine/id/id-generator.ts +7 -0
- package/lib/engine/id/memory-id-generator.ts +20 -0
- package/lib/engine/id/node-id-generator.ts +7 -0
- package/lib/engine/logger/logger.ts +11 -0
- package/lib/engine/logger/memory-logger.ts +28 -0
- package/lib/engine/logger/node-logger.ts +49 -0
- package/lib/engine/logger/noop-logger.ts +9 -0
- package/lib/engine/mcp/channel-server.ts +204 -0
- package/lib/{modules/mcp/funnel-mcp.ts → engine/mcp/mcp.ts} +29 -10
- package/lib/{modules/process/memory-funnel-process-runner.ts → engine/process/memory-process-runner.ts} +1 -1
- package/lib/{modules/process/node-funnel-process-runner.ts → engine/process/node-process-runner.ts} +12 -21
- package/lib/{modules/process/funnel-process-runner.ts → engine/process/process-runner.ts} +5 -0
- package/lib/engine/profiles/profile-channel-checker.ts +7 -0
- package/lib/engine/profiles/profiles.ts +126 -0
- package/lib/{modules/settings/mock-funnel-settings-reader.ts → engine/settings/mock-settings-reader.ts} +4 -3
- package/lib/{modules/settings/funnel-settings-reader.ts → engine/settings/settings-reader.ts} +1 -1
- package/lib/engine/settings/settings-schema.ts +46 -0
- package/lib/engine/settings/settings-store.ts +110 -0
- package/lib/engine/time/clock.ts +15 -0
- package/lib/engine/time/memory-clock.ts +26 -0
- package/lib/engine/time/node-clock.ts +7 -0
- package/lib/funnel.ts +148 -56
- package/lib/gateway/auth-middleware.ts +44 -0
- package/lib/gateway/broadcaster.ts +319 -0
- package/lib/gateway/daemon.ts +47 -0
- package/lib/gateway/factory.ts +10 -0
- package/lib/gateway/funnel-event-store.ts +155 -0
- package/lib/gateway/gateway-server.ts +414 -0
- package/lib/gateway/gateway-token.ts +79 -0
- package/lib/{modules/gateway/funnel-gateway.ts → gateway/gateway.ts} +70 -27
- package/lib/{modules/gateway → gateway}/kill-competing-slack-gateways.ts +7 -3
- package/lib/gateway/listener-supervisor.ts +339 -0
- package/lib/gateway/listeners-client.ts +128 -0
- package/lib/gateway/resolve-daemon-script.ts +26 -0
- package/lib/gateway/routes/channels.connectors.call.ts +39 -0
- package/lib/gateway/routes/health.ts +13 -0
- package/lib/gateway/routes/index.ts +24 -0
- package/lib/gateway/routes/listeners.list.ts +6 -0
- package/lib/gateway/routes/listeners.restart.ts +15 -0
- package/lib/gateway/routes/listeners.start.ts +15 -0
- package/lib/gateway/routes/listeners.stop.ts +15 -0
- package/lib/gateway/routes/route-deps.ts +11 -0
- package/lib/gateway/routes/status.ts +15 -0
- package/lib/gateway/routes/validator.ts +17 -0
- package/lib/index.ts +50 -92
- package/lib/logger/leuco-human-file-writer.ts +65 -0
- package/lib/logger/leuco-human-logger.ts +98 -0
- package/lib/logger/leuco-human-record.ts +16 -0
- package/lib/logger/leuco-human-stdout-writer.ts +26 -0
- package/lib/logger/leuco-human-writer.ts +14 -0
- package/lib/logger/leuco-logger-memory-sink.ts +67 -0
- package/lib/logger/leuco-logger-record.ts +13 -0
- package/lib/logger/leuco-logger-sink.ts +33 -0
- package/lib/logger/leuco-logger-sqlite-sink.ts +355 -0
- package/lib/logger/leuco-logger.ts +135 -0
- package/lib/tui/app.tsx +357 -0
- package/lib/tui/components/add-row.tsx +18 -0
- package/lib/tui/components/brand.tsx +27 -0
- package/lib/tui/components/card.tsx +44 -0
- package/lib/tui/components/detail-bar.tsx +46 -0
- package/lib/tui/components/editable-field.tsx +33 -0
- package/lib/tui/components/empty-state.tsx +11 -0
- package/lib/tui/components/gateway-status.tsx +66 -0
- package/lib/tui/components/keymap.tsx +29 -0
- package/lib/tui/components/menu-item.tsx +73 -0
- package/lib/tui/components/menu.tsx +26 -0
- package/lib/tui/components/panel-header.tsx +22 -0
- package/lib/tui/components/readonly-field.tsx +18 -0
- package/lib/tui/components/section-header.tsx +25 -0
- package/lib/tui/components/selection-accent.tsx +32 -0
- package/lib/tui/components/session-item.tsx +33 -0
- package/lib/tui/components/session-list.tsx +33 -0
- package/lib/tui/components/ui/hascii/accordion-item.tsx +88 -0
- package/lib/tui/components/ui/hascii/accordion.tsx +96 -0
- package/lib/tui/components/ui/hascii/alert-dialog.tsx +43 -0
- package/lib/tui/components/ui/hascii/badge.tsx +51 -0
- package/lib/tui/components/ui/hascii/breadcrumb.tsx +58 -0
- package/lib/tui/components/ui/hascii/button.tsx +194 -0
- package/lib/tui/components/ui/hascii/card-content.tsx +14 -0
- package/lib/tui/components/ui/hascii/card-description.tsx +13 -0
- package/lib/tui/components/ui/hascii/card-footer.tsx +14 -0
- package/lib/tui/components/ui/hascii/card-header.tsx +14 -0
- package/lib/tui/components/ui/hascii/card-title.tsx +13 -0
- package/lib/tui/components/ui/hascii/card.tsx +27 -0
- package/lib/tui/components/ui/hascii/checkbox.tsx +65 -0
- package/lib/tui/components/ui/hascii/command.tsx +159 -0
- package/lib/tui/components/ui/hascii/dialog-content.tsx +14 -0
- package/lib/tui/components/ui/hascii/dialog-description.tsx +13 -0
- package/lib/tui/components/ui/hascii/dialog-footer.tsx +14 -0
- package/lib/tui/components/ui/hascii/dialog-header.tsx +14 -0
- package/lib/tui/components/ui/hascii/dialog-title.tsx +13 -0
- package/lib/tui/components/ui/hascii/dialog.tsx +27 -0
- package/lib/tui/components/ui/hascii/file-tree.tsx +142 -0
- package/lib/tui/components/ui/hascii/focus-group.tsx +62 -0
- package/lib/tui/components/ui/hascii/form-item.tsx +43 -0
- package/lib/tui/components/ui/hascii/input-otp.tsx +86 -0
- package/lib/tui/components/ui/hascii/input.tsx +130 -0
- package/lib/tui/components/ui/hascii/pagination.tsx +105 -0
- package/lib/tui/components/ui/hascii/progress.tsx +28 -0
- package/lib/tui/components/ui/hascii/select.tsx +131 -0
- package/lib/tui/components/ui/hascii/separator.tsx +35 -0
- package/lib/tui/components/ui/hascii/sidebar-content.tsx +23 -0
- package/lib/tui/components/ui/hascii/sidebar-header.tsx +14 -0
- package/lib/tui/components/ui/hascii/sidebar-menu-item.tsx +67 -0
- package/lib/tui/components/ui/hascii/sidebar.tsx +24 -0
- package/lib/tui/components/ui/hascii/skeleton.tsx +60 -0
- package/lib/tui/components/ui/hascii/slider.tsx +91 -0
- package/lib/tui/components/ui/hascii/snackbar.tsx +75 -0
- package/lib/tui/components/ui/hascii/sparkline.tsx +53 -0
- package/lib/tui/components/ui/hascii/spinner.tsx +47 -0
- package/lib/tui/components/ui/hascii/stepper.tsx +54 -0
- package/lib/tui/components/ui/hascii/switch.tsx +66 -0
- package/lib/tui/components/ui/hascii/table.tsx +95 -0
- package/lib/tui/components/ui/hascii/tabs.tsx +59 -0
- package/lib/tui/components/ui/hascii/toggle-group-item.tsx +45 -0
- package/lib/tui/components/ui/hascii/toggle-group.tsx +99 -0
- package/lib/tui/components/ui/hascii/tree.tsx +104 -0
- package/lib/tui/components/view-shell.tsx +44 -0
- package/lib/tui/filter-input.tsx +33 -0
- package/lib/tui/hooks/hascii/use-pressable.ts +54 -0
- package/lib/tui/parse-comma-list.ts +14 -0
- package/lib/tui/profile-launcher.tsx +61 -0
- package/lib/tui/scrollbar-options.ts +19 -0
- package/lib/tui/sidebar.tsx +50 -0
- package/lib/tui/theme.ts +40 -0
- package/lib/tui/tui.tsx +20 -0
- package/lib/tui/types.ts +38 -0
- package/lib/tui/unique-name.ts +18 -0
- package/lib/tui/use-event-stream.ts +133 -0
- package/lib/tui/use-snapshot.ts +99 -0
- package/lib/tui/utils/hascii/form-item-context.tsx +23 -0
- package/lib/tui/utils/hascii/input-focus-context.tsx +31 -0
- package/lib/tui/utils/hascii/theme-context.tsx +26 -0
- package/lib/tui/utils/hascii/theme.ts +176 -0
- package/lib/tui/views/channels-view.tsx +108 -0
- package/lib/tui/views/connectors-view.tsx +164 -0
- package/lib/tui/views/events-view.tsx +160 -0
- package/lib/tui/views/listeners-view.tsx +80 -0
- package/lib/tui/views/profiles-view.tsx +152 -0
- package/package.json +51 -34
- package/lib/modules/channels/channel-connector-ref-updater.ts +0 -4
- package/lib/modules/channels/funnel-channels.ts +0 -155
- package/lib/modules/connectors/connector-config-schema.ts +0 -16
- package/lib/modules/connectors/connector-existence-checker.ts +0 -3
- package/lib/modules/connectors/funnel-callable-connector-store.ts +0 -9
- package/lib/modules/connectors/funnel-connector-listener.ts +0 -5
- package/lib/modules/connectors/funnel-connector-stores.ts +0 -24
- package/lib/modules/connectors/funnel-connector-type-store.ts +0 -24
- package/lib/modules/connectors/funnel-connectors.ts +0 -145
- package/lib/modules/connectors/funnel-discord-listener.ts +0 -65
- package/lib/modules/connectors/funnel-discord-store.ts +0 -84
- package/lib/modules/connectors/funnel-gh-store.ts +0 -84
- package/lib/modules/connectors/funnel-json-connector-store.ts +0 -100
- package/lib/modules/connectors/funnel-schedule-listener.ts +0 -124
- package/lib/modules/connectors/funnel-schedule-store.ts +0 -178
- package/lib/modules/connectors/funnel-slack-adapter.ts +0 -31
- package/lib/modules/connectors/funnel-slack-store.ts +0 -86
- package/lib/modules/connectors/migrate-legacy-connectors.ts +0 -77
- package/lib/modules/connectors/schedule-connector-schema.ts +0 -18
- package/lib/modules/connectors/schedule-last-fired-store.ts +0 -48
- package/lib/modules/gateway/daemon.ts +0 -207
- package/lib/modules/gateway/funnel-broadcaster.ts +0 -37
- package/lib/modules/gateway/funnel-event-logger.ts +0 -59
- package/lib/modules/logger.ts +0 -26
- package/lib/modules/mcp/channel-server.ts +0 -76
- package/lib/modules/profiles/funnel-profiles.ts +0 -123
- package/lib/modules/profiles/profile-channel-checker.ts +0 -3
- package/lib/modules/profiles/profile-channel-ref-updater.ts +0 -3
- package/lib/modules/repos/funnel-repositories.ts +0 -107
- package/lib/modules/schedule/funnel-schedule.ts +0 -34
- package/lib/modules/settings/funnel-settings-store.ts +0 -56
- package/lib/modules/settings/settings-schema.ts +0 -33
- package/lib/modules/tui/app.tsx +0 -44
- package/lib/modules/tui/tui.tsx +0 -13
- package/lib/routes/channels/add.help.ts +0 -3
- package/lib/routes/channels/add.ts +0 -21
- package/lib/routes/channels/connectors-attach.help.ts +0 -3
- package/lib/routes/channels/connectors-attach.ts +0 -17
- package/lib/routes/channels/connectors-detach.help.ts +0 -3
- package/lib/routes/channels/connectors-detach.ts +0 -17
- package/lib/routes/channels/group.help.ts +0 -16
- package/lib/routes/channels/group.ts +0 -22
- package/lib/routes/channels/remove.help.ts +0 -3
- package/lib/routes/channels/remove.ts +0 -17
- package/lib/routes/channels/rename.help.ts +0 -5
- package/lib/routes/channels/rename.ts +0 -17
- package/lib/routes/channels/routes.ts +0 -19
- package/lib/routes/channels/show.help.ts +0 -1
- package/lib/routes/channels/show.ts +0 -26
- package/lib/routes/claude/claude.help.ts +0 -16
- package/lib/routes/claude/claude.ts +0 -76
- package/lib/routes/claude/routes.ts +0 -4
- package/lib/routes/connectors/add.help.ts +0 -28
- package/lib/routes/connectors/add.ts +0 -64
- package/lib/routes/connectors/group.help.ts +0 -14
- package/lib/routes/connectors/group.ts +0 -18
- package/lib/routes/connectors/remove.help.ts +0 -3
- package/lib/routes/connectors/remove.ts +0 -17
- package/lib/routes/connectors/rename.help.ts +0 -5
- package/lib/routes/connectors/rename.ts +0 -17
- package/lib/routes/connectors/routes.ts +0 -23
- package/lib/routes/connectors/schedules-add.help.ts +0 -11
- package/lib/routes/connectors/schedules-add.ts +0 -33
- package/lib/routes/connectors/schedules-group.help.ts +0 -1
- package/lib/routes/connectors/schedules-group.ts +0 -38
- package/lib/routes/connectors/schedules-remove.help.ts +0 -3
- package/lib/routes/connectors/schedules-remove.ts +0 -17
- package/lib/routes/connectors/set.help.ts +0 -8
- package/lib/routes/connectors/set.ts +0 -72
- package/lib/routes/connectors/show.help.ts +0 -1
- package/lib/routes/connectors/show.ts +0 -41
- package/lib/routes/gateway/group.help.ts +0 -15
- package/lib/routes/gateway/group.ts +0 -28
- package/lib/routes/gateway/logs.help.ts +0 -13
- package/lib/routes/gateway/logs.ts +0 -100
- package/lib/routes/gateway/restart.help.ts +0 -10
- package/lib/routes/gateway/routes.ts +0 -18
- package/lib/routes/gateway/run.help.ts +0 -12
- package/lib/routes/gateway/run.ts +0 -35
- package/lib/routes/gateway/start.help.ts +0 -15
- package/lib/routes/gateway/start.ts +0 -32
- package/lib/routes/gateway/status.help.ts +0 -9
- package/lib/routes/gateway/status.ts +0 -28
- package/lib/routes/gateway/stop.help.ts +0 -8
- package/lib/routes/gateway/stop.ts +0 -21
- package/lib/routes/profiles/add.help.ts +0 -3
- package/lib/routes/profiles/add.ts +0 -33
- package/lib/routes/profiles/group.help.ts +0 -16
- package/lib/routes/profiles/group.ts +0 -25
- package/lib/routes/profiles/launch.help.ts +0 -4
- package/lib/routes/profiles/launch.ts +0 -36
- package/lib/routes/profiles/remove.help.ts +0 -3
- package/lib/routes/profiles/remove.ts +0 -17
- package/lib/routes/profiles/rename.help.ts +0 -5
- package/lib/routes/profiles/rename.ts +0 -17
- package/lib/routes/profiles/routes.ts +0 -18
- package/lib/routes/profiles/set.help.ts +0 -5
- package/lib/routes/profiles/set.ts +0 -32
- package/lib/routes/repos/add.help.ts +0 -6
- package/lib/routes/repos/add.ts +0 -20
- package/lib/routes/repos/group.help.ts +0 -11
- package/lib/routes/repos/group.ts +0 -18
- package/lib/routes/repos/remove.help.ts +0 -3
- package/lib/routes/repos/remove.ts +0 -17
- package/lib/routes/repos/rename.help.ts +0 -5
- package/lib/routes/repos/rename.ts +0 -17
- package/lib/routes/repos/routes.ts +0 -17
- package/lib/routes/repos/set.help.ts +0 -5
- package/lib/routes/repos/set.ts +0 -21
- package/lib/routes/repos/show.help.ts +0 -1
- package/lib/routes/repos/show.ts +0 -19
- package/lib/routes/request/discord-help.ts +0 -9
- package/lib/routes/request/discord.help.ts +0 -19
- package/lib/routes/request/discord.ts +0 -65
- package/lib/routes/request/group.help.ts +0 -15
- package/lib/routes/request/group.ts +0 -9
- package/lib/routes/request/routes.ts +0 -14
- package/lib/routes/request/slack-help.ts +0 -9
- package/lib/routes/request/slack.help.ts +0 -19
- package/lib/routes/request/slack.ts +0 -61
- package/lib/routes/status/routes.ts +0 -4
- package/lib/routes/status/status.help.ts +0 -6
- package/lib/routes/status/status.ts +0 -77
- package/lib/routes/update/routes.ts +0 -4
- package/lib/routes/update/update.help.ts +0 -5
- package/lib/routes/update/update.ts +0 -21
- package/lib/routes.ts +0 -40
- /package/lib/{factory.ts → cli/factory.ts} +0 -0
- /package/lib/{modules → cli}/router/query-to-cli-args.ts +0 -0
- /package/lib/{modules → cli}/router/validator.ts +0 -0
- /package/lib/{modules/connectors/funnel-connector-adapter.ts → connectors/connector-adapter.ts} +0 -0
- /package/lib/{modules/connectors/funnel-discord-event-processor.ts → connectors/discord-event-processor.ts} +0 -0
- /package/lib/{modules/http/funnel-http-client.ts → engine/http/http-client.ts} +0 -0
|
@@ -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)
|