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..74727f3b --- /dev/null +++ b/src/channels/__tests__/slack-http-receiver.test.ts @@ -0,0 +1,788 @@ +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 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({ + 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" }, + }, + body: { team_id: TEAM_ID }, + }); + 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" } }, + body: { team_id: TEAM_ID }, + }); + 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("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(); + 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 parseable team_id is dropped (defense in depth, no host fallback)", 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(""); + }); + + 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 () => { + 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/__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-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-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 new file mode 100644 index 00000000..6eab81e7 --- /dev/null +++ b/src/channels/slack-gateway-verifier.ts @@ -0,0 +1,115 @@ +// 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) + * - 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 { + 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"); + const presBuf = Buffer.from(presented, "hex"); + 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..591fb026 --- /dev/null +++ b/src/channels/slack-http-events.ts @@ -0,0 +1,165 @@ +// 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. +// +// 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"; + +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 eventTeamId = extractTeamIdFromBody(body); + if (eventTeamId !== host.getTeamId()) { + 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; + + 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); + if (eventTeamId !== host.getTeamId()) { + console.log(`[slack-http] Dropping DM with foreign or missing 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, 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"; + 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..7e5ece09 --- /dev/null +++ b/src/channels/slack-http-receiver.ts @@ -0,0 +1,295 @@ +// 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. 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`. +// +// `signatureVerification: false` on ExpressReceiver skips Slack's signing +// secret because the gateway has already verified Slack's signature. + +import { App, ExpressReceiver, type LogLevel } from "@slack/bolt"; +import type { Request, RequestHandler, Response } from "express"; +import type { SlackBlock } from "./feedback.ts"; +import { registerSlackActions } from "./slack-actions.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 = { + botToken: string; + gatewaySigningSecret: string; + teamId: string; + installerUserId: string; + teamName: string; + listenPort: number; + listenPath: string; +}; + +type ConnectionState = "disconnected" | "connecting" | "connected" | "error"; + +const LOG_TAG = "slack-http"; + +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; + + 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; + } + + 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"), ""); + } + return text.replace(/^<@[A-Z0-9]+>\s*/, ""); + } + + // 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(); + 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) { + if (!isUrlVerificationBody(raw, getContentType(req))) { + res.status(403).end("forbidden"); + return; + } + } else if (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; 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(`[${LOG_TAG}] Resolved bot user <@${this.botUserId}>`); + + await this.receiver.start(this.listenPort); + this.connectionState = "connected"; + console.log(`[${LOG_TAG}] Listening on :${this.listenPort}${this.listenPath}/{events,interactivity,commands}`); + } catch (err: unknown) { + this.connectionState = "error"; + 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; + } + } + + 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(`[${LOG_TAG}] Error during disconnect: ${redactTokens(msg)}`); + } + this.connectionState = "disconnected"; + console.log(`[${LOG_TAG}] Disconnected`); + } + + async send(conversationId: string, message: OutboundMessage): Promise { + return egressSend(this.egressContext(), conversationId, message); + } + + 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 { + return egressPostToChannel(this.egressContext(), channelId, text); + } + + async sendDm(userId: string, text: string): Promise { + return egressSendDm(this.egressContext(), userId, text); + } + + async postThinking(channel: string, threadTs: string): Promise { + return egressPostThinking(this.egressContext(), channel, threadTs); + } + + async updateMessage(channel: string, ts: string, text: string, blocks?: SlackBlock[]): Promise { + return egressUpdateMessage(this.egressContext(), channel, ts, text, blocks); + } + + async updateWithFeedback(channel: string, ts: string, text: string): Promise { + return egressUpdateWithFeedback(this.egressContext(), channel, ts, text); + } + + async addReaction(channel: string, messageTs: string, emoji: string): Promise { + return egressAddReaction(this.egressContext(), channel, messageTs, emoji); + } + + async removeReaction(channel: string, messageTs: string, emoji: string): Promise { + return egressRemoveReaction(this.egressContext(), channel, messageTs, emoji); + } +} 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-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/channels/slack.ts b/src/channels/slack.ts index 587a4267..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 = { @@ -11,6 +19,7 @@ export type SlackChannelConfig = { appToken: string; defaultChannelId?: string; ownerUserId?: string; + transport?: "socket"; }; type ConnectionState = "disconnected" | "connecting" | "connected" | "error"; @@ -45,6 +54,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, @@ -134,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 { @@ -175,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 { @@ -410,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/__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..8170b35b --- /dev/null +++ b/src/config/identity-fetcher.ts @@ -0,0 +1,141 @@ +// 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. +// +// 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"; + +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"); + // `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") ?? ""; + + 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; +} 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..5a4a3b7d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,8 @@ 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 { 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"; @@ -295,16 +296,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: SlackTransport | 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 +330,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; }