import { DeliveryDedupeCache, type IngestHttpServerHandle } from '@moxxy/channel-kit'; import { type SlackEventCallback } from './schema.js'; /** * The HTTP front-end for the Slack channel. Binds a `node:http` server to an * ephemeral loopback port; the channel exposes it publicly via the proxy * tunnel. Slack POSTs every event to `/slack/events`. * * The transport scaffold (routing, health probe, size-capped raw-body read, * verify gate, catch-all error handling) is `@moxxy/channel-kit`'s * {@link IngestHttpServer}; Slack's HMAC scheme stays HERE as its verify hook, * and this module owns everything after verification. * * Handler order — every gate runs BEFORE the session is touched (skill A8/A46): * 1. POST-only (+ a GET `/slack/health` liveness probe). [kit] * 2. read the RAW body bytes (size cap) — required for the HMAC. [kit] * 3. signature gate → 401 (verify over the raw bytes, before JSON.parse). * 4. zod-validate the envelope → 400. * 5. `url_verification` → echo `{ challenge }` (the handshake). * 6. dedupe — drop on `X-Slack-Retry-Num` or a seen `event_id`. * 7. drop the bot's own messages (`event.user === botUserId` / `event.bot_id`). * 8. pairing gate — ignore unless the team/channel is authorized. * 9. ACK 200 synchronously, THEN run the turn fire-and-forget (Slack's 3s * ack budget; never run `runTurn` on the request path). * * Every handler error is caught so a bad request can never escalate to a * process-level uncaughtException. */ declare const EVENTS_PATH = "/slack/events"; /** Decision the channel makes about an inbound event. */ export interface DispatchContext { readonly teamId: string | undefined; readonly channel: string; readonly text: string; readonly user: string | undefined; readonly threadTs: string; readonly eventType: string; } export interface IngestServerHooks { /** The bot's own user id, captured at start via `auth.test` (drop self-messages). */ readonly botUserId: string; /** Is this team/channel authorized to drive the session? (pairing gate) */ isAuthorized(teamId: string | undefined, channel: string | undefined): boolean; /** * Observe a verified inbound event from a (possibly unauthorized) team/channel. * Used by the TOFU `pair` flow to capture the first event and persist it. * Returns true if the event was consumed by pairing (so it should NOT also * drive a turn). */ onVerifiedEvent?(ev: SlackEventCallback): boolean | Promise; /** Run a turn for an authorized, deduped, non-self event (fire-and-forget). */ dispatch(ctx: DispatchContext): void; } export interface IngestServerOptions { readonly host?: string; readonly signingSecret: string; readonly hooks: IngestServerHooks; /** Max request body size in bytes. Default 1MB. */ readonly maxBodyBytes?: number; /** Override dedupe cache (tests). */ readonly dedupe?: DeliveryDedupeCache; readonly logger?: { info?(msg: string, meta?: Record): void; warn?(msg: string, meta?: Record): void; }; } export type IngestServerHandle = IngestHttpServerHandle; export declare class IngestServer { private readonly opts; private readonly inner; private readonly dedupe; constructor(opts: IngestServerOptions); get port(): number; /** Bind on an ephemeral loopback port. Resolves once listening. */ start(): Promise; stop(): Promise; /** Steps 4–9: everything after the raw-body + signature gates. */ private handleVerified; } export { EVENTS_PATH as SLACK_EVENTS_PATH }; //# sourceMappingURL=ingest-server.d.ts.map