Skip to content

channels: mount Slack HTTP receiver on shared Bun.serve#103

Merged
mcheemaa merged 3 commits intomainfrom
fix/slack-http-receiver-port-collision
Apr 28, 2026
Merged

channels: mount Slack HTTP receiver on shared Bun.serve#103
mcheemaa merged 3 commits intomainfrom
fix/slack-http-receiver-port-collision

Conversation

@mcheemaa
Copy link
Copy Markdown
Member

Summary

The HTTP-mode Slack channel was constructing an ExpressReceiver and calling receiver.start(config.port) from connect(), which collided with Phantom's main Bun.serve listener already bound to that port. The bind failed silently, router.connectAll swallowed the rejection, and the channel stayed disconnected; POST /slack/events returned 404 and /health.channels.slack reported false.

This refactor replaces ExpressReceiver with a Bun-native handler chain on the existing Bun.serve listener. The Bolt App is preserved (it still drives auth.test, chat.postMessage, action handlers, and the synthetic introduction DM); we only swap the receiver for a tiny BunReceiver whose start/stop are no-ops, and we mount the three Slack ingress paths on the shared listener.

File-by-file

  • src/channels/slack-http-receiver.ts (refactor): drop ExpressReceiver, drop listenPort/listenPath, keep the Channel interface and the firstDmSent gate. Add public handleEvent/handleInteractivity/handleCommand methods that run the existing verifier guard and dispatch via app.processEvent.
  • src/channels/slack-http-handlers.ts (new): BunReceiver (Bolt Receiver with no-op start/stop) plus dispatchToBolt, which verifies the gateway HMAC, parses the body, short-circuits the url_verification challenge, and resolves the AckFn chain into a Bun Response. The slash-command path falls back to top-level urlencoded team_id for binding without touching the verifier file.
  • src/channels/slack-http-routes.ts (new): tryHandleSlackHttp(req) returns a Response for /slack/events, /slack/interactivity, /slack/commands, or null for any other path. The channel is supplied via a provider so Socket Mode tenants fall through cleanly.
  • src/core/server.ts: invoke tryHandleSlackHttp inside the existing Bun.serve fetch switch.
  • src/channels/slack-channel-factory.ts: drop the port parameter from CreateSlackChannelInput; the channel no longer owns a port.
  • src/index.ts: register the SlackHttpChannel instance with setSlackHttpChannelProvider so core/server.ts resolves it lazily per request.
  • src/channels/slack-gateway-verifier.ts: untouched.

Tests

  • Existing slack-http-receiver.test.ts rewritten to drive the new public methods through real Requests (53 -> 54 cases covering happy path, signed-but-foreign team, missing/stale/future timestamp, tampered body, wrong secret, tampered urlencoded payload, slash command, retry headers, url_verification challenge).
  • New src/core/__tests__/server-slack-routes.test.ts: spins up a real Bun.serve on an ephemeral port, registers a stub channel via the provider, and asserts POST /slack/{events,interactivity,commands} reaches the handler. Pins the regression class.
  • slack-channel-factory.test.ts: drops the now-unused port argument.

Test plan

  • bun test passes (1976 total, 1965 pass, 0 fail; +7 vs main).
  • bun run typecheck clean.
  • bun run lint clean.
  • No file in src/channels/ exceeds 300 lines (max is 277).
  • Verifier file untouched.
  • Channel interface contract preserved (connect/disconnect/send/onMessage).
  • End-to-end against a live tenant after merge.

@codex please review.

