From abceca74fcc1913de002ffa273dd1d75ab1b8937 Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema Date: Sat, 25 Apr 2026 11:32:39 -0700 Subject: [PATCH 1/3] channels: add Slack HTTP receiver for distributed-app mode Phantom Cloud tenants flip SLACK_TRANSPORT=http to receive Slack events via the central phantom-slack-events gateway instead of opening a Socket Mode WebSocket out to Slack themselves. Self-hosters keep the Socket Mode flow at slack.ts unchanged. The hybrid metadata pattern is locked: identity (team_id, installer_user_id, team_name) flows through /v1/identity.slack on the phantomd metadata gateway; secrets (slack_bot_token, slack_gateway_signing_secret) flow through /v1/secrets/ via the Phase C path. The new MetadataIdentityFetcher mirrors the existing MetadataSecretFetcher and parses the optional slack subfield. SlackHttpChannel reuses Bolt's App with an ExpressReceiver and signatureVerification: false. A custom Express middleware verifies the gateway's HMAC over :: with a 5-minute replay window, then enforces a defense-in-depth team_id match against this.teamId before Bolt sees the body. Failures return 401 (HMAC) or 403 (team_id) before any handler runs. The existing slack-actions.ts, slack-formatter.ts, feedback.ts, progress-stream.ts, and status-reactions.ts are receiver-agnostic and reused via the same app.action()/app.event() registrations. Owner access control redesigns for HTTP mode per plan section 6.4: "any user from this.teamId" replaces the strict OWNER_SLACK_USER_ID check. The Socket Mode rejection DM is intentionally not replicated; HTTP-mode tenants are multi-user. Test count: +88 new tests (1922 vs 1834 baseline), zero plaintext bot token in any log line, error message, or test assertion. --- .../__tests__/slack-channel-factory.test.ts | 212 ++++++ .../__tests__/slack-gateway-verifier.test.ts | 254 +++++++ .../__tests__/slack-http-receiver.test.ts | 684 ++++++++++++++++++ src/channels/slack-channel-factory.ts | 83 +++ src/channels/slack-gateway-verifier.ts | 118 +++ src/channels/slack-http-events.ts | 152 ++++ src/channels/slack-http-receiver.ts | 427 +++++++++++ src/channels/slack-transport.ts | 10 + src/config/__tests__/identity-fetcher.test.ts | 170 +++++ src/config/identity-fetcher.ts | 126 ++++ 10 files changed, 2236 insertions(+) create mode 100644 src/channels/__tests__/slack-channel-factory.test.ts create mode 100644 src/channels/__tests__/slack-gateway-verifier.test.ts create mode 100644 src/channels/__tests__/slack-http-receiver.test.ts create mode 100644 src/channels/slack-channel-factory.ts create mode 100644 src/channels/slack-gateway-verifier.ts create mode 100644 src/channels/slack-http-events.ts create mode 100644 src/channels/slack-http-receiver.ts create mode 100644 src/channels/slack-transport.ts create mode 100644 src/config/__tests__/identity-fetcher.test.ts create mode 100644 src/config/identity-fetcher.ts diff --git a/src/channels/__tests__/slack-channel-factory.test.ts b/src/channels/__tests__/slack-channel-factory.test.ts new file mode 100644 index 00000000..e8fc09fe --- /dev/null +++ b/src/channels/__tests__/slack-channel-factory.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { ChannelsConfig } from "../../config/schemas.ts"; + +// Mock the Slack Bolt SDK before importing the factory so the underlying +// channel constructors don't reach real network. The receiver test file +// already mocks @slack/bolt, but module mocks are scoped per file under bun. +const mockApp = mock(() => ({ + event: () => {}, + action: () => {}, + client: { + auth: { test: () => Promise.resolve({ user_id: "U_BOT" }) }, + chat: { postMessage: () => Promise.resolve({ ts: "1.0" }), update: () => Promise.resolve({ ok: true }) }, + conversations: { open: () => Promise.resolve({ channel: { id: "D1" } }) }, + reactions: { add: () => Promise.resolve({ ok: true }), remove: () => Promise.resolve({ ok: true }) }, + }, +})); + +const mockReceiver = { + app: { use: () => {} }, + start: () => Promise.resolve({}), + stop: () => Promise.resolve(), +}; + +mock.module("@slack/bolt", () => ({ + App: mockApp, + ExpressReceiver: mock(() => mockReceiver), +})); + +const { createSlackChannel, readSlackTransportFromEnv } = await import("../slack-channel-factory.ts"); +const { SlackChannel } = await import("../slack.ts"); +const { SlackHttpChannel } = await import("../slack-http-receiver.ts"); + +const SOCKET_CONFIG: ChannelsConfig = { + slack: { + enabled: true, + bot_token: "xoxb-1", + app_token: "xapp-1", + owner_user_id: "U_OWNER", + }, +}; + +const HTTP_IDENTITY = { + slack: { + teamId: "T9TK3CUKW", + installerUserId: "U_INSTALLER", + teamName: "Acme Corp", + installedAt: "2026-04-25T00:00:00Z", + }, +}; + +const SECRET_RESPONSES: Record = { + slack_bot_token: "xoxb-from-metadata", + slack_gateway_signing_secret: "0123456789abcdef".repeat(4), +}; + +describe("readSlackTransportFromEnv", () => { + test("returns 'socket' when SLACK_TRANSPORT is unset", () => { + expect(readSlackTransportFromEnv({} as NodeJS.ProcessEnv)).toBe("socket"); + }); + + test("returns 'socket' when SLACK_TRANSPORT='socket'", () => { + expect(readSlackTransportFromEnv({ SLACK_TRANSPORT: "socket" } as NodeJS.ProcessEnv)).toBe("socket"); + }); + + test("returns 'http' when SLACK_TRANSPORT='http'", () => { + expect(readSlackTransportFromEnv({ SLACK_TRANSPORT: "http" } as NodeJS.ProcessEnv)).toBe("http"); + }); + + test("throws on an unknown value", () => { + expect(() => readSlackTransportFromEnv({ SLACK_TRANSPORT: "garbage" } as NodeJS.ProcessEnv)).toThrow( + /Unknown SLACK_TRANSPORT/, + ); + }); + + test("ignores leading/trailing whitespace via trim()", () => { + expect(readSlackTransportFromEnv({ SLACK_TRANSPORT: " http " } as NodeJS.ProcessEnv)).toBe("http"); + }); +}); + +describe("createSlackChannel", () => { + beforeEach(() => { + mockApp.mockClear(); + }); + + afterEach(() => { + // Nothing to reset; module mock is sticky for the file's lifetime. + }); + + test("transport=socket with no Slack creds returns null", async () => { + const ch = await createSlackChannel({ + transport: "socket", + channelsConfig: null, + port: 3100, + }); + expect(ch).toBeNull(); + }); + + test("transport=socket with disabled Slack creds returns null", async () => { + const disabled: ChannelsConfig = { + slack: { enabled: false, bot_token: "x", app_token: "y" }, + }; + const ch = await createSlackChannel({ + transport: "socket", + channelsConfig: disabled, + port: 3100, + }); + expect(ch).toBeNull(); + }); + + test("transport=socket with valid creds returns a SlackChannel instance", async () => { + const ch = await createSlackChannel({ + transport: "socket", + channelsConfig: SOCKET_CONFIG, + port: 3100, + }); + expect(ch).toBeInstanceOf(SlackChannel); + }); + + test("transport=http with no slack subfield in identity throws a clear error", async () => { + const idFetcher = { get: () => Promise.resolve({}) }; + const secFetcher = { get: () => Promise.resolve("unused") }; + await expect( + createSlackChannel({ + transport: "http", + channelsConfig: null, + port: 3100, + identityFetcher: idFetcher, + secretsFetcher: secFetcher, + }), + ).rejects.toThrow(/SLACK_TRANSPORT=http requires a Slack install/); + }); + + test("transport=http with slack identity and metadata secrets returns a SlackHttpChannel", async () => { + const idFetcher = { get: () => Promise.resolve(HTTP_IDENTITY) }; + const secFetcher = { get: (name: string) => Promise.resolve(SECRET_RESPONSES[name] ?? "") }; + const ch = await createSlackChannel({ + transport: "http", + channelsConfig: null, + port: 3100, + identityFetcher: idFetcher, + secretsFetcher: secFetcher, + }); + expect(ch).toBeInstanceOf(SlackHttpChannel); + // Cast to the concrete type to inspect the wired identity. + const httpCh = ch as InstanceType; + expect(httpCh.getTeamId()).toBe("T9TK3CUKW"); + expect(httpCh.getInstallerUserId()).toBe("U_INSTALLER"); + expect(httpCh.getTeamName()).toBe("Acme Corp"); + }); + + test("transport=http fetches both required secrets in parallel", async () => { + const requested: string[] = []; + const idFetcher = { get: () => Promise.resolve(HTTP_IDENTITY) }; + const secFetcher = { + get: (name: string) => { + requested.push(name); + return Promise.resolve(SECRET_RESPONSES[name] ?? ""); + }, + }; + await createSlackChannel({ + transport: "http", + channelsConfig: null, + port: 3100, + identityFetcher: idFetcher, + secretsFetcher: secFetcher, + }); + expect(requested).toContain("slack_bot_token"); + expect(requested).toContain("slack_gateway_signing_secret"); + }); + + test("transport=http never reads bot_token or app_token from channels.yaml", async () => { + // Even when channels.yaml has socket creds, the http path uses metadata. + const idFetcher = { get: () => Promise.resolve(HTTP_IDENTITY) }; + const secretCalls: string[] = []; + const secFetcher = { + get: (name: string) => { + secretCalls.push(name); + return Promise.resolve(SECRET_RESPONSES[name] ?? ""); + }, + }; + const ch = await createSlackChannel({ + transport: "http", + channelsConfig: SOCKET_CONFIG, + port: 3100, + identityFetcher: idFetcher, + secretsFetcher: secFetcher, + }); + expect(ch).toBeInstanceOf(SlackHttpChannel); + // The http path always pulls from metadata; SOCKET_CONFIG.slack.bot_token + // is irrelevant. Pin this so a future refactor cannot accidentally + // fall back to channels.yaml on a half-provisioned tenant. + expect(secretCalls.length).toBe(2); + }); + + test("default identity fetcher uses the link-local URL", async () => { + // We don't need to run the full http path here, just assert that the + // factory wires the documented default base URL when no custom URL is + // passed. The fetcher class is responsible for the URL it constructs. + const idFetcher = { get: () => Promise.resolve(HTTP_IDENTITY) }; + const secFetcher = { get: (name: string) => Promise.resolve(SECRET_RESPONSES[name] ?? "") }; + // Pin the contract: passing through metadataBaseUrl propagates. + const ch = await createSlackChannel({ + transport: "http", + channelsConfig: null, + port: 3100, + metadataBaseUrl: "http://gateway.test", + identityFetcher: idFetcher, + secretsFetcher: secFetcher, + }); + expect(ch).toBeInstanceOf(SlackHttpChannel); + }); +}); diff --git a/src/channels/__tests__/slack-gateway-verifier.test.ts b/src/channels/__tests__/slack-gateway-verifier.test.ts new file mode 100644 index 00000000..620334a1 --- /dev/null +++ b/src/channels/__tests__/slack-gateway-verifier.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, test } from "bun:test"; +import { createHmac } from "node:crypto"; +import { + REPLAY_WINDOW_MS, + SIGNATURE_PREFIX, + extractTeamId, + signGatewayRequest, + verifyGatewaySignature, +} from "../slack-gateway-verifier.ts"; + +const SECRET = "0123456789abcdef0123456789abcdef"; + +function makeSig(args: { + forwardedAt: number; + eventId: string; + body: string | Buffer; + secret?: string; +}): string { + const raw = Buffer.isBuffer(args.body) ? args.body : Buffer.from(args.body); + return signGatewayRequest({ + forwardedAt: args.forwardedAt, + eventId: args.eventId, + rawBody: raw, + secret: args.secret ?? SECRET, + }); +} + +describe("verifyGatewaySignature", () => { + test("accepts a correctly-signed fresh request", () => { + const now = 1714000000000; + const body = '{"type":"event_callback","team_id":"T1"}'; + const sig = makeSig({ forwardedAt: now, eventId: "Ev1", body }); + expect( + verifyGatewaySignature({ + sigHeader: sig, + fwdHeader: String(now), + eventId: "Ev1", + raw: Buffer.from(body), + secret: SECRET, + now, + }), + ).toBe(true); + }); + + test("rejects when X-Phantom-Signature is missing", () => { + expect( + verifyGatewaySignature({ + sigHeader: null, + fwdHeader: "1714000000000", + eventId: "Ev1", + raw: Buffer.from("{}"), + secret: SECRET, + now: 1714000000000, + }), + ).toBe(false); + }); + + test("rejects when signature lacks v1= prefix", () => { + expect( + verifyGatewaySignature({ + sigHeader: "abcdef0123456789", + fwdHeader: "1714000000000", + eventId: "Ev1", + raw: Buffer.from("{}"), + secret: SECRET, + now: 1714000000000, + }), + ).toBe(false); + }); + + test("rejects when X-Phantom-Forwarded-At is missing", () => { + const sig = makeSig({ forwardedAt: 1714000000000, eventId: "Ev1", body: "{}" }); + expect( + verifyGatewaySignature({ + sigHeader: sig, + fwdHeader: null, + eventId: "Ev1", + raw: Buffer.from("{}"), + secret: SECRET, + now: 1714000000000, + }), + ).toBe(false); + }); + + test("rejects when X-Phantom-Forwarded-At is non-numeric", () => { + const sig = makeSig({ forwardedAt: 1714000000000, eventId: "Ev1", body: "{}" }); + expect( + verifyGatewaySignature({ + sigHeader: sig, + fwdHeader: "not-a-number", + eventId: "Ev1", + raw: Buffer.from("{}"), + secret: SECRET, + now: 1714000000000, + }), + ).toBe(false); + }); + + test("rejects when X-Phantom-Forwarded-At is more than 5 minutes old", () => { + const past = 1714000000000; + const now = past + REPLAY_WINDOW_MS + 1; + const sig = makeSig({ forwardedAt: past, eventId: "Ev1", body: "{}" }); + expect( + verifyGatewaySignature({ + sigHeader: sig, + fwdHeader: String(past), + eventId: "Ev1", + raw: Buffer.from("{}"), + secret: SECRET, + now, + }), + ).toBe(false); + }); + + test("rejects when X-Phantom-Forwarded-At is more than 5 minutes in the future", () => { + const now = 1714000000000; + const future = now + REPLAY_WINDOW_MS + 1; + const sig = makeSig({ forwardedAt: future, eventId: "Ev1", body: "{}" }); + expect( + verifyGatewaySignature({ + sigHeader: sig, + fwdHeader: String(future), + eventId: "Ev1", + raw: Buffer.from("{}"), + secret: SECRET, + now, + }), + ).toBe(false); + }); + + test("rejects on tampered body (same headers, different bytes)", () => { + const now = 1714000000000; + const body = '{"team_id":"T1"}'; + const sig = makeSig({ forwardedAt: now, eventId: "Ev1", body }); + const tampered = '{"team_id":"T2"}'; + expect( + verifyGatewaySignature({ + sigHeader: sig, + fwdHeader: String(now), + eventId: "Ev1", + raw: Buffer.from(tampered), + secret: SECRET, + now, + }), + ).toBe(false); + }); + + test("rejects when computed digest uses a different signing secret", () => { + const now = 1714000000000; + const body = "{}"; + const sig = makeSig({ forwardedAt: now, eventId: "Ev1", body, secret: "wrong-secret" }); + expect( + verifyGatewaySignature({ + sigHeader: sig, + fwdHeader: String(now), + eventId: "Ev1", + raw: Buffer.from(body), + secret: SECRET, + now, + }), + ).toBe(false); + }); + + test("rejects when presented digest length does not match", () => { + const now = 1714000000000; + expect( + verifyGatewaySignature({ + sigHeader: `${SIGNATURE_PREFIX}deadbeef`, + fwdHeader: String(now), + eventId: "Ev1", + raw: Buffer.from("{}"), + secret: SECRET, + now, + }), + ).toBe(false); + }); + + test("rejects when presented digest is not valid hex", () => { + const now = 1714000000000; + // 64 chars but with a non-hex 'z' + const bogus = "z".repeat(64); + expect( + verifyGatewaySignature({ + sigHeader: `${SIGNATURE_PREFIX}${bogus}`, + fwdHeader: String(now), + eventId: "Ev1", + raw: Buffer.from("{}"), + secret: SECRET, + now, + }), + ).toBe(false); + }); + + test("uses Date.now() when caller omits the now argument", () => { + // We cannot inject Date.now without overrides, but we can assert that a + // fresh signature with forwardedAt=Date.now() passes when `now` is omitted. + const stamp = Date.now(); + const sig = makeSig({ forwardedAt: stamp, eventId: "Ev1", body: "{}" }); + expect( + verifyGatewaySignature({ + sigHeader: sig, + fwdHeader: String(stamp), + eventId: "Ev1", + raw: Buffer.from("{}"), + secret: SECRET, + }), + ).toBe(true); + }); + + test("canonical string matches the documented `::` shape", () => { + const now = 1714000000000; + const body = "raw bytes"; + const expected = SIGNATURE_PREFIX + createHmac("sha256", SECRET).update(`${now}:Ev1:${body}`).digest("hex"); + expect(makeSig({ forwardedAt: now, eventId: "Ev1", body })).toBe(expected); + }); +}); + +describe("extractTeamId", () => { + test("returns top-level team_id from JSON", () => { + const body = Buffer.from('{"team_id":"T9TK3CUKW","event":{"type":"app_mention"}}'); + expect(extractTeamId(body, "application/json")).toBe("T9TK3CUKW"); + }); + + test("returns nested team.id from JSON when team_id absent", () => { + const body = Buffer.from('{"team":{"id":"T9TK3CUKW","domain":"acme"}}'); + expect(extractTeamId(body, "application/json")).toBe("T9TK3CUKW"); + }); + + test("returns team.id from urlencoded interactivity payload", () => { + const payload = JSON.stringify({ + type: "block_actions", + team: { id: "T9TK3CUKW", domain: "acme" }, + }); + const body = Buffer.from(`payload=${encodeURIComponent(payload)}`); + expect(extractTeamId(body, "application/x-www-form-urlencoded")).toBe("T9TK3CUKW"); + }); + + test("returns undefined for malformed JSON", () => { + expect(extractTeamId(Buffer.from("not json"), "application/json")).toBeUndefined(); + }); + + test("returns undefined when urlencoded body has no payload", () => { + expect(extractTeamId(Buffer.from("foo=bar"), "application/x-www-form-urlencoded")).toBeUndefined(); + }); + + test("returns undefined when urlencoded payload is malformed JSON", () => { + expect(extractTeamId(Buffer.from("payload=not-json"), "application/x-www-form-urlencoded")).toBeUndefined(); + }); + + test("returns undefined when team_id is non-string", () => { + const body = Buffer.from('{"team_id":12345}'); + expect(extractTeamId(body, "application/json")).toBeUndefined(); + }); +}); diff --git a/src/channels/__tests__/slack-http-receiver.test.ts b/src/channels/__tests__/slack-http-receiver.test.ts new file mode 100644 index 00000000..8d388c58 --- /dev/null +++ b/src/channels/__tests__/slack-http-receiver.test.ts @@ -0,0 +1,684 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { signGatewayRequest } from "../slack-gateway-verifier.ts"; + +// Mock Slack Bolt's App and ExpressReceiver. The receiver shape we replace +// captures the registered Bolt handlers so the test driver can invoke them +// directly, mirroring the slack.test.ts pattern. The Express app sits on +// the receiver and exposes `use(path, mw)` so the channel's installVerifier +// can register guards we then drive end-to-end. +const mockStart = mock(() => Promise.resolve({})); +const mockStop = mock(() => Promise.resolve()); +const mockAuthTest = mock(() => Promise.resolve({ user_id: "U_BOT123" })); +const mockPostMessage = mock(() => Promise.resolve({ ts: "1234567890.123456" })); +const mockChatUpdate = mock(() => Promise.resolve({ ok: true })); +const mockReactionsAdd = mock(() => Promise.resolve({ ok: true })); +const mockReactionsRemove = mock(() => Promise.resolve({ ok: true })); +const mockConversationsOpen = mock(() => Promise.resolve({ channel: { id: "D_DM_OPEN" } })); + +type EventHandler = (...args: unknown[]) => Promise; +type Middleware = (req: unknown, res: unknown, next: () => void) => unknown | Promise; + +const eventHandlers = new Map(); +const actionHandlers = new Map(); +const guards = new Map(); + +const expressApp = { + use(path: string, mw: Middleware): void { + guards.set(path, mw); + }, +}; + +const mockReceiver = { + app: expressApp, + start: mockStart, + stop: mockStop, +}; + +const MockExpressReceiver = mock(() => mockReceiver); + +const MockApp = mock(() => ({ + event: (name: string, handler: EventHandler) => { + eventHandlers.set(name, handler); + }, + action: (pattern: string | RegExp, handler: EventHandler) => { + const key = pattern instanceof RegExp ? pattern.source : pattern; + actionHandlers.set(key, handler); + }, + client: { + auth: { test: mockAuthTest }, + chat: { postMessage: mockPostMessage, update: mockChatUpdate }, + conversations: { open: mockConversationsOpen }, + reactions: { add: mockReactionsAdd, remove: mockReactionsRemove }, + }, +})); + +mock.module("@slack/bolt", () => ({ + App: MockApp, + ExpressReceiver: MockExpressReceiver, +})); + +// Import the channel AFTER the module mock so the constructor uses our doubles. +const { SlackHttpChannel } = await import("../slack-http-receiver.ts"); +type SlackHttpChannelType = InstanceType; + +const SECRET = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const TEAM_ID = "T9TK3CUKW"; +const FOREIGN_TEAM_ID = "T_FOREIGN"; + +const baseConfig = { + botToken: "xoxb-test-token", + gatewaySigningSecret: SECRET, + teamId: TEAM_ID, + installerUserId: "U_INSTALLER", + teamName: "Acme Corp", + listenPort: 3100, + listenPath: "/slack", +}; + +type MockReq = { + headers: Record; + rawBody?: Buffer; + body?: unknown; + on?: (event: string, listener: (chunk?: Buffer) => void) => void; +}; + +type MockRes = { + statusCode: number; + body: string | undefined; + status(code: number): MockRes; + end(body?: string): void; +}; + +function makeRes(): MockRes { + const res: MockRes = { + statusCode: 0, + body: undefined, + status(code) { + res.statusCode = code; + return res; + }, + end(body) { + res.body = body; + }, + }; + return res; +} + +function makeReq(args: { + bodyJson: unknown; + contentType?: string; + now?: number; + eventId?: string; + skipSig?: boolean; + skipFwd?: boolean; + tamperedBody?: string; + wrongSecret?: boolean; + staleMs?: number; + futureMs?: number; +}): MockReq { + const ctype = args.contentType ?? "application/json"; + const bodyText = + typeof args.bodyJson === "string" + ? args.bodyJson + : ctype.includes("urlencoded") + ? `payload=${encodeURIComponent(JSON.stringify(args.bodyJson))}` + : JSON.stringify(args.bodyJson); + const raw = Buffer.from(args.tamperedBody ?? bodyText); + const now = args.now ?? Date.now(); + const fwd = args.staleMs !== undefined ? now - args.staleMs : args.futureMs !== undefined ? now + args.futureMs : now; + const eventId = args.eventId ?? "Ev1"; + const sig = signGatewayRequest({ + forwardedAt: fwd, + eventId, + rawBody: Buffer.from(bodyText), + secret: args.wrongSecret ? "wrong-secret" : SECRET, + }); + const headers: Record = { "content-type": ctype }; + if (!args.skipSig) headers["x-phantom-signature"] = sig; + if (!args.skipFwd) headers["x-phantom-forwarded-at"] = String(fwd); + headers["x-phantom-slack-event-id"] = eventId; + const req: MockReq = { headers, rawBody: raw }; + // Provide req.on for the readRequestBody fallback path; not used when + // rawBody is preset, but keeps the type contract honest for any guard + // that re-reads the body. + req.on = () => {}; + return req; +} + +async function callGuard(channel: SlackHttpChannelType, path: string, req: MockReq): Promise { + void channel; + const guard = guards.get(path); + if (!guard) throw new Error(`no guard at ${path}`); + const res = makeRes(); + await new Promise((resolve) => { + const next = () => resolve(); + const out = guard(req, res, next); + if (out instanceof Promise) { + out.then(() => { + if (res.statusCode !== 0) resolve(); + }); + } else if (res.statusCode !== 0) { + resolve(); + } + }); + return res; +} + +beforeEach(() => { + eventHandlers.clear(); + actionHandlers.clear(); + guards.clear(); + mockStart.mockClear(); + mockStop.mockClear(); + mockAuthTest.mockClear(); + mockPostMessage.mockClear(); + mockChatUpdate.mockClear(); + mockReactionsAdd.mockClear(); + mockReactionsRemove.mockClear(); + mockConversationsOpen.mockClear(); +}); + +afterEach(() => { + // Reset auth.test to the default success payload so a single "revoked + // token" test does not bleed into other tests. + mockAuthTest.mockImplementation(() => Promise.resolve({ user_id: "U_BOT123" })); +}); + +// ----- constructor --------------------------------------------------------- + +describe("SlackHttpChannel constructor", () => { + test("wires receiver and Bolt App with the provided config", () => { + const channel = new SlackHttpChannel(baseConfig); + expect(channel.id).toBe("slack"); + expect(channel.name).toBe("Slack"); + expect(channel.getTeamId()).toBe(TEAM_ID); + expect(channel.getInstallerUserId()).toBe("U_INSTALLER"); + expect(channel.getTeamName()).toBe("Acme Corp"); + expect(MockExpressReceiver).toHaveBeenCalled(); + expect(MockApp).toHaveBeenCalled(); + }); + + test("registers verifier guards on the three Slack endpoints", () => { + new SlackHttpChannel(baseConfig); + expect(guards.has("/slack/events")).toBe(true); + expect(guards.has("/slack/interactivity")).toBe(true); + expect(guards.has("/slack/commands")).toBe(true); + }); + + test("rejects empty botToken with a clear error", () => { + expect(() => new SlackHttpChannel({ ...baseConfig, botToken: "" })).toThrow(/botToken is required/); + }); + + test("rejects empty gatewaySigningSecret with a clear error", () => { + expect(() => new SlackHttpChannel({ ...baseConfig, gatewaySigningSecret: "" })).toThrow( + /gatewaySigningSecret is required/, + ); + }); + + test("rejects empty teamId with a clear error", () => { + expect(() => new SlackHttpChannel({ ...baseConfig, teamId: "" })).toThrow(/teamId is required/); + }); + + test("starts in the disconnected state", () => { + const channel = new SlackHttpChannel(baseConfig); + expect(channel.isConnected()).toBe(false); + expect(channel.getConnectionState()).toBe("disconnected"); + }); + + test("constructor does not log the bot token", () => { + const logs: string[] = []; + const original = console.log; + console.log = (...args: unknown[]) => { + logs.push(args.map(String).join(" ")); + }; + try { + new SlackHttpChannel(baseConfig); + } finally { + console.log = original; + } + const all = logs.join("\n"); + expect(all).not.toContain("xoxb-test-token"); + }); +}); + +// ----- guard middleware ---------------------------------------------------- + +describe("guard middleware (HMAC + replay window + team_id)", () => { + test("accepts a correctly-signed request and calls next()", async () => { + const channel = new SlackHttpChannel(baseConfig); + const req = makeReq({ bodyJson: { type: "event_callback", team_id: TEAM_ID } }); + const res = await callGuard(channel, "/slack/events", req); + expect(res.statusCode).toBe(0); + }); + + test("rejects 401 when X-Phantom-Signature is missing", async () => { + const channel = new SlackHttpChannel(baseConfig); + const req = makeReq({ bodyJson: { team_id: TEAM_ID }, skipSig: true }); + const res = await callGuard(channel, "/slack/events", req); + expect(res.statusCode).toBe(401); + }); + + test("rejects 401 when X-Phantom-Forwarded-At is missing", async () => { + const channel = new SlackHttpChannel(baseConfig); + const req = makeReq({ bodyJson: { team_id: TEAM_ID }, skipFwd: true }); + const res = await callGuard(channel, "/slack/events", req); + expect(res.statusCode).toBe(401); + }); + + test("rejects 401 on a stale forwarded-at (>5 minutes old)", async () => { + const channel = new SlackHttpChannel(baseConfig); + const req = makeReq({ + bodyJson: { team_id: TEAM_ID }, + staleMs: 6 * 60 * 1000, + }); + const res = await callGuard(channel, "/slack/events", req); + expect(res.statusCode).toBe(401); + }); + + test("rejects 401 on a future forwarded-at (>5 minutes ahead)", async () => { + const channel = new SlackHttpChannel(baseConfig); + const req = makeReq({ + bodyJson: { team_id: TEAM_ID }, + futureMs: 6 * 60 * 1000, + }); + const res = await callGuard(channel, "/slack/events", req); + expect(res.statusCode).toBe(401); + }); + + test("rejects 401 on tampered body (signature over original bytes)", async () => { + const channel = new SlackHttpChannel(baseConfig); + const req = makeReq({ + bodyJson: { team_id: TEAM_ID }, + tamperedBody: '{"team_id":"T_OTHER"}', + }); + const res = await callGuard(channel, "/slack/events", req); + expect(res.statusCode).toBe(401); + }); + + test("rejects 401 when signature was computed with a different secret", async () => { + const channel = new SlackHttpChannel(baseConfig); + const req = makeReq({ bodyJson: { team_id: TEAM_ID }, wrongSecret: true }); + const res = await callGuard(channel, "/slack/events", req); + expect(res.statusCode).toBe(401); + }); + + test("rejects 403 on team_id mismatch even with valid HMAC", async () => { + const channel = new SlackHttpChannel(baseConfig); + const req = makeReq({ bodyJson: { team_id: FOREIGN_TEAM_ID } }); + const res = await callGuard(channel, "/slack/events", req); + expect(res.statusCode).toBe(403); + }); + + test("accepts a request whose body has no team_id (e.g. url_verification ping)", async () => { + const channel = new SlackHttpChannel(baseConfig); + const req = makeReq({ bodyJson: { type: "url_verification", challenge: "abc" } }); + const res = await callGuard(channel, "/slack/events", req); + expect(res.statusCode).toBe(0); + }); + + test("accepts an interactivity payload (urlencoded form) when team.id matches", async () => { + const channel = new SlackHttpChannel(baseConfig); + const req = makeReq({ + bodyJson: { type: "block_actions", team: { id: TEAM_ID, domain: "acme" } }, + contentType: "application/x-www-form-urlencoded", + }); + const res = await callGuard(channel, "/slack/interactivity", req); + expect(res.statusCode).toBe(0); + }); + + test("rejects 403 on interactivity payload with foreign team.id", async () => { + const channel = new SlackHttpChannel(baseConfig); + const req = makeReq({ + bodyJson: { type: "block_actions", team: { id: FOREIGN_TEAM_ID, domain: "evil" } }, + contentType: "application/x-www-form-urlencoded", + }); + const res = await callGuard(channel, "/slack/interactivity", req); + expect(res.statusCode).toBe(403); + }); + + test("rehydrates JSON body for downstream Bolt parsers", async () => { + const channel = new SlackHttpChannel(baseConfig); + const req = makeReq({ bodyJson: { team_id: TEAM_ID, foo: "bar" } }); + await callGuard(channel, "/slack/events", req); + expect(req.body).toEqual({ team_id: TEAM_ID, foo: "bar" }); + }); +}); + +// ----- event routing ------------------------------------------------------- + +describe("event routing", () => { + test("registers app_mention, message, reaction_added on connect", async () => { + const channel = new SlackHttpChannel(baseConfig); + await channel.connect(); + expect(eventHandlers.has("app_mention")).toBe(true); + expect(eventHandlers.has("message")).toBe(true); + expect(eventHandlers.has("reaction_added")).toBe(true); + }); + + test("routes app_mention to the message handler", async () => { + const channel = new SlackHttpChannel(baseConfig); + let received = ""; + channel.onMessage(async (msg) => { + received = msg.text; + }); + await channel.connect(); + + const handler = eventHandlers.get("app_mention"); + if (!handler) throw new Error("no handler"); + await handler({ + event: { + text: "<@U_BOT123> hello", + user: "U_USER1", + channel: "C_CHAN1", + ts: "1715000000.000001", + }, + body: { team_id: TEAM_ID }, + }); + expect(received).toBe("hello"); + }); + + test("drops app_mention with a foreign team_id (defense in depth)", async () => { + const channel = new SlackHttpChannel(baseConfig); + let called = false; + channel.onMessage(async () => { + called = true; + }); + await channel.connect(); + + const handler = eventHandlers.get("app_mention"); + if (!handler) throw new Error("no handler"); + await handler({ + event: { text: "<@U_BOT123> hi", user: "U_X", channel: "C1", ts: "1715000000.000002" }, + body: { team_id: FOREIGN_TEAM_ID }, + }); + expect(called).toBe(false); + }); + + test("routes a DM (message.im) to the message handler", async () => { + const channel = new SlackHttpChannel(baseConfig); + let received = ""; + channel.onMessage(async (msg) => { + received = msg.text; + }); + await channel.connect(); + + const handler = eventHandlers.get("message"); + if (!handler) throw new Error("no handler"); + await handler({ + event: { + text: "DM hello", + user: "U_USER1", + channel: "D_DM1", + channel_type: "im", + ts: "1715000000.000003", + }, + body: { team_id: TEAM_ID }, + }); + expect(received).toBe("DM hello"); + }); + + test("ignores DM bot self-messages", async () => { + const channel = new SlackHttpChannel(baseConfig); + let called = false; + channel.onMessage(async () => { + called = true; + }); + await channel.connect(); + + const handler = eventHandlers.get("message"); + if (!handler) throw new Error("no handler"); + await handler({ + event: { + text: "self", + user: "U_BOT123", + channel: "D_DM1", + channel_type: "im", + ts: "1715000000.000004", + }, + body: { team_id: TEAM_ID }, + }); + expect(called).toBe(false); + }); + + test("ignores messages with subtype (e.g. message_changed)", async () => { + const channel = new SlackHttpChannel(baseConfig); + let called = false; + channel.onMessage(async () => { + called = true; + }); + await channel.connect(); + const handler = eventHandlers.get("message"); + if (!handler) throw new Error("no handler"); + await handler({ + event: { subtype: "message_changed", channel: "D1", channel_type: "im", ts: "1.0" }, + body: { team_id: TEAM_ID }, + }); + expect(called).toBe(false); + }); + + test("drops DM with foreign team_id even if everything else matches", async () => { + const channel = new SlackHttpChannel(baseConfig); + let called = false; + channel.onMessage(async () => { + called = true; + }); + await channel.connect(); + const handler = eventHandlers.get("message"); + if (!handler) throw new Error("no handler"); + await handler({ + event: { + text: "hello from outside", + user: "U_OUT", + channel: "D_DM1", + channel_type: "im", + ts: "1715000000.000005", + }, + body: { team_id: FOREIGN_TEAM_ID }, + }); + expect(called).toBe(false); + }); + + test("registers feedback action handlers on connect (compatibility regression)", async () => { + const channel = new SlackHttpChannel(baseConfig); + await channel.connect(); + // Three feedback action_ids registered by registerSlackActions: + expect(actionHandlers.has("phantom:feedback:positive")).toBe(true); + expect(actionHandlers.has("phantom:feedback:negative")).toBe(true); + expect(actionHandlers.has("phantom:feedback:partial")).toBe(true); + }); + + test("routes positive reaction events through onReaction", async () => { + const channel = new SlackHttpChannel(baseConfig); + let captured: { isPositive?: boolean; reaction?: string } = {}; + channel.onReaction((e) => { + captured = { isPositive: e.isPositive, reaction: e.reaction }; + }); + await channel.connect(); + const handler = eventHandlers.get("reaction_added"); + if (!handler) throw new Error("no handler"); + await handler({ + event: { + reaction: "thumbsup", + user: "U_USER1", + item: { ts: "1715000000.000001", channel: "C1" }, + }, + }); + expect(captured.isPositive).toBe(true); + expect(captured.reaction).toBe("thumbsup"); + }); + + test("ignores non-feedback reactions", async () => { + const channel = new SlackHttpChannel(baseConfig); + let called = false; + channel.onReaction(() => { + called = true; + }); + await channel.connect(); + const handler = eventHandlers.get("reaction_added"); + if (!handler) throw new Error("no handler"); + await handler({ + event: { reaction: "eyes", user: "U_USER1", item: { ts: "1.0", channel: "C1" } }, + }); + expect(called).toBe(false); + }); +}); + +// ----- bot user ID discovery & lifecycle ----------------------------------- + +describe("lifecycle and bot user discovery", () => { + test("connect resolves bot_user_id via auth.test", async () => { + const channel = new SlackHttpChannel(baseConfig); + await channel.connect(); + expect(channel.getBotUserId()).toBe("U_BOT123"); + expect(mockAuthTest).toHaveBeenCalledTimes(1); + }); + + test("connect transitions to connected state", async () => { + const channel = new SlackHttpChannel(baseConfig); + await channel.connect(); + expect(channel.isConnected()).toBe(true); + expect(channel.getConnectionState()).toBe("connected"); + expect(mockStart).toHaveBeenCalledTimes(1); + }); + + test("disconnect transitions back to disconnected", async () => { + const channel = new SlackHttpChannel(baseConfig); + await channel.connect(); + await channel.disconnect(); + expect(channel.isConnected()).toBe(false); + expect(channel.getConnectionState()).toBe("disconnected"); + expect(mockStop).toHaveBeenCalledTimes(1); + }); + + test("connect refuses to start when auth.test returns no user_id (revoked token)", async () => { + mockAuthTest.mockImplementation(() => Promise.resolve({ user_id: "" as string })); + const channel = new SlackHttpChannel(baseConfig); + await expect(channel.connect()).rejects.toThrow(/auth\.test returned no user_id/); + expect(channel.getConnectionState()).toBe("error"); + }); + + test("connect surfaces a non-token error message; never logs the bot token", async () => { + mockAuthTest.mockImplementation(() => Promise.reject(new Error("invalid_auth"))); + const channel = new SlackHttpChannel(baseConfig); + const errors: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }; + try { + await channel.connect().catch(() => {}); + } finally { + console.error = original; + } + const all = errors.join("\n"); + expect(all).toContain("invalid_auth"); + expect(all).not.toContain("xoxb-test-token"); + }); + + test("disconnect on a never-connected channel is a no-op", async () => { + const channel = new SlackHttpChannel(baseConfig); + await channel.disconnect(); + expect(mockStop).toHaveBeenCalledTimes(0); + }); +}); + +// ----- send and outbound API ---------------------------------------------- + +describe("send / outbound", () => { + test("send posts to the correct channel and thread", async () => { + const channel = new SlackHttpChannel(baseConfig); + await channel.connect(); + const result = await channel.send("slack:C1:1715000000.000001", { text: "Hello" }); + expect(result.channelId).toBe("slack"); + expect(mockPostMessage).toHaveBeenCalledWith({ + channel: "C1", + text: "Hello", + thread_ts: "1715000000.000001", + }); + }); + + test("postToChannel chunks long messages but keeps one chat.postMessage per chunk", async () => { + const channel = new SlackHttpChannel(baseConfig); + await channel.connect(); + await channel.postToChannel("C1", "short"); + expect(mockPostMessage).toHaveBeenCalledTimes(1); + }); + + test("sendDm opens a DM and posts there", async () => { + const channel = new SlackHttpChannel(baseConfig); + await channel.connect(); + await channel.sendDm("U_USER1", "Hi"); + expect(mockConversationsOpen).toHaveBeenCalledWith({ users: "U_USER1" }); + expect(mockPostMessage).toHaveBeenCalledWith({ channel: "D_DM_OPEN", text: "Hi" }); + }); + + test("addReaction calls reactions.add with the correct shape", async () => { + const channel = new SlackHttpChannel(baseConfig); + await channel.connect(); + await channel.addReaction("C1", "1.0", "+1"); + expect(mockReactionsAdd).toHaveBeenCalledWith({ channel: "C1", timestamp: "1.0", name: "+1" }); + }); + + test("removeReaction calls reactions.remove with the correct shape", async () => { + const channel = new SlackHttpChannel(baseConfig); + await channel.connect(); + await channel.removeReaction("C1", "1.0", "+1"); + expect(mockReactionsRemove).toHaveBeenCalledWith({ channel: "C1", timestamp: "1.0", name: "+1" }); + }); +}); + +// ----- access control ------------------------------------------------------ + +describe("access control (HTTP mode = any user from this.teamId)", () => { + test("any user from this.teamId can talk to Phantom", async () => { + const channel = new SlackHttpChannel(baseConfig); + let received = ""; + channel.onMessage(async (msg) => { + received = msg.text; + }); + await channel.connect(); + const handler = eventHandlers.get("app_mention"); + if (!handler) throw new Error("no handler"); + // A random non-installer user mentions Phantom: should be allowed. + await handler({ + event: { text: "<@U_BOT123> hi", user: "U_RANDOM_TEAMMATE", channel: "C1", ts: "1715000000.0" }, + body: { team_id: TEAM_ID }, + }); + expect(received).toBe("hi"); + }); + + test("event with no team_id and no defense default falls back to this.teamId (allow)", async () => { + const channel = new SlackHttpChannel(baseConfig); + let received = ""; + channel.onMessage(async (msg) => { + received = msg.text; + }); + await channel.connect(); + const handler = eventHandlers.get("app_mention"); + if (!handler) throw new Error("no handler"); + await handler({ + event: { text: "<@U_BOT123> hi", user: "U_RANDOM", channel: "C1", ts: "1715000000.0" }, + body: {}, + }); + expect(received).toBe("hi"); + }); + + test("foreign team_id is dropped in the event handler even after the guard passed", async () => { + const channel = new SlackHttpChannel(baseConfig); + let called = false; + channel.onMessage(async () => { + called = true; + }); + await channel.connect(); + const handler = eventHandlers.get("app_mention"); + if (!handler) throw new Error("no handler"); + // Defense in depth: even if a future regression weakened the + // HMAC guard's team_id check, the per-event handler must still + // drop foreign team events. + await handler({ + event: { text: "<@U_BOT123> hi", user: "U_RANDOM", channel: "C1", ts: "1.0" }, + body: { team_id: FOREIGN_TEAM_ID }, + }); + expect(called).toBe(false); + }); +}); diff --git a/src/channels/slack-channel-factory.ts b/src/channels/slack-channel-factory.ts new file mode 100644 index 00000000..00a9d617 --- /dev/null +++ b/src/channels/slack-channel-factory.ts @@ -0,0 +1,83 @@ +// Phase 5b: factory that picks between Socket Mode (`SlackChannel`) and the +// HTTP receiver (`SlackHttpChannel`) based on the SLACK_TRANSPORT env var. +// Pulled out of `src/index.ts` so the dispatch logic has a unit-testable +// surface; the index.ts wiring just calls this factory. + +import { DEFAULT_METADATA_BASE_URL, MetadataIdentityFetcher, type SlackIdentity } from "../config/identity-fetcher.ts"; +import { MetadataSecretFetcher } from "../config/metadata-fetcher.ts"; +import type { ChannelsConfig } from "../config/schemas.ts"; +import { SlackHttpChannel } from "./slack-http-receiver.ts"; +import type { SlackTransport } from "./slack-transport.ts"; +import { SlackChannel } from "./slack.ts"; + +export type SlackTransportMode = "socket" | "http"; + +export type CreateSlackChannelInput = { + transport: SlackTransportMode; + channelsConfig: ChannelsConfig | null; + port: number; + metadataBaseUrl?: string; + // The identity and secrets fetchers are injectable so tests can substitute + // mocks without going through real fetch. In production, omit them and the + // factory constructs the link-local default fetchers. + identityFetcher?: { get(): Promise<{ slack?: SlackIdentity }> }; + secretsFetcher?: { get(name: string): Promise }; +}; + +/** + * Returns the Slack channel implementation appropriate for the configured + * transport mode, or `null` when no Slack channel can be constructed (the + * Socket Mode path with no Slack creds in channels.yaml is the only happy + * "no channel" case; everything else throws so misconfiguration is loud). + * + * The HTTP path is always loud on misconfiguration because a tenant booting + * with SLACK_TRANSPORT=http but no /v1/identity.slack install means the + * provisioning flow is broken and we want the operator to know immediately. + */ +export async function createSlackChannel(input: CreateSlackChannelInput): Promise { + if (input.transport === "socket") { + const sc = input.channelsConfig?.slack; + if (!sc?.enabled || !sc.bot_token || !sc.app_token) return null; + return new SlackChannel({ + botToken: sc.bot_token, + appToken: sc.app_token, + defaultChannelId: sc.default_channel_id, + ownerUserId: sc.owner_user_id, + }); + } + + if (input.transport === "http") { + const baseUrl = input.metadataBaseUrl ?? DEFAULT_METADATA_BASE_URL; + const idFetcher = input.identityFetcher ?? new MetadataIdentityFetcher(baseUrl); + const secFetcher = input.secretsFetcher ?? new MetadataSecretFetcher(baseUrl); + const identity = await idFetcher.get(); + if (!identity.slack) { + throw new Error( + "SLACK_TRANSPORT=http requires a Slack install in /v1/identity.slack; got none. " + + "Run the OAuth flow via phantom-control or revert to SLACK_TRANSPORT=socket.", + ); + } + const [botToken, signingSecret] = await Promise.all([ + secFetcher.get("slack_bot_token"), + secFetcher.get("slack_gateway_signing_secret"), + ]); + return new SlackHttpChannel({ + botToken, + gatewaySigningSecret: signingSecret, + teamId: identity.slack.teamId, + installerUserId: identity.slack.installerUserId, + teamName: identity.slack.teamName, + listenPort: input.port, + listenPath: "/slack", + }); + } + + throw new Error(`Unknown SLACK_TRANSPORT: ${String(input.transport)} (expected "socket" or "http")`); +} + +export function readSlackTransportFromEnv(env: NodeJS.ProcessEnv = process.env): SlackTransportMode { + const value = env.SLACK_TRANSPORT?.trim(); + if (!value || value === "socket") return "socket"; + if (value === "http") return "http"; + throw new Error(`Unknown SLACK_TRANSPORT: ${value} (expected "socket" or "http")`); +} diff --git a/src/channels/slack-gateway-verifier.ts b/src/channels/slack-gateway-verifier.ts new file mode 100644 index 00000000..46a6afd1 --- /dev/null +++ b/src/channels/slack-gateway-verifier.ts @@ -0,0 +1,118 @@ +// Phase 5b: HMAC verifier for the gateway -> tenant Phantom forwarding +// hop. The phantom-slack-events gateway signs every forwarded request with +// `HMAC-SHA256(gatewaySigningSecret, "::")`. +// The tenant Phantom verifies the signature here before letting the request +// reach Bolt, and the parsed team_id is checked separately by the channel. +// +// We keep this file laser-focused on the verifier so the receiver itself +// stays under the 300-line budget. Keeping the verifier pure (no `this`, +// no I/O, no time-of-day side channels beyond the explicit `now` argument) +// makes the unit tests cheap and the failure modes obvious. + +import { createHmac, timingSafeEqual } from "node:crypto"; + +export const REPLAY_WINDOW_MS = 5 * 60 * 1000; +export const SIGNATURE_PREFIX = "v1="; + +export type VerifyInput = { + sigHeader: string | null; + fwdHeader: string | null; + eventId: string | null; + raw: Buffer; + secret: string; + now?: number; +}; + +/** + * Returns true iff the gateway's signature, replay window, and HMAC digest + * all check out. Never throws; never logs; never includes plaintext. + * + * Failure modes that return false: + * - missing or non-`v1=` X-Phantom-Signature + * - missing or non-numeric X-Phantom-Forwarded-At + * - timestamp more than 5 minutes off from `now` (in either direction; + * handles clock skew on the gateway and on this VM) + * - hex-decode failure on the presented signature + * - presented and computed digest lengths differ + * - constant-time compare returns inequality + */ +export function verifyGatewaySignature(input: VerifyInput): boolean { + const { sigHeader, fwdHeader, eventId, raw, secret } = input; + const now = input.now ?? Date.now(); + + if (!sigHeader || !sigHeader.startsWith(SIGNATURE_PREFIX)) return false; + if (!fwdHeader) return false; + + const fwdAt = Number.parseInt(fwdHeader, 10); + if (!Number.isFinite(fwdAt)) return false; + if (Math.abs(now - fwdAt) > REPLAY_WINDOW_MS) return false; + + const canonical = `${fwdHeader}:${eventId ?? ""}:${raw.toString("utf-8")}`; + const expected = createHmac("sha256", secret).update(canonical).digest("hex"); + const presented = sigHeader.slice(SIGNATURE_PREFIX.length); + + const expBuf = Buffer.from(expected, "hex"); + let presBuf: Buffer; + try { + presBuf = Buffer.from(presented, "hex"); + } catch { + return false; + } + if (presBuf.length !== expBuf.length) return false; + return timingSafeEqual(expBuf, presBuf); +} + +/** + * Helper for tests and for the receiver to compute the canonical signature + * the gateway would emit. Exported so the test suite can sign a forged + * request body without re-implementing the canonical string format. + */ +export function signGatewayRequest(args: { + forwardedAt: number; + eventId: string; + rawBody: Buffer; + secret: string; +}): string { + const canonical = `${args.forwardedAt}:${args.eventId}:${args.rawBody.toString("utf-8")}`; + const mac = createHmac("sha256", args.secret).update(canonical).digest("hex"); + return `${SIGNATURE_PREFIX}${mac}`; +} + +/** + * Extract `team_id` from a forwarded request body. Both Slack events + * (application/json) and interactivity payloads + * (application/x-www-form-urlencoded with a JSON `payload` parameter) carry + * the team identifier, but in different shapes. + * + * Returns undefined when the body shape cannot be parsed; the caller decides + * what undefined means (we treat it as "do not enforce" because some pings + * legitimately have no team_id). + */ +export function extractTeamId(raw: Buffer, contentType: string): string | undefined { + const lower = contentType.toLowerCase(); + const text = raw.toString("utf-8"); + if (lower.includes("application/x-www-form-urlencoded")) { + const params = new URLSearchParams(text); + const payload = params.get("payload"); + if (!payload) return undefined; + try { + const parsed = JSON.parse(payload) as Record; + return readTeamId(parsed); + } catch { + return undefined; + } + } + try { + const parsed = JSON.parse(text) as Record; + return readTeamId(parsed); + } catch { + return undefined; + } +} + +function readTeamId(parsed: Record): string | undefined { + if (typeof parsed.team_id === "string") return parsed.team_id; + const team = parsed.team as Record | undefined; + if (team && typeof team.id === "string") return team.id; + return undefined; +} diff --git a/src/channels/slack-http-events.ts b/src/channels/slack-http-events.ts new file mode 100644 index 00000000..6998ce57 --- /dev/null +++ b/src/channels/slack-http-events.ts @@ -0,0 +1,152 @@ +// Phase 5b: Bolt event-handler registrations for the HTTP receiver. These +// mirror the Socket Mode handlers in slack.ts so the agent's view of an +// inbound message is identical regardless of transport. The functions below +// take the `SlackHttpChannel` as a host argument; we pass the parts the +// handlers need (botUserId, teamId, messageHandler, reactionHandler) so the +// receiver class itself stays focused on lifecycle and Slack API egress. + +import type { App } from "@slack/bolt"; +import type { InboundMessage } from "./types.ts"; + +export type EventDispatchHost = { + readonly id: string; + getBotUserId(): string | null; + getTeamId(): string; + getMessageHandler(): ((message: InboundMessage) => Promise) | null; + getReactionHandler(): ReactionFn | null; + stripBotMention(text: string): string; +}; + +export type ReactionFn = (event: { + reaction: string; + userId: string; + messageTs: string; + channel: string; + isPositive: boolean; +}) => void; + +export function registerHttpEventHandlers(app: App, host: EventDispatchHost): void { + app.event("app_mention", async ({ event, body }) => { + const handler = host.getMessageHandler(); + if (!handler) return; + + const senderId = event.user ?? "unknown"; + const eventTeamId = extractTeamIdFromBody(body) ?? host.getTeamId(); + if (eventTeamId !== host.getTeamId()) { + console.log(`[slack-http] Dropping app_mention with foreign team_id: ${eventTeamId}`); + return; + } + + const cleanText = host.stripBotMention(event.text); + if (!cleanText.trim()) return; + + const threadTs = event.thread_ts ?? event.ts; + const conversationId = `slack:${event.channel}:${threadTs}`; + + const inbound: InboundMessage = { + id: event.ts, + channelId: host.id, + conversationId, + threadId: threadTs, + senderId, + text: cleanText.trim(), + timestamp: new Date(Number.parseFloat(event.ts) * 1000), + metadata: { + slackChannel: event.channel, + slackThreadTs: threadTs, + slackMessageTs: event.ts, + source: "app_mention", + }, + }; + + try { + await handler(inbound); + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + console.error(`[slack-http] Error handling app_mention: ${errMsg}`); + } + }); + + app.event("message", async ({ event, body }) => { + const handler = host.getMessageHandler(); + if (!handler) return; + + const m = event as unknown as Record; + if (m.subtype) return; + if (m.bot_id) return; + + const userId = m.user as string | undefined; + const botUserId = host.getBotUserId(); + if (botUserId && userId === botUserId) return; + + const channelType = m.channel_type as string | undefined; + if (channelType !== "im") return; + + const eventTeamId = extractTeamIdFromBody(body) ?? host.getTeamId(); + if (eventTeamId !== host.getTeamId()) { + console.log(`[slack-http] Dropping DM with foreign team_id: ${eventTeamId}`); + return; + } + + const text = (m.text as string) ?? ""; + if (!text.trim()) return; + + const channel = m.channel as string; + const ts = m.ts as string; + const threadTs = (m.thread_ts as string) ?? ts; + const conversationId = `slack:${channel}:${threadTs}`; + + const inbound: InboundMessage = { + id: ts, + channelId: host.id, + conversationId, + threadId: threadTs, + senderId: userId ?? "unknown", + text: text.trim(), + timestamp: new Date(Number.parseFloat(ts) * 1000), + metadata: { + slackChannel: channel, + slackThreadTs: threadTs, + slackMessageTs: ts, + source: "dm", + }, + }; + + try { + await handler(inbound); + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + console.error(`[slack-http] Error handling DM: ${errMsg}`); + } + }); + + app.event("reaction_added", async ({ event }) => { + const reaction = event.reaction; + const isPositive = + reaction === "+1" || reaction === "thumbsup" || reaction === "heart" || reaction === "white_check_mark"; + const isNegative = reaction === "-1" || reaction === "thumbsdown" || reaction === "x"; + if (!isPositive && !isNegative) return; + + console.log(`[slack-http] Reaction ${isPositive ? "positive" : "negative"}: :${reaction}: from ${event.user}`); + + const reactionHandler = host.getReactionHandler(); + if (reactionHandler) { + reactionHandler({ + reaction, + userId: event.user, + messageTs: event.item.ts, + channel: event.item.channel, + isPositive, + }); + } + }); +} + +function extractTeamIdFromBody(body: unknown): string | undefined { + if (!body || typeof body !== "object") return undefined; + const obj = body as Record; + if (typeof obj.team_id === "string") return obj.team_id; + const team = obj.team as Record | undefined; + if (team && typeof team.id === "string") return team.id; + return undefined; +} diff --git a/src/channels/slack-http-receiver.ts b/src/channels/slack-http-receiver.ts new file mode 100644 index 00000000..f16f04de --- /dev/null +++ b/src/channels/slack-http-receiver.ts @@ -0,0 +1,427 @@ +// Phase 5b: HTTP receiver mode for Phantom Cloud tenants. Slack events are +// captured by a shared central gateway (phantom-slack-events), verified +// against Slack's signing secret there, then forwarded over HTTPS to the +// per-tenant Phantom on this VM. Self-hosters keep the Socket Mode flow at +// `slack.ts`; SLACK_TRANSPORT=http opts a tenant into this class. +// +// Three security layers operate at this boundary: +// 1. The Caddy edge in front of the tenant validates the gateway's HMAC +// and strips inbound X-Phantom-* headers (defense in depth at the +// reverse proxy). +// 2. This class re-verifies the gateway's HMAC on every request via the +// `slack-gateway-verifier` helper before Bolt sees the body. +// 3. After HMAC succeeds, the parsed body's team_id MUST match the +// tenant's installer team_id. The gateway has already verified team_id +// maps to this tenant, but a misrouted forward must not be processed. +// +// We reuse Bolt's App + ExpressReceiver so the existing slack-actions.ts +// registrations (`app.action(...)`) compose unchanged. ExpressReceiver is +// constructed with `signatureVerification: false` because we verified the +// gateway's HMAC ourselves. + +import { randomUUID } from "node:crypto"; +import { App, ExpressReceiver, type LogLevel } from "@slack/bolt"; +import type { Request, RequestHandler, Response } from "express"; +import type { SlackBlock } from "./feedback.ts"; +import { buildFeedbackBlocks } from "./feedback.ts"; +import { registerSlackActions } from "./slack-actions.ts"; +import { splitMessage, toSlackMarkdown, truncateForSlack } from "./slack-formatter.ts"; +import { extractTeamId, verifyGatewaySignature } from "./slack-gateway-verifier.ts"; +import { type EventDispatchHost, type ReactionFn, registerHttpEventHandlers } from "./slack-http-events.ts"; +import type { Channel, ChannelCapabilities, InboundMessage, OutboundMessage, SentMessage } from "./types.ts"; + +export type SlackHttpChannelConfig = { + botToken: string; + gatewaySigningSecret: string; + teamId: string; + installerUserId: string; + teamName: string; + listenPort: number; + listenPath: string; +}; + +type ConnectionState = "disconnected" | "connecting" | "connected" | "error"; +type RequestWithRawBody = Request & { rawBody?: Buffer }; + +export class SlackHttpChannel implements Channel, EventDispatchHost { + readonly id = "slack"; + readonly name = "Slack"; + readonly capabilities: ChannelCapabilities = { + threads: true, + richText: true, + attachments: true, + buttons: true, + reactions: true, + progressUpdates: true, + }; + + private readonly app: App; + private readonly receiver: ExpressReceiver; + private readonly teamId: string; + private readonly installerUserId: string; + private readonly teamName: string; + private readonly gatewaySigningSecret: string; + private readonly listenPort: number; + private readonly listenPath: string; + + private messageHandler: ((message: InboundMessage) => Promise) | null = null; + private reactionHandler: ReactionFn | null = null; + private connectionState: ConnectionState = "disconnected"; + private botUserId: string | null = null; + private phantomName = "Phantom"; + + constructor(config: SlackHttpChannelConfig) { + if (!config.botToken) throw new Error("SlackHttpChannel: botToken is required"); + if (!config.gatewaySigningSecret) throw new Error("SlackHttpChannel: gatewaySigningSecret is required"); + if (!config.teamId) throw new Error("SlackHttpChannel: teamId is required"); + + this.teamId = config.teamId; + this.installerUserId = config.installerUserId; + this.teamName = config.teamName; + this.gatewaySigningSecret = config.gatewaySigningSecret; + this.listenPort = config.listenPort; + this.listenPath = config.listenPath; + + // `signatureVerification: false` skips Slack-signing-secret verification + // because the gateway has already verified Slack's signature, and we + // will verify the gateway's HMAC ourselves. + this.receiver = new ExpressReceiver({ + signingSecret: "phase-5b-unused-gateway-verifies-instead", + signatureVerification: false, + endpoints: { + events: `${this.listenPath}/events`, + interactive: `${this.listenPath}/interactivity`, + commands: `${this.listenPath}/commands`, + }, + processBeforeResponse: false, + logLevel: "ERROR" as LogLevel, + }); + + this.installVerifier(); + + this.app = new App({ + token: config.botToken, + receiver: this.receiver, + logLevel: "ERROR" as LogLevel, + }); + } + + setPhantomName(name: string): void { + this.phantomName = name; + } + getInstallerUserId(): string { + return this.installerUserId; + } + getTeamId(): string { + return this.teamId; + } + getTeamName(): string { + return this.teamName; + } + getBotUserId(): string | null { + return this.botUserId; + } + getPhantomName(): string { + return this.phantomName; + } + getMessageHandler(): ((message: InboundMessage) => Promise) | null { + return this.messageHandler; + } + getReactionHandler(): ReactionFn | null { + return this.reactionHandler; + } + getClient(): App["client"] { + return this.app.client; + } + + stripBotMention(text: string): string { + if (this.botUserId) { + return text.replace(new RegExp(`<@${this.botUserId}>\\s*`, "g"), ""); + } + return text.replace(/^<@[A-Z0-9]+>\s*/, ""); + } + + /** + * Install Express middleware in front of Bolt's routes. Fails closed: + * missing headers return 401, tampered body fails the HMAC compare and + * returns 401, stale or future-skewed timestamp returns 401, foreign + * team_id returns 403. + */ + private installVerifier(): void { + const expressApp = this.receiver.app; + const guard = this.makeGuardMiddleware(); + expressApp.use(`${this.listenPath}/events`, guard); + expressApp.use(`${this.listenPath}/interactivity`, guard); + expressApp.use(`${this.listenPath}/commands`, guard); + } + + private makeGuardMiddleware(): RequestHandler { + const secret = this.gatewaySigningSecret; + const expectedTeamId = this.teamId; + + return async (req: Request, res: Response, next): Promise => { + let raw: Buffer; + try { + raw = await readRequestBody(req as RequestWithRawBody); + } catch { + res.status(400).end("bad request"); + return; + } + + (req as RequestWithRawBody).rawBody = raw; + + const sigHeader = headerString(req, "x-phantom-signature"); + const fwdHeader = headerString(req, "x-phantom-forwarded-at"); + const eventId = headerString(req, "x-phantom-slack-event-id"); + + if (!verifyGatewaySignature({ sigHeader, fwdHeader, eventId, raw, secret })) { + res.status(401).end("unauthorized"); + return; + } + + const eventTeamId = extractTeamId(raw, getContentType(req)); + if (eventTeamId !== undefined && eventTeamId !== expectedTeamId) { + res.status(403).end("forbidden"); + return; + } + + rehydrateBody(req, raw); + next(); + }; + } + + async connect(): Promise { + if (this.connectionState === "connected") return; + this.connectionState = "connecting"; + + registerHttpEventHandlers(this.app, this); + registerSlackActions(this.app); + + try { + // `auth.test` validates the bot token against Slack and returns the + // bot user id. If the token has been revoked between OAuth callback + // and tenant boot, this fails and we refuse to start. + const authResult = await this.app.client.auth.test(); + this.botUserId = authResult.user_id ?? null; + if (!this.botUserId) { + this.connectionState = "error"; + throw new Error("auth.test returned no user_id; bot token may be revoked"); + } + console.log(`[slack-http] Resolved bot user <@${this.botUserId}>`); + + await this.receiver.start(this.listenPort); + this.connectionState = "connected"; + console.log(`[slack-http] Listening on :${this.listenPort}${this.listenPath}/{events,interactivity,commands}`); + } catch (err: unknown) { + this.connectionState = "error"; + const msg = err instanceof Error ? err.message : String(err); + // Do NOT include any token material in error logs. + console.error(`[slack-http] Failed to start: ${msg}`); + throw err; + } + } + + async disconnect(): Promise { + if (this.connectionState === "disconnected") return; + try { + await this.receiver.stop(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[slack-http] Error during disconnect: ${msg}`); + } + this.connectionState = "disconnected"; + console.log("[slack-http] Disconnected"); + } + + async send(conversationId: string, message: OutboundMessage): Promise { + const { channel, threadTs } = parseConversationId(conversationId); + const formattedText = toSlackMarkdown(message.text); + const replyThreadTs = message.threadId ?? threadTs; + const chunks = splitMessage(formattedText); + let lastTs = ""; + for (const chunk of chunks) { + const result = await this.app.client.chat.postMessage({ + channel, + text: chunk, + thread_ts: replyThreadTs, + }); + lastTs = result.ts ?? ""; + } + return { + id: lastTs || randomUUID(), + channelId: this.id, + conversationId, + timestamp: new Date(), + }; + } + + onMessage(handler: (message: InboundMessage) => Promise): void { + this.messageHandler = handler; + } + + onReaction(handler: ReactionFn): void { + this.reactionHandler = handler; + } + + isConnected(): boolean { + return this.connectionState === "connected"; + } + + getConnectionState(): ConnectionState { + return this.connectionState; + } + + async postToChannel(channelId: string, text: string): Promise { + const formattedText = toSlackMarkdown(text); + const chunks = splitMessage(formattedText); + let lastTs: string | null = null; + for (const chunk of chunks) { + try { + const result = await this.app.client.chat.postMessage({ channel: channelId, text: chunk }); + lastTs = result.ts ?? null; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[slack-http] Failed to post to channel ${channelId}: ${msg}`); + return null; + } + } + return lastTs; + } + + async sendDm(userId: string, text: string): Promise { + try { + const openResult = await this.app.client.conversations.open({ users: userId }); + const dmChannelId = openResult.channel?.id; + if (!dmChannelId) { + console.error(`[slack-http] Failed to open DM with user ${userId}: no channel returned`); + return null; + } + return this.postToChannel(dmChannelId, text); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[slack-http] Failed to send DM to user ${userId}: ${msg}`); + return null; + } + } + + async postThinking(channel: string, threadTs: string): Promise { + try { + const result = await this.app.client.chat.postMessage({ + channel, + thread_ts: threadTs, + text: "Working on it...", + }); + return result.ts ?? null; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[slack-http] Failed to post thinking indicator: ${msg}`); + return null; + } + } + + async updateMessage(channel: string, ts: string, text: string, blocks?: SlackBlock[]): Promise { + const formattedText = toSlackMarkdown(text); + const truncated = truncateForSlack(formattedText); + try { + const updateArgs: Record = { channel, ts, text: truncated }; + if (blocks) updateArgs.blocks = blocks; + await this.app.client.chat.update(updateArgs as unknown as Parameters[0]); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[slack-http] Failed to update message: ${msg}`); + } + } + + async updateWithFeedback(channel: string, ts: string, text: string): Promise { + const formattedText = toSlackMarkdown(text); + const truncated = truncateForSlack(formattedText); + const feedbackBlocks = buildFeedbackBlocks(ts); + const blocks: SlackBlock[] = [{ type: "section", text: { type: "mrkdwn", text: truncated } }, ...feedbackBlocks]; + try { + const updateArgs: Record = { channel, ts, text: truncated, blocks }; + await this.app.client.chat.update(updateArgs as unknown as Parameters[0]); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[slack-http] Failed to update message with feedback: ${msg}`); + } + } + + async addReaction(channel: string, messageTs: string, emoji: string): Promise { + try { + await this.app.client.reactions.add({ channel, timestamp: messageTs, name: emoji }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes("already_reacted")) { + console.warn(`[slack-http] Failed to add reaction :${emoji}:: ${msg}`); + } + } + } + + async removeReaction(channel: string, messageTs: string, emoji: string): Promise { + try { + await this.app.client.reactions.remove({ channel, timestamp: messageTs, name: emoji }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes("no_reaction")) { + console.warn(`[slack-http] Failed to remove reaction :${emoji}:: ${msg}`); + } + } + } +} + +// --- helpers --------------------------------------------------------------- + +function headerString(req: Request, name: string): string | null { + const value = req.headers[name.toLowerCase()]; + if (Array.isArray(value)) return value[0] ?? null; + return typeof value === "string" ? value : null; +} + +function getContentType(req: Request): string { + return headerString(req, "content-type") ?? "application/json"; +} + +async function readRequestBody(req: RequestWithRawBody): Promise { + if (req.rawBody) { + return Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(req.rawBody); + } + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer | string) => { + if (chunk == null) return; + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", (err: Error) => reject(err)); + }); +} + +/** + * After we have consumed the request stream to verify HMAC, ExpressReceiver's + * downstream body parser still expects to read a body. We pre-parse JSON or + * urlencoded bodies onto `req.body` so subsequent middleware finds the + * already-parsed payload via the standard Express convention. + */ +function rehydrateBody(req: Request, raw: Buffer): void { + const ctype = getContentType(req).toLowerCase(); + if (ctype.includes("application/json")) { + try { + req.body = JSON.parse(raw.toString("utf-8")); + } catch { + // Leave body unset; downstream parser will surface the error. + } + } else if (ctype.includes("application/x-www-form-urlencoded")) { + const params = new URLSearchParams(raw.toString("utf-8")); + const obj: Record = {}; + for (const [k, v] of params) obj[k] = v; + req.body = obj; + } +} + +function parseConversationId(conversationId: string): { channel: string; threadTs: string | undefined } { + const parts = conversationId.split(":"); + if (parts[1] === "dm") { + return { channel: parts[2], threadTs: undefined }; + } + return { channel: parts[1], threadTs: parts[2] }; +} diff --git a/src/channels/slack-transport.ts b/src/channels/slack-transport.ts new file mode 100644 index 00000000..2bcfdc4e --- /dev/null +++ b/src/channels/slack-transport.ts @@ -0,0 +1,10 @@ +// Phase 5b: shared structural type for the two Slack channel implementations. +// Code that doesn't care which transport is in use (the scheduler delivery +// paths, the /trigger endpoint, the index.ts wiring) accepts `SlackTransport` +// instead of importing the concrete classes. This file is the one place that +// references both, keeping the transport choice opaque to everything else. + +import type { SlackHttpChannel } from "./slack-http-receiver.ts"; +import type { SlackChannel } from "./slack.ts"; + +export type SlackTransport = SlackChannel | SlackHttpChannel; diff --git a/src/config/__tests__/identity-fetcher.test.ts b/src/config/__tests__/identity-fetcher.test.ts new file mode 100644 index 00000000..01447047 --- /dev/null +++ b/src/config/__tests__/identity-fetcher.test.ts @@ -0,0 +1,170 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { DEFAULT_METADATA_BASE_URL, MetadataIdentityFetcher } from "../identity-fetcher.ts"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +const minimalIdentity = { + tenant_id: "ten_alpha", + tenant_slug: "alpha", + region: "eu-central-1", + host_id: "host-01", + env: "prod", + source_ip: "10.0.0.5", + fleet_rotation_counter: 0, + supported_secret_name_patterns: [], +}; + +describe("MetadataIdentityFetcher", () => { + test("DEFAULT_METADATA_BASE_URL points at the link-local address", () => { + expect(DEFAULT_METADATA_BASE_URL).toBe("http://169.254.169.254"); + }); + + test("returns parsed identity on 200", async () => { + globalThis.fetch = mock((url: string | Request) => { + expect(String(url)).toBe("http://gateway.test/v1/identity"); + return Promise.resolve(new Response(JSON.stringify(minimalIdentity), { status: 200 })); + }) as unknown as typeof fetch; + + const fetcher = new MetadataIdentityFetcher("http://gateway.test"); + const id = await fetcher.get(); + expect(id.tenantId).toBe("ten_alpha"); + expect(id.tenantSlug).toBe("alpha"); + expect(id.region).toBe("eu-central-1"); + expect(id.hostId).toBe("host-01"); + expect(id.env).toBe("prod"); + expect(id.slack).toBeUndefined(); + }); + + test("parses slack subfield when present", async () => { + const withSlack = { + ...minimalIdentity, + slack: { + team_id: "T9TK3CUKW", + installer_user_id: "U061F7AUR", + team_name: "Acme Corp", + installed_at: "2026-04-25T12:00:00Z", + }, + }; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify(withSlack), { status: 200 })), + ) as unknown as typeof fetch; + + const fetcher = new MetadataIdentityFetcher("http://gateway.test"); + const id = await fetcher.get(); + expect(id.slack).toBeDefined(); + expect(id.slack?.teamId).toBe("T9TK3CUKW"); + expect(id.slack?.installerUserId).toBe("U061F7AUR"); + expect(id.slack?.teamName).toBe("Acme Corp"); + expect(id.slack?.installedAt).toBe("2026-04-25T12:00:00Z"); + }); + + test("treats slack: null as no install (defensive over JSON encoders)", async () => { + // Phantomd uses omitempty so the wire never carries `"slack": null`, + // but a forwarding proxy or future encoder could. Tolerate both. + const withNull = { ...minimalIdentity, slack: null }; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify(withNull), { status: 200 })), + ) as unknown as typeof fetch; + + const fetcher = new MetadataIdentityFetcher("http://gateway.test"); + const id = await fetcher.get(); + expect(id.slack).toBeUndefined(); + }); + + test("HTTP 500 throws with status, never with body", async () => { + // The /v1/identity body is not a secret, but error messages that quote + // response bodies tend to grow teeth. Keep error context to status only. + globalThis.fetch = mock(() => + Promise.resolve(new Response("internal error", { status: 500, statusText: "Server Error" })), + ) as unknown as typeof fetch; + + const fetcher = new MetadataIdentityFetcher("http://gateway.test"); + try { + await fetcher.get(); + throw new Error("expected get() to throw"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + expect(msg).toContain("500"); + expect(msg).toContain("identity"); + expect(msg).not.toContain("internal error"); + } + }); + + test("network error wraps with 'metadata: fetch identity failed'", async () => { + globalThis.fetch = mock(() => Promise.reject(new Error("ECONNREFUSED"))) as unknown as typeof fetch; + + const fetcher = new MetadataIdentityFetcher("http://gateway.test"); + await expect(fetcher.get()).rejects.toThrow(/metadata: fetch identity failed/); + await expect(fetcher.get()).rejects.toThrow(/ECONNREFUSED/); + }); + + test("malformed JSON surfaces a clear error", async () => { + globalThis.fetch = mock(() => + Promise.resolve(new Response("not json", { status: 200 })), + ) as unknown as typeof fetch; + + const fetcher = new MetadataIdentityFetcher("http://gateway.test"); + await expect(fetcher.get()).rejects.toThrow(/malformed JSON/); + }); + + test("missing tenant_id throws", async () => { + const broken = { ...minimalIdentity }; + // biome-ignore lint/performance/noDelete: deliberate field deletion for test + delete (broken as Record).tenant_id; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify(broken), { status: 200 })), + ) as unknown as typeof fetch; + + const fetcher = new MetadataIdentityFetcher("http://gateway.test"); + await expect(fetcher.get()).rejects.toThrow(/tenant_id is not a string/); + }); + + test("slack subfield missing team_id throws", async () => { + const broken = { + ...minimalIdentity, + slack: { installer_user_id: "U1", team_name: "x", installed_at: "2026-04-25T12:00:00Z" }, + }; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify(broken), { status: 200 })), + ) as unknown as typeof fetch; + + const fetcher = new MetadataIdentityFetcher("http://gateway.test"); + await expect(fetcher.get()).rejects.toThrow(/slack\.team_id is not a string/); + }); + + test("slack subfield with null team_id throws", async () => { + const broken = { + ...minimalIdentity, + slack: { team_id: null, installer_user_id: "U1", team_name: "x", installed_at: "z" }, + }; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify(broken), { status: 200 })), + ) as unknown as typeof fetch; + + const fetcher = new MetadataIdentityFetcher("http://gateway.test"); + await expect(fetcher.get()).rejects.toThrow(/slack\.team_id is not a string/); + }); + + test("slack non-object value throws", async () => { + const broken = { ...minimalIdentity, slack: "not-an-object" }; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify(broken), { status: 200 })), + ) as unknown as typeof fetch; + + const fetcher = new MetadataIdentityFetcher("http://gateway.test"); + await expect(fetcher.get()).rejects.toThrow(/identity\.slack is not an object/); + }); + + test("non-object response body throws", async () => { + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify("just a string"), { status: 200 })), + ) as unknown as typeof fetch; + + const fetcher = new MetadataIdentityFetcher("http://gateway.test"); + await expect(fetcher.get()).rejects.toThrow(/identity response is not an object/); + }); +}); diff --git a/src/config/identity-fetcher.ts b/src/config/identity-fetcher.ts new file mode 100644 index 00000000..346c6bae --- /dev/null +++ b/src/config/identity-fetcher.ts @@ -0,0 +1,126 @@ +// Phase 5b: tenant Phantom fetches its non-secret identity from the host +// metadata gateway at boot. Identity is per-tenant context (tenant_id, slug, +// region, host_id, env) plus an optional `slack` subfield populated by +// phantom-control after the OAuth handshake. Secrets (bot token, gateway +// signing secret) live under /v1/secrets/ via Phase C; identity is +// the parallel non-secret path. +// +// Three invariants live here: +// 1. No body is included in error messages. The /v1/identity body is not +// a secret, but error messages that quote response bodies tend to grow +// teeth as schemas evolve. We keep error context to HTTP status only. +// 2. Schema is validated before the value is returned. A future field that +// drifts from the documented shape produces a clear parse error rather +// than a silent undefined-typed runtime hazard. +// 3. No cache. Identity is fetched once at boot. The caller holds the +// result in process memory; rotation is an out-of-band operator action +// via UpdateSlackIdentity gRPC followed by daemon restart. + +export const DEFAULT_METADATA_BASE_URL = "http://169.254.169.254"; + +export type SlackIdentity = { + teamId: string; + installerUserId: string; + teamName: string; + installedAt: string; +}; + +export type TenantIdentity = { + tenantId: string; + tenantSlug: string; + region: string; + hostId: string; + env: string; + slack?: SlackIdentity; +}; + +export class MetadataIdentityFetcher { + private readonly baseUrl: string; + + constructor(baseUrl: string) { + this.baseUrl = baseUrl; + } + + async get(): Promise { + const url = `${this.baseUrl}/v1/identity`; + + let res: Response; + try { + res = await fetch(url, { method: "GET" }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`metadata: fetch identity failed: ${msg}`); + } + + if (res.status !== 200) { + throw new Error(`metadata: fetch identity failed: HTTP ${res.status} ${res.statusText}`); + } + + let parsed: unknown; + try { + parsed = await res.json(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`metadata: fetch identity failed: malformed JSON: ${msg}`); + } + + return parseIdentity(parsed); + } +} + +function parseIdentity(raw: unknown): TenantIdentity { + if (!raw || typeof raw !== "object") { + throw new Error("metadata: identity response is not an object"); + } + const obj = raw as Record; + + const tenantId = requireString(obj, "tenant_id"); + const tenantSlug = requireString(obj, "tenant_slug"); + const region = optionalString(obj, "region") ?? ""; + const hostId = optionalString(obj, "host_id") ?? ""; + const env = optionalString(obj, "env") ?? ""; + + const identity: TenantIdentity = { + tenantId, + tenantSlug, + region, + hostId, + env, + }; + + if (obj.slack !== undefined && obj.slack !== null) { + identity.slack = parseSlack(obj.slack); + } + + return identity; +} + +function parseSlack(raw: unknown): SlackIdentity { + if (!raw || typeof raw !== "object") { + throw new Error("metadata: identity.slack is not an object"); + } + const obj = raw as Record; + return { + teamId: requireString(obj, "team_id", "slack.team_id"), + installerUserId: requireString(obj, "installer_user_id", "slack.installer_user_id"), + teamName: requireString(obj, "team_name", "slack.team_name"), + installedAt: requireString(obj, "installed_at", "slack.installed_at"), + }; +} + +function requireString(obj: Record, key: string, label?: string): string { + const value = obj[key]; + if (typeof value !== "string") { + throw new Error(`metadata: identity field ${label ?? key} is not a string`); + } + return value; +} + +function optionalString(obj: Record, key: string): string | undefined { + const value = obj[key]; + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") { + throw new Error(`metadata: identity field ${key} is not a string`); + } + return value; +} From 454efd42b9c771cfb0a67611bfb8fdab588fe9d4 Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema Date: Sat, 25 Apr 2026 11:32:50 -0700 Subject: [PATCH 2/3] channels: wire SLACK_TRANSPORT dispatch into Phantom boot The new factory at slack-channel-factory.ts picks SlackChannel or SlackHttpChannel based on SLACK_TRANSPORT (default "socket", "http" opts a tenant into the distributed-app flow). The factory is the one place that depends on both classes; everywhere else takes the structural SlackTransport union from slack-transport.ts. The 5-line constructor guard in slack.ts rejects transport: "http" up front so a misconfigured tenant fails loudly at construction instead of silently running Socket Mode. Scheduler delivery, /trigger endpoint, and the onboarding flow widen their type signatures from SlackChannel to SlackTransport. Both classes share the same method surface (sendDm, postToChannel, isConnected, getClient, etc.) so the call sites are byte-identical. Default SLACK_TRANSPORT=socket path is byte-identical to today's behaviour: the existing 30 SlackChannel tests pass unchanged. --- src/channels/slack.ts | 4 ++++ src/core/server.ts | 4 ++-- src/index.ts | 33 ++++++++++++++++++++++----------- src/onboarding/flow.ts | 4 ++-- src/scheduler/delivery.ts | 4 ++-- src/scheduler/executor.ts | 4 ++-- src/scheduler/service.ts | 8 ++++---- 7 files changed, 38 insertions(+), 23 deletions(-) diff --git a/src/channels/slack.ts b/src/channels/slack.ts index 587a4267..6f342383 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -11,6 +11,7 @@ export type SlackChannelConfig = { appToken: string; defaultChannelId?: string; ownerUserId?: string; + transport?: "socket"; }; type ConnectionState = "disconnected" | "connecting" | "connected" | "error"; @@ -45,6 +46,9 @@ export class SlackChannel implements Channel { private rejectedUsers = new Set(); constructor(config: SlackChannelConfig) { + if (config.transport && config.transport !== "socket") { + throw new Error("SlackChannel only supports Socket Mode. Use SlackHttpChannel for HTTP receiver mode."); + } this.app = new App({ token: config.botToken, socketMode: true, diff --git a/src/core/server.ts b/src/core/server.ts index 307ed30a..c45f21a8 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -1,6 +1,6 @@ import { resolve as pathResolve } from "node:path"; import type { AgentRuntime } from "../agent/runtime.ts"; -import type { SlackChannel } from "../channels/slack.ts"; +import type { SlackTransport } from "../channels/slack-transport.ts"; import { handleEmailLogin } from "../chat/email-login.ts"; import type { PhantomConfig } from "../config/types.ts"; import { AuthMiddleware } from "../mcp/auth.ts"; @@ -27,7 +27,7 @@ type PeerHealthProvider = () => Record SchedulerHealthSummary | null; type TriggerDeps = { runtime: AgentRuntime; - slackChannel?: SlackChannel; + slackChannel?: SlackTransport; ownerUserId?: string; }; diff --git a/src/index.ts b/src/index.ts index e68d91a9..5112a8fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,9 @@ import { formatToolActivity } from "./channels/progress-stream.ts"; import { createProgressStream } from "./channels/progress-stream.ts"; import { ChannelRouter } from "./channels/router.ts"; import { setActionFollowUpHandler } from "./channels/slack-actions.ts"; -import { SlackChannel } from "./channels/slack.ts"; +import { createSlackChannel, readSlackTransportFromEnv } from "./channels/slack-channel-factory.ts"; +import type { SlackHttpChannel } from "./channels/slack-http-receiver.ts"; +import type { SlackChannel } from "./channels/slack.ts"; import { createStatusReactionController } from "./channels/status-reactions.ts"; import { TelegramChannel } from "./channels/telegram.ts"; import { WebhookChannel } from "./channels/webhook.ts"; @@ -295,16 +297,25 @@ async function main(): Promise { const router = new ChannelRouter(); - // Register Slack channel - let slackChannel: SlackChannel | null = null; + // Register Slack channel. + // + // SLACK_TRANSPORT controls which receiver runs: + // - "socket" (default): the OSS Socket Mode flow. Reads bot_token and + // app_token from channels.yaml. + // - "http": the Phantom Cloud distributed-app flow. Fetches identity + // and bot token from the in-tenant metadata gateway at boot. The + // gateway pushes /v1/identity.slack after the OAuth handshake; this + // code path requires that subfield to be populated, otherwise the + // factory throws so a mis-provisioned tenant fails loudly. const channelsConfig = loadChannelsConfig(); - if (channelsConfig?.slack?.enabled && channelsConfig.slack.bot_token && channelsConfig.slack.app_token) { - slackChannel = new SlackChannel({ - botToken: channelsConfig.slack.bot_token, - appToken: channelsConfig.slack.app_token, - defaultChannelId: channelsConfig.slack.default_channel_id, - ownerUserId: channelsConfig.slack.owner_user_id, - }); + const slackChannel: SlackChannel | SlackHttpChannel | null = await createSlackChannel({ + transport: readSlackTransportFromEnv(), + channelsConfig, + port: config.port, + metadataBaseUrl: process.env.METADATA_BASE_URL, + }); + + if (slackChannel) { slackChannel.setPhantomName(config.name); // Wire Slack reaction feedback to evolution @@ -320,7 +331,7 @@ async function main(): Promise { }); router.register(slackChannel); - console.log("[phantom] Slack channel registered"); + console.log(`[phantom] Slack channel registered (transport=${process.env.SLACK_TRANSPORT ?? "socket"})`); } // Register Telegram channel diff --git a/src/onboarding/flow.ts b/src/onboarding/flow.ts index 45234f6c..f0eaf478 100644 --- a/src/onboarding/flow.ts +++ b/src/onboarding/flow.ts @@ -1,5 +1,5 @@ import type { Database } from "bun:sqlite"; -import type { SlackChannel } from "../channels/slack.ts"; +import type { SlackTransport } from "../channels/slack-transport.ts"; import type { RoleTemplate } from "../roles/types.ts"; import { type OwnerProfile, type SlackProfileClient, hasPersonalizationData, profileOwner } from "./profiler.ts"; import { markOnboardingStarted } from "./state.ts"; @@ -44,7 +44,7 @@ function buildPersonalizedIntro(phantomName: string, _role: RoleTemplate, profil * Falls back to generic intro if profiling fails or no owner is configured. */ export async function startOnboarding( - slack: SlackChannel, + slack: SlackTransport, target: OnboardingTarget, phantomName: string, role: RoleTemplate, diff --git a/src/scheduler/delivery.ts b/src/scheduler/delivery.ts index dc0e4e30..900e8774 100644 --- a/src/scheduler/delivery.ts +++ b/src/scheduler/delivery.ts @@ -1,4 +1,4 @@ -import type { SlackChannel } from "../channels/slack.ts"; +import type { SlackTransport } from "../channels/slack-transport.ts"; import type { ScheduledJob } from "./types.ts"; /** @@ -15,7 +15,7 @@ export type DeliveryOutcome = | `error:${string}`; export type DeliveryContext = { - slackChannel: SlackChannel | undefined; + slackChannel: SlackTransport | undefined; ownerUserId: string | null; }; diff --git a/src/scheduler/executor.ts b/src/scheduler/executor.ts index c4c135ad..80a3f7f4 100644 --- a/src/scheduler/executor.ts +++ b/src/scheduler/executor.ts @@ -1,6 +1,6 @@ import type { Database } from "bun:sqlite"; import type { AgentRuntime } from "../agent/runtime.ts"; -import type { SlackChannel } from "../channels/slack.ts"; +import type { SlackTransport } from "../channels/slack-transport.ts"; import { type DeliveryOutcome, deliverResult } from "./delivery.ts"; import { computeBackoffNextRun, computeNextRunAt } from "./schedule.ts"; import { JOB_STATUS_VALUES, type ScheduledJob } from "./types.ts"; @@ -10,7 +10,7 @@ export const MAX_CONSECUTIVE_ERRORS = 10; export type ExecutorContext = { db: Database; runtime: AgentRuntime; - slackChannel: SlackChannel | undefined; + slackChannel: SlackTransport | undefined; ownerUserId: string | null; notifyOwner: (text: string) => void; }; diff --git a/src/scheduler/service.ts b/src/scheduler/service.ts index 61cee2cf..807fd231 100644 --- a/src/scheduler/service.ts +++ b/src/scheduler/service.ts @@ -1,7 +1,7 @@ import type { Database } from "bun:sqlite"; import { randomUUID } from "node:crypto"; import type { AgentRuntime } from "../agent/runtime.ts"; -import type { SlackChannel } from "../channels/slack.ts"; +import type { SlackTransport } from "../channels/slack-transport.ts"; import { validateCreateInput } from "./create-validation.ts"; import { executeJob } from "./executor.ts"; import { type SchedulerHealthSummary, computeHealthSummary } from "./health.ts"; @@ -24,14 +24,14 @@ const MAX_TIMER_MS = 60 * 60 * 1000; type SchedulerDeps = { db: Database; runtime: AgentRuntime; - slackChannel?: SlackChannel; + slackChannel?: SlackTransport; ownerUserId?: string | null; }; export class Scheduler { private db: Database; private runtime: AgentRuntime; - private slackChannel: SlackChannel | undefined; + private slackChannel: SlackTransport | undefined; private ownerUserId: string | null; private timer: ReturnType | null = null; private running = false; @@ -54,7 +54,7 @@ export class Scheduler { * (C3): owner-targeted delivery is skipped until ownerUserId is set, but * channel-id (C...) and user-id (U...) targets work immediately. */ - setSlackChannel(channel: SlackChannel, ownerUserId: string | null): void { + setSlackChannel(channel: SlackTransport, ownerUserId: string | null): void { this.slackChannel = channel; this.ownerUserId = ownerUserId ?? null; } From f5eeb0c46d7d81e5de057a17e534a13f43308a21 Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema Date: Sat, 25 Apr 2026 11:59:04 -0700 Subject: [PATCH 3/3] channels: tighten Slack 5b team_id defense-in-depth and address review findings Closes the team_id defense-in-depth gap (Critical findings 1-2 plus Major 7) by rejecting events without parseable team_id at all three layers (middleware, per-event app_mention/message handlers, and the previously-unguarded reaction_added handler), with the single allowed exception being Slack's url_verification challenge ping. Splits Slack API egress methods into slack-egress.ts and Express helpers into slack-http-utils.ts so slack-http-receiver.ts lands at 295 lines (under the 300-line cap), and delegates the same egress helpers from slack.ts to DRY the duplicate methods. Adds redactTokens on the auth.test() failure log path so a future Bolt SDK change cannot leak a token via err.message. Aligns the identity fetcher's tenant_slug to phantomd's omitempty wire shape and documents the schema-drift policy for the hand-rolled guards. Plus four Minor cleanups: adds the slack.ts constructor-guard test (M-4), drops the dead hex-decode try/catch in the verifier (m-9), uses the SlackTransport alias in index.ts (m-12), and pins the redaction contract with a token-bearing auth.test() failure test (m-16). --- .../__tests__/slack-http-receiver.test.ts | 110 +++++++- src/channels/__tests__/slack.test.ts | 13 + src/channels/slack-egress.ts | 184 +++++++++++++ src/channels/slack-gateway-verifier.ts | 13 +- src/channels/slack-http-events.ts | 25 +- src/channels/slack-http-receiver.ts | 244 ++++-------------- src/channels/slack-http-utils.ts | 82 ++++++ src/channels/slack.ts | 141 ++-------- src/config/identity-fetcher.ts | 17 +- src/index.ts | 5 +- 10 files changed, 507 insertions(+), 327 deletions(-) create mode 100644 src/channels/slack-egress.ts create mode 100644 src/channels/slack-http-utils.ts diff --git a/src/channels/__tests__/slack-http-receiver.test.ts b/src/channels/__tests__/slack-http-receiver.test.ts index 8d388c58..74727f3b 100644 --- a/src/channels/__tests__/slack-http-receiver.test.ts +++ b/src/channels/__tests__/slack-http-receiver.test.ts @@ -309,13 +309,28 @@ describe("guard middleware (HMAC + replay window + team_id)", () => { expect(res.statusCode).toBe(403); }); - test("accepts a request whose body has no team_id (e.g. url_verification ping)", async () => { + test("accepts a url_verification challenge body (the one shape with no team_id)", async () => { const channel = new SlackHttpChannel(baseConfig); const req = makeReq({ bodyJson: { type: "url_verification", challenge: "abc" } }); const res = await callGuard(channel, "/slack/events", req); expect(res.statusCode).toBe(0); }); + test("rejects 403 when body has no team_id and is not url_verification", async () => { + const channel = new SlackHttpChannel(baseConfig); + const req = makeReq({ bodyJson: { type: "event_callback", event: { type: "app_mention" } } }); + const res = await callGuard(channel, "/slack/events", req); + expect(res.statusCode).toBe(403); + }); + + test("rejects 403 when body is malformed JSON (defense in depth)", async () => { + const channel = new SlackHttpChannel(baseConfig); + // Bypass JSON.stringify by passing a pre-built malformed string. + const req = makeReq({ bodyJson: "{not-json" }); + const res = await callGuard(channel, "/slack/events", req); + expect(res.statusCode).toBe(403); + }); + test("accepts an interactivity payload (urlencoded form) when team.id matches", async () => { const channel = new SlackHttpChannel(baseConfig); const req = makeReq({ @@ -502,6 +517,7 @@ describe("event routing", () => { user: "U_USER1", item: { ts: "1715000000.000001", channel: "C1" }, }, + body: { team_id: TEAM_ID }, }); expect(captured.isPositive).toBe(true); expect(captured.reaction).toBe("thumbsup"); @@ -518,6 +534,7 @@ describe("event routing", () => { if (!handler) throw new Error("no handler"); await handler({ event: { reaction: "eyes", user: "U_USER1", item: { ts: "1.0", channel: "C1" } }, + body: { team_id: TEAM_ID }, }); expect(called).toBe(false); }); @@ -575,6 +592,31 @@ describe("lifecycle and bot user discovery", () => { expect(all).not.toContain("xoxb-test-token"); }); + test("auth.test failure with a hostile token-bearing message redacts the token", async () => { + // Pin the redaction contract: even if a future Bolt SDK upgrade includes + // the offending token in `err.message`, the receiver must strip it + // before logging. The message text ("invalid_auth") still surfaces so + // operators can debug, but the token is replaced with a sentinel. + mockAuthTest.mockImplementation(() => + Promise.reject(new Error("invalid_auth: xoxb-test-token-leaked exposed in error")), + ); + const channel = new SlackHttpChannel(baseConfig); + const errors: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }; + try { + await channel.connect().catch(() => {}); + } finally { + console.error = original; + } + const all = errors.join("\n"); + expect(all).toContain("invalid_auth"); + expect(all).toContain("[REDACTED-TOKEN]"); + expect(all).not.toContain("xoxb-test-token-leaked"); + }); + test("disconnect on a never-connected channel is a no-op", async () => { const channel = new SlackHttpChannel(baseConfig); await channel.disconnect(); @@ -647,7 +689,7 @@ describe("access control (HTTP mode = any user from this.teamId)", () => { expect(received).toBe("hi"); }); - test("event with no team_id and no defense default falls back to this.teamId (allow)", async () => { + test("event with no parseable team_id is dropped (defense in depth, no host fallback)", async () => { const channel = new SlackHttpChannel(baseConfig); let received = ""; channel.onMessage(async (msg) => { @@ -660,7 +702,69 @@ describe("access control (HTTP mode = any user from this.teamId)", () => { event: { text: "<@U_BOT123> hi", user: "U_RANDOM", channel: "C1", ts: "1715000000.0" }, body: {}, }); - expect(received).toBe("hi"); + expect(received).toBe(""); + }); + + test("DM with no parseable team_id is dropped (defense in depth, no host fallback)", async () => { + const channel = new SlackHttpChannel(baseConfig); + let called = false; + channel.onMessage(async () => { + called = true; + }); + await channel.connect(); + const handler = eventHandlers.get("message"); + if (!handler) throw new Error("no handler"); + await handler({ + event: { + text: "DM with no team_id", + user: "U_USER1", + channel: "D_DM1", + channel_type: "im", + ts: "1715000000.000010", + }, + body: {}, + }); + expect(called).toBe(false); + }); + + test("reaction with foreign team_id is dropped (defense in depth)", async () => { + const channel = new SlackHttpChannel(baseConfig); + let captured = false; + channel.onReaction(() => { + captured = true; + }); + await channel.connect(); + const handler = eventHandlers.get("reaction_added"); + if (!handler) throw new Error("no handler"); + await handler({ + event: { + reaction: "thumbsup", + user: "U_OUT", + item: { ts: "1715000000.000020", channel: "C1" }, + }, + body: { team_id: FOREIGN_TEAM_ID }, + }); + expect(captured).toBe(false); + }); + + test("reaction with no team_id is dropped (defense in depth)", async () => { + const channel = new SlackHttpChannel(baseConfig); + let captured = false; + channel.onReaction(() => { + captured = true; + }); + await channel.connect(); + const handler = eventHandlers.get("reaction_added"); + if (!handler) throw new Error("no handler"); + await handler({ + event: { + reaction: "thumbsup", + user: "U_OUT", + item: { ts: "1715000000.000021", channel: "C1" }, + }, + body: {}, + }); + expect(captured).toBe(false); }); test("foreign team_id is dropped in the event handler even after the guard passed", async () => { diff --git a/src/channels/__tests__/slack.test.ts b/src/channels/__tests__/slack.test.ts index fd9eab9e..c9399660 100644 --- a/src/channels/__tests__/slack.test.ts +++ b/src/channels/__tests__/slack.test.ts @@ -84,6 +84,19 @@ describe("SlackChannel", () => { expect(channel.getConnectionState()).toBe("disconnected"); }); + test("rejects transport: 'http' with a clear error", () => { + // The typed API forbids `transport: "http"` at compile time; the + // runtime guard is the safety net for any caller that bypasses TS + // (dynamic config, JS-callable, future plumbing). + expect( + () => + new SlackChannel({ + ...testConfig, + transport: "http" as unknown as "socket", + }), + ).toThrow(/Use SlackHttpChannel/); + }); + test("connect transitions to connected state", async () => { const channel = new SlackChannel(testConfig); await channel.connect(); diff --git a/src/channels/slack-egress.ts b/src/channels/slack-egress.ts new file mode 100644 index 00000000..335cc86d --- /dev/null +++ b/src/channels/slack-egress.ts @@ -0,0 +1,184 @@ +// Phase 5b: shared Slack API egress helpers. Both `SlackChannel` +// (Socket Mode) and `SlackHttpChannel` (HTTP receiver) need to post +// messages, open DMs, update messages, and add/remove reactions through +// the Bolt `App["client"]`. The mechanics are identical regardless of +// transport, so the methods live here and the channels delegate. +// +// Keeping these helpers transport-agnostic also lets the receiver class +// stay focused on lifecycle (HMAC verification, auth.test, port binding) +// without inflating past the 300-line file budget. Each helper logs with +// a caller-supplied tag so the source channel is identifiable in logs. + +import { randomUUID } from "node:crypto"; +import type { App } from "@slack/bolt"; +import type { SlackBlock } from "./feedback.ts"; +import { buildFeedbackBlocks } from "./feedback.ts"; +import { splitMessage, toSlackMarkdown, truncateForSlack } from "./slack-formatter.ts"; +import type { OutboundMessage, SentMessage } from "./types.ts"; + +export type EgressContext = { + client: App["client"]; + channelId: string; + logTag: string; +}; + +export async function egressSend( + ctx: EgressContext, + conversationId: string, + message: OutboundMessage, +): Promise { + const { channel, threadTs } = parseConversationId(conversationId); + const formattedText = toSlackMarkdown(message.text); + const replyThreadTs = message.threadId ?? threadTs; + const chunks = splitMessage(formattedText); + let lastTs = ""; + for (const chunk of chunks) { + const result = await ctx.client.chat.postMessage({ + channel, + text: chunk, + thread_ts: replyThreadTs, + }); + lastTs = result.ts ?? ""; + } + return { + id: lastTs || randomUUID(), + channelId: ctx.channelId, + conversationId, + timestamp: new Date(), + }; +} + +export async function egressPostToChannel(ctx: EgressContext, channelId: string, text: string): Promise { + const formattedText = toSlackMarkdown(text); + const chunks = splitMessage(formattedText); + let lastTs: string | null = null; + for (const chunk of chunks) { + try { + const result = await ctx.client.chat.postMessage({ channel: channelId, text: chunk }); + lastTs = result.ts ?? null; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[${ctx.logTag}] Failed to post to channel ${channelId}: ${msg}`); + return null; + } + } + return lastTs; +} + +export async function egressSendDm(ctx: EgressContext, userId: string, text: string): Promise { + try { + const openResult = await ctx.client.conversations.open({ users: userId }); + const dmChannelId = openResult.channel?.id; + if (!dmChannelId) { + console.error(`[${ctx.logTag}] Failed to open DM with user ${userId}: no channel returned`); + return null; + } + return egressPostToChannel(ctx, dmChannelId, text); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[${ctx.logTag}] Failed to send DM to user ${userId}: ${msg}`); + return null; + } +} + +export async function egressPostThinking( + ctx: EgressContext, + channel: string, + threadTs: string, +): Promise { + try { + const result = await ctx.client.chat.postMessage({ + channel, + thread_ts: threadTs, + text: "Working on it...", + }); + return result.ts ?? null; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[${ctx.logTag}] Failed to post thinking indicator: ${msg}`); + return null; + } +} + +export async function egressUpdateMessage( + ctx: EgressContext, + channel: string, + ts: string, + text: string, + blocks?: SlackBlock[], +): Promise { + const formattedText = toSlackMarkdown(text); + const truncated = truncateForSlack(formattedText); + try { + // Bolt's chat.update has a strict overload-driven signature that does + // not accept the dynamically-built record cleanly; the runtime checks + // inside Bolt validate the same shape we build, so we relax to the + // parameter type via `as unknown as`. + const updateArgs: Record = { channel, ts, text: truncated }; + if (blocks) updateArgs.blocks = blocks; + await ctx.client.chat.update(updateArgs as unknown as Parameters[0]); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[${ctx.logTag}] Failed to update message: ${msg}`); + } +} + +export async function egressUpdateWithFeedback( + ctx: EgressContext, + channel: string, + ts: string, + text: string, +): Promise { + const formattedText = toSlackMarkdown(text); + const truncated = truncateForSlack(formattedText); + const feedbackBlocks = buildFeedbackBlocks(ts); + const blocks: SlackBlock[] = [{ type: "section", text: { type: "mrkdwn", text: truncated } }, ...feedbackBlocks]; + try { + // Same Bolt overload story as egressUpdateMessage. + const updateArgs: Record = { channel, ts, text: truncated, blocks }; + await ctx.client.chat.update(updateArgs as unknown as Parameters[0]); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[${ctx.logTag}] Failed to update message with feedback: ${msg}`); + } +} + +export async function egressAddReaction( + ctx: EgressContext, + channel: string, + messageTs: string, + emoji: string, +): Promise { + try { + await ctx.client.reactions.add({ channel, timestamp: messageTs, name: emoji }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes("already_reacted")) { + console.warn(`[${ctx.logTag}] Failed to add reaction :${emoji}:: ${msg}`); + } + } +} + +export async function egressRemoveReaction( + ctx: EgressContext, + channel: string, + messageTs: string, + emoji: string, +): Promise { + try { + await ctx.client.reactions.remove({ channel, timestamp: messageTs, name: emoji }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes("no_reaction")) { + console.warn(`[${ctx.logTag}] Failed to remove reaction :${emoji}:: ${msg}`); + } + } +} + +function parseConversationId(conversationId: string): { channel: string; threadTs: string | undefined } { + const parts = conversationId.split(":"); + if (parts[1] === "dm") { + return { channel: parts[2], threadTs: undefined }; + } + return { channel: parts[1], threadTs: parts[2] }; +} diff --git a/src/channels/slack-gateway-verifier.ts b/src/channels/slack-gateway-verifier.ts index 46a6afd1..6eab81e7 100644 --- a/src/channels/slack-gateway-verifier.ts +++ b/src/channels/slack-gateway-verifier.ts @@ -32,8 +32,10 @@ export type VerifyInput = { * - missing or non-numeric X-Phantom-Forwarded-At * - timestamp more than 5 minutes off from `now` (in either direction; * handles clock skew on the gateway and on this VM) - * - hex-decode failure on the presented signature - * - presented and computed digest lengths differ + * - presented signature has odd length, contains non-hex chars, or fails + * the byte-length pre-check below (Node's hex decoder silently truncates + * on malformed input, so a malformed signature produces a wrong-length + * buffer that this check rejects) * - constant-time compare returns inequality */ export function verifyGatewaySignature(input: VerifyInput): boolean { @@ -52,12 +54,7 @@ export function verifyGatewaySignature(input: VerifyInput): boolean { const presented = sigHeader.slice(SIGNATURE_PREFIX.length); const expBuf = Buffer.from(expected, "hex"); - let presBuf: Buffer; - try { - presBuf = Buffer.from(presented, "hex"); - } catch { - return false; - } + const presBuf = Buffer.from(presented, "hex"); if (presBuf.length !== expBuf.length) return false; return timingSafeEqual(expBuf, presBuf); } diff --git a/src/channels/slack-http-events.ts b/src/channels/slack-http-events.ts index 6998ce57..591fb026 100644 --- a/src/channels/slack-http-events.ts +++ b/src/channels/slack-http-events.ts @@ -4,6 +4,13 @@ // take the `SlackHttpChannel` as a host argument; we pass the parts the // handlers need (botUserId, teamId, messageHandler, reactionHandler) so the // receiver class itself stays focused on lifecycle and Slack API egress. +// +// Defense in depth: the gateway and the middleware guard already verify +// team_id, but each handler also extracts team_id from the body and drops +// the event when it does not match this tenant's installer team. A missing +// team_id (`extractTeamIdFromBody` returns undefined) is treated as a +// non-match and dropped. The middleware allows `url_verification` through +// without a team_id, and Bolt handles that path before any handler runs. import type { App } from "@slack/bolt"; import type { InboundMessage } from "./types.ts"; @@ -30,13 +37,13 @@ export function registerHttpEventHandlers(app: App, host: EventDispatchHost): vo const handler = host.getMessageHandler(); if (!handler) return; - const senderId = event.user ?? "unknown"; - const eventTeamId = extractTeamIdFromBody(body) ?? host.getTeamId(); + const eventTeamId = extractTeamIdFromBody(body); if (eventTeamId !== host.getTeamId()) { - console.log(`[slack-http] Dropping app_mention with foreign team_id: ${eventTeamId}`); + console.log(`[slack-http] Dropping app_mention with foreign or missing team_id: ${eventTeamId ?? ""}`); return; } + const senderId = event.user ?? "unknown"; const cleanText = host.stripBotMention(event.text); if (!cleanText.trim()) return; @@ -82,9 +89,9 @@ export function registerHttpEventHandlers(app: App, host: EventDispatchHost): vo const channelType = m.channel_type as string | undefined; if (channelType !== "im") return; - const eventTeamId = extractTeamIdFromBody(body) ?? host.getTeamId(); + const eventTeamId = extractTeamIdFromBody(body); if (eventTeamId !== host.getTeamId()) { - console.log(`[slack-http] Dropping DM with foreign team_id: ${eventTeamId}`); + console.log(`[slack-http] Dropping DM with foreign or missing team_id: ${eventTeamId ?? ""}`); return; } @@ -120,7 +127,13 @@ export function registerHttpEventHandlers(app: App, host: EventDispatchHost): vo } }); - app.event("reaction_added", async ({ event }) => { + app.event("reaction_added", async ({ event, body }) => { + const eventTeamId = extractTeamIdFromBody(body); + if (eventTeamId !== host.getTeamId()) { + console.log(`[slack-http] Dropping reaction with foreign or missing team_id: ${eventTeamId ?? ""}`); + return; + } + const reaction = event.reaction; const isPositive = reaction === "+1" || reaction === "thumbsup" || reaction === "heart" || reaction === "white_check_mark"; diff --git a/src/channels/slack-http-receiver.ts b/src/channels/slack-http-receiver.ts index f16f04de..7e5ece09 100644 --- a/src/channels/slack-http-receiver.ts +++ b/src/channels/slack-http-receiver.ts @@ -5,29 +5,40 @@ // `slack.ts`; SLACK_TRANSPORT=http opts a tenant into this class. // // Three security layers operate at this boundary: -// 1. The Caddy edge in front of the tenant validates the gateway's HMAC -// and strips inbound X-Phantom-* headers (defense in depth at the -// reverse proxy). -// 2. This class re-verifies the gateway's HMAC on every request via the -// `slack-gateway-verifier` helper before Bolt sees the body. -// 3. After HMAC succeeds, the parsed body's team_id MUST match the -// tenant's installer team_id. The gateway has already verified team_id -// maps to this tenant, but a misrouted forward must not be processed. +// 1. Caddy validates the gateway HMAC and strips inbound X-Phantom-* headers. +// 2. This class re-verifies the gateway HMAC via `slack-gateway-verifier`. +// 3. The parsed body's team_id MUST match the tenant's installer team_id. +// The one body shape allowed without team_id is `url_verification`. // -// We reuse Bolt's App + ExpressReceiver so the existing slack-actions.ts -// registrations (`app.action(...)`) compose unchanged. ExpressReceiver is -// constructed with `signatureVerification: false` because we verified the -// gateway's HMAC ourselves. +// `signatureVerification: false` on ExpressReceiver skips Slack's signing +// secret because the gateway has already verified Slack's signature. -import { randomUUID } from "node:crypto"; import { App, ExpressReceiver, type LogLevel } from "@slack/bolt"; import type { Request, RequestHandler, Response } from "express"; import type { SlackBlock } from "./feedback.ts"; -import { buildFeedbackBlocks } from "./feedback.ts"; import { registerSlackActions } from "./slack-actions.ts"; -import { splitMessage, toSlackMarkdown, truncateForSlack } from "./slack-formatter.ts"; +import { + type EgressContext, + egressAddReaction, + egressPostThinking, + egressPostToChannel, + egressRemoveReaction, + egressSend, + egressSendDm, + egressUpdateMessage, + egressUpdateWithFeedback, +} from "./slack-egress.ts"; import { extractTeamId, verifyGatewaySignature } from "./slack-gateway-verifier.ts"; import { type EventDispatchHost, type ReactionFn, registerHttpEventHandlers } from "./slack-http-events.ts"; +import { + type RequestWithRawBody, + getContentType, + headerString, + isUrlVerificationBody, + readRequestBody, + redactTokens, + rehydrateBody, +} from "./slack-http-utils.ts"; import type { Channel, ChannelCapabilities, InboundMessage, OutboundMessage, SentMessage } from "./types.ts"; export type SlackHttpChannelConfig = { @@ -41,7 +52,8 @@ export type SlackHttpChannelConfig = { }; type ConnectionState = "disconnected" | "connecting" | "connected" | "error"; -type RequestWithRawBody = Request & { rawBody?: Buffer }; + +const LOG_TAG = "slack-http"; export class SlackHttpChannel implements Channel, EventDispatchHost { readonly id = "slack"; @@ -82,9 +94,6 @@ export class SlackHttpChannel implements Channel, EventDispatchHost { this.listenPort = config.listenPort; this.listenPath = config.listenPath; - // `signatureVerification: false` skips Slack-signing-secret verification - // because the gateway has already verified Slack's signature, and we - // will verify the gateway's HMAC ourselves. this.receiver = new ExpressReceiver({ signingSecret: "phase-5b-unused-gateway-verifies-instead", signatureVerification: false, @@ -134,6 +143,10 @@ export class SlackHttpChannel implements Channel, EventDispatchHost { return this.app.client; } + private egressContext(): EgressContext { + return { client: this.app.client, channelId: this.id, logTag: LOG_TAG }; + } + stripBotMention(text: string): string { if (this.botUserId) { return text.replace(new RegExp(`<@${this.botUserId}>\\s*`, "g"), ""); @@ -141,12 +154,8 @@ export class SlackHttpChannel implements Channel, EventDispatchHost { return text.replace(/^<@[A-Z0-9]+>\s*/, ""); } - /** - * Install Express middleware in front of Bolt's routes. Fails closed: - * missing headers return 401, tampered body fails the HMAC compare and - * returns 401, stale or future-skewed timestamp returns 401, foreign - * team_id returns 403. - */ + // Fails closed: missing headers, tampered body, stale/future timestamp -> 401; + // foreign or unknown team_id -> 403. Only `url_verification` may lack team_id. private installVerifier(): void { const expressApp = this.receiver.app; const guard = this.makeGuardMiddleware(); @@ -180,7 +189,12 @@ export class SlackHttpChannel implements Channel, EventDispatchHost { } const eventTeamId = extractTeamId(raw, getContentType(req)); - if (eventTeamId !== undefined && eventTeamId !== expectedTeamId) { + if (eventTeamId === undefined) { + if (!isUrlVerificationBody(raw, getContentType(req))) { + res.status(403).end("forbidden"); + return; + } + } else if (eventTeamId !== expectedTeamId) { res.status(403).end("forbidden"); return; } @@ -198,25 +212,23 @@ export class SlackHttpChannel implements Channel, EventDispatchHost { registerSlackActions(this.app); try { - // `auth.test` validates the bot token against Slack and returns the - // bot user id. If the token has been revoked between OAuth callback - // and tenant boot, this fails and we refuse to start. + // `auth.test` validates the bot token; a revoked token fails here. const authResult = await this.app.client.auth.test(); this.botUserId = authResult.user_id ?? null; if (!this.botUserId) { this.connectionState = "error"; throw new Error("auth.test returned no user_id; bot token may be revoked"); } - console.log(`[slack-http] Resolved bot user <@${this.botUserId}>`); + console.log(`[${LOG_TAG}] Resolved bot user <@${this.botUserId}>`); await this.receiver.start(this.listenPort); this.connectionState = "connected"; - console.log(`[slack-http] Listening on :${this.listenPort}${this.listenPath}/{events,interactivity,commands}`); + console.log(`[${LOG_TAG}] Listening on :${this.listenPort}${this.listenPath}/{events,interactivity,commands}`); } catch (err: unknown) { this.connectionState = "error"; - const msg = err instanceof Error ? err.message : String(err); - // Do NOT include any token material in error logs. - console.error(`[slack-http] Failed to start: ${msg}`); + const rawMsg = err instanceof Error ? err.message : String(err); + // `redactTokens` defends against a future Bolt change emitting tokens. + console.error(`[${LOG_TAG}] Failed to start: ${redactTokens(rawMsg)}`); throw err; } } @@ -227,32 +239,14 @@ export class SlackHttpChannel implements Channel, EventDispatchHost { await this.receiver.stop(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - console.warn(`[slack-http] Error during disconnect: ${msg}`); + console.warn(`[${LOG_TAG}] Error during disconnect: ${redactTokens(msg)}`); } this.connectionState = "disconnected"; - console.log("[slack-http] Disconnected"); + console.log(`[${LOG_TAG}] Disconnected`); } async send(conversationId: string, message: OutboundMessage): Promise { - const { channel, threadTs } = parseConversationId(conversationId); - const formattedText = toSlackMarkdown(message.text); - const replyThreadTs = message.threadId ?? threadTs; - const chunks = splitMessage(formattedText); - let lastTs = ""; - for (const chunk of chunks) { - const result = await this.app.client.chat.postMessage({ - channel, - text: chunk, - thread_ts: replyThreadTs, - }); - lastTs = result.ts ?? ""; - } - return { - id: lastTs || randomUUID(), - channelId: this.id, - conversationId, - timestamp: new Date(), - }; + return egressSend(this.egressContext(), conversationId, message); } onMessage(handler: (message: InboundMessage) => Promise): void { @@ -272,156 +266,30 @@ export class SlackHttpChannel implements Channel, EventDispatchHost { } async postToChannel(channelId: string, text: string): Promise { - const formattedText = toSlackMarkdown(text); - const chunks = splitMessage(formattedText); - let lastTs: string | null = null; - for (const chunk of chunks) { - try { - const result = await this.app.client.chat.postMessage({ channel: channelId, text: chunk }); - lastTs = result.ts ?? null; - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`[slack-http] Failed to post to channel ${channelId}: ${msg}`); - return null; - } - } - return lastTs; + return egressPostToChannel(this.egressContext(), channelId, text); } async sendDm(userId: string, text: string): Promise { - try { - const openResult = await this.app.client.conversations.open({ users: userId }); - const dmChannelId = openResult.channel?.id; - if (!dmChannelId) { - console.error(`[slack-http] Failed to open DM with user ${userId}: no channel returned`); - return null; - } - return this.postToChannel(dmChannelId, text); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`[slack-http] Failed to send DM to user ${userId}: ${msg}`); - return null; - } + return egressSendDm(this.egressContext(), userId, text); } async postThinking(channel: string, threadTs: string): Promise { - try { - const result = await this.app.client.chat.postMessage({ - channel, - thread_ts: threadTs, - text: "Working on it...", - }); - return result.ts ?? null; - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.warn(`[slack-http] Failed to post thinking indicator: ${msg}`); - return null; - } + return egressPostThinking(this.egressContext(), channel, threadTs); } async updateMessage(channel: string, ts: string, text: string, blocks?: SlackBlock[]): Promise { - const formattedText = toSlackMarkdown(text); - const truncated = truncateForSlack(formattedText); - try { - const updateArgs: Record = { channel, ts, text: truncated }; - if (blocks) updateArgs.blocks = blocks; - await this.app.client.chat.update(updateArgs as unknown as Parameters[0]); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.warn(`[slack-http] Failed to update message: ${msg}`); - } + return egressUpdateMessage(this.egressContext(), channel, ts, text, blocks); } async updateWithFeedback(channel: string, ts: string, text: string): Promise { - const formattedText = toSlackMarkdown(text); - const truncated = truncateForSlack(formattedText); - const feedbackBlocks = buildFeedbackBlocks(ts); - const blocks: SlackBlock[] = [{ type: "section", text: { type: "mrkdwn", text: truncated } }, ...feedbackBlocks]; - try { - const updateArgs: Record = { channel, ts, text: truncated, blocks }; - await this.app.client.chat.update(updateArgs as unknown as Parameters[0]); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.warn(`[slack-http] Failed to update message with feedback: ${msg}`); - } + return egressUpdateWithFeedback(this.egressContext(), channel, ts, text); } async addReaction(channel: string, messageTs: string, emoji: string): Promise { - try { - await this.app.client.reactions.add({ channel, timestamp: messageTs, name: emoji }); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - if (!msg.includes("already_reacted")) { - console.warn(`[slack-http] Failed to add reaction :${emoji}:: ${msg}`); - } - } + return egressAddReaction(this.egressContext(), channel, messageTs, emoji); } async removeReaction(channel: string, messageTs: string, emoji: string): Promise { - try { - await this.app.client.reactions.remove({ channel, timestamp: messageTs, name: emoji }); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - if (!msg.includes("no_reaction")) { - console.warn(`[slack-http] Failed to remove reaction :${emoji}:: ${msg}`); - } - } - } -} - -// --- helpers --------------------------------------------------------------- - -function headerString(req: Request, name: string): string | null { - const value = req.headers[name.toLowerCase()]; - if (Array.isArray(value)) return value[0] ?? null; - return typeof value === "string" ? value : null; -} - -function getContentType(req: Request): string { - return headerString(req, "content-type") ?? "application/json"; -} - -async function readRequestBody(req: RequestWithRawBody): Promise { - if (req.rawBody) { - return Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(req.rawBody); - } - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - req.on("data", (chunk: Buffer | string) => { - if (chunk == null) return; - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - req.on("end", () => resolve(Buffer.concat(chunks))); - req.on("error", (err: Error) => reject(err)); - }); -} - -/** - * After we have consumed the request stream to verify HMAC, ExpressReceiver's - * downstream body parser still expects to read a body. We pre-parse JSON or - * urlencoded bodies onto `req.body` so subsequent middleware finds the - * already-parsed payload via the standard Express convention. - */ -function rehydrateBody(req: Request, raw: Buffer): void { - const ctype = getContentType(req).toLowerCase(); - if (ctype.includes("application/json")) { - try { - req.body = JSON.parse(raw.toString("utf-8")); - } catch { - // Leave body unset; downstream parser will surface the error. - } - } else if (ctype.includes("application/x-www-form-urlencoded")) { - const params = new URLSearchParams(raw.toString("utf-8")); - const obj: Record = {}; - for (const [k, v] of params) obj[k] = v; - req.body = obj; - } -} - -function parseConversationId(conversationId: string): { channel: string; threadTs: string | undefined } { - const parts = conversationId.split(":"); - if (parts[1] === "dm") { - return { channel: parts[2], threadTs: undefined }; + return egressRemoveReaction(this.egressContext(), channel, messageTs, emoji); } - return { channel: parts[1], threadTs: parts[2] }; } diff --git a/src/channels/slack-http-utils.ts b/src/channels/slack-http-utils.ts new file mode 100644 index 00000000..473cc0eb --- /dev/null +++ b/src/channels/slack-http-utils.ts @@ -0,0 +1,82 @@ +// Phase 5b: Express-side request helpers used by the HTTP receiver guard +// middleware. Body reading, header normalization, content-type sniffing, +// rehydration after stream consumption, the url_verification body shape +// detector, and the token redactor all live here so the receiver itself +// can focus on lifecycle and dispatch. + +import type { Request } from "express"; + +export type RequestWithRawBody = Request & { rawBody?: Buffer }; + +export function headerString(req: Request, name: string): string | null { + const value = req.headers[name.toLowerCase()]; + if (Array.isArray(value)) return value[0] ?? null; + return typeof value === "string" ? value : null; +} + +export function getContentType(req: Request): string { + return headerString(req, "content-type") ?? "application/json"; +} + +export async function readRequestBody(req: RequestWithRawBody): Promise { + if (req.rawBody) { + return Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(req.rawBody); + } + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer | string) => { + if (chunk == null) return; + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", (err: Error) => reject(err)); + }); +} + +/** + * After we have consumed the request stream to verify HMAC, ExpressReceiver's + * downstream body parser still expects to read a body. We pre-parse JSON or + * urlencoded bodies onto `req.body` so subsequent middleware finds the + * already-parsed payload via the standard Express convention. + */ +export function rehydrateBody(req: Request, raw: Buffer): void { + const ctype = getContentType(req).toLowerCase(); + if (ctype.includes("application/json")) { + try { + req.body = JSON.parse(raw.toString("utf-8")); + } catch { + // Leave body unset; downstream parser will surface the error. + } + } else if (ctype.includes("application/x-www-form-urlencoded")) { + const params = new URLSearchParams(raw.toString("utf-8")); + const obj: Record = {}; + for (const [k, v] of params) obj[k] = v; + req.body = obj; + } +} + +/** + * Detects Slack's `url_verification` challenge ping. This is the one + * legitimate forwarded body shape that lacks a `team_id` field. Returning + * `true` is the only path the middleware uses to allow a no-team_id + * request through; everything else is rejected as defense in depth. + */ +export function isUrlVerificationBody(raw: Buffer, contentType: string): boolean { + if (!contentType.toLowerCase().includes("application/json")) return false; + try { + const parsed = JSON.parse(raw.toString("utf-8")) as Record; + return parsed?.type === "url_verification"; + } catch { + return false; + } +} + +/** + * Strip Slack token prefixes (xoxb-, xoxp-, xapp-, xoxc-, xoxe-) from any + * string. The auth.test() failure path runs the upstream error message + * through this redactor so a hostile or future-Bolt-debug error message + * carrying a token cannot leak it via our stderr. + */ +export function redactTokens(s: string): string { + return s.replace(/xox[bpaec]-[a-zA-Z0-9-]+/g, "[REDACTED-TOKEN]").replace(/xapp-[a-zA-Z0-9-]+/g, "[REDACTED-TOKEN]"); +} diff --git a/src/channels/slack.ts b/src/channels/slack.ts index 6f342383..ebbaa7d3 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -1,9 +1,17 @@ -import { randomUUID } from "node:crypto"; import { App, type LogLevel } from "@slack/bolt"; import type { SlackBlock } from "./feedback.ts"; -import { buildFeedbackBlocks } from "./feedback.ts"; import { registerSlackActions } from "./slack-actions.ts"; -import { splitMessage, toSlackMarkdown, truncateForSlack } from "./slack-formatter.ts"; +import { + type EgressContext, + egressAddReaction, + egressPostThinking, + egressPostToChannel, + egressRemoveReaction, + egressSend, + egressSendDm, + egressUpdateMessage, + egressUpdateWithFeedback, +} from "./slack-egress.ts"; import type { Channel, ChannelCapabilities, InboundMessage, OutboundMessage, SentMessage } from "./types.ts"; export type SlackChannelConfig = { @@ -138,28 +146,12 @@ export class SlackChannel implements Channel { console.log("[slack] Disconnected"); } - async send(conversationId: string, message: OutboundMessage): Promise { - const { channel, threadTs } = parseConversationId(conversationId); - const formattedText = toSlackMarkdown(message.text); - const replyThreadTs = message.threadId ?? threadTs; - const chunks = splitMessage(formattedText); - let lastTs = ""; - - for (const chunk of chunks) { - const result = await this.app.client.chat.postMessage({ - channel, - text: chunk, - thread_ts: replyThreadTs, - }); - lastTs = result.ts ?? ""; - } + private egressContext(): EgressContext { + return { client: this.app.client, channelId: this.id, logTag: "slack" }; + } - return { - id: lastTs || randomUUID(), - channelId: this.id, - conversationId, - timestamp: new Date(), - }; + async send(conversationId: string, message: OutboundMessage): Promise { + return egressSend(this.egressContext(), conversationId, message); } onMessage(handler: (message: InboundMessage) => Promise): void { @@ -179,111 +171,32 @@ export class SlackChannel implements Channel { } async postToChannel(channelId: string, text: string): Promise { - const formattedText = toSlackMarkdown(text); - const chunks = splitMessage(formattedText); - let lastTs: string | null = null; - - for (const chunk of chunks) { - try { - const result = await this.app.client.chat.postMessage({ - channel: channelId, - text: chunk, - }); - lastTs = result.ts ?? null; - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`[slack] Failed to post to channel ${channelId}: ${msg}`); - return null; - } - } - - return lastTs; + return egressPostToChannel(this.egressContext(), channelId, text); } async sendDm(userId: string, text: string): Promise { - try { - const openResult = await this.app.client.conversations.open({ users: userId }); - const dmChannelId = openResult.channel?.id; - if (!dmChannelId) { - console.error(`[slack] Failed to open DM with user ${userId}: no channel returned`); - return null; - } - return this.postToChannel(dmChannelId, text); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`[slack] Failed to send DM to user ${userId}: ${msg}`); - return null; - } + return egressSendDm(this.egressContext(), userId, text); } async postThinking(channel: string, threadTs: string): Promise { - try { - const result = await this.app.client.chat.postMessage({ - channel, - thread_ts: threadTs, - text: "Working on it...", - }); - return result.ts ?? null; - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.warn(`[slack] Failed to post thinking indicator: ${msg}`); - return null; - } + return egressPostThinking(this.egressContext(), channel, threadTs); } async updateMessage(channel: string, ts: string, text: string, blocks?: SlackBlock[]): Promise { - const formattedText = toSlackMarkdown(text); - const truncated = truncateForSlack(formattedText); - - try { - const updateArgs: Record = { channel, ts, text: truncated }; - if (blocks) updateArgs.blocks = blocks; - await this.app.client.chat.update(updateArgs as unknown as Parameters[0]); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.warn(`[slack] Failed to update message: ${msg}`); - } + return egressUpdateMessage(this.egressContext(), channel, ts, text, blocks); } /** Update a message with text + feedback buttons appended */ async updateWithFeedback(channel: string, ts: string, text: string): Promise { - const formattedText = toSlackMarkdown(text); - const truncated = truncateForSlack(formattedText); - const feedbackBlocks = buildFeedbackBlocks(ts); - - const blocks: SlackBlock[] = [{ type: "section", text: { type: "mrkdwn", text: truncated } }, ...feedbackBlocks]; - - try { - const updateArgs: Record = { channel, ts, text: truncated, blocks }; - await this.app.client.chat.update(updateArgs as unknown as Parameters[0]); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.warn(`[slack] Failed to update message with feedback: ${msg}`); - } + return egressUpdateWithFeedback(this.egressContext(), channel, ts, text); } async addReaction(channel: string, messageTs: string, emoji: string): Promise { - try { - await this.app.client.reactions.add({ channel, timestamp: messageTs, name: emoji }); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - // "already_reacted" is not a real error - if (!msg.includes("already_reacted")) { - console.warn(`[slack] Failed to add reaction :${emoji}:: ${msg}`); - } - } + return egressAddReaction(this.egressContext(), channel, messageTs, emoji); } async removeReaction(channel: string, messageTs: string, emoji: string): Promise { - try { - await this.app.client.reactions.remove({ channel, timestamp: messageTs, name: emoji }); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - // "no_reaction" is expected when the reaction was already removed - if (!msg.includes("no_reaction")) { - console.warn(`[slack] Failed to remove reaction :${emoji}:: ${msg}`); - } - } + return egressRemoveReaction(this.egressContext(), channel, messageTs, emoji); } private registerEventHandlers(): void { @@ -414,11 +327,3 @@ export class SlackChannel implements Channel { function buildConversationId(channel: string, threadTs: string): string { return `slack:${channel}:${threadTs}`; } - -function parseConversationId(conversationId: string): { channel: string; threadTs: string | undefined } { - const parts = conversationId.split(":"); - if (parts[1] === "dm") { - return { channel: parts[2], threadTs: undefined }; - } - return { channel: parts[1], threadTs: parts[2] }; -} diff --git a/src/config/identity-fetcher.ts b/src/config/identity-fetcher.ts index 346c6bae..8170b35b 100644 --- a/src/config/identity-fetcher.ts +++ b/src/config/identity-fetcher.ts @@ -15,6 +15,19 @@ // 3. No cache. Identity is fetched once at boot. The caller holds the // result in process memory; rotation is an out-of-band operator action // via UpdateSlackIdentity gRPC followed by daemon restart. +// +// Schema drift policy (hand-rolled, not Zod): +// The phantomd `IdentityResponse` ships several fields this fetcher +// intentionally drops because Phantom does not consume them: +// - source_ip +// - phantomd_version +// - fleet_rotation_counter +// - supported_secret_name_patterns +// - owner_email +// Add a new dropped field to this list as you ignore it. If phantomd +// adds a new REQUIRED field, this fetcher MUST be updated to consume +// it; see `phantomd/internal/metadata/secrets_handler.go IdentityResponse` +// for the canonical wire shape. export const DEFAULT_METADATA_BASE_URL = "http://169.254.169.254"; @@ -75,7 +88,9 @@ function parseIdentity(raw: unknown): TenantIdentity { const obj = raw as Record; const tenantId = requireString(obj, "tenant_id"); - const tenantSlug = requireString(obj, "tenant_slug"); + // `tenant_slug` is `omitempty` on the Go side, so an empty slug + // disappears from the JSON. Mirror that by defaulting to "". + const tenantSlug = optionalString(obj, "tenant_slug") ?? ""; const region = optionalString(obj, "region") ?? ""; const hostId = optionalString(obj, "host_id") ?? ""; const env = optionalString(obj, "env") ?? ""; diff --git a/src/index.ts b/src/index.ts index 5112a8fa..5a4a3b7d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,8 +12,7 @@ import { createProgressStream } from "./channels/progress-stream.ts"; import { ChannelRouter } from "./channels/router.ts"; import { setActionFollowUpHandler } from "./channels/slack-actions.ts"; import { createSlackChannel, readSlackTransportFromEnv } from "./channels/slack-channel-factory.ts"; -import type { SlackHttpChannel } from "./channels/slack-http-receiver.ts"; -import type { SlackChannel } from "./channels/slack.ts"; +import type { SlackTransport } from "./channels/slack-transport.ts"; import { createStatusReactionController } from "./channels/status-reactions.ts"; import { TelegramChannel } from "./channels/telegram.ts"; import { WebhookChannel } from "./channels/webhook.ts"; @@ -308,7 +307,7 @@ async function main(): Promise { // code path requires that subfield to be populated, otherwise the // factory throws so a mis-provisioned tenant fails loudly. const channelsConfig = loadChannelsConfig(); - const slackChannel: SlackChannel | SlackHttpChannel | null = await createSlackChannel({ + const slackChannel: SlackTransport | null = await createSlackChannel({ transport: readSlackTransportFromEnv(), channelsConfig, port: config.port,