diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3346616dac..eb35b3117e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -285,6 +285,20 @@ jobs: test "$(yq 'select(.kind == "Deployment" and .metadata.name == "backend") | .spec.template.spec.containers[] | select(.name == "backend") | .readinessProbe.httpGet.path' /tmp/dev-rendered.yaml)" = "/api/health/ready" test "$(yq 'select(.kind == "Deployment" and .metadata.name == "backend") | .spec.template.spec.containers[] | select(.name == "backend") | .lifecycle.preStop.exec.command[2]' /tmp/dev-rendered.yaml)" = "sleep 10" + - name: Render Slack OAuth secret wiring + run: | + helm template commonly-slack k8s/helm/commonly \ + -f k8s/helm/commonly/values.yaml \ + -f k8s/helm/commonly/values-dev.yaml \ + --set slack.oauth.enabled=true \ + > /tmp/slack-rendered.yaml + for key in slack-client-id slack-client-secret slack-signing-secret connector-secret-keys connector-secret-active-key; do + test "$(yq 'select(.kind == "ExternalSecret" and .metadata.name == "api-keys") | .spec.data[].secretKey' /tmp/slack-rendered.yaml | grep -Fx "$key")" = "$key" + done + for name in SLACK_CLIENT_ID SLACK_CLIENT_SECRET SLACK_SIGNING_SECRET CONNECTOR_SECRET_KEYS CONNECTOR_SECRET_ACTIVE_KEY; do + test "$(yq 'select(.kind == "Deployment" and .metadata.name == "backend") | .spec.template.spec.containers[] | select(.name == "backend") | .env[].name' /tmp/slack-rendered.yaml | grep -Fx "$name")" = "$name" + done + - name: Render and lint prod chart (if values-prod.yaml exists) run: | if [ -f k8s/helm/commonly/values-prod.yaml ]; then diff --git a/backend/__tests__/unit/routes/slack.webhook.signature.test.js b/backend/__tests__/unit/routes/slack.webhook.signature.test.js new file mode 100644 index 0000000000..90525833a2 --- /dev/null +++ b/backend/__tests__/unit/routes/slack.webhook.signature.test.js @@ -0,0 +1,67 @@ +const crypto = require('crypto'); +const request = require('supertest'); +const express = require('express'); + +jest.mock('../../../models/Integration', () => ({ findOne: jest.fn(), findById: jest.fn() })); +jest.mock('../../../integrations', () => ({ get: jest.fn() })); +jest.mock('../../../services/slackEventReceiptService', () => ({ claim: jest.fn(), markDone: jest.fn() })); +jest.mock('../../../services/slackBridgeService', () => ({ relaySlackMessageToPod: jest.fn() })); + +const receipts = require('../../../services/slackEventReceiptService'); +const Integration = require('../../../models/Integration'); +const slackRoutes = require('../../../routes/webhooks/slack'); + +const app = express(); +app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString(); } })); +app.use('/api/webhooks/slack', slackRoutes); + +const signingSecret = 'slack-signing-secret'; +const eventBody = { + event_id: 'Ev1', + team_id: 'T1', + event: { + type: 'message', channel_type: 'im', channel: 'D1', user: 'U1', text: 'hello', + }, +}; +const signatureHeaders = (body, timestamp = Math.floor(Date.now() / 1000)) => { + const raw = JSON.stringify(body); + const signature = `v0=${crypto.createHmac('sha256', signingSecret).update(`v0:${timestamp}:${raw}`).digest('hex')}`; + return { 'X-Slack-Request-Timestamp': String(timestamp), 'X-Slack-Signature': signature }; +}; + +describe('installable Slack webhook signature and acknowledgement', () => { + beforeEach(() => { + process.env.SLACK_SIGNING_SECRET = signingSecret; + jest.clearAllMocks(); + }); + afterAll(() => delete process.env.SLACK_SIGNING_SECRET); + + test('rejects invalid signatures before receipt creation', async () => { + const response = await request(app).post('/api/webhooks/slack/events').send(eventBody); + expect(response.status).toBe(401); + expect(receipts.claim).not.toHaveBeenCalled(); + }); + + test('drops non-DM events before they create a receipt or resolve a connector', async () => { + const body = { ...eventBody, event: { ...eventBody.event, channel_type: 'channel' } }; + const response = await request(app).post('/api/webhooks/slack/events').set(signatureHeaders(body)).send(body); + expect(response.status).toBe(200); + expect(receipts.claim).not.toHaveBeenCalled(); + expect(Integration.findOne).not.toHaveBeenCalled(); + }); + + test('acknowledges a claimed DM event and resolves only its bound team/channel', async () => { + receipts.claim.mockResolvedValue('claimed'); + receipts.markDone.mockResolvedValue(undefined); + Integration.findOne.mockReturnValue({ lean: jest.fn().mockResolvedValue(null) }); + + const response = await request(app).post('/api/webhooks/slack/events').set(signatureHeaders(eventBody)).send(eventBody); + expect(response.status).toBe(200); + await new Promise((resolve) => setImmediate(resolve)); + expect(receipts.claim).toHaveBeenCalledWith('Ev1', 'T1'); + expect(Integration.findOne).toHaveBeenCalledWith(expect.objectContaining({ + type: 'slack', 'config.teamId': 'T1', 'config.chatId': 'D1', 'config.chatType': 'im', isActive: true, + })); + expect(receipts.markDone).toHaveBeenCalledWith('Ev1'); + }); +}); diff --git a/backend/__tests__/unit/routes/slackOAuth.installables.test.js b/backend/__tests__/unit/routes/slackOAuth.installables.test.js new file mode 100644 index 0000000000..3810ac385a --- /dev/null +++ b/backend/__tests__/unit/routes/slackOAuth.installables.test.js @@ -0,0 +1,296 @@ +const request = require('supertest'); +const express = require('express'); +const TEST_PUBLIC_APP_URL = 'https://connectors.example.test'; +const originalPublicAppUrl = process.env.PUBLIC_APP_URL; +const originalFrontendUrl = process.env.FRONTEND_URL; + +jest.mock('../../../middleware/auth', () => (req, res, next) => { + req.user = { id: '64b64c48c4f37a6b2f34c111' }; + next(); +}); +jest.mock('../../../middleware/integrationRateLimit', () => ({ + writeIntegrationsRateLimit: (_req, _res, next) => next(), +})); +jest.mock('../../../models/Pod', () => ({ findById: jest.fn() })); +jest.mock('../../../models/Integration', () => ({ + findOne: jest.fn(), findOneAndUpdate: jest.fn(), updateOne: jest.fn(), +})); +jest.mock('../../../models/InstallableInstallation', () => ({ findOne: jest.fn() })); +jest.mock('../../../utils/secret', () => ({ randomSecret: jest.fn(() => 'nonce-value') })); +jest.mock('../../../services/telegramConnectCode', () => ({ + mintConnectCode: jest.fn(() => ({ connectCode: 'r'.repeat(32), connectCodeExpiresAt: new Date(Date.now() + 60_000) })), +})); +jest.mock('../../../services/connectorSecrets', () => ({ get: jest.fn(), put: jest.fn(), revoke: jest.fn() })); +jest.mock('../../../services/slackApi', () => jest.fn().mockImplementation(() => ({ + openConversation: jest.fn(), +}))); +jest.mock('../../../services/slackOAuthService', () => { + class SlackOAuthConfigurationError extends Error { constructor() { super('not configured'); this.code = 'slack_oauth_not_configured'; } } + class SlackOAuthExchangeError extends Error { constructor() { super('exchange failed'); this.code = 'slack_oauth_exchange_failed'; } } + return { + SlackOAuthConfigurationError, + SlackOAuthExchangeError, + buildAuthorizeUrl: jest.fn((state) => `https://slack.test/authorize?state=${state}`), + exchangeCode: jest.fn(), + }; +}); +jest.mock('../../../services/installable/installableInstallationService', () => ({ + install: jest.fn(), uninstall: jest.fn(), + InstallLockLostError: class InstallLockLostError extends Error {}, + InstallableAlreadyInstalledError: class InstallableAlreadyInstalledError extends Error {}, + InstallableNotFoundError: class InstallableNotFoundError extends Error {}, + InstallableProjectionError: class InstallableProjectionError extends Error {}, + InstallInProgressError: class InstallInProgressError extends Error {}, +})); + +const Integration = require('../../../models/Integration'); +const Pod = require('../../../models/Pod'); +const InstallableInstallation = require('../../../models/InstallableInstallation'); +const connectorSecrets = require('../../../services/connectorSecrets'); +const { mintConnectCode } = require('../../../services/telegramConnectCode'); +const SlackApi = require('../../../services/slackApi'); +const slackOAuth = require('../../../services/slackOAuthService'); +const installableRoutes = require('../../../routes/installables'); + +const app = express(); +app.use(express.json()); +app.use('/api/installables', installableRoutes); +app.use('/api/webhooks/slack/oauth', installableRoutes.slackOAuthCallbackRouter); + +const ownerId = '64b64c48c4f37a6b2f34c111'; +const integration = { + _id: 'integration-1', + installationId: 'install-1', + type: 'slack', + isActive: true, + podId: '64b64c48c4f37a6b2f34c222', + config: { + connectCode: 'c'.repeat(32), + connectCodeExpiresAt: new Date(Date.now() + 60_000), + oauthStateNonce: 'nonce-value', + }, +}; + +const own = () => { + InstallableInstallation.findOne.mockResolvedValue({ _id: 'install-1', targetId: ownerId, status: 'active' }); + Integration.findOne.mockResolvedValue({ ...integration, config: { ...integration.config } }); +}; + +describe('Slack installable OAuth routes', () => { + beforeAll(() => { + process.env.PUBLIC_APP_URL = TEST_PUBLIC_APP_URL; + delete process.env.FRONTEND_URL; + }); + + afterAll(() => { + if (originalPublicAppUrl === undefined) delete process.env.PUBLIC_APP_URL; + else process.env.PUBLIC_APP_URL = originalPublicAppUrl; + if (originalFrontendUrl === undefined) delete process.env.FRONTEND_URL; + else process.env.FRONTEND_URL = originalFrontendUrl; + }); + + beforeEach(() => { + jest.clearAllMocks(); + own(); + }); + + test('mints a browser-bound nonce and sends only the lifecycle code as OAuth state', async () => { + Integration.findOneAndUpdate.mockResolvedValue({ ...integration }); + const response = await request(app).post('/api/installables/slack/authorize-url'); + + expect(response.status).toBe(200); + expect(response.body.authorizeUrl).toContain(`state=${integration.config.connectCode}`); + expect(response.headers['set-cookie'][0]).toContain('commonly_slack_oauth_nonce=nonce-value'); + expect(Integration.findOneAndUpdate).toHaveBeenCalledWith( + expect.objectContaining({ _id: 'integration-1', 'config.connectCode': integration.config.connectCode }), + expect.objectContaining({ + $set: expect.objectContaining({ + 'config.oauthStateNonce': 'nonce-value', + }), + }), + expect.anything(), + ); + }); + + test('re-mints an expired Slack OAuth state without disconnecting the install', async () => { + const expired = { + ...integration, + config: { ...integration.config, connectCodeExpiresAt: new Date(Date.now() - 1_000) }, + }; + Integration.findOne.mockResolvedValue(expired); + Integration.findOneAndUpdate + .mockResolvedValueOnce({ ...integration, config: { ...integration.config, connectCode: 'r'.repeat(32) } }) + .mockResolvedValueOnce({ ...integration, config: { ...integration.config, connectCode: 'r'.repeat(32) } }); + + const response = await request(app).post('/api/installables/slack/authorize-url'); + + expect(response.status).toBe(200); + expect(mintConnectCode).toHaveBeenCalledTimes(1); + expect(slackOAuth.buildAuthorizeUrl).toHaveBeenCalledWith('r'.repeat(32)); + expect(Integration.findOneAndUpdate.mock.calls[0]).toEqual([ + expect.objectContaining({ _id: 'integration-1', type: 'slack', isActive: true }), + expect.objectContaining({ $set: expect.objectContaining({ 'config.connectCode': 'r'.repeat(32) }) }), + expect.anything(), + ]); + }); + + test('refuses a callback without its browser nonce before Slack exchange', async () => { + const response = await request(app) + .get(`/api/webhooks/slack/oauth/callback?state=${integration.config.connectCode}&code=slack-code`); + + expect(response.status).toBe(302); + expect(slackOAuth.exchangeCode).not.toHaveBeenCalled(); + expect(Integration.findOneAndUpdate).not.toHaveBeenCalled(); + expect(response.headers.location).toBe(`${TEST_PUBLIC_APP_URL}/v2/connectors?slack=error&code=invalid_state`); + }); + + test('refuses a callback with a different-length browser nonce before Slack exchange', async () => { + const response = await request(app) + .get(`/api/webhooks/slack/oauth/callback?state=${integration.config.connectCode}&code=slack-code`) + .set('Cookie', 'commonly_slack_oauth_nonce=short'); + + expect(slackOAuth.exchangeCode).not.toHaveBeenCalled(); + expect(Integration.findOneAndUpdate).not.toHaveBeenCalled(); + expect(response.status).toBe(302); + expect(response.headers.location).toBe(`${TEST_PUBLIC_APP_URL}/v2/connectors?slack=error&code=invalid_state`); + }); + + test('stores only a secret reference in a pending bind after a claimed callback', async () => { + Integration.findOneAndUpdate + .mockResolvedValueOnce({ ...integration }) + .mockResolvedValueOnce({ ...integration, config: { pendingBind: { botTokenRef: 'secret-ref' } } }); + slackOAuth.exchangeCode.mockResolvedValue({ + accessToken: 'xoxb-never-store-on-integration', + teamId: 'T1', teamName: 'Example', slackUserId: 'U1', slackUserName: 'sam', + }); + SlackApi.mock.instances[0]?.openConversation?.mockResolvedValue({ ok: true, channel: { id: 'D1' } }); + // Constructor instance is created inside the handler; set its method via + // the default implementation's return object after construction below. + SlackApi.mockImplementationOnce(() => ({ openConversation: jest.fn().mockResolvedValue({ ok: true, channel: { id: 'D1' } }) })); + connectorSecrets.put.mockResolvedValue('secret-ref'); + + const response = await request(app) + .get(`/api/webhooks/slack/oauth/callback?state=${integration.config.connectCode}&code=slack-code`) + .set('Cookie', 'commonly_slack_oauth_nonce=nonce-value'); + + expect(response.status).toBe(302); + expect(response.headers.location).toBe(`${TEST_PUBLIC_APP_URL}/v2/connectors?slack=pending`); + expect(slackOAuth.exchangeCode).toHaveBeenCalledWith('slack-code'); + expect(connectorSecrets.put).toHaveBeenCalledWith('integration-1', 'slack', 'xoxb-never-store-on-integration'); + const [, commit] = Integration.findOneAndUpdate.mock.calls[1]; + expect(commit.$set['config.pendingBind']).toMatchObject({ teamId: 'T1', chatId: 'D1', botTokenRef: 'secret-ref' }); + expect(JSON.stringify(commit)).not.toContain('xoxb-never-store-on-integration'); + }); + + test('refuses a replayed browser nonce when its row already has a pending bind', async () => { + Integration.findOne + .mockResolvedValueOnce({ ...integration, config: { ...integration.config } }) + // A replay sees the same row after the first callback has committed its + // pending bind. The selector below must exclude it, so Mongo returns no + // candidate rather than exposing its consumed nonce. + .mockResolvedValueOnce(null); + Integration.findOneAndUpdate + .mockResolvedValueOnce({ ...integration }) + .mockResolvedValueOnce({ ...integration, config: { pendingBind: { botTokenRef: 'secret-ref' } } }); + slackOAuth.exchangeCode.mockResolvedValue({ + accessToken: 'xoxb-never-store-on-integration', + teamId: 'T1', teamName: 'Example', slackUserId: 'U1', slackUserName: 'sam', + }); + SlackApi.mockImplementationOnce(() => ({ openConversation: jest.fn().mockResolvedValue({ ok: true, channel: { id: 'D1' } }) })); + connectorSecrets.put.mockResolvedValue('secret-ref'); + + const first = await request(app) + .get(`/api/webhooks/slack/oauth/callback?state=${integration.config.connectCode}&code=slack-code`) + .set('Cookie', 'commonly_slack_oauth_nonce=nonce-value'); + const exchangesBeforeReplay = slackOAuth.exchangeCode.mock.calls.length; + const writesBeforeReplay = Integration.findOneAndUpdate.mock.calls.length; + const replay = await request(app) + .get(`/api/webhooks/slack/oauth/callback?state=${integration.config.connectCode}&code=slack-code`) + .set('Cookie', 'commonly_slack_oauth_nonce=nonce-value'); + + expect(Integration.findOne.mock.calls[1][0]).toEqual(expect.objectContaining({ + 'config.pendingBind': { $exists: false }, + })); + expect(slackOAuth.exchangeCode).toHaveBeenCalledTimes(exchangesBeforeReplay); + expect(Integration.findOneAndUpdate).toHaveBeenCalledTimes(writesBeforeReplay); + expect(first.headers.location).toBe(`${TEST_PUBLIC_APP_URL}/v2/connectors?slack=pending`); + expect(replay.status).toBe(302); + expect(replay.headers.location).toBe(`${TEST_PUBLIC_APP_URL}/v2/connectors?slack=error&code=invalid_state`); + expect(exchangesBeforeReplay).toBe(1); + expect(writesBeforeReplay).toBe(2); + }); + + test('confirms only the owner binding and never serializes secret material', async () => { + const pending = { + teamId: 'T1', teamName: 'Example', slackUserId: 'U1', slackUserName: 'sam', + chatId: 'D1', botTokenRef: 'secret-ref', expiresAt: new Date(Date.now() + 60_000), + }; + Integration.findOne.mockResolvedValue({ ...integration, config: { pendingBind: pending } }); + Pod.findById + .mockResolvedValueOnce({ createdBy: { toString: () => 'another' }, members: [ownerId] }) + .mockReturnValueOnce({ + select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ name: 'Launch' }) }), + }); + Integration.findOneAndUpdate.mockResolvedValue({ + ...integration, + status: 'connected', + // Plain-object return deliberately bypasses Integration.toJSON(), as a + // lean query would. The route backstop must still strip both values. + config: { ...pending, botTokenRef: 'secret-ref', oauthStateNonce: 'nonce-never-return', chatType: 'im' }, + }); + connectorSecrets.get.mockResolvedValue('xoxb-secret'); + SlackApi.mockImplementationOnce(() => ({ postMessage: jest.fn().mockResolvedValue({ ok: true, ts: '1.1' }) })); + + const response = await request(app).post('/api/installables/slack/confirm'); + + expect(response.status).toBe(200); + expect(Integration.findOneAndUpdate).toHaveBeenCalledWith( + expect.objectContaining({ _id: 'integration-1', 'config.pendingBind.botTokenRef': 'secret-ref' }), + expect.objectContaining({ $set: expect.objectContaining({ 'config.chatType': 'im', 'config.botTokenRef': 'secret-ref' }) }), + expect.anything(), + ); + expect(JSON.stringify(response.body)).not.toContain('secret-ref'); + expect(JSON.stringify(response.body)).not.toContain('nonce-never-return'); + expect(SlackApi.mock.results[0].value.postMessage).toHaveBeenCalledWith('D1', '[Launch] connected'); + }); + + test('rejects a pending Slack bind and revokes its secret reference', async () => { + const pending = { + teamId: 'T1', slackUserId: 'U1', chatId: 'D1', botTokenRef: 'secret-ref', + expiresAt: new Date(Date.now() + 60_000), + }; + Integration.findOne.mockResolvedValue({ ...integration, config: { pendingBind: pending } }); + Integration.findOneAndUpdate.mockResolvedValue({ ...integration, config: {} }); + + const response = await request(app).post('/api/installables/slack/reject'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: 'rejected' }); + expect(Integration.findOneAndUpdate).toHaveBeenCalledWith( + expect.objectContaining({ _id: 'integration-1', 'config.pendingBind.botTokenRef': 'secret-ref' }), + { $unset: { 'config.pendingBind': 1 } }, + expect.anything(), + ); + expect(connectorSecrets.revoke).toHaveBeenCalledWith('secret-ref'); + }); + + test('expires a stale pending bind back to active pending, not an invisible inactive row', async () => { + const pending = { + teamId: 'T1', slackUserId: 'U1', chatId: 'D1', botTokenRef: 'secret-ref', + expiresAt: new Date(Date.now() - 60_000), + }; + Integration.findOne.mockResolvedValue({ ...integration, config: { pendingBind: pending } }); + Integration.findOneAndUpdate.mockResolvedValue({ ...integration, status: 'pending', isActive: true, config: {} }); + + const response = await request(app).post('/api/installables/slack/confirm'); + + expect(response.status).toBe(409); + expect(response.body.code).toBe('slack_bind_expired'); + expect(Integration.findOneAndUpdate).toHaveBeenCalledWith( + expect.objectContaining({ _id: 'integration-1', 'config.pendingBind.botTokenRef': 'secret-ref' }), + expect.objectContaining({ $set: { status: 'pending', errorMessage: null } }), + expect.anything(), + ); + expect(connectorSecrets.revoke).toHaveBeenCalledWith('secret-ref'); + }); +}); diff --git a/backend/__tests__/unit/services/connectorSecrets.test.js b/backend/__tests__/unit/services/connectorSecrets.test.js new file mode 100644 index 0000000000..5dade6108b --- /dev/null +++ b/backend/__tests__/unit/services/connectorSecrets.test.js @@ -0,0 +1,79 @@ +const mongoose = require('mongoose'); + +const ConnectorSecret = require('../../../models/ConnectorSecret'); +const { + ConnectorSecretKeyMissing, + get, + put, + rewrap, + revoke, +} = require('../../../services/connectorSecrets'); +const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../../utils/testUtils'); + +const key = (fill) => Buffer.alloc(32, fill).toString('base64'); + +describe('connectorSecrets', () => { + const originalKeys = process.env.CONNECTOR_SECRET_KEYS; + const originalActiveKey = process.env.CONNECTOR_SECRET_ACTIVE_KEY; + + beforeAll(async () => { + await setupMongoDb(); + await ConnectorSecret.syncIndexes(); + }); + + afterAll(async () => { + await closeMongoDb(); + process.env.CONNECTOR_SECRET_KEYS = originalKeys; + process.env.CONNECTOR_SECRET_ACTIVE_KEY = originalActiveKey; + }); + + beforeEach(async () => { + await clearMongoDb(); + process.env.CONNECTOR_SECRET_KEYS = `k1:${key(1)}`; + process.env.CONNECTOR_SECRET_ACTIVE_KEY = 'k1'; + }); + + it('encrypts with a fresh IV and keeps material out of the document', async () => { + const integrationId = new mongoose.Types.ObjectId().toString(); + const first = await put(integrationId, 'slack', 'xoxb-secret'); + const second = await put(integrationId, 'slack', 'xoxb-secret'); + + expect(second).toBe(first); + const stored = await ConnectorSecret.findById(first).lean(); + expect(stored).toMatchObject({ provider: 'slack', keyId: 'k1' }); + expect(stored.ciphertext).not.toContain('xoxb-secret'); + expect(stored.iv).toHaveLength(16); // base64 of a fresh 96-bit IV + expect(await get(first)).toBe('xoxb-secret'); + }); + + it('fails closed when ciphertext authentication fails', async () => { + const ref = await put(new mongoose.Types.ObjectId().toString(), 'slack', 'xoxb-secret'); + await ConnectorSecret.updateOne({ _id: ref }, { $set: { tag: Buffer.alloc(16, 9).toString('base64') } }); + + await expect(get(ref)).rejects.toThrow(); + }); + + it('rewraps under the active key before the former key is removed', async () => { + const ref = await put(new mongoose.Types.ObjectId().toString(), 'slack', 'xoxb-secret'); + process.env.CONNECTOR_SECRET_KEYS = `k1:${key(1)},k2:${key(2)}`; + process.env.CONNECTOR_SECRET_ACTIVE_KEY = 'k2'; + + await rewrap(ref); + process.env.CONNECTOR_SECRET_KEYS = `k2:${key(2)}`; + expect(await get(ref)).toBe('xoxb-secret'); + }); + + it('raises a typed error for a key removed without a rewrap', async () => { + const ref = await put(new mongoose.Types.ObjectId().toString(), 'slack', 'xoxb-secret'); + process.env.CONNECTOR_SECRET_KEYS = `k2:${key(2)}`; + process.env.CONNECTOR_SECRET_ACTIVE_KEY = 'k2'; + + await expect(get(ref)).rejects.toBeInstanceOf(ConnectorSecretKeyMissing); + }); + + it('deletes the secret by its opaque reference', async () => { + const ref = await put(new mongoose.Types.ObjectId().toString(), 'slack', 'xoxb-secret'); + await revoke(ref); + expect(await ConnectorSecret.findById(ref)).toBeNull(); + }); +}); diff --git a/backend/__tests__/unit/services/installableEventHandlers.test.js b/backend/__tests__/unit/services/installableEventHandlers.test.js index 3400b91d1e..fd25a63dfa 100644 --- a/backend/__tests__/unit/services/installableEventHandlers.test.js +++ b/backend/__tests__/unit/services/installableEventHandlers.test.js @@ -13,7 +13,7 @@ const { eventHandlers, } = require('../../../services/installable/eventHandlers'); const telegramSend = require('../../../services/telegramService'); -const { TELEGRAM_CONNECTOR } = require('../../../scripts/seed-builtin-connectors'); +const { TELEGRAM_CONNECTOR, SLACK_CONNECTOR } = require('../../../scripts/seed-builtin-connectors'); const { setupMongoDb, closeMongoDb, @@ -24,6 +24,7 @@ const freshId = () => new mongoose.Types.ObjectId().toString(); describe('installable event dispatcher', () => { let originalTelegramHandler; + let originalSlackHandler; beforeAll(async () => { await setupMongoDb(); @@ -40,11 +41,17 @@ describe('installable event dispatcher', () => { ...TELEGRAM_CONNECTOR, stats: { totalInstalls: 0, activeInstalls: 0, forkCount: 0 }, }); + await Installable.create({ + ...SLACK_CONNECTOR, + stats: { totalInstalls: 0, activeInstalls: 0, forkCount: 0 }, + }); originalTelegramHandler = eventHandlers['telegram.relay']; + originalSlackHandler = eventHandlers['slack.relay']; }); afterEach(() => { eventHandlers['telegram.relay'] = originalTelegramHandler; + eventHandlers['slack.relay'] = originalSlackHandler; }); it('selects only the event pod connector before invoking its handler', async () => { @@ -153,6 +160,49 @@ describe('installable event dispatcher', () => { expect(relay.mock.calls[0][0].podId).toBe(podId); }); + it('selects installed Slack and Telegram rows independently for one pod', async () => { + const podId = freshId(); + await install({ installableId: 'telegram', installedBy: freshId(), podId }); + await install({ installableId: 'slack', installedBy: freshId(), podId }); + const telegramRelay = jest.fn().mockResolvedValue(undefined); + const slackRelay = jest.fn().mockResolvedValue(undefined); + eventHandlers['telegram.relay'] = telegramRelay; + eventHandlers['slack.relay'] = slackRelay; + + await dispatch('chat.message', { + podId, + agentUsername: 'kai', + displayName: 'Kai', + content: 'One message, two subscriptions', + podMessageId: 'message-mixed', + }); + + expect(telegramRelay).toHaveBeenCalledTimes(1); + expect(slackRelay).toHaveBeenCalledTimes(1); + expect(String(slackRelay.mock.calls[0][0].integration.podId)).toBe(podId); + }); + + it('does not dispatch an error-gated Slack row while keeping its projection active for recovery', async () => { + const podId = freshId(); + const installed = await install({ installableId: 'slack', installedBy: freshId(), podId }); + await Integration.updateOne( + { _id: installed.integration._id }, + { $set: { status: 'error', isActive: true } }, + ); + const slackRelay = jest.fn().mockResolvedValue(undefined); + eventHandlers['slack.relay'] = slackRelay; + + await dispatch('chat.message', { + podId, + agentUsername: 'kai', + displayName: 'Kai', + content: 'Do not send through an unavailable connector', + podMessageId: 'message-error-gated', + }); + + expect(slackRelay).not.toHaveBeenCalled(); + }); + it('contains an individual handler failure', async () => { const podId = freshId(); await install({ installableId: 'telegram', installedBy: freshId(), podId }); diff --git a/backend/__tests__/unit/services/installableInstallationService.test.js b/backend/__tests__/unit/services/installableInstallationService.test.js index b521667035..b2061ae195 100644 --- a/backend/__tests__/unit/services/installableInstallationService.test.js +++ b/backend/__tests__/unit/services/installableInstallationService.test.js @@ -5,6 +5,7 @@ const mongoose = require('mongoose'); const Installable = require('../../../models/Installable'); const InstallableInstallation = require('../../../models/InstallableInstallation'); const Integration = require('../../../models/Integration'); +const ConnectorSecret = require('../../../models/ConnectorSecret'); const { install, uninstall, @@ -13,7 +14,8 @@ const { InstallableProjectionError, } = require('../../../services/installable/installableInstallationService'); const { sweep } = require('../../../services/installable/installableReconciler'); -const { TELEGRAM_CONNECTOR } = require('../../../scripts/seed-builtin-connectors'); +const { TELEGRAM_CONNECTOR, SLACK_CONNECTOR } = require('../../../scripts/seed-builtin-connectors'); +const { put } = require('../../../services/connectorSecrets'); const { setupMongoDb, closeMongoDb, @@ -27,13 +29,19 @@ const ids = () => ({ }); describe('installable connector projection', () => { + const originalKeys = process.env.CONNECTOR_SECRET_KEYS; + const originalActiveKey = process.env.CONNECTOR_SECRET_ACTIVE_KEY; + beforeAll(async () => { await setupMongoDb(); await InstallableInstallation.syncIndexes(); + await ConnectorSecret.syncIndexes(); }); afterAll(async () => { await closeMongoDb(); + process.env.CONNECTOR_SECRET_KEYS = originalKeys; + process.env.CONNECTOR_SECRET_ACTIVE_KEY = originalActiveKey; }); beforeEach(async () => { @@ -42,6 +50,12 @@ describe('installable connector projection', () => { ...TELEGRAM_CONNECTOR, stats: { totalInstalls: 0, activeInstalls: 0, forkCount: 0 }, }); + await Installable.create({ + ...SLACK_CONNECTOR, + stats: { totalInstalls: 0, activeInstalls: 0, forkCount: 0 }, + }); + process.env.CONNECTOR_SECRET_KEYS = `k1:${Buffer.alloc(32, 1).toString('base64')}`; + process.env.CONNECTOR_SECRET_ACTIVE_KEY = 'k1'; }); it('projects Telegram once, mints last, and shares one Integration between components', async () => { @@ -392,6 +406,21 @@ describe('installable connector projection', () => { expect(replacementAfterSecondUninstall.isActive).toBe(false); }); + it('revokes Slack’s envelope secret when the installation is uninstalled', async () => { + const { userId, podId } = ids(); + const installed = await install({ installableId: 'slack', installedBy: userId, podId }); + const ref = await put(String(installed.integration._id), 'slack', 'xoxb-secret'); + await Integration.updateOne( + { _id: installed.integration._id }, + { $set: { 'config.botTokenRef': ref } }, + ); + + await uninstall({ installableId: 'slack', installedBy: userId }); + + expect(await ConnectorSecret.findById(ref)).toBeNull(); + expect((await Integration.findById(installed.integration._id)).config.botTokenRef).toBeUndefined(); + }); + it('finishes a stale revocation before re-installing a new parent and projection', async () => { const { userId, podId } = ids(); const first = await install({ installableId: 'telegram', installedBy: userId, podId }); @@ -516,6 +545,80 @@ describe('installable connector projection', () => { expect(parent.components.every((component) => component.status === 'stale')).toBe(true); }); + it('expires a pending Slack bind, revokes its encrypted token, and leaves a retryable active card', async () => { + const { userId, podId } = ids(); + const installed = await install({ installableId: 'slack', installedBy: userId, podId }); + const ref = await put(String(installed.integration._id), 'slack', 'xoxb-secret'); + await Integration.updateOne( + { _id: installed.integration._id }, + { + $set: { + 'config.pendingBind': { + teamId: 'T1', slackUserId: 'U1', chatId: 'D1', botTokenRef: ref, + expiresAt: new Date(Date.now() - 1), + }, + }, + }, + ); + + const reconciled = await sweep(new Date()); + const integration = await Integration.findById(installed.integration._id); + const parent = await InstallableInstallation.findById(installed.installation._id); + + expect(reconciled.expiredSlackBinds).toBe(1); + expect(integration.isActive).toBe(true); + expect(integration.status).toBe('pending'); + expect(integration.config.pendingBind?.botTokenRef).toBeUndefined(); + expect(await ConnectorSecret.findById(ref)).toBeNull(); + expect(parent.components.every((component) => component.status === 'active')).toBe(true); + }); + + it('does not sweep a freshly written orphaned secret before a callback can commit its bind', async () => { + const ref = await put(new mongoose.Types.ObjectId().toString(), 'slack', 'xoxb-secret'); + const now = new Date(); + + const fresh = await sweep(now); + expect(fresh.orphanedSecrets).toBe(0); + expect(await ConnectorSecret.findById(ref)).not.toBeNull(); + + await ConnectorSecret.collection.updateOne( + { _id: new mongoose.Types.ObjectId(ref) }, + { $set: { createdAt: new Date(now.getTime() - (10 * 60_000) - 1) } }, + ); + const expired = await sweep(now); + expect(expired.orphanedSecrets).toBe(1); + expect(await ConnectorSecret.findById(ref)).toBeNull(); + }); + + it('keeps a Slack projection visible but error-gated when its secret key disappears from the ring', async () => { + const { userId, podId } = ids(); + const installed = await install({ installableId: 'slack', installedBy: userId, podId }); + const ref = await put(String(installed.integration._id), 'slack', 'xoxb-secret'); + await Integration.updateOne( + { _id: installed.integration._id }, + { + $set: { + 'config.botTokenRef': ref, + 'config.teamId': 'T1', + 'config.chatId': 'D1', + 'config.chatType': 'im', + }, + }, + ); + process.env.CONNECTOR_SECRET_KEYS = `k2:${Buffer.alloc(32, 2).toString('base64')}`; + process.env.CONNECTOR_SECRET_ACTIVE_KEY = 'k2'; + + const reconciled = await sweep(new Date()); + const integration = await Integration.findById(installed.integration._id); + const parent = await InstallableInstallation.findById(installed.installation._id); + + expect(reconciled.unavailableSlackSecretKeys).toBe(1); + expect(integration.isActive).toBe(true); + expect(integration.status).toBe('error'); + expect(integration.errorMessage).toMatch(/secret key/i); + expect(parent.components.every((component) => component.status === 'active')).toBe(true); + }); + it('completes a stale activating row with its already-active, same-generation projection', async () => { const { userId, podId } = ids(); const result = await install({ installableId: 'telegram', installedBy: userId, podId }); diff --git a/backend/__tests__/unit/services/installableReconciler.schedule.test.js b/backend/__tests__/unit/services/installableReconciler.schedule.test.js new file mode 100644 index 0000000000..bbfa7d4196 --- /dev/null +++ b/backend/__tests__/unit/services/installableReconciler.schedule.test.js @@ -0,0 +1,19 @@ +const fs = require('fs'); +const path = require('path'); + +describe('installable reconciler scheduling', () => { + test('runs every five minutes without borrowing the heartbeat lease', () => { + const source = fs.readFileSync( + path.join(__dirname, '../../../services/schedulerService.ts'), + 'utf8', + ); + const start = source.indexOf('const installableReconcileJob'); + const job = source.slice(start - 500, source.indexOf('this.jobs = [', start)); + + expect(job).toContain("'*/5 * * * *'"); + expect(job).toContain("require('./installable/installableReconciler')"); + expect(job).toContain('await reconciler.sweep()'); + expect(job).toContain('status+claimId CAS'); + expect(job).not.toContain('tryAcquireHeartbeatDispatchLease'); + }); +}); diff --git a/backend/__tests__/unit/services/slackBridgeService.test.js b/backend/__tests__/unit/services/slackBridgeService.test.js new file mode 100644 index 0000000000..ae19a96e0d --- /dev/null +++ b/backend/__tests__/unit/services/slackBridgeService.test.js @@ -0,0 +1,68 @@ +jest.mock('../../../models/Integration', () => ({ findOne: jest.fn(), findByIdAndUpdate: jest.fn() })); +jest.mock('../../../models/Pod', () => ({ findById: jest.fn() })); +jest.mock('../../../services/connectorSecrets', () => ({ get: jest.fn() })); +jest.mock('../../../services/slackApi', () => jest.fn().mockImplementation(() => ({ postMessage: jest.fn() }))); + +const Integration = require('../../../models/Integration'); +const Pod = require('../../../models/Pod'); +const connectorSecrets = require('../../../services/connectorSecrets'); +const SlackApi = require('../../../services/slackApi'); +const { relayAgentMessageToSlack, routeSlackReplyContent } = require('../../../services/slackBridgeService'); + +const integration = { + _id: 'integration-1', + podId: 'pod-1', + type: 'slack', + isActive: true, + config: { + teamId: 'T1', chatId: 'D1', chatType: 'im', botTokenRef: 'secret-ref', liveRelay: true, + relayAllAgentMessages: true, + }, +}; + +describe('Slack installable bridge', () => { + beforeEach(() => { + jest.clearAllMocks(); + Pod.findById.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ name: 'Launch' }) }) }); + connectorSecrets.get.mockResolvedValue('xoxb-secret'); + SlackApi.mockImplementation(() => ({ postMessage: jest.fn().mockResolvedValue({ ok: true, ts: '171234.0001' }) })); + Integration.findByIdAndUpdate.mockResolvedValue(undefined); + }); + + test('uses the selected Slack row, secret reference, and generic D11 map', async () => { + await relayAgentMessageToSlack({ + podId: 'pod-1', agentUsername: 'kai', displayName: 'Kai', content: 'Hello from the pod', + podMessageId: 'message-1', integration, + }); + + expect(connectorSecrets.get).toHaveBeenCalledWith('secret-ref'); + expect(SlackApi).toHaveBeenCalledWith('xoxb-secret'); + const api = SlackApi.mock.results[0].value; + expect(api.postMessage).toHaveBeenCalledWith('D1', '[Launch] Kai: Hello from the pod'); + expect(Integration.findByIdAndUpdate).toHaveBeenCalledWith('integration-1', expect.objectContaining({ + $push: expect.objectContaining({ + 'config.relayMap': expect.objectContaining({ + $each: [expect.objectContaining({ externalMessageId: '171234.0001', podId: 'pod-1' })], + }), + }), + })); + }); + + test('routes a Slack thread reply to the agent whose relayed message was quoted', () => { + expect(routeSlackReplyContent({ + content: 'Can you clarify?', + threadTs: '171234.0001', + relayMap: [{ externalMessageId: '171234.0001', agentUsername: 'kai' }], + })).toEqual({ content: '@kai Can you clarify?', routedAgent: 'kai' }); + }); + + test('does not relay through a visible recovery row whose secret is unavailable', async () => { + await relayAgentMessageToSlack({ + podId: 'pod-1', agentUsername: 'kai', displayName: 'Kai', content: 'Must stay in Commonly', + integration: { ...integration, status: 'error' }, + }); + + expect(connectorSecrets.get).not.toHaveBeenCalled(); + expect(SlackApi).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/__tests__/unit/services/slackEventReceiptService.test.js b/backend/__tests__/unit/services/slackEventReceiptService.test.js new file mode 100644 index 0000000000..500cede70b --- /dev/null +++ b/backend/__tests__/unit/services/slackEventReceiptService.test.js @@ -0,0 +1,31 @@ +const SlackEventReceipt = require('../../../models/SlackEventReceipt'); +const { claim, markDone, EVENT_CLAIM_TTL_MS } = require('../../../services/slackEventReceiptService'); +const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../../utils/testUtils'); + +describe('SlackEventReceipt claim lifecycle', () => { + beforeAll(async () => { + await setupMongoDb(); + await SlackEventReceipt.syncIndexes(); + }); + afterAll(() => closeMongoDb()); + beforeEach(() => clearMongoDb()); + + test('marks a completed event done so a retry cannot relay it again', async () => { + expect(await claim('Ev1', 'T1')).toBe('claimed'); + await markDone('Ev1'); + expect(await claim('Ev1', 'T1')).toBe('duplicate_done'); + }); + + test('takes over an abandoned processing claim with one CAS winner', async () => { + const old = new Date(Date.now() - EVENT_CLAIM_TTL_MS - 1); + await SlackEventReceipt.create({ eventId: 'Ev-stale', teamId: 'T1', state: 'processing', claimedAt: old, receivedAt: old }); + + const outcomes = await Promise.all([ + claim('Ev-stale', 'T1'), + claim('Ev-stale', 'T1'), + ]); + + expect(outcomes.filter((outcome) => outcome === 'claimed')).toHaveLength(1); + expect(outcomes.filter((outcome) => outcome === 'duplicate_processing')).toHaveLength(1); + }); +}); diff --git a/backend/__tests__/unit/services/slackOAuthService.test.js b/backend/__tests__/unit/services/slackOAuthService.test.js new file mode 100644 index 0000000000..c53c0f2b30 --- /dev/null +++ b/backend/__tests__/unit/services/slackOAuthService.test.js @@ -0,0 +1,39 @@ +/* eslint-disable import/extensions, import/no-unresolved */ +const { buildAuthorizeUrl } = require('../../../services/slackOAuthService'); + +const envKeys = [ + 'SLACK_CLIENT_ID', + 'SLACK_CLIENT_SECRET', + 'SLACK_OAUTH_REDIRECT_URI', + 'PUBLIC_API_URL', + 'BACKEND_URL', +]; +const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); + +const restoreEnv = () => { + envKeys.forEach((key) => { + if (originalEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalEnv[key]; + }); +}; + +describe('slackOAuthService', () => { + beforeEach(() => { + process.env.SLACK_CLIENT_ID = 'slack-client'; + process.env.SLACK_CLIENT_SECRET = 'slack-secret'; + delete process.env.SLACK_OAUTH_REDIRECT_URI; + delete process.env.PUBLIC_API_URL; + delete process.env.BACKEND_URL; + }); + + afterAll(restoreEnv); + + it('uses BACKEND_URL for the callback when no public API override is set', () => { + process.env.BACKEND_URL = 'https://api.self-hosted.test/'; + + const authorizeUrl = new URL(buildAuthorizeUrl('connect-code')); + + expect(authorizeUrl.searchParams.get('redirect_uri')) + .toBe('https://api.self-hosted.test/api/webhooks/slack/oauth/callback'); + }); +}); diff --git a/backend/models/ConnectorSecret.ts b/backend/models/ConnectorSecret.ts new file mode 100644 index 0000000000..91b8f1fb03 --- /dev/null +++ b/backend/models/ConnectorSecret.ts @@ -0,0 +1,47 @@ +import mongoose, { Document, Model, Schema, Types } from 'mongoose'; + +/** + * Encrypted connector credential material. + * + * Integration rows keep only this document's id in `config.botTokenRef`; the + * token itself must never be serialised through an Integration route. + */ +export interface IConnectorSecret extends Document { + integrationId: Types.ObjectId; + provider: string; + ciphertext: string; + iv: string; + tag: string; + keyId: string; + createdAt: Date; + updatedAt: Date; +} + +const ConnectorSecretSchema = new Schema( + { + integrationId: { + type: Schema.Types.ObjectId, + ref: 'Integration', + required: true, + unique: true, + }, + provider: { type: String, required: true }, + ciphertext: { type: String, required: true }, + // Base64-encoded 96-bit AES-GCM nonce and authentication tag. + iv: { type: String, required: true }, + tag: { type: String, required: true }, + keyId: { type: String, required: true }, + }, + { timestamps: true, collection: 'connector_secrets' }, +); + +ConnectorSecretSchema.index({ integrationId: 1 }, { unique: true }); + +const ConnectorSecret: Model = + (mongoose.models.ConnectorSecret as Model) + || mongoose.model('ConnectorSecret', ConnectorSecretSchema); + +export default ConnectorSecret; +// CJS compat: let require() return the default export directly +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = exports.default; Object.assign(module.exports, exports); diff --git a/backend/models/Integration.ts b/backend/models/Integration.ts index b17793a1a2..917d5d07ca 100644 --- a/backend/models/Integration.ts +++ b/backend/models/Integration.ts @@ -77,6 +77,10 @@ export interface IIntegration extends Document { lastExternalTimestamp?: Date; connectCode?: string; connectCodeExpiresAt?: Date | null; + /** Slack OAuth callback nonce — random, short-lived, and never exposed. */ + oauthStateNonce?: string; + oauthStateNonceExpiresAt?: Date; + oauthStateClaimId?: string; permissions?: string[]; webhookListenerEnabled?: boolean; lastSummaryAt?: Date; @@ -93,10 +97,29 @@ export interface IIntegration extends Document { leadAgentUsername?: string; relayAllAgentMessages?: boolean; relayMutedUntil?: Date; + /** Opaque ConnectorSecret id; credentials never live on the Integration. */ + botTokenRef?: string; + /** Slack's workspace identity and the bound one-to-one DM. */ + teamId?: string; + teamName?: string; + slackUserId?: string; + slackUserName?: string; + pendingBind?: { + teamId: string; + teamName?: string; + slackUserId: string; + slackUserName?: string; + chatId: string; + botTokenRef: string; + expiresAt: Date; + }; relayMap?: { - tgMessageId: string; + /** Generic D11 reply key. Telegram retains tgMessageId during migration. */ + externalMessageId?: string; + tgMessageId?: string; agentUsername: string; podMessageId?: string | null; + podId?: string; }[]; }; ingestTokens: IIngestToken[]; @@ -164,6 +187,9 @@ const IntegrationSchema = new Schema( lastExternalTimestamp: Date, connectCode: String, connectCodeExpiresAt: Date, + oauthStateNonce: String, + oauthStateNonceExpiresAt: Date, + oauthStateClaimId: String, permissions: [String], webhookListenerEnabled: { type: Boolean, default: false }, lastSummaryAt: Date, @@ -185,11 +211,29 @@ const IntegrationSchema = new Schema( leadAgentUsername: String, relayAllAgentMessages: { type: Boolean, default: false }, relayMutedUntil: Date, + // Connector secrets live in ConnectorSecret; routes must never accept + // this reference from clients (see SERVER_OWNED_CONFIG_KEYS). + botTokenRef: String, + teamId: String, + teamName: String, + slackUserId: String, + slackUserName: String, + pendingBind: { + teamId: String, + teamName: String, + slackUserId: String, + slackUserName: String, + chatId: String, + botTokenRef: String, + expiresAt: Date, + }, relayMap: [ { + externalMessageId: String, tgMessageId: String, agentUsername: String, podMessageId: String, + podId: String, }, ], agentAccessEnabled: { type: Boolean, default: false }, @@ -217,6 +261,9 @@ IntegrationSchema.index({ status: 1 }); IntegrationSchema.index({ createdBy: 1 }); IntegrationSchema.index({ installationId: 1 }, { unique: true, sparse: true }); IntegrationSchema.index({ 'ingestTokens.tokenHash': 1 }); +// Installable Slack Events API lookup: a global endpoint resolves a bound DM +// solely by its workspace and channel, then still checks isActive. +IntegrationSchema.index({ type: 1, 'config.teamId': 1, 'config.chatId': 1, isActive: 1 }); IntegrationSchema.virtual('platformIntegration', { ref() { @@ -233,7 +280,23 @@ IntegrationSchema.virtual('platformIntegration', { justOne: true, }); -IntegrationSchema.set('toJSON', { virtuals: true }); +// A ConnectorSecret reference is itself not a bearer credential, but returning +// it still widens the set of clients that can reason about server-side secret +// storage. Keep it server-only in every normal JSON response, including the +// pending OAuth bind that needs to show its workspace/user details. +IntegrationSchema.set('toJSON', { + virtuals: true, + transform: (_doc: unknown, returned: { config?: Record }) => { + if (!returned.config) return returned; + delete returned.config.botTokenRef; + delete returned.config.oauthStateNonce; + const pending = returned.config.pendingBind; + if (pending && typeof pending === 'object') { + delete (pending as Record).botTokenRef; + } + return returned; + }, +}); IntegrationSchema.set('toObject', { virtuals: true }); export default mongoose.model('Integration', IntegrationSchema); diff --git a/backend/models/SlackEventReceipt.ts b/backend/models/SlackEventReceipt.ts new file mode 100644 index 0000000000..876f09364a --- /dev/null +++ b/backend/models/SlackEventReceipt.ts @@ -0,0 +1,40 @@ +import mongoose, { Document, Model, Schema } from 'mongoose'; + +/** + * Cross-replica event claim for Slack's at-least-once Events API delivery. + * + * A receipt is deliberately not synonymous with completion: a stale + * `processing` claim can be CAS-taken over after a worker dies, while `done` + * is retained for 24h so normal Slack retries never double-author a message. + */ +export interface ISlackEventReceipt extends Document { + eventId: string; + teamId: string; + state: 'processing' | 'done'; + claimedAt: Date; + receivedAt: Date; + createdAt: Date; + updatedAt: Date; +} + +const SlackEventReceiptSchema = new Schema( + { + eventId: { type: String, required: true, unique: true }, + teamId: { type: String, required: true }, + state: { type: String, enum: ['processing', 'done'], required: true }, + claimedAt: { type: Date, required: true }, + receivedAt: { type: Date, required: true, index: { expires: 24 * 60 * 60 } }, + }, + { timestamps: true, collection: 'slack_event_receipts' }, +); + +SlackEventReceiptSchema.index({ eventId: 1 }, { unique: true }); + +const SlackEventReceipt: Model = + (mongoose.models.SlackEventReceipt as Model) + || mongoose.model('SlackEventReceipt', SlackEventReceiptSchema); + +export default SlackEventReceipt; +// CJS compatibility for the route's current require style. +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = exports.default; Object.assign(module.exports, exports); diff --git a/backend/routes/installables.ts b/backend/routes/installables.ts index c2d03c4890..4c4fcd4608 100644 --- a/backend/routes/installables.ts +++ b/backend/routes/installables.ts @@ -1,11 +1,32 @@ // eslint-disable-next-line global-require const express = require('express'); // eslint-disable-next-line global-require +const crypto = require('crypto'); +// eslint-disable-next-line global-require const auth = require('../middleware/auth'); // eslint-disable-next-line global-require const Pod = require('../models/Pod'); // eslint-disable-next-line global-require +const Integration = require('../models/Integration'); +// eslint-disable-next-line global-require +const InstallableInstallation = require('../models/InstallableInstallation'); +// eslint-disable-next-line global-require const isPodMember = require('../utils/isPodMember'); +// eslint-disable-next-line global-require +const { randomSecret } = require('../utils/secret'); +// eslint-disable-next-line global-require +const { mintConnectCode } = require('../services/telegramConnectCode'); +// eslint-disable-next-line global-require +const connectorSecrets = require('../services/connectorSecrets'); +// eslint-disable-next-line global-require +const SlackApi = require('../services/slackApi'); +// eslint-disable-next-line global-require +const { + SlackOAuthConfigurationError, + SlackOAuthExchangeError, + buildAuthorizeUrl, + exchangeCode, +} = require('../services/slackOAuthService'); import { Types } from 'mongoose'; import { writeIntegrationsRateLimit } from '../middleware/integrationRateLimit'; // eslint-disable-next-line global-require @@ -24,17 +45,404 @@ interface AuthReq { userId?: string; params?: Record; body?: Record; + query?: Record; + headers?: Record; } interface Res { status: (status: number) => Res; json: (body: unknown) => void; + redirect: (status: number, url: string) => Res; + cookie?: (name: string, value: string, options: Record) => Res; + clearCookie?: (name: string, options?: Record) => Res; } const router: ReturnType = express.Router(); const requesterId = (req: AuthReq): string | undefined => req.userId || req.user?.id || req.user?._id; +const SLACK_NONCE_COOKIE = 'commonly_slack_oauth_nonce'; +const SLACK_NONCE_TTL_MS = 5 * 60_000; +const SLACK_BIND_TTL_MS = 10 * 60_000; + +// The nonce is random, short-lived, and never returned in JSON. Compare the +// cookie to the server record in constant time instead of treating it as a +// password-derived value. +const matchesSlackNonce = (stored: unknown, supplied: string): boolean => { + if (typeof stored !== 'string') return false; + const expected = Buffer.from(stored); + const actual = Buffer.from(supplied); + return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); +}; + +const cookieValue = (req: AuthReq, name: string): string | undefined => { + const header = req.headers?.cookie; + const serialized = Array.isArray(header) ? header.join(';') : header; + if (!serialized) return undefined; + const hit = serialized.split(';').map((part) => part.trim()).find((part) => part.startsWith(`${name}=`)); + if (!hit) return undefined; + try { + return decodeURIComponent(hit.slice(name.length + 1)); + } catch { + return undefined; + } +}; + +const ownedSlackIntegration = async (userId: string): Promise<{ installation: any; integration: any } | null> => { + const installation = await InstallableInstallation.findOne({ + installableId: 'slack', + targetType: 'user', + targetId: userId, + status: 'active', + }); + if (!installation) return null; + const integration = await Integration.findOne({ installationId: String(installation._id), type: 'slack' }); + return integration ? { installation, integration } : null; +}; + +const slackError = (res: Res, status: number, code: string, error: string): void => { + res.status(status).json({ code, error }); +}; + +const publicAppUrl = (): string => { + const configured = String(process.env.PUBLIC_APP_URL || process.env.FRONTEND_URL || 'https://commonly.me') + .split(',')[0] + .trim(); + return (configured || 'https://commonly.me').replace(/\/+$/, ''); +}; + +const slackCallbackRedirect = (res: Res, state: 'pending' | 'error', code?: string): Res => { + res.clearCookie?.(SLACK_NONCE_COOKIE, { + httpOnly: true, + sameSite: 'lax', + secure: true, + path: '/api/webhooks/slack/oauth/callback', + }); + const query = new URLSearchParams({ slack: state }); + if (code) query.set('code', code); + // Connectors is a v2 shell route; `/connectors` is not a mounted page on + // the public app, so send the browser to the actual rendered surface. + return res.redirect(302, `${publicAppUrl()}/v2/connectors?${query.toString()}`); +}; + +// Mongoose's Integration toJSON transform is the normal guard. Keep this +// small route-bound backstop too: tests, lean queries, and a future service +// refactor must not accidentally turn an opaque ConnectorSecret ref or the +// browser-bound OAuth nonce into API data by bypassing document serialization. +const publicIntegration = (integration: unknown): unknown => { + if (!integration || typeof integration !== 'object') return integration; + const raw = typeof (integration as { toJSON?: () => unknown }).toJSON === 'function' + ? (integration as { toJSON: () => unknown }).toJSON() + : JSON.parse(JSON.stringify(integration)); + if (!raw || typeof raw !== 'object') return raw; + const result = raw as { config?: Record }; + if (!result.config) return result; + delete result.config.botTokenRef; + delete result.config.oauthStateNonce; + const pending = result.config.pendingBind; + if (pending && typeof pending === 'object') delete (pending as Record).botTokenRef; + return result; +}; + +/** + * POST /api/installables/slack/authorize-url + * + * The lifecycle's minted connect code is Slack's OAuth state. A second, + * short-lived HttpOnly nonce binds the browser which asked for the link; the + * unauthenticated callback checks both before it ever calls Slack. + */ +router.post('/slack/authorize-url', writeIntegrationsRateLimit, auth, async (req: AuthReq, res: Res) => { + const userId = requesterId(req); + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + try { + const owned = await ownedSlackIntegration(userId); + if (!owned) return slackError(res, 404, 'slack_installation_not_found', 'Slack installation not found.'); + let code = owned.integration.config?.connectCode; + let expiresAt = owned.integration.config?.connectCodeExpiresAt; + if (!owned.integration.isActive) { + return slackError(res, 409, 'slack_authorization_unavailable', 'Slack authorization is no longer available.'); + } + if (owned.integration.config?.chatId || owned.integration.config?.pendingBind) { + return slackError(res, 409, 'slack_already_authorized', 'Slack is already awaiting confirmation or connected.'); + } + // OAuth state expires after ten minutes. Re-mint it here, rather than + // forcing an otherwise healthy installed connector through Disconnect + // before the owner can try Slack again. The same no-chat/no-pending CAS + // prevents a second browser from replacing a state that has already + // reached Slack's callback. + if (!code || !expiresAt || new Date(expiresAt) <= new Date()) { + const minted = mintConnectCode(); + const reminted = await Integration.findOneAndUpdate( + { + _id: owned.integration._id, + type: 'slack', + isActive: true, + 'config.chatId': { $exists: false }, + 'config.pendingBind': { $exists: false }, + }, + { + $set: { + 'config.connectCode': minted.connectCode, + 'config.connectCodeExpiresAt': minted.connectCodeExpiresAt, + }, + $unset: { + 'config.oauthStateNonce': 1, + 'config.oauthStateNonceExpiresAt': 1, + 'config.oauthStateClaimId': 1, + }, + }, + { new: true }, + ); + if (!reminted) { + return slackError(res, 409, 'slack_authorization_unavailable', 'Slack authorization is no longer available.'); + } + code = reminted.config?.connectCode; + expiresAt = reminted.config?.connectCodeExpiresAt; + } + if (!code || !expiresAt || new Date(expiresAt) <= new Date()) { + return slackError(res, 409, 'slack_authorization_unavailable', 'Slack authorization is no longer available.'); + } + // Fail configuration before writing a nonce. Otherwise a deployment with + // missing OAuth settings consumes a browser attempt it can never service. + const url = buildAuthorizeUrl(String(code)); + const nonce = randomSecret(32); + const now = new Date(); + const claimed = await Integration.findOneAndUpdate( + { + _id: owned.integration._id, + type: 'slack', + isActive: true, + 'config.connectCode': code, + 'config.connectCodeExpiresAt': { $gt: now }, + 'config.chatId': { $exists: false }, + 'config.pendingBind': { $exists: false }, + }, + { + $set: { + 'config.oauthStateNonce': nonce, + 'config.oauthStateNonceExpiresAt': new Date(now.getTime() + SLACK_NONCE_TTL_MS), + }, + $unset: { 'config.oauthStateClaimId': 1 }, + }, + { new: true }, + ); + if (!claimed) { + return slackError(res, 409, 'slack_authorization_unavailable', 'Slack authorization is no longer available.'); + } + // Keep these options at the sink: CodeQL must be able to prove that the + // browser-bound nonce is never script-readable or sent over HTTP. + res.cookie?.(SLACK_NONCE_COOKIE, nonce, { + httpOnly: true, + sameSite: 'lax', + secure: true, + maxAge: SLACK_NONCE_TTL_MS, + path: '/api/webhooks/slack/oauth/callback', + }); + return res.json({ authorizeUrl: url, expiresAt }); + } catch (error) { + if (error instanceof SlackOAuthConfigurationError) { + const oauthError = error as { code: string; message: string }; + return slackError(res, 503, oauthError.code, oauthError.message); + } + console.error('[slack-oauth] could not create authorize URL:', (error as Error).message); + return slackError(res, 500, 'slack_authorization_failed', 'Could not begin Slack authorization.'); + } +}); + +/** + * GET /api/webhooks/slack/oauth/callback + * + * This deliberately lives in the installables router so it shares the owner + * and lifecycle helpers, but server mounts it separately below the Slack raw + * webhook path. It never trusts the Slack identity as Commonly identity: + * callback only creates a pending bind for the authenticated owner to confirm. + */ +const slackOAuthCallback = async (req: AuthReq, res: Res) => { + const state = typeof req.query?.state === 'string' ? req.query.state : ''; + const code = typeof req.query?.code === 'string' ? req.query.code : ''; + const nonce = cookieValue(req, SLACK_NONCE_COOKIE); + if (!state || !code || !nonce) { + return slackCallbackRedirect(res, 'error', 'invalid_state'); + } + const now = new Date(); + const claimId = randomSecret(16); + let integration: any; + try { + const candidate = await Integration.findOne({ + type: 'slack', + isActive: true, + 'config.connectCode': state, + 'config.connectCodeExpiresAt': { $gt: now }, + 'config.oauthStateNonceExpiresAt': { $gt: now }, + 'config.oauthStateClaimId': { $exists: false }, + 'config.chatId': { $exists: false }, + 'config.pendingBind': { $exists: false }, + }); + if (!candidate || !matchesSlackNonce(candidate.config?.oauthStateNonce, nonce)) { + return slackCallbackRedirect(res, 'error', 'invalid_state'); + } + // Claim before exchange. This consumes the one-use browser state across + // replicas while still allowing a transient exchange failure to unclaim + // below. No forged or replayed state can spend Slack exchange capacity. + integration = await Integration.findOneAndUpdate( + { + type: 'slack', + isActive: true, + 'config.connectCode': state, + 'config.connectCodeExpiresAt': { $gt: now }, + 'config.oauthStateNonce': candidate.config.oauthStateNonce, + 'config.oauthStateNonceExpiresAt': { $gt: now }, + 'config.oauthStateClaimId': { $exists: false }, + 'config.chatId': { $exists: false }, + 'config.pendingBind': { $exists: false }, + }, + { $set: { 'config.oauthStateClaimId': claimId } }, + { new: true }, + ); + if (!integration) { + return slackCallbackRedirect(res, 'error', 'invalid_state'); + } + const { accessToken, ...binding } = await exchangeCode(code); + const dm = await new SlackApi(accessToken).openConversation(binding.slackUserId); + if (!dm.ok || !dm.channel?.id) throw new SlackOAuthExchangeError(); + const botTokenRef = await connectorSecrets.put(String(integration._id), 'slack', accessToken); + const committed = await Integration.findOneAndUpdate( + { + _id: integration._id, + type: 'slack', + isActive: true, + 'config.connectCode': state, + 'config.oauthStateClaimId': claimId, + 'config.chatId': { $exists: false }, + }, + { + $set: { + 'config.pendingBind': { + ...binding, + chatId: dm.channel.id, + botTokenRef, + expiresAt: new Date(Date.now() + SLACK_BIND_TTL_MS), + }, + }, + $unset: { + 'config.connectCode': 1, + 'config.connectCodeExpiresAt': 1, + 'config.oauthStateNonce': 1, + 'config.oauthStateNonceExpiresAt': 1, + 'config.oauthStateClaimId': 1, + }, + }, + { new: true }, + ); + if (!committed) { + await connectorSecrets.revoke(botTokenRef); + return slackCallbackRedirect(res, 'error', 'state_consumed'); + } + return slackCallbackRedirect(res, 'pending'); + } catch (error) { + if (integration?._id) { + await Integration.updateOne( + { _id: integration._id, 'config.oauthStateClaimId': claimId }, + { $unset: { 'config.oauthStateClaimId': 1 } }, + ); + } + if (error instanceof SlackOAuthConfigurationError || error instanceof SlackOAuthExchangeError) { + const oauthError = error as { code: string; message: string }; + return slackCallbackRedirect(res, 'error', oauthError.code); + } + console.error('[slack-oauth] callback failed:', (error as Error).message); + return slackCallbackRedirect(res, 'error', 'callback_failed'); + } +}; + +// The public Slack redirect must sit under the webhook origin, while its +// owner-facing lifecycle siblings remain under /api/installables. Export a +// narrow mounted router rather than duplicating callback logic or adding a +// broad unauthenticated path to this router. +const slackOAuthCallbackRouter: ReturnType = express.Router(); +slackOAuthCallbackRouter.get('/callback', writeIntegrationsRateLimit, slackOAuthCallback); + +router.post('/slack/confirm', writeIntegrationsRateLimit, auth, async (req: AuthReq, res: Res) => { + const userId = requesterId(req); + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + const owned = await ownedSlackIntegration(userId); + if (!owned) return slackError(res, 404, 'slack_installation_not_found', 'Slack installation not found.'); + const pending = owned.integration.config?.pendingBind; + if (!pending) return slackError(res, 409, 'slack_bind_missing', 'There is no Slack authorization to confirm.'); + if (new Date(pending.expiresAt) <= new Date()) { + const cleared = await Integration.findOneAndUpdate( + { _id: owned.integration._id, 'config.pendingBind.botTokenRef': pending.botTokenRef }, + { + $set: { status: 'pending', errorMessage: null }, + $unset: { 'config.pendingBind': 1 }, + }, + { new: true }, + ); + if (cleared) await connectorSecrets.revoke(pending.botTokenRef); + return slackError(res, 409, 'slack_bind_expired', 'Slack authorization expired. Start again.'); + } + const pod = await Pod.findById(owned.integration.podId); + if (!pod || !isPodMember(pod, userId)) { + return slackError(res, 403, 'slack_pod_access_denied', 'You no longer have access to this pod.'); + } + const confirmed = await Integration.findOneAndUpdate( + { + _id: owned.integration._id, + type: 'slack', + isActive: true, + 'config.pendingBind.botTokenRef': pending.botTokenRef, + 'config.pendingBind.expiresAt': { $gt: new Date() }, + 'config.chatId': { $exists: false }, + }, + { + $set: { + status: 'connected', + 'config.teamId': pending.teamId, + 'config.teamName': pending.teamName, + 'config.slackUserId': pending.slackUserId, + 'config.slackUserName': pending.slackUserName, + 'config.chatId': pending.chatId, + 'config.chatType': 'im', + 'config.botTokenRef': pending.botTokenRef, + }, + $unset: { 'config.pendingBind': 1 }, + }, + { new: true }, + ); + if (!confirmed) return slackError(res, 409, 'slack_bind_missing', 'Slack authorization is no longer available.'); + // Confirmation is the point at which this DM becomes a real attention + // surface. Send the one explicit round-trip marker the stranger smoke uses; + // an API hiccup must not undo the already durable owner confirmation. + try { + const token = await connectorSecrets.get(pending.botTokenRef); + const livePod = await Pod.findById(confirmed.podId).select('name').lean(); + await new SlackApi(token).postMessage( + pending.chatId, + `[${String(livePod?.name || 'Commonly')}] connected`, + ); + } catch (error) { + console.warn('[slack-oauth] connected marker could not be sent:', (error as Error).message); + } + return res.json({ status: 'connected', integration: publicIntegration(confirmed) }); +}); + +router.post('/slack/reject', writeIntegrationsRateLimit, auth, async (req: AuthReq, res: Res) => { + const userId = requesterId(req); + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + const owned = await ownedSlackIntegration(userId); + if (!owned) return slackError(res, 404, 'slack_installation_not_found', 'Slack installation not found.'); + const pending = owned.integration.config?.pendingBind; + if (!pending) return slackError(res, 409, 'slack_bind_missing', 'There is no Slack authorization to reject.'); + const rejected = await Integration.findOneAndUpdate( + { _id: owned.integration._id, 'config.pendingBind.botTokenRef': pending.botTokenRef }, + { $unset: { 'config.pendingBind': 1 } }, + { new: true }, + ); + if (!rejected) return slackError(res, 409, 'slack_bind_missing', 'Slack authorization is no longer available.'); + await connectorSecrets.revoke(pending.botTokenRef); + return res.json({ status: 'rejected' }); +}); + const sendInstallError = (error: Error, res: Res): void => { if (error instanceof InstallableNotFoundError) { res.status(404).json({ code: 'installable_not_found', error: error.message }); @@ -112,7 +520,7 @@ router.post('/:installableId/install', writeIntegrationsRateLimit, auth, async ( return res.status(result.httpStatus).json({ status: result.state, installation: result.installation, - integration: result.integration, + integration: publicIntegration(result.integration), ...(result.boundPodId ? { boundPodId: result.boundPodId } : {}), }); } catch (error) { @@ -144,3 +552,4 @@ router.delete('/:installableId/install', writeIntegrationsRateLimit, auth, async }); module.exports = router; +module.exports.slackOAuthCallbackRouter = slackOAuthCallbackRouter; diff --git a/backend/routes/integrations.ts b/backend/routes/integrations.ts index 9ebf45b15f..b94207bd1b 100644 --- a/backend/routes/integrations.ts +++ b/backend/routes/integrations.ts @@ -42,7 +42,12 @@ import { // are written only by the /commonly-enable webhook (the code is the proof); // connectCode is minted here. Accepting any of them from a client body lets a // caller name someone else as the author or bind a chat without a code. -const SERVER_OWNED_CONFIG_KEYS = ['linkedUserId', 'connectCode', 'connectCodeExpiresAt', 'chatId', 'chatType', 'chatTitle']; +const SERVER_OWNED_CONFIG_KEYS = [ + 'linkedUserId', 'connectCode', 'connectCodeExpiresAt', 'chatId', 'chatType', 'chatTitle', + // OAuth callback and connectorSecrets own Slack identity and its opaque + // credential reference. Accepting either from a browser body defeats D6. + 'botTokenRef', 'teamId', 'teamName', 'slackUserId', 'slackUserName', 'pendingBind', +]; const stripServerOwnedConfig = (config: Record): Record => { const next = { ...config }; SERVER_OWNED_CONFIG_KEYS.forEach((k) => { delete next[k]; }); diff --git a/backend/routes/webhooks/slack.ts b/backend/routes/webhooks/slack.ts index 744d56baee..f4530bbf29 100644 --- a/backend/routes/webhooks/slack.ts +++ b/backend/routes/webhooks/slack.ts @@ -1,10 +1,179 @@ +import { createHmac } from 'crypto'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require const express = require('express'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const rateLimit = require('express-rate-limit'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require const Integration = require('../../models/Integration'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require const registry = require('../../integrations'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const { safeEqual } = require('../../utils/secret'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const slackReceipts = require('../../services/slackEventReceiptService'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const { relaySlackMessageToPod } = require('../../services/slackBridgeService'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const { cloudflareIpRateLimitKeyGenerator } = require('../../middleware/ipRateLimit'); const router = express.Router({ mergeParams: true }); +const SIGNATURE_WINDOW_MS = 5 * 60_000; + +// Slack retries delivery aggressively, so this budget deliberately leaves +// room for a busy shared workspace while still bounding unauthenticated work +// before HMAC verification and receipt creation. +const slackWebhookRateLimit = rateLimit({ + windowMs: 60_000, + max: 240, + standardHeaders: true, + legacyHeaders: false, + skip: () => process.env.NODE_ENV === 'test', + keyGenerator: cloudflareIpRateLimitKeyGenerator, + handler: (_req: unknown, res: any) => res.status(429).json({ error: 'Too many Slack webhook requests' }), +}); + +const header = (req: any, name: string): string => String(req.get?.(name) || req.headers?.[name.toLowerCase()] || ''); + +export const verifySlackSignature = (req: any, now = Date.now()): boolean => { + const signingSecret = process.env.SLACK_SIGNING_SECRET; + const timestamp = header(req, 'x-slack-request-timestamp'); + const signature = header(req, 'x-slack-signature'); + if (!signingSecret || !timestamp || !signature || !/^\d+$/.test(timestamp)) return false; + if (Math.abs(now - Number(timestamp) * 1000) > SIGNATURE_WINDOW_MS) return false; + // server.ts captures rawBody before JSON/form parsing. The fallback makes + // the narrow unit router testable; production never signs a reserialized body. + const rawBody = typeof req.rawBody === 'string' ? req.rawBody : JSON.stringify(req.body || {}); + const expected = `v0=${createHmac('sha256', signingSecret) + .update(`v0:${timestamp}:${rawBody}`) + .digest('hex')}`; + return safeEqual(expected, signature); +}; + +const signed = (req: any, res: any, next: () => void) => { + if (!verifySlackSignature(req)) return res.status(401).json({ error: 'Invalid Slack signature' }); + return next(); +}; + +const finishEvent = async (eventId: string, teamId: string, event: any): Promise => { + try { + // Keep provider input scalar before it reaches Mongoose. The direct + // String/strip form is intentionally adjacent to the selector so both + // the runtime boundary and CodeQL's NoSQL-injection model see the fence. + const safeTeamId = String(teamId || '').replace(/[^a-zA-Z0-9_-]/g, ''); + const channelId = String(event?.channel || '').replace(/[^a-zA-Z0-9_-]/g, ''); + if (!safeTeamId || !channelId) { + await slackReceipts.markDone(eventId); + return; + } + const integration = await Integration.findOne({ + type: 'slack', + isActive: true, + 'config.teamId': safeTeamId, + 'config.chatId': channelId, + 'config.chatType': 'im', + 'config.liveRelay': true, + status: { $ne: 'error' }, + }).lean(); + if (integration) await relaySlackMessageToPod({ integration, event }); + await slackReceipts.markDone(eventId); + } catch (error) { + // Keep state=processing. A stale delivery can then be CAS-reclaimed by a + // provider retry after a worker failure; done is reserved for a completed + // or intentionally dropped event, never for an exception. + console.error('[slack-events] processing failed:', (error as Error).message); + } +}; + +/** + * Installable Slack Events API endpoint. It is intentionally separate from + * the legacy /:integrationId provider route below: one Slack app has one + * global request URL and resolves its target by team + DM channel. + */ +router.post('/events', slackWebhookRateLimit, signed, async (req: any, res: any) => { + const body = req.body || {}; + if (body.type === 'url_verification') return res.status(200).json({ challenge: body.challenge }); + const event = body.event; + if (!body.event_id || !event || event.type !== 'message') return res.status(200).json({ ok: true }); + // D8/private-chat gate in Slack spelling. Do this before a DB lookup or a + // receipt: group/channel traffic must not create either side effect. + if (event.channel_type !== 'im' || event.subtype) return res.status(200).json({ ok: true }); + let claimed: string; + try { + claimed = await slackReceipts.claim(String(body.event_id), String(body.team_id || event.team || '')); + } catch (error) { + // Do not manufacture duplicate pod authorship if the shared claim store is + // down. A non-2xx asks Slack to redeliver once Mongo is reachable again. + console.error('[slack-events] receipt claim failed:', (error as Error).message); + return res.status(503).json({ error: 'Slack event receipt unavailable' }); + } + if (claimed !== 'claimed') return res.status(200).json({ ok: true }); + // Ack before the bridge's database/PG work. Slack's 3s deadline is a + // transport concern; state in SlackEventReceipt carries the work claim. + res.status(200).json({ ok: true }); + const teamId = String(body.team_id || event.team || '').replace(/[^a-zA-Z0-9_-]/g, ''); + setImmediate(() => { void finishEvent(String(body.event_id), teamId, event); }); + return undefined; +}); + +const commandHelp = 'Use /commonly status, mode mirror|attention, mute [minutes], or unmute.'; + +router.post('/commands', slackWebhookRateLimit, signed, async (req: any, res: any) => { + const body = req.body || {}; + const teamId = String(body.team_id || '').replace(/[^a-zA-Z0-9_-]/g, ''); + const channelId = String(body.channel_id || '').replace(/[^a-zA-Z0-9_-]/g, ''); + const slackUserId = String(body.user_id || '').replace(/[^a-zA-Z0-9_-]/g, ''); + if (!teamId || !channelId || !slackUserId) { + return res.status(400).json({ response_type: 'ephemeral', text: 'Invalid Slack command context.' }); + } + const integration = await Integration.findOne({ + type: 'slack', + isActive: true, + 'config.teamId': teamId, + 'config.chatId': channelId, + 'config.chatType': 'im', + 'config.slackUserId': slackUserId, + status: { $ne: 'error' }, + }); + if (!integration) { + return res.status(404).json({ + response_type: 'ephemeral', + text: 'This Slack DM is not connected to Commonly.', + }); + } + const [command, argument] = String(body.text || '').trim().split(/\s+/, 2); + const normalized = command?.toLowerCase() || 'help'; + if (normalized === 'mode') { + if (argument !== 'mirror' && argument !== 'attention') { + return res.json({ response_type: 'ephemeral', text: 'Usage: /commonly mode mirror|attention' }); + } + await Integration.updateOne( + { _id: integration._id }, + { $set: { 'config.relayAllAgentMessages': argument === 'mirror' } }, + ); + return res.json({ response_type: 'ephemeral', text: `Relay mode: ${argument}.` }); + } + if (normalized === 'mute') { + const minutes = Math.min(Math.max(parseInt(argument || '60', 10) || 60, 1), 24 * 60); + await Integration.updateOne( + { _id: integration._id }, + { $set: { 'config.relayMutedUntil': new Date(Date.now() + minutes * 60_000) } }, + ); + return res.json({ response_type: 'ephemeral', text: `Muted for ${minutes} minutes.` }); + } + if (normalized === 'unmute') { + await Integration.updateOne({ _id: integration._id }, { $unset: { 'config.relayMutedUntil': 1 } }); + return res.json({ response_type: 'ephemeral', text: 'Relay unmuted.' }); + } + if (normalized === 'status') { + const mode = integration.config?.relayAllAgentMessages ? 'mirror' : 'attention'; + return res.json({ response_type: 'ephemeral', text: `Relay is ${mode}.` }); + } + return res.json({ response_type: 'ephemeral', text: commandHelp }); +}); -// Slack sends JSON with signature based on raw body; raw middleware is set in server.js +// Legacy per-row Slack integrations preserve their old provider path. It is +// intentionally last so /events and /commands cannot be swallowed as an id. router.post('/:integrationId', async (req: any, res: any) => { try { const { integrationId } = req.params; @@ -22,6 +191,6 @@ router.post('/:integrationId', async (req: any, res: any) => { }); module.exports = router; -// LEGACY: in-platform webhook. External provider service will replace this route. +module.exports.verifySlackSignature = verifySlackSignature; export {}; diff --git a/backend/scripts/rewrap-connector-secrets.ts b/backend/scripts/rewrap-connector-secrets.ts new file mode 100644 index 0000000000..28567cbc42 --- /dev/null +++ b/backend/scripts/rewrap-connector-secrets.ts @@ -0,0 +1,55 @@ +#!/usr/bin/env node +/* + * Re-encrypt ConnectorSecret rows under CONNECTOR_SECRET_ACTIVE_KEY. + * + * Rotation is intentionally two-phase: deploy a ring containing old + new, + * point CONNECTOR_SECRET_ACTIVE_KEY at new, run this script until it reports + * zero remaining rows, then remove the old key. It is idempotent: rewrap() + * leaves rows already under the active key untouched. + * + * Usage: + * npx ts-node backend/scripts/rewrap-connector-secrets.ts # dry run + * npx ts-node backend/scripts/rewrap-connector-secrets.ts --apply + */ + +/* eslint-disable no-console */ +const mongoose = require('mongoose'); +const ConnectorSecret = require('../models/ConnectorSecret'); +const { rewrap } = require('../services/connectorSecrets'); + +const APPLY = process.argv.includes('--apply'); + +const main = async () => { + if (!process.env.MONGO_URI) throw new Error('MONGO_URI is required'); + if (!process.env.CONNECTOR_SECRET_ACTIVE_KEY) { + throw new Error('CONNECTOR_SECRET_ACTIVE_KEY is required'); + } + await mongoose.connect(process.env.MONGO_URI); + const activeKeyId = process.env.CONNECTOR_SECRET_ACTIVE_KEY; + const rows = await ConnectorSecret.find({ keyId: { $ne: activeKeyId } }).select('_id keyId integrationId').lean(); + console.log(`connector secrets needing rewrap: ${rows.length}`); + rows.slice(0, 20).forEach((row: any) => { + console.log(` ${row._id} key=${row.keyId} integration=${row.integrationId}`); + }); + if (!APPLY) { + console.log('DRY RUN — re-run with --apply to re-encrypt.'); + await mongoose.disconnect(); + return; + } + let rewritten = 0; + for (const row of rows) { + // The module decrypts with the row's key id and encrypts with the active + // key; the row-level CAS inside it makes a concurrent rerun harmless. + // eslint-disable-next-line no-await-in-loop + await rewrap(String(row._id)); + rewritten += 1; + } + const remaining = await ConnectorSecret.countDocuments({ keyId: { $ne: activeKeyId } }); + console.log(`rewrapped ${rewritten}; remaining under non-active keys: ${remaining}`); + await mongoose.disconnect(); +}; + +main().catch((error) => { + console.error('[rewrap-connector-secrets] failed:', error.message); + process.exit(1); +}); diff --git a/backend/scripts/seed-builtin-connectors.ts b/backend/scripts/seed-builtin-connectors.ts index 03ac1a435b..02b7407f24 100644 --- a/backend/scripts/seed-builtin-connectors.ts +++ b/backend/scripts/seed-builtin-connectors.ts @@ -7,6 +7,10 @@ const Installable = require('../models/Installable'); // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require const InstallableInstallation = require('../models/InstallableInstallation'); // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const ConnectorSecret = require('../models/ConnectorSecret'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const SlackEventReceipt = require('../models/SlackEventReceipt'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require const { manifests } = require('../integrations/manifests'); const telegramCatalog = manifests.telegram?.catalog; @@ -46,6 +50,41 @@ const TELEGRAM_CONNECTOR = { ], }; +const slackCatalog = manifests.slack?.catalog; +if (!slackCatalog) { + throw new Error('Slack provider manifest is missing its catalog metadata'); +} + +const SLACK_CONNECTOR = { + installableId: 'slack', + name: slackCatalog.label, + description: 'Link your Slack DM to Commonly — every pod you\'re in gets a voice where you already talk.', + version: '1.0.0', + kind: 'app', + source: 'builtin', + scope: 'user', + status: 'active', + requires: ['chat:read', 'chat:write', 'integrations:manage'], + components: [ + { + name: 'slack-webhook', + type: 'webhook', + webhookPath: '/api/webhooks/slack', + webhookEvents: ['message.im', 'oauth.callback', 'command'], + addresses: [{ mode: 'webhook', identifier: '/api/webhooks/slack' }], + scopes: ['chat:write'], + }, + { + name: 'slack-relay', + type: 'event-handler', + eventType: 'chat.message', + eventHandler: 'internal:slack.relay', + addresses: [{ mode: 'event', identifier: 'chat.message' }], + scopes: ['chat:read'], + }, + ], +}; + const migrateInstallationIndex = async (): Promise => { // The scaffolding index was unique across historical uninstalls. D17 makes // a new installation a new projection, so replace it exactly once with the @@ -65,22 +104,28 @@ const migrateInstallationIndex = async (): Promise => { export const seedBuiltinConnectors = async (): Promise => { try { await migrateInstallationIndex(); - await Installable.findOneAndUpdate( - { installableId: TELEGRAM_CONNECTOR.installableId }, - { - $set: TELEGRAM_CONNECTOR, - $setOnInsert: { - stats: { totalInstalls: 0, activeInstalls: 0, forkCount: 0 }, + // These models are first reached through asynchronous Slack callbacks; + // create their uniqueness/TTL indexes during the deterministic boot seed + // rather than discovering a missing index on the first external request. + await Promise.all([ConnectorSecret.syncIndexes(), SlackEventReceipt.syncIndexes()]); + await Promise.all([TELEGRAM_CONNECTOR, SLACK_CONNECTOR].map((connector) => ( + Installable.findOneAndUpdate( + { installableId: connector.installableId }, + { + $set: connector, + $setOnInsert: { + stats: { totalInstalls: 0, activeInstalls: 0, forkCount: 0 }, + }, }, - }, - { upsert: true, new: true, setDefaultsOnInsert: true }, - ); - console.log('[builtin-connectors] Telegram manifest ready'); + { upsert: true, new: true, setDefaultsOnInsert: true }, + ) + ))); + console.log('[builtin-connectors] Telegram and Slack manifests ready'); } catch (error) { console.error('[builtin-connectors] seed failed:', (error as Error).message); } }; -export { TELEGRAM_CONNECTOR }; +export { TELEGRAM_CONNECTOR, SLACK_CONNECTOR }; -module.exports = { seedBuiltinConnectors, TELEGRAM_CONNECTOR }; +module.exports = { seedBuiltinConnectors, TELEGRAM_CONNECTOR, SLACK_CONNECTOR }; diff --git a/backend/server.ts b/backend/server.ts index 4a7e73793c..c16e992434 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -152,6 +152,11 @@ app.use( }, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization', 'x-auth-token'], + // Slack OAuth starts in the authenticated shell and returns through an + // unauthenticated callback. The browser-bound nonce is HttpOnly on the + // API origin, so the authorize-url request must be allowed to persist it + // when the shell is served from commonly.me. + credentials: true, }), ); @@ -173,6 +178,17 @@ app.use( }, }), ); +// Slack slash commands are form posts, but their signature is over the same +// raw bytes. Capture those too before any parser turns them into an object. +app.use( + '/api/webhooks/slack', + express.urlencoded({ + extended: false, + verify: (req: any, _res: any, buf: Buffer) => { + req.rawBody = buf.toString(); + }, + }), +); // Standard JSON for GroupMe and Telegram webhooks app.use('/api/webhooks/groupme', express.json()); @@ -200,7 +216,12 @@ app.use('/api/uploads', uploadsRoutes); app.use('/api/docs', docsRoutes); app.use('/api/summaries', summariesRoutes); app.use('/api/integrations', integrationRoutes); -app.use('/api/installables', require('./routes/installables')); +const installableRoutes = require('./routes/installables'); +app.use('/api/installables', installableRoutes); +// Slack's redirect URI is deliberately a webhook URL so the Slack app's +// public surface stays together. The handler itself remains in the +// installables module because it acts on the owner-scoped installation. +app.use('/api/webhooks/slack/oauth', installableRoutes.slackOAuthCallbackRouter); app.use('/api/apps', appPlatformRoutes); app.use('/api/webhooks/discord', discordWebhookRoutes); app.use('/api/webhooks/slack', slackWebhookRoutes); diff --git a/backend/services/connectorRelayPolicy.ts b/backend/services/connectorRelayPolicy.ts new file mode 100644 index 0000000000..df22b28535 --- /dev/null +++ b/backend/services/connectorRelayPolicy.ts @@ -0,0 +1,30 @@ +// Shared, deterministic interruption policy for connector bridges. Keeping it +// outside a provider means Telegram and Slack cannot silently diverge about +// which pod messages are allowed to interrupt a person's attention surface. + +export interface RelayPolicyIntegration { + config?: { + relayAllAgentMessages?: boolean; + leadAgentUsername?: string; + }; +} + +const ESCALATION_MARKERS = /\[(BLOCKED|ESCALATE|DECISION|NEEDS[-_ ]?HUMAN|APPROVAL)\]/i; +const QUESTION_AT_HUMAN = /@[a-z0-9_.-]+[^\n]{0,200}\?/i; + +export const shouldEscalate = (opts: { + content: string; + agentUsername: string; + integration: RelayPolicyIntegration; +}): boolean => { + const { content, agentUsername, integration } = opts; + const cfg = integration.config || {}; + if (cfg.relayAllAgentMessages) return true; + if (cfg.leadAgentUsername + && agentUsername.toLowerCase() === String(cfg.leadAgentUsername).toLowerCase()) { + return true; + } + return ESCALATION_MARKERS.test(content) || QUESTION_AT_HUMAN.test(content); +}; + +module.exports = { shouldEscalate }; diff --git a/backend/services/connectorSecrets.ts b/backend/services/connectorSecrets.ts new file mode 100644 index 0000000000..7dff55a600 --- /dev/null +++ b/backend/services/connectorSecrets.ts @@ -0,0 +1,179 @@ +import { + createCipheriv, + createDecipheriv, + randomBytes, +} from 'crypto'; +import { Types } from 'mongoose'; + +import type { IConnectorSecret } from '../models/ConnectorSecret'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const ConnectorSecret = require('../models/ConnectorSecret'); + +const ALGORITHM = 'aes-256-gcm'; +const IV_BYTES = 12; + +interface KeyRing { + activeKeyId: string; + keys: Map; +} + +export class ConnectorSecretKeyMissing extends Error { + code = 'connector_secret_key_missing'; + + constructor(keyId: string) { + super(`Connector secret key '${keyId}' is not available in the active key ring.`); + this.name = 'ConnectorSecretKeyMissing'; + } +} + +export class ConnectorSecretConfigurationError extends Error { + code = 'connector_secret_configuration_invalid'; + + constructor(message: string) { + super(message); + this.name = 'ConnectorSecretConfigurationError'; + } +} + +export class ConnectorSecretNotFoundError extends Error { + code = 'connector_secret_not_found'; + + constructor(ref: string) { + super(`Connector secret '${ref}' was not found.`); + this.name = 'ConnectorSecretNotFoundError'; + } +} + +const parseKeyRing = (): KeyRing => { + const serialized = process.env.CONNECTOR_SECRET_KEYS; + const activeKeyId = process.env.CONNECTOR_SECRET_ACTIVE_KEY; + if (!serialized || !activeKeyId) { + throw new ConnectorSecretConfigurationError( + 'CONNECTOR_SECRET_KEYS and CONNECTOR_SECRET_ACTIVE_KEY must both be configured.', + ); + } + + const keys = new Map(); + for (const entry of serialized.split(',')) { + const separator = entry.indexOf(':'); + if (separator <= 0 || separator === entry.length - 1) { + throw new ConnectorSecretConfigurationError('Each connector secret key must be keyId:base64Key.'); + } + const keyId = entry.slice(0, separator).trim(); + const encodedKey = entry.slice(separator + 1).trim(); + const key = Buffer.from(encodedKey, 'base64'); + if (!keyId || key.length !== 32) { + throw new ConnectorSecretConfigurationError( + `Connector secret key '${keyId || '(missing)'}' must decode to exactly 32 bytes.`, + ); + } + if (keys.has(keyId)) { + throw new ConnectorSecretConfigurationError(`Connector secret key '${keyId}' is duplicated.`); + } + keys.set(keyId, key); + } + if (!keys.has(activeKeyId)) throw new ConnectorSecretKeyMissing(activeKeyId); + return { activeKeyId, keys }; +}; + +const getKey = (ring: KeyRing, keyId: string): Buffer => { + const key = ring.keys.get(keyId); + if (!key) throw new ConnectorSecretKeyMissing(keyId); + return key; +}; + +const encrypt = (material: string, ring: KeyRing): Pick => { + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv(ALGORITHM, getKey(ring, ring.activeKeyId), iv); + const ciphertext = Buffer.concat([cipher.update(material, 'utf8'), cipher.final()]); + return { + ciphertext: ciphertext.toString('base64'), + iv: iv.toString('base64'), + tag: cipher.getAuthTag().toString('base64'), + keyId: ring.activeKeyId, + }; +}; + +const decrypt = (secret: Pick, ring: KeyRing): string => { + const decipher = createDecipheriv(ALGORITHM, getKey(ring, secret.keyId), Buffer.from(secret.iv, 'base64')); + decipher.setAuthTag(Buffer.from(secret.tag, 'base64')); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(secret.ciphertext, 'base64')), + decipher.final(), + ]); + return plaintext.toString('utf8'); +}; + +const asObjectId = (integrationId: string): Types.ObjectId => { + if (!Types.ObjectId.isValid(integrationId)) { + throw new ConnectorSecretConfigurationError('integrationId must be a valid ObjectId.'); + } + return new Types.ObjectId(integrationId); +}; + +const upsertEncrypted = async ( + integrationId: Types.ObjectId, + provider: string, + encrypted: Pick, +): Promise => { + try { + return await ConnectorSecret.findOneAndUpdate( + { integrationId }, + { $set: { provider, ...encrypted } }, + { new: true, upsert: true, setDefaultsOnInsert: true }, + ) as IConnectorSecret; + } catch (error) { + // Concurrent OAuth callbacks may race the first upsert. The unique + // integration key makes the loser retry as an ordinary update. + if ((error as { code?: number }).code !== 11000) throw error; + return ConnectorSecret.findOneAndUpdate( + { integrationId }, + { $set: { provider, ...encrypted } }, + { new: true }, + ) as Promise; + } +}; + +export const put = async (integrationId: string, provider: string, material: string): Promise => { + if (!material) throw new ConnectorSecretConfigurationError('Connector secret material must not be empty.'); + const secret = await upsertEncrypted(asObjectId(integrationId), provider, encrypt(material, parseKeyRing())); + return String(secret._id); +}; + +export const get = async (ref: string): Promise => { + const secret = await ConnectorSecret.findById(ref) as IConnectorSecret | null; + if (!secret) throw new ConnectorSecretNotFoundError(ref); + return decrypt(secret, parseKeyRing()); +}; + +export const revoke = async (ref: string | undefined): Promise => { + if (!ref) return; + await ConnectorSecret.deleteOne({ _id: ref }); +}; + +export const rewrap = async (ref: string): Promise => { + const secret = await ConnectorSecret.findById(ref) as IConnectorSecret | null; + if (!secret) throw new ConnectorSecretNotFoundError(ref); + const ring = parseKeyRing(); + if (secret.keyId === ring.activeKeyId) return String(secret._id); + const encrypted = encrypt(decrypt(secret, ring), ring); + await ConnectorSecret.updateOne({ _id: secret._id, keyId: secret.keyId }, { $set: encrypted }); + return String(secret._id); +}; + +export const listWithUnavailableKey = async (): Promise => { + const { keys } = parseKeyRing(); + return ConnectorSecret.find({ keyId: { $nin: [...keys.keys()] } }) as Promise; +}; + +module.exports = { + ConnectorSecretKeyMissing, + ConnectorSecretConfigurationError, + ConnectorSecretNotFoundError, + put, + get, + revoke, + rewrap, + listWithUnavailableKey, +}; diff --git a/backend/services/installable/eventHandlers.ts b/backend/services/installable/eventHandlers.ts index d36829230d..b360fad0a2 100644 --- a/backend/services/installable/eventHandlers.ts +++ b/backend/services/installable/eventHandlers.ts @@ -5,6 +5,8 @@ import { Types } from 'mongoose'; const Integration = require('../../models/Integration'); // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require const telegramBridgeService = require('../telegramBridgeService'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const slackBridgeService = require('../slackBridgeService'); export interface ChatMessageEventPayload { podId: string; @@ -23,6 +25,7 @@ export type InternalEventHandler = ( // add one registration instead of another dispatcher branch. export const eventHandlers: Record = { 'telegram.relay': telegramBridgeService.relayAgentMessageToTelegram, + 'slack.relay': slackBridgeService.relayAgentMessageToSlack, }; export const hasEventHandler = (reference: string): boolean => ( @@ -30,7 +33,7 @@ export const hasEventHandler = (reference: string): boolean => ( && typeof eventHandlers[reference.slice('internal:'.length)] === 'function' ); -const activeTelegramHandlersForPod = async (podId: string): Promise> => { @@ -41,8 +44,9 @@ const activeTelegramHandlersForPod = async (podId: string): Promise; try { - selected = await activeTelegramHandlersForPod(payload.podId); + selected = await activeHandlersForPod(payload.podId); } catch (error) { console.warn('[installable-dispatch] selector failed:', (error as Error).message); return; @@ -148,5 +156,6 @@ export const dispatch = async ( module.exports = { eventHandlers, hasEventHandler, + activeHandlersForPod, dispatch, }; diff --git a/backend/services/installable/installableReconciler.ts b/backend/services/installable/installableReconciler.ts index 3a77dd675e..cbeb93d079 100644 --- a/backend/services/installable/installableReconciler.ts +++ b/backend/services/installable/installableReconciler.ts @@ -5,6 +5,12 @@ import { INSTALL_LOCK_TTL_MS } from './installableInstallationService'; const InstallableInstallation = require('../../models/InstallableInstallation'); // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require const Integration = require('../../models/Integration'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const ConnectorSecret = require('../../models/ConnectorSecret'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const connectorSecrets = require('../connectorSecrets'); + +const ORPHAN_SECRET_GRACE_MS = 10 * 60_000; const installationIdFor = (installation: IInstallableInstallation): string => String(installation._id); @@ -118,18 +124,121 @@ const sweepStaleUninstalls = async (now: Date): Promise => { return completed; }; +const markSlackIntegrationUnavailable = async ( + integration: { _id: unknown }, + message: string, +): Promise => { + await Integration.updateOne( + { _id: integration._id, isActive: true }, + { $set: { status: 'error', errorMessage: message } }, + ); +}; + +const sweepExpiredSlackBinds = async (now: Date): Promise => { + const rows = await Integration.find({ + type: 'slack', + isActive: true, + 'config.pendingBind.expiresAt': { $lte: now }, + }).select('installationId config.pendingBind').lean() as Array<{ + _id: unknown; + installationId?: string; + config?: { pendingBind?: { botTokenRef?: string } }; + }>; + let expired = 0; + for (const integration of rows) { + const ref = integration.config?.pendingBind?.botTokenRef; + const claimed = await Integration.findOneAndUpdate( + { + _id: integration._id, + isActive: true, + 'config.pendingBind.expiresAt': { $lte: now }, + 'config.pendingBind.botTokenRef': ref, + }, + { + $set: { status: 'pending', errorMessage: null }, + $unset: { 'config.pendingBind': 1 }, + }, + { new: true }, + ); + if (!claimed) continue; + await connectorSecrets.revoke(ref); + expired += 1; + } + return expired; +}; + +const sweepOrphanedConnectorSecrets = async (now: Date): Promise => { + const secrets = await ConnectorSecret.find({ + createdAt: { $lte: new Date(now.getTime() - ORPHAN_SECRET_GRACE_MS) }, + }).select('_id integrationId createdAt').lean() as Array<{ + _id: unknown; + integrationId: unknown; + createdAt: Date; + }>; + let revoked = 0; + for (const secret of secrets) { + const integration = await Integration.findById(secret.integrationId) + .select('isActive config.botTokenRef config.pendingBind.botTokenRef').lean() as { + isActive?: boolean; + config?: { botTokenRef?: string; pendingBind?: { botTokenRef?: string } }; + } | null; + const ref = String(secret._id); + if ( + integration?.isActive === true + && (integration.config?.botTokenRef === ref || integration.config?.pendingBind?.botTokenRef === ref) + ) continue; + await connectorSecrets.revoke(ref); + revoked += 1; + } + return revoked; +}; + +const sweepUnavailableSlackSecretKeys = async (): Promise => { + const secretCount = await ConnectorSecret.countDocuments(); + if (!secretCount) return 0; + let unavailable: Array<{ integrationId: unknown }>; + try { + unavailable = await connectorSecrets.listWithUnavailableKey(); + } catch (error) { + // A malformed or wholly absent ring makes every stored secret unreadable. + // That is an error state, not an invitation to keep attempting sends. + console.error('[installable-reconciler] connector secret key ring unavailable:', (error as Error).message); + unavailable = await ConnectorSecret.find({}).select('integrationId').lean(); + } + for (const secret of unavailable) { + const integration = await Integration.findById(secret.integrationId) + .select('installationId').lean() as { _id: unknown; installationId?: string } | null; + if (integration) await markSlackIntegrationUnavailable(integration, 'Slack connector secret key is unavailable'); + } + return unavailable.length; +}; + export const sweep = async (now: Date = new Date()): Promise<{ completed: number; errored: number; staleComponents: number; uninstallsCompleted: number; deactivated: number; + expiredSlackBinds: number; + orphanedSecrets: number; + unavailableSlackSecretKeys: number; }> => { const locks = await sweepStaleLocks(now); const staleComponents = await sweepActiveInstallations(); const uninstallsCompleted = await sweepStaleUninstalls(now); const deactivated = await sweepUninstalledInstallations(); - const result = { ...locks, staleComponents, uninstallsCompleted, deactivated }; + const expiredSlackBinds = await sweepExpiredSlackBinds(now); + const orphanedSecrets = await sweepOrphanedConnectorSecrets(now); + const unavailableSlackSecretKeys = await sweepUnavailableSlackSecretKeys(); + const result = { + ...locks, + staleComponents, + uninstallsCompleted, + deactivated, + expiredSlackBinds, + orphanedSecrets, + unavailableSlackSecretKeys, + }; console.log('[installable-reconciler] sweep', result); return result; }; diff --git a/backend/services/installable/projectors/webhookProjector.ts b/backend/services/installable/projectors/webhookProjector.ts index 73a029f8a2..78f919bbfa 100644 --- a/backend/services/installable/projectors/webhookProjector.ts +++ b/backend/services/installable/projectors/webhookProjector.ts @@ -3,14 +3,17 @@ import type { ComponentProjector, ProjectionContext, ProjectionIds } from './typ // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require const Integration = require('../../../models/Integration'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const connectorSecrets = require('../../connectorSecrets'); -const INTERNAL_WEBHOOK_PROVIDERS: Record = { +const INTERNAL_WEBHOOK_PROVIDERS: Record = { '/api/webhooks/telegram': 'telegram', + '/api/webhooks/slack': 'slack', }; const installationIdFor = (context: ProjectionContext): string => String(context.installation._id); -const resolveProvider = (component: IComponent): 'telegram' => { +const resolveProvider = (component: IComponent): 'telegram' | 'slack' => { const provider = component.webhookPath ? INTERNAL_WEBHOOK_PROVIDERS[component.webhookPath] : undefined; @@ -57,10 +60,23 @@ export const webhookProjector: ComponentProjector = { }, async unproject( - _component: IComponent, + component: IComponent, context: ProjectionContext, _projectionIds: ProjectionIds, ): Promise { + const integration = await Integration.findOne({ + installationId: installationIdFor(context), + }).select('type config.botTokenRef config.pendingBind.botTokenRef').lean(); + // Slack owns a per-workspace secret. Attempting Slack's remote auth.revoke + // is a later best-effort addition; deleting our envelope is local, + // idempotent, and must happen even when the integration row is already + // partially deactivated. + if (component.webhookPath === '/api/webhooks/slack' && integration?.type === 'slack') { + await Promise.all([ + connectorSecrets.revoke(integration.config?.botTokenRef), + connectorSecrets.revoke(integration.config?.pendingBind?.botTokenRef), + ]); + } await Integration.findOneAndUpdate( { installationId: installationIdFor(context) }, { @@ -71,6 +87,11 @@ export const webhookProjector: ComponentProjector = { $unset: { 'config.connectCode': 1, 'config.connectCodeExpiresAt': 1, + 'config.oauthStateNonce': 1, + 'config.oauthStateNonceExpiresAt': 1, + 'config.oauthStateClaimId': 1, + 'config.pendingBind': 1, + 'config.botTokenRef': 1, }, }, ); diff --git a/backend/services/schedulerService.ts b/backend/services/schedulerService.ts index 9f1eda3714..63604bc681 100644 --- a/backend/services/schedulerService.ts +++ b/backend/services/schedulerService.ts @@ -446,6 +446,25 @@ class SchedulerService { { scheduled: false, timezone: 'UTC' }, ); + // Installable cleanup is deliberately lease-free. A rollout can run this + // on two replicas, but every sweep mutation is a status+claimId CAS and + // revoke/delete is idempotent. A duplicate pass is wasted work, never a + // duplicate side effect. Heartbeat dispatch differs: it enqueues events, + // so that job retains its cross-replica lease. + const installableReconcileJob: CronJob = cron.schedule( + '*/5 * * * *', + async () => { + try { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const reconciler = require('./installable/installableReconciler'); + await reconciler.sweep(); + } catch (error) { + console.error('[installable-reconciler] scheduled sweep failed:', error); + } + }, + { scheduled: false, timezone: 'UTC' }, + ); + this.jobs = [ summarizerJob, externalFeedJob, @@ -463,6 +482,7 @@ class SchedulerService { onboardingSilenceJob, stalledConnectJob, kernelWorkSweepJob, + installableReconcileJob, ]; this.jobs.forEach((job) => job.start()); this.isRunning = true; @@ -501,6 +521,7 @@ class SchedulerService { + `${process.env.ONBOARDING_ALERT_EMAIL ? '' : ' (LOG-ONLY: ONBOARDING_ALERT_EMAIL unset)'}`, ); console.log('- Skills catalog refreshes from upstream every 6 hours (stars re-fetched via GitHub API)'); + console.log('- Installable reconciliation runs every 5 minutes (CAS/idempotent; no lease required)'); setTimeout(() => { SchedulerService.runSummarizer().catch((error) => { diff --git a/backend/services/slackApi.ts b/backend/services/slackApi.ts index 35c32dc883..a13c2d9ae0 100644 --- a/backend/services/slackApi.ts +++ b/backend/services/slackApi.ts @@ -21,6 +21,12 @@ interface HistoryParams { latest?: string; } +interface ConversationOpenResponse { + ok: boolean; + channel?: { id?: string }; + error?: string; +} + class SlackApi { private client: AxiosInstance; @@ -50,6 +56,13 @@ class SlackApi { const res = await this.client.get('/conversations.history', { params }); return res.data; } + + async openConversation(userId: string): Promise { + const res = await this.client.post('/conversations.open', { + users: userId, + }); + return res.data; + } } export = SlackApi; diff --git a/backend/services/slackBridgeService.ts b/backend/services/slackBridgeService.ts new file mode 100644 index 0000000000..907195ca0f --- /dev/null +++ b/backend/services/slackBridgeService.ts @@ -0,0 +1,264 @@ +import SlackApi = require('./slackApi'); + +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const Integration = require('../models/Integration'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const Pod = require('../models/Pod'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const connectorSecrets = require('./connectorSecrets'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const { shouldEscalate } = require('./connectorRelayPolicy'); + +const RELAY_MAP_CAP = 100; +const OUTBOUND_TEXT_CAP = 2_800; + +interface SlackIntegrationDoc { + _id: unknown; + installationId?: string; + podId: unknown; + type?: string; + isActive?: boolean; + status?: string; + config?: { + teamId?: string; + chatId?: string; + chatType?: string; + botTokenRef?: string; + linkedUserId?: string; + slackUserId?: string; + liveRelay?: boolean; + relayAllAgentMessages?: boolean; + leadAgentUsername?: string; + relayMutedUntil?: Date | string; + relayMap?: Array<{ + externalMessageId?: string; + tgMessageId?: string; + agentUsername?: string; + podMessageId?: string | null; + podId?: string; + }>; + }; +} + +const isRelayableIntegration = (integration: SlackIntegrationDoc, podId: string): boolean => ( + String(integration.podId) === String(podId) + && integration.type === 'slack' + && integration.isActive === true + && integration.status !== 'error' + && integration.config?.liveRelay === true + && integration.config?.chatType === 'im' + && Boolean(integration.config?.teamId) + && Boolean(integration.config?.chatId) + && Boolean(integration.config?.botTokenRef) +); + +const findLiveIntegration = async (podId: string): Promise => ( + Integration.findOne({ + type: 'slack', + isActive: true, + status: { $ne: 'error' }, + podId, + 'config.liveRelay': true, + 'config.chatType': 'im', + 'config.teamId': { $exists: true, $ne: null }, + 'config.chatId': { $exists: true, $ne: null }, + 'config.botTokenRef': { $exists: true, $ne: null }, + }).lean() +); + +// Outbound is intentionally secret-ref-only: legacy Slack rows remain on the +// legacy provider path and cannot be selected by the installable dispatcher. +export const relayAgentMessageToSlack = async (opts: { + podId: string; + agentUsername: string; + displayName: string; + content: string; + podMessageId?: string | null; + integration?: SlackIntegrationDoc; +}): Promise => { + const { + podId, agentUsername, displayName, content, podMessageId, + } = opts; + try { + const integration = opts.integration ?? await findLiveIntegration(podId); + if (!integration || !isRelayableIntegration(integration, podId)) return; + const mutedUntil = integration.config?.relayMutedUntil; + if (mutedUntil && new Date(mutedUntil) > new Date()) return; + if (!shouldEscalate({ content, agentUsername, integration })) return; + + const [pod, token] = await Promise.all([ + Pod.findById(podId).select('name').lean(), + connectorSecrets.get(String(integration.config!.botTokenRef)), + ]); + const podName = String(pod?.name || 'Commonly'); + const text = `[${podName}] ${displayName || agentUsername}: ${String(content).slice(0, OUTBOUND_TEXT_CAP)}`; + const result = await new SlackApi(token).postMessage(String(integration.config!.chatId), text); + if (!result.ok || !result.ts) { + throw new Error(`chat.postMessage failed: ${String(result.error || 'unknown error')}`); + } + await Integration.findByIdAndUpdate(integration._id, { + $push: { + 'config.relayMap': { + $each: [{ + externalMessageId: String(result.ts), agentUsername, podMessageId: podMessageId || null, podId, + }], + $slice: -RELAY_MAP_CAP, + }, + }, + }); + } catch (error) { + const config = opts.integration?.config; + console.warn( + `[slack-bridge] outbound relay failed integration=${String(opts.integration?._id || 'lookup')} ` + + `team=${String(config?.teamId || 'unknown')}: ${(error as Error).message}`, + ); + } +}; + +// D11: a Slack thread attached to a relayed line is a direct answer to that +// line's agent. Keep the map generic so Telegram can migrate from tgMessageId +// without changing this reader. +export const routeSlackReplyContent = (opts: { + content: string; + threadTs?: string | null; + relayMap?: Array<{ + externalMessageId?: string; + tgMessageId?: string; + agentUsername?: string; + }>; +}): { content: string; routedAgent: string | null } => { + const { content, threadTs, relayMap } = opts; + if (!threadTs || !Array.isArray(relayMap)) return { content, routedAgent: null }; + const hit = relayMap.find((entry) => String(entry.externalMessageId || entry.tgMessageId) === String(threadTs)); + if (!hit?.agentUsername) return { content, routedAgent: null }; + const mention = `@${hit.agentUsername}`; + return content.toLowerCase().includes(mention.toLowerCase()) + ? { content, routedAgent: hit.agentUsername } + : { content: `${mention} ${content}`, routedAgent: hit.agentUsername }; +}; + +// Inbound Slack DM → Commonly pod. The event route has already proven the +// Slack signature and selected the team/channel row; this function repeats +// the ownership-shaped checks because a bridge is never allowed to rely on a +// caller's selection alone. +export const relaySlackMessageToPod = async (opts: { + integration: SlackIntegrationDoc; + event: { + text?: string; + user?: string; + ts?: string; + thread_ts?: string; + user_profile?: { real_name?: string; display_name?: string }; + }; +}): Promise<{ relayed: boolean; routedAgent?: string | null }> => { + const { integration, event } = opts; + const rawText = String(event.text || '').trim(); + if (!rawText || rawText.startsWith('/')) return { relayed: false }; + const config = integration.config || {}; + if ( + !isRelayableIntegration(integration, String(integration.podId)) + || !config.linkedUserId + || !config.slackUserId + || event.user !== config.slackUserId + ) { + console.warn( + `[slack-bridge] inbound dropped integration=${String(integration._id)}: ` + + 'unbound or mismatched DM sender', + ); + return { relayed: false }; + } + const { content: routedText, routedAgent } = routeSlackReplyContent({ + content: rawText, + threadTs: event.thread_ts, + relayMap: config.relayMap, + }); + const podId = String(integration.podId); + const linkedUserId = String(config.linkedUserId); + const senderName = event.user_profile?.display_name || event.user_profile?.real_name; + const content = senderName + ? `💬 ${senderName} (via Slack): ${routedText}` + : `💬 (via Slack): ${routedText}`; + + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const User = require('../models/User'); + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const PodModel = require('../models/Pod'); + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const PGMessage = require('../models/pg/Message'); + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const { deliverMessageToAgents } = require('./messageAgentDeliveryService'); + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const socketConfig = require('../config/socket'); + + const [linkedUser, pod] = await Promise.all([ + User.findById(linkedUserId).select('username profilePicture').lean(), + PodModel.findById(podId).select('type').lean(), + ]); + if (!linkedUser || !pod) { + console.warn('[slack-bridge] inbound dropped — linked user or pod missing'); + return { relayed: false }; + } + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const PGPod = require('../models/pg/Pod'); + if (!await PGPod.findById(podId)) { + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const { syncPodFromMongo } = require('./pgPodSyncService'); + await syncPodFromMongo(podId, linkedUserId); + } + } catch (error) { + console.warn('[slack-bridge] PG pod backfill skipped:', (error as Error).message); + } + const created = await PGMessage.create(podId, linkedUserId, content, 'text', null, null, null); + let message: Record = created; + try { + const populated = created?.id ? await PGMessage.findById(created.id) : null; + if (populated) message = populated; + } catch (error) { + console.warn('[slack-bridge] post-write read failed:', (error as Error).message); + } + // A post is durable. As in the Telegram bridge, do not turn a later wake or + // socket failure into a Slack retry that writes the same human message again. + try { + await deliverMessageToAgents({ + podId, + podType: pod.type, + message, + userId: linkedUserId, + requestUser: { username: linkedUser.username }, + replyToMessageId: null, + }); + } catch (error) { + console.error('[slack-bridge] agent delivery failed after pod write:', (error as Error).message); + } + try { + const io = socketConfig.getIO(); + if (io) { + io.to(`pod_${podId}`).emit('newMessage', { + _id: (message as { id?: unknown }).id, + id: (message as { id?: unknown }).id, + pod_id: podId, + podId, + content: (message as { content?: unknown }).content || content, + messageType: 'text', + userId: { _id: linkedUserId, username: linkedUser.username, profilePicture: linkedUser.profilePicture }, + username: linkedUser.username, + profile_picture: linkedUser.profilePicture, + createdAt: (message as { created_at?: unknown }).created_at || new Date(), + replyTo: null, + thread_root_id: null, + payload: null, + }); + } + } catch (error) { + console.warn('[slack-bridge] socket emit failed:', (error as Error).message); + } + return { relayed: true, routedAgent }; +}; + +module.exports = { + relayAgentMessageToSlack, + relaySlackMessageToPod, + routeSlackReplyContent, + isRelayableIntegration, +}; diff --git a/backend/services/slackEventReceiptService.ts b/backend/services/slackEventReceiptService.ts new file mode 100644 index 0000000000..a196f585dc --- /dev/null +++ b/backend/services/slackEventReceiptService.ts @@ -0,0 +1,44 @@ +import type { ISlackEventReceipt } from '../models/SlackEventReceipt'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const SlackEventReceipt = require('../models/SlackEventReceipt'); + +export const EVENT_CLAIM_TTL_MS = 10_000; + +export type SlackEventClaim = 'claimed' | 'duplicate_done' | 'duplicate_processing'; + +export const claim = async ( + eventId: string, + teamId: string, + now: Date = new Date(), +): Promise => { + try { + await SlackEventReceipt.create({ + eventId, + teamId, + state: 'processing', + claimedAt: now, + receivedAt: now, + }); + return 'claimed'; + } catch (error) { + if ((error as { code?: number }).code !== 11000) throw error; + } + + const existing = await SlackEventReceipt.findOne({ eventId }) as ISlackEventReceipt | null; + if (!existing || existing.state === 'done') return 'duplicate_done'; + const staleBefore = new Date(now.getTime() - EVENT_CLAIM_TTL_MS); + if (existing.claimedAt > staleBefore) return 'duplicate_processing'; + const recovered = await SlackEventReceipt.findOneAndUpdate( + { eventId, state: 'processing', claimedAt: { $lte: staleBefore } }, + { $set: { claimedAt: now } }, + { new: true }, + ); + return recovered ? 'claimed' : 'duplicate_processing'; +}; + +export const markDone = async (eventId: string): Promise => { + await SlackEventReceipt.updateOne({ eventId, state: 'processing' }, { $set: { state: 'done' } }); +}; + +module.exports = { EVENT_CLAIM_TTL_MS, claim, markDone }; diff --git a/backend/services/slackOAuthService.ts b/backend/services/slackOAuthService.ts new file mode 100644 index 0000000000..e1c3d7c4f2 --- /dev/null +++ b/backend/services/slackOAuthService.ts @@ -0,0 +1,98 @@ +import axios from 'axios'; + +export class SlackOAuthConfigurationError extends Error { + code = 'slack_oauth_not_configured'; + + constructor(message = 'Slack OAuth is not configured.') { + super(message); + this.name = 'SlackOAuthConfigurationError'; + } +} + +export class SlackOAuthExchangeError extends Error { + code = 'slack_oauth_exchange_failed'; + + constructor(message = 'Slack authorization could not be completed.') { + super(message); + this.name = 'SlackOAuthExchangeError'; + } +} + +export interface SlackOAuthGrant { + accessToken: string; + teamId: string; + teamName?: string; + slackUserId: string; + slackUserName?: string; +} + +const oauthConfig = (): { clientId: string; clientSecret: string; redirectUri: string } => { + const { SLACK_CLIENT_ID: clientId, SLACK_CLIENT_SECRET: clientSecret } = process.env; + const apiBase = String( + process.env.PUBLIC_API_URL || process.env.BACKEND_URL || 'https://api.commonly.me', + ).replace(/\/$/, ''); + const redirectUri = process.env.SLACK_OAUTH_REDIRECT_URI + || `${apiBase}/api/webhooks/slack/oauth/callback`; + if (!clientId || !clientSecret) { + throw new SlackOAuthConfigurationError('SLACK_CLIENT_ID and SLACK_CLIENT_SECRET must be configured.'); + } + return { clientId, clientSecret, redirectUri }; +}; + +export const buildAuthorizeUrl = (state: string): string => { + const { clientId, redirectUri } = oauthConfig(); + const query = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + state, + // App Home DM plus the registered /commonly command. The operator-side + // manifest carries the matching scopes; keeping this list explicit makes + // a future scope change visible at the authorization boundary. + scope: 'im:history,im:write,chat:write,users:read,commands', + }); + return `https://slack.com/oauth/v2/authorize?${query.toString()}`; +}; + +export const exchangeCode = async (code: string): Promise => { + const { clientId, clientSecret, redirectUri } = oauthConfig(); + const body = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + }); + let response: Record; + try { + const result = await axios.post('https://slack.com/api/oauth.v2.access', body.toString(), { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + response = result.data as Record; + } catch { + throw new SlackOAuthExchangeError(); + } + + const team = response.team as { id?: unknown; name?: unknown } | undefined; + const authedUser = response.authed_user as { id?: unknown; name?: unknown } | undefined; + if ( + response.ok !== true + || typeof response.access_token !== 'string' + || !team?.id + || !authedUser?.id + ) { + throw new SlackOAuthExchangeError(); + } + return { + accessToken: response.access_token, + teamId: String(team.id), + ...(team.name ? { teamName: String(team.name) } : {}), + slackUserId: String(authedUser.id), + ...(authedUser.name ? { slackUserName: String(authedUser.name) } : {}), + }; +}; + +module.exports = { + SlackOAuthConfigurationError, + SlackOAuthExchangeError, + buildAuthorizeUrl, + exchangeCode, +}; diff --git a/backend/services/telegramBridgeService.ts b/backend/services/telegramBridgeService.ts index 2f0c7167e0..b9fb924f77 100644 --- a/backend/services/telegramBridgeService.ts +++ b/backend/services/telegramBridgeService.ts @@ -22,6 +22,8 @@ const IntegrationModel = require('../models/Integration'); // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require const telegramSend = require('./telegramService'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const { shouldEscalate } = require('./connectorRelayPolicy'); const RELAY_MAP_CAP = 100; const OUTBOUND_TEXT_CAP = 900; @@ -53,32 +55,7 @@ const escapeHtml = (raw: string): string => String(raw) .replace(//g, '>'); -// Rule-based escalation gate. Deliberately structural and cheap — the same -// shape as the cascade cap: no model call decides whether to interrupt a -// human. A message crosses to the channel when it: -// - carries an explicit escalation/blocked/decision marker, or -// - asks the human something (question addressed at a person), or -// - comes from the designated lead agent (their reports are the digest), or -// - the integration opts into relay-everything (demo/verbose mode). -const ESCALATION_MARKERS = /\[(BLOCKED|ESCALATE|DECISION|NEEDS[-_ ]?HUMAN|APPROVAL)\]/i; -const QUESTION_AT_HUMAN = /@[a-z0-9_.-]+[^\n]{0,200}\?/i; - -export const shouldEscalate = (opts: { - content: string; - agentUsername: string; - integration: TelegramIntegrationDoc; -}): boolean => { - const { content, agentUsername, integration } = opts; - const cfg = integration.config || {}; - if (cfg.relayAllAgentMessages) return true; - if (cfg.leadAgentUsername - && agentUsername.toLowerCase() === String(cfg.leadAgentUsername).toLowerCase()) { - return true; - } - if (ESCALATION_MARKERS.test(content)) return true; - if (QUESTION_AT_HUMAN.test(content)) return true; - return false; -}; +export { shouldEscalate }; // Prefix an inbound Telegram quote-reply with the @mention of the agent whose // relayed line was quoted, so the normal mention pipeline routes it. Pure — diff --git a/k8s/helm/commonly/templates/core/backend-deployment.yaml b/k8s/helm/commonly/templates/core/backend-deployment.yaml index 35dab7e507..e0e25457e5 100644 --- a/k8s/helm/commonly/templates/core/backend-deployment.yaml +++ b/k8s/helm/commonly/templates/core/backend-deployment.yaml @@ -352,6 +352,39 @@ spec: name: api-keys key: slack-bot-token optional: true + # Slack installable OAuth and per-workspace token encryption. Every + # reference is optional so instances that have not enabled Slack's + # OAuth ExternalSecret entries continue to start normally. + - name: SLACK_CLIENT_ID + valueFrom: + secretKeyRef: + name: api-keys + key: slack-client-id + optional: true + - name: SLACK_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: api-keys + key: slack-client-secret + optional: true + - name: SLACK_SIGNING_SECRET + valueFrom: + secretKeyRef: + name: api-keys + key: slack-signing-secret + optional: true + - name: CONNECTOR_SECRET_KEYS + valueFrom: + secretKeyRef: + name: api-keys + key: connector-secret-keys + optional: true + - name: CONNECTOR_SECRET_ACTIVE_KEY + valueFrom: + secretKeyRef: + name: api-keys + key: connector-secret-active-key + optional: true - name: GROUPME_BOT_ID valueFrom: secretKeyRef: diff --git a/k8s/helm/commonly/templates/secrets/api-keys.yaml b/k8s/helm/commonly/templates/secrets/api-keys.yaml index 8d05e9c6d5..aa3b961ccb 100644 --- a/k8s/helm/commonly/templates/secrets/api-keys.yaml +++ b/k8s/helm/commonly/templates/secrets/api-keys.yaml @@ -169,6 +169,25 @@ spec: - secretKey: slack-bot-token remoteRef: key: commonly-dev-slack-bot-token + {{- if .Values.slack.oauth.enabled }} + # Slack installable OAuth. These five must be created in Secret Manager + # before enabling: a missing remoteRef prevents ESO from syncing api-keys. + - secretKey: slack-client-id + remoteRef: + key: commonly-dev-slack-client-id + - secretKey: slack-client-secret + remoteRef: + key: commonly-dev-slack-client-secret + - secretKey: slack-signing-secret + remoteRef: + key: commonly-dev-slack-signing-secret + - secretKey: connector-secret-keys + remoteRef: + key: commonly-dev-connector-secret-keys + - secretKey: connector-secret-active-key + remoteRef: + key: commonly-dev-connector-secret-active-key + {{- end }} - secretKey: groupme-bot-id remoteRef: key: commonly-dev-groupme-bot-id diff --git a/k8s/helm/commonly/templates/secrets/local-secrets.yaml b/k8s/helm/commonly/templates/secrets/local-secrets.yaml index 704fee23c0..034518ebe6 100644 --- a/k8s/helm/commonly/templates/secrets/local-secrets.yaml +++ b/k8s/helm/commonly/templates/secrets/local-secrets.yaml @@ -48,6 +48,11 @@ stringData: # Integrations — empty by default slack-bot-token: {{ .Values.localSecrets.slackBotToken | default "" | quote }} + slack-client-id: {{ .Values.localSecrets.slackClientId | default "" | quote }} + slack-client-secret: {{ .Values.localSecrets.slackClientSecret | default "" | quote }} + slack-signing-secret: {{ .Values.localSecrets.slackSigningSecret | default "" | quote }} + connector-secret-keys: {{ .Values.localSecrets.connectorSecretKeys | default "" | quote }} + connector-secret-active-key: {{ .Values.localSecrets.connectorSecretActiveKey | default "" | quote }} groupme-bot-id: {{ .Values.localSecrets.groupmeBotId | default "" | quote }} telegram-bot-token: {{ .Values.localSecrets.telegramBotToken | default "" | quote }} brave-api-key: {{ .Values.localSecrets.braveApiKey | default "" | quote }} diff --git a/k8s/helm/commonly/values-local.yaml b/k8s/helm/commonly/values-local.yaml index 35ce74fc3f..c691266182 100644 --- a/k8s/helm/commonly/values-local.yaml +++ b/k8s/helm/commonly/values-local.yaml @@ -188,6 +188,11 @@ localSecrets: xOauthClientId: "" xOauthClientSecret: "" slackBotToken: "" + slackClientId: "" + slackClientSecret: "" + slackSigningSecret: "" + connectorSecretKeys: "" + connectorSecretActiveKey: "" groupmeBotId: "" telegramBotToken: "" braveApiKey: "" diff --git a/k8s/helm/commonly/values.yaml b/k8s/helm/commonly/values.yaml index 0afd58832b..178859a165 100644 --- a/k8s/helm/commonly/values.yaml +++ b/k8s/helm/commonly/values.yaml @@ -34,6 +34,15 @@ errorTracking: billing: enabled: false +# Slack's installable connector needs its own OAuth app credentials and the +# envelope-encryption key ring. Keep the ExternalSecret entries gated: ESO +# rejects the entire api-keys resource when one remote secret is absent. +# Populate all five `commonly-dev-{slack-*,connector-secret-*}` secrets before +# enabling this in an environment. +slack: + oauth: + enabled: false + # Agent provisioning (RBAC + PVCs) agentProvisioning: enabled: true