The HTTP-mode Slack channel was binding ExpressReceiver to the same
port as Phantom's main Bun.serve listener. The bind silently failed,
the router caught the rejection, and /slack/* never came up; live
canary traffic verified `POST /slack/events` returned 404 and
`/health.channels.slack` was false.

Refactor SlackHttpChannel into a Bolt App driven by a tiny
BunReceiver whose start/stop are no-ops. The three Slack ingress
paths now mount on the existing Bun.serve via slack-http-routes.ts;
the channel exposes handleEvent, handleInteractivity, and
handleCommand, each running the existing slack-gateway-verifier
guard before dispatching the parsed body into app.processEvent.

The verifier file is unchanged. Slash commands (urlencoded with
top-level team_id) are picked up by a small extractor in the
dispatcher so the third Slack endpoint can satisfy team-id binding
without touching the verifier.

Tests cover the verifier guard happy and failure paths via real
Request objects, plus a hermetic Bun.serve test that proves the
routes are alive end-to-end.
@mcheemaa
Copy link
Copy Markdown
Member Author

@codex please review

1 similar comment
@mcheemaa
Copy link
Copy Markdown
Member Author

@codex please review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: edd6aaa3b7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +31 to +33
const channel = slackHttpChannelProvider?.();
if (!channel) {
// Socket Mode tenants never mount these routes; an inbound POST is
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate Slack ingress on connection state

tryHandleSlackHttp forwards any Slack POST as soon as a channel provider exists, but it never checks whether that channel is actually connected. Because setSlackHttpChannelProvider is wired during startup before router.connectAll() completes, requests arriving in that window (or after a later disconnect/error) are still dispatched and acknowledged, which can cause Slack events to be accepted while the channel is not ready and prevent upstream retries. Please return a retryable error (e.g., 503) until channel.isConnected() is true, or defer provider exposure until after successful connect.

Useful? React with 👍 / 👎.

The provider is wired in src/index.ts during channel setup before
router.connectAll() runs, so inbound POSTs arriving in the startup
window dispatched into a half-initialized channel. The gate also
covers the post-disconnect() path (state flips to "disconnected")
and the auth-failure path (state flips to "error").

Both 503 paths return JSON. Slack retries 503 up to 3 times within
5 minutes per the inbound contract, so the gate resolves naturally.

+3 tests pinning the not-yet-connected case, the post-disconnect
sibling case (audit doc inline), and the same gate on /slack/interactivity.
@mcheemaa
Copy link
Copy Markdown
Member Author

Codex P1 addressed in 2c232c5: tryHandleSlackHttp now gates dispatch on channel.isConnected() and returns JSON 503 until the channel finishes connect()/auth.test (also covers the post-disconnect and auth-error states; sibling-bug audit documented inline in the test). +3 tests; 1979 total. Re-running review.

@mcheemaa
Copy link
Copy Markdown
Member Author

@codex please re-review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2c232c5611

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/channels/slack-http-handlers.ts Outdated
};

try {
await input.app.processEvent(receiverEvent);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return Slack ack without waiting for listener completion

dispatchToBolt blocks on await input.app.processEvent(receiverEvent) before returning awaitAck, but Bolt’s processEvent waits for listener middleware to finish, not just for ack(). In this codebase, Slack message listeners await long-running runtime work (router.onMessage -> runtime.handleMessage), so /slack/events can stay open far past Slack’s ~3s acknowledgement window. This regresses the previous HTTPReceiver behavior (processBeforeResponse=false) where the HTTP response is sent immediately on ack(), and will cause retries/duplicate deliveries under normal load.

Useful? React with 👍 / 👎.

Comment thread src/channels/slack-http-handlers.ts Outdated
// processEvent already invokes ack via Bolt's auto-ack pathway in
// most cases, so the awaitAck promise has already resolved. We
// only fall back to a 500 when the ack never fired.
await ackFn();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve error status when processEvent throws before ack

In the catch path, await ackFn() always resolves to an empty 200 response because ackFn treats undefined as success. That means if processEvent throws before any ack is sent (for example, middleware/listener failures on interactive requests), the handler reports success to Slack instead of surfacing a server error, suppressing retries and dropping the failed interaction.

Useful? React with 👍 / 👎.

…d 2 P1+P2)

Previously dispatchToBolt awaited input.app.processEvent before returning,
but Bolt's processEvent resolves only when listener middleware finishes,
not when ack() fires. Phantom's Slack listener calls runtime.handleMessage
which routinely outlasts Slack's ~3s ack window, so /slack/events stayed
open past the deadline and triggered Slack-side retries. The catch path
also called ackFn() with no args, which resolves to 200 and silently
suppressed listener failures.

Replace the await + try/catch with Promise.race over three tagged
outcomes: ack winner returns the listener's response immediately,
processEvent rejecting before ack surfaces as 500 so Slack retries, and
processEvent resolving without any ack falls back to a 200 with an
operator warning (defense against a hypothetical Bolt regression). The
async listener work continues in the background, matching the previous
HTTPReceiver processBeforeResponse=false semantics.

Tests: +3 covering long-running listener (handler returns 200 while
listener continues), processEvent rejects before ack (handler 500),
and processEvent resolves without ack (handler 200 + warning). Total
1971 pass, typecheck and lint clean. File at 294 lines (under 300
channels/ budget).
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@mcheemaa mcheemaa merged commit be1839e into main Apr 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant