diff --git a/.gitleaks.toml b/.gitleaks.toml index 95360d5fcb..14c67407ee 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -34,6 +34,13 @@ regexes = [ # captured string — a real Secret value that merely STARTS with this prefix is not # whitelisted and still fails. Verified against gitleaks 8.21.2 locally. '''^secretKey: credentials$''', + # Two fake Discord user ids (snowflakes) in the F131 tests (#3456): + # `INSTALLER_DISCORD = '…'` and `discordUserId: '…'` sit next to the word + # "discord", so the discord-client-id rule reads the sequential digits as a + # client id. A Discord user id is public, and these two are made up. + # ANCHORED to the exact captured value, so any other id still fails. + '''^345678901234567890$''', + '''^456789012345678901$''', ] paths = [ diff --git a/backend/__tests__/service/discordInstallBind.test.js b/backend/__tests__/service/discordInstallBind.test.js index 7960928c92..66b59f0a99 100644 --- a/backend/__tests__/service/discordInstallBind.test.js +++ b/backend/__tests__/service/discordInstallBind.test.js @@ -25,6 +25,7 @@ const OWNER = '6a8f6de2a1dccf2e02f31401'; const MEMBER = '6a8f6de2a1dccf2e02f31402'; const POD_ID = '6a8f6de2a1dccf2e02f31499'; const GUILD = '123456789012345678'; +const INSTALLER_DISCORD = '456789012345678901'; const CHANNEL = '234567890123456789'; const WEBHOOK = { id: '345678901234567890', token: 'webhook-token' }; @@ -116,6 +117,7 @@ beforeEach(async () => { return { data: [{ id: CHANNEL, name: 'general', type: 0 }, { id: '999999999999999999', name: 'Voice', type: 2 }] }; } if (url === `https://discord.com/api/channels/${CHANNEL}`) return { data: { id: CHANNEL, guild_id: GUILD, type: 0 } }; + if (url === 'https://discord.com/api/users/@me') return { data: { id: INSTALLER_DISCORD, username: 'owner' } }; throw new Error(`unexpected GET ${url}`); }); }); @@ -133,7 +135,8 @@ test('the install link sends Discord back to the callback with a code', async () // A signed state, not the bare pod id a crafted link could carry. expect(url.searchParams.get('state')).not.toBe(`pod_${POD_ID}`); expect(url.searchParams.get('state')).not.toContain(POD_ID); - expect(url.searchParams.get('scope')).toBe('bot applications.commands'); + // `identify` lets the callback read the installer's Discord user id (F131). + expect(url.searchParams.get('scope')).toBe('bot applications.commands identify'); }); test('the link ignores a caller-supplied client id', async () => { @@ -192,6 +195,9 @@ test('a bot add from the link ends in a bound pod', async () => { expect(row.config.serverId).toBe(GUILD); expect(row.config.channelId).toBe(CHANNEL); expect(row.config.webhookUrlRef).toBe('secret-ref-1'); + // F131: the installer's Discord id, from users/@me through the proof, survives + // the strict config subdocument. + expect(row.config.discordUserId).toBe(INSTALLER_DISCORD); const platform = await DiscordIntegration.findOne({ integrationId: row._id }).lean(); expect(platform).toMatchObject({ serverId: GUILD, channelId: CHANNEL, webhookId: WEBHOOK.id }); diff --git a/backend/__tests__/unit/models/integrationPublicConfig.test.js b/backend/__tests__/unit/models/integrationPublicConfig.test.js index 39cb380a66..54a86f9c4d 100644 --- a/backend/__tests__/unit/models/integrationPublicConfig.test.js +++ b/backend/__tests__/unit/models/integrationPublicConfig.test.js @@ -101,6 +101,16 @@ describe('projectIntegrationForViewer', () => { expect(out.config).not.toHaveProperty('messageBuffer'); }); + it('keeps a Discord installer’s user id from every viewer but the creator and an admin (F131)', () => { + const discord = () => connector({ type: 'discord', config: { channelId: 'c', linkedUserId: CREATOR, discordUserId: '345678901234567890' } }); + const other = projectIntegrationForViewer(discord(), { requesterId: '64b000000000000000000099', isAdmin: false }); + expect(other.config).not.toHaveProperty('discordUserId'); + expect(projectIntegrationForViewer(discord(), { requesterId: CREATOR, isAdmin: false }).config.discordUserId) + .toBe('345678901234567890'); + expect(projectIntegrationForViewer(discord(), { requesterId: 'x', isAdmin: true }).config.discordUserId) + .toBe('345678901234567890'); + }); + it('gives the connector creator and an admin the row whole, `linked` included', () => { for (const viewer of [{ requesterId: CREATOR, isAdmin: false }, { requesterId: 'someone-else', isAdmin: true }]) { const out = projectIntegrationForViewer(connector(), viewer); diff --git a/backend/__tests__/unit/routes/discordBindingContainment.test.js b/backend/__tests__/unit/routes/discordBindingContainment.test.js index 43f7e8f617..eb97261d88 100644 --- a/backend/__tests__/unit/routes/discordBindingContainment.test.js +++ b/backend/__tests__/unit/routes/discordBindingContainment.test.js @@ -746,4 +746,189 @@ describe('Discord binding containment (TASK-123 a)', () => { expect(Object.keys(update).filter((key) => key.includes('botToken'))).toEqual([]); }); }); + // F131: the installer's Discord user id is proven by the OAuth callback's + // users/@me read, carried in the signed install proof, and stored server-side + // as config.discordUserId. Live relay on a Discord row needs it. + describe('the installer’s Discord identity (F131)', () => { + const { mintDiscordInstallProof, readDiscordInstallProof, mintDiscordInstallState } = require('../../../utils/discordInstallProof'); + const INSTALLER_DISCORD = '456789012345678901'; + const STATE = () => encodeURIComponent(mintDiscordInstallState({ podId: 'pod-1', userId: MEMBER })); + const callback = () => request(app).get(`/api/discord/callback?code=abc&state=${STATE()}&guild_id=${GUILD}`); + const proofIn = (res) => readDiscordInstallProof(new URL(res.headers.location).searchParams.get('install')); + const meResponds = (impl) => axios.get.mockImplementation(async (url, ...rest) => ( + url === 'https://discord.com/api/users/@me' ? impl(url, ...rest) : { data: { name: 'Guild' } })); + + it('asks Discord for identify on the install link', async () => { + const res = await request(app).get('/api/discord/install-link/pod-1').set('Authorization', 'Bearer user-token'); + + expect(res.status).toBe(200); + expect(new URL(res.body.installUrl).searchParams.get('scope').split(' ')).toEqual( + expect.arrayContaining(['bot', 'applications.commands', 'identify']), + ); + }); + + it('reads users/@me with the exchange’s user token and carries that id in the proof', async () => { + axios.post.mockResolvedValueOnce({ data: { access_token: 'user-access', guild: { id: GUILD } } }); + meResponds(async () => ({ data: { id: INSTALLER_DISCORD, username: 'sam' } })); + + const res = await callback(); + + expect(axios.get).toHaveBeenCalledWith( + 'https://discord.com/api/users/@me', + expect.objectContaining({ headers: { Authorization: 'Bearer user-access' } }), + ); + expect(new URL(res.headers.location).pathname).toBe('/discord/success'); + expect(proofIn(res)).toEqual({ + podId: 'pod-1', guildId: GUILD, userId: MEMBER, discordUserId: INSTALLER_DISCORD, + }); + }); + + it('a failed users/@me read still installs, with no Discord id in the proof', async () => { + axios.post.mockResolvedValueOnce({ data: { access_token: 'user-access', guild: { id: GUILD } } }); + meResponds(async () => { throw Object.assign(new Error('401'), { response: { status: 401 } }); }); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const res = await callback(); + + expect(new URL(res.headers.location).pathname).toBe('/discord/success'); + expect(proofIn(res)).toEqual({ podId: 'pod-1', guildId: GUILD, userId: MEMBER }); + } finally { + warn.mockRestore(); + } + }); + + it('never takes a malformed id from users/@me', async () => { + axios.post.mockResolvedValueOnce({ data: { access_token: 'user-access', guild: { id: GUILD } } }); + meResponds(async () => ({ data: { id: 'not-a-snowflake' } })); + + const res = await callback(); + + expect(proofIn(res).discordUserId).toBeUndefined(); + }); + + describe('POST /api/integrations', () => { + const BODY = { podId: 'pod-1', type: 'discord', config: { serverId: GUILD, channelId: CHANNEL } }; + const post = (body) => request(app).post('/api/integrations').set('Authorization', 'Bearer user-token').send(body); + const stored = () => Integration.__getLastInstance().config; + + beforeEach(() => { + Integration.findOne.mockResolvedValue(null); + axios.get.mockImplementation(async (url) => ( + url === `https://discord.com/api/channels/${CHANNEL}` + ? { data: { id: CHANNEL, type: 0, guild_id: GUILD } } + : { data: [] })); + }); + + it('stores the proof’s Discord id as config.discordUserId', async () => { + const install = mintDiscordInstallProof({ + podId: 'pod-1', guildId: GUILD, userId: MEMBER, discordUserId: INSTALLER_DISCORD, + }); + + const res = await post({ ...BODY, install }); + + expect(res.status).toBe(201); + expect(stored().discordUserId).toBe(INSTALLER_DISCORD); + }); + + it('strips a body-supplied discordUserId, with or without a proof', async () => { + const noId = mintDiscordInstallProof({ podId: 'pod-1', guildId: GUILD, userId: MEMBER }); + const res = await post({ ...BODY, config: { ...BODY.config, discordUserId: '999999999999999999' }, install: noId }); + expect(res.status).toBe(201); + expect(stored().discordUserId).toBeUndefined(); + + const withId = mintDiscordInstallProof({ + podId: 'pod-1', guildId: GUILD, userId: MEMBER, discordUserId: INSTALLER_DISCORD, + }); + await post({ ...BODY, config: { ...BODY.config, discordUserId: '999999999999999999' }, install: withId }); + expect(stored().discordUserId).toBe(INSTALLER_DISCORD); + }); + + it('takes no Discord id from a binding made on the prior-binding arm', async () => { + Integration.findOne.mockResolvedValue(discordRow()); + + const res = await post(BODY); + + expect(res.status).toBe(201); + expect(stored().discordUserId).toBeUndefined(); + }); + + it('refuses live relay on create without a proven Discord id, before the row or the webhook', async () => { + Integration.findOne.mockResolvedValue(discordRow()); + const before = Integration.__getLastInstance(); + + const res = await post({ ...BODY, config: { ...BODY.config, liveRelay: true } }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/Discord account/); + expect(Integration.__getLastInstance()).toBe(before); + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('allows live relay on create with the proof, stamping the installer as the linked user', async () => { + const install = mintDiscordInstallProof({ + podId: 'pod-1', guildId: GUILD, userId: MEMBER, discordUserId: INSTALLER_DISCORD, + }); + + const res = await post({ ...BODY, config: { ...BODY.config, liveRelay: true }, install }); + + expect(res.status).toBe(201); + expect(stored()).toMatchObject({ liveRelay: true, linkedUserId: MEMBER, discordUserId: INSTALLER_DISCORD }); + }); + }); + + describe('PATCH /api/integrations/:id', () => { + const patch = (body) => request(app) + .patch('/api/integrations/64b64c7f8a9e2f0012345678') + .set('Authorization', 'Bearer user-token') + .send(body); + const provenRow = (overrides = {}) => discordRow({ + config: { serverId: GUILD, channelId: CHANNEL, discordUserId: INSTALLER_DISCORD }, + ...overrides, + }); + + it('strips a body-supplied discordUserId', async () => { + Integration.findById.mockResolvedValue(discordRow()); + Integration.findByIdAndUpdate.mockResolvedValue(discordRow()); + + const res = await patch({ config: { discordUserId: '999999999999999999', channelName: 'general' } }); + + expect(res.status).toBe(200); + const update = Integration.findByIdAndUpdate.mock.calls[0][1]; + expect(Object.keys(update).filter((key) => key.includes('discordUserId'))).toEqual([]); + }); + + it('refuses live relay on a row with no proven Discord id', async () => { + Integration.findById.mockResolvedValue(discordRow()); + + const res = await patch({ config: { liveRelay: true } }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/Discord account/); + expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + it('lets the installer turn live relay on, stamped as the linked user', async () => { + Integration.findById.mockResolvedValue(provenRow()); + Integration.findByIdAndUpdate.mockResolvedValue(provenRow()); + + const res = await patch({ config: { liveRelay: true } }); + + expect(res.status).toBe(200); + expect(Integration.findByIdAndUpdate.mock.calls[0][1]).toMatchObject({ + 'config.liveRelay': true, 'config.linkedUserId': MEMBER, + }); + }); + + it('refuses the pod creator who did not add the bot: their id would author the installer’s lines', async () => { + // MEMBER created the pod (canDeleteIntegration passes) but user-3 added the bot. + Integration.findById.mockResolvedValue(provenRow({ createdBy: { toString: () => 'user-3' } })); + + const res = await patch({ config: { liveRelay: true } }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/Only the person who added the bot/); + expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/backend/__tests__/unit/routes/integrations.linkedUserId.test.js b/backend/__tests__/unit/routes/integrations.linkedUserId.test.js index 7c4a1d321b..527c382df3 100644 --- a/backend/__tests__/unit/routes/integrations.linkedUserId.test.js +++ b/backend/__tests__/unit/routes/integrations.linkedUserId.test.js @@ -565,3 +565,52 @@ describe('POST /api/integrations — telegram first-run defaults', () => { expect(res.body.integration.config.relayAllAgentMessages).toBe(false); }); }); + +// Slack keeps its OAuth DM as chatType 'im', not 'private'. The shared +// private-chat rule refused live relay on every bound Slack DM (CodeRabbit on +// #3456); Slack now declares its own rule. +describe('PATCH /api/integrations/:id — live relay on Slack', () => { + const slackRow = (config) => ({ + _id: 'integration-1', + type: 'slack', + podId: 'pod-1', + createdBy: { toString: () => 'user-1' }, + config: { ...config, toObject() { return { ...config }; } }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + User.findById.mockResolvedValue({ _id: 'user-1', role: 'member' }); + Pod.findById.mockResolvedValue(null); + Integration.findByIdAndUpdate.mockResolvedValue({ _id: 'integration-1' }); + }); + + it('turns live relay on for a bound Slack DM, stamped as the caller', async () => { + Integration.findById.mockResolvedValue(slackRow({ teamId: 'T1', chatId: 'D1', chatType: 'im' })); + + const res = await request(app) + .patch(`/api/integrations/${integrationId}`) + .send({ config: { liveRelay: true } }); + + expect(res.status).toBe(200); + expect(Integration.findByIdAndUpdate.mock.calls[0][1]).toMatchObject({ + 'config.liveRelay': true, 'config.linkedUserId': 'user-1', + }); + }); + + it.each([ + ['a channel', { teamId: 'T1', chatId: 'C1', chatType: 'channel' }], + ['a legacy per-channel row', { channelId: 'C1' }], + ['a DM row that also names a channel', { teamId: 'T1', chatId: 'D1', chatType: 'im', channelId: 'C1' }], + ])('refuses live relay on %s', async (_label, config) => { + Integration.findById.mockResolvedValue(slackRow(config)); + + const res = await request(app) + .patch(`/api/integrations/${integrationId}`) + .send({ config: { liveRelay: true } }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/direct message/); + expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/__tests__/unit/services/chatRelayCore.test.js b/backend/__tests__/unit/services/chatRelayCore.test.js index 22c39679ca..5190fcdd50 100644 --- a/backend/__tests__/unit/services/chatRelayCore.test.js +++ b/backend/__tests__/unit/services/chatRelayCore.test.js @@ -286,3 +286,89 @@ describe('the adapter registry drives the dispatcher', () => { expect(pipeline[0].$match.type).toEqual({ $in: ['telegram', 'slack', 'discordish'] }); }); }); + +describe('inbound: only a proven sender reaches the write (F131)', () => { + const { + proveInboundSender, + writeInboundAsLinkedUser, + loadInboundAuthor, + privateChatLiveRelayRefusal, + } = require('../../../services/chatRelay/chatRelayCore'); + const { inboundRelayAdapters, liveRelayRefusalFor } = require('../../../services/chatRelay/adapters'); + + const alwaysAdapter = (answer) => ({ + provider: 'test', senderIsLinkedUser: () => answer, liveRelayRefusal: () => null, + }); + + test('the adapter’s proof mints a sender naming the linked user', () => { + const sender = proveInboundSender(alwaysAdapter(true), { config: { linkedUserId: 'user-1' } }, {}); + expect(sender).toMatchObject({ provider: 'test', linkedUserId: 'user-1' }); + expect(Object.isFrozen(sender)).toBe(true); + }); + + test.each([ + ['a failed proof', alwaysAdapter(false), { config: { linkedUserId: 'user-1' } }], + ['a truthy non-true proof', alwaysAdapter('yes'), { config: { linkedUserId: 'user-1' } }], + ['a proof that throws', { provider: 't', senderIsLinkedUser: () => { throw new Error('x'); } }, { config: { linkedUserId: 'user-1' } }], + ['no linked user', alwaysAdapter(true), { config: {} }], + ])('mints nothing on %s', (_label, adapter, integration) => { + expect(proveInboundSender(adapter, integration, {})).toBeNull(); + }); + + test('the write refuses a hand-built sender before loading or writing anything', async () => { + const forged = { provider: 'telegram', linkedUserId: 'victim' }; + await expect(writeInboundAsLinkedUser({ + podId: 'pod-1', sender: forged, linkedUser: {}, content: 'x', replyToMessageId: null, logPrefix: '[t]', + })).rejects.toThrow(/not proven/); + await expect(loadInboundAuthor({ + podId: 'pod-1', sender: forged, logPrefix: '[t]', onNotMember: async () => {}, + })).rejects.toThrow(/not proven/); + expect(Pod.findById).not.toHaveBeenCalled(); + }); + + test('every platform that relays inbound declares an inbound adapter; outbound stays telegram and slack', () => { + expect(Object.keys(inboundRelayAdapters)).toEqual(['telegram', 'slack', 'discord']); + Object.entries(inboundRelayAdapters).forEach(([provider, adapter]) => { + expect(adapter.provider).toBe(provider); + expect(typeof adapter.senderIsLinkedUser).toBe('function'); + expect(typeof adapter.liveRelayRefusal).toBe('function'); + }); + expect(chatRelayIntegrationTypes()).toEqual(['telegram', 'slack']); + }); + + test('Telegram keeps the private-chat live-relay rule; an undeclared type falls back to it', () => { + const group = { config: { chatId: '42', chatType: 'group' } }; + const privateChat = { config: { chatId: '42', chatType: 'private' } }; + const unbound = { config: {} }; + ['telegram', 'groupme', undefined].forEach((type) => { + expect(liveRelayRefusalFor(type, group, 'u')).toBe(privateChatLiveRelayRefusal(group)); + expect(liveRelayRefusalFor(type, group, 'u')).toMatch(/private chat/); + expect(liveRelayRefusalFor(type, privateChat, 'u')).toBeNull(); + expect(liveRelayRefusalFor(type, unbound, 'u')).toBeNull(); + }); + }); + + test('Slack’s rule is its own: the OAuth DM (`im`, no channel) may relay live, a channel may not', () => { + // Slack stores its DM as chatType 'im'; the shared private-chat rule read + // that as a group and refused every bound Slack DM (CodeRabbit on #3456). + // Same audience test as F143's surfaceBindDeclarations.slack. + expect(liveRelayRefusalFor('slack', { config: { chatId: 'D1', chatType: 'im' } }, 'u')).toBeNull(); + expect(liveRelayRefusalFor('slack', { config: {} }, 'u')).toBeNull(); + expect(liveRelayRefusalFor('slack', { config: { chatId: 'D1', chatType: 'im', channelId: 'C1' } }, 'u')) + .toMatch(/direct message/); + expect(liveRelayRefusalFor('slack', { config: { chatId: 'C1', chatType: 'channel' } }, 'u')).toMatch(/direct message/); + expect(liveRelayRefusalFor('slack', { config: { chatId: 'C1', chatType: 'private' } }, 'u')).toMatch(/direct message/); + // A legacy per-channel row names only channelId. + expect(liveRelayRefusalFor('slack', { config: { channelId: 'C1' } }, 'u')).toMatch(/direct message/); + }); + + test('Discord’s rule is its own declaration: a proven installer, not a chat type', () => { + expect(liveRelayRefusalFor('discord', { config: {}, createdBy: 'u' }, 'u')).toMatch(/Discord account/); + expect(liveRelayRefusalFor('discord', { config: { discordUserId: '345678901234567890' }, createdBy: 'u' }, 'u')).toBeNull(); + expect(liveRelayRefusalFor('discord', { config: { discordUserId: '345678901234567890' }, createdBy: 'u' }, 'v')).toMatch(/Only the person/); + }); + + test('an inherited property name is not a platform', () => { + expect(liveRelayRefusalFor('toString', { config: {} }, 'u')).toBeNull(); + }); +}); diff --git a/backend/__tests__/unit/services/discordBridgeService.test.js b/backend/__tests__/unit/services/discordBridgeService.test.js new file mode 100644 index 0000000000..20de418d97 --- /dev/null +++ b/backend/__tests__/unit/services/discordBridgeService.test.js @@ -0,0 +1,369 @@ +// F131 (D249): a bound Discord channel's lines relay live into its pod, through +// the shared chat-relay core, only when their author is the Discord account the +// install proved. Everyone else's lines keep the buffer-for-summary path. +// +// The claim store is an in-memory stand-in for WebhookDelivery's unique index, +// with the real webhookDeliveryService over it, so "two replicas, one write" is +// the same duplicate-key path production takes. +jest.mock('discord.js', () => ({ + Client: jest.fn(), + GatewayIntentBits: { Guilds: 1, GuildMessages: 2, MessageContent: 4 }, +})); +jest.mock('../../../models/Integration', () => ({ + find: jest.fn(), + findById: jest.fn(), + findByIdAndUpdate: jest.fn(), +})); +jest.mock('../../../models/Pod', () => ({ findById: jest.fn() })); +jest.mock('../../../models/User', () => ({ findById: jest.fn() })); +jest.mock('../../../models/pg/Message', () => ({ create: jest.fn(), findById: jest.fn() })); +jest.mock('../../../models/pg/Pod', () => ({ findById: jest.fn(async () => ({ id: 'pod-1' })) })); +jest.mock('../../../services/messageAgentDeliveryService', () => ({ deliverMessageToAgents: jest.fn() })); +jest.mock('../../../config/socket', () => ({ getIO: jest.fn(() => null) })); +jest.mock('../../../models/WebhookDelivery', () => { + const claimed = new Set(); + return { + __claimed: claimed, + __unavailable: { value: false }, + create: jest.fn(async function create({ provider, deliveryId }) { + // eslint-disable-next-line global-require + const self = require('../../../models/WebhookDelivery'); + if (self.__unavailable.value) throw new Error('mongo down'); + const key = `${provider}|${deliveryId}`; + if (claimed.has(key)) throw Object.assign(new Error('E11000 duplicate key'), { code: 11000 }); + claimed.add(key); + return {}; + }), + deleteOne: jest.fn(), + }; +}); + +const Integration = require('../../../models/Integration'); +const Pod = require('../../../models/Pod'); +const User = require('../../../models/User'); +const PGMessage = require('../../../models/pg/Message'); +const WebhookDelivery = require('../../../models/WebhookDelivery'); +const { + relayDiscordMessageToPod, + relayDiscordMessageForIntegration, + discordInboundAdapter, +} = require('../../../services/discordBridgeService'); +const discordGatewayService = require('../../../services/discordGatewayService'); + +const GUILD = '123456789012345678'; +const CHANNEL = '234567890123456789'; +const INSTALLER_DISCORD = '345678901234567890'; +const OTHER_DISCORD = '456789012345678901'; +const INSTALLER = 'user-1'; + +const row = (overrides = {}, config = {}) => ({ + _id: 'integration-1', + podId: 'pod-1', + type: 'discord', + isActive: true, + status: 'connected', + createdBy: INSTALLER, + ...overrides, + config: { + serverId: GUILD, + channelId: CHANNEL, + liveRelay: true, + linkedUserId: INSTALLER, + discordUserId: INSTALLER_DISCORD, + ...config, + }, +}); + +let nextId = 1000; +const discordMessage = (overrides = {}) => ({ + id: String(nextId++), + type: 0, + system: false, + webhookId: null, + guild: { id: GUILD }, + channel: { id: CHANNEL }, + author: { id: INSTALLER_DISCORD, username: 'sam', bot: false }, + member: { displayName: 'Sam' }, + content: 'ship it @kai', + createdAt: new Date('2026-11-04T12:00:00Z'), + attachments: new Map(), + ...overrides, +}); + +const writes = () => PGMessage.create.mock.calls; + +describe('Discord inbound live relay (F131)', () => { + beforeEach(() => { + jest.clearAllMocks(); + WebhookDelivery.__claimed.clear(); + WebhookDelivery.__unavailable.value = false; + Pod.findById.mockReturnValue({ + select: () => ({ lean: async () => ({ _id: 'pod-1', type: 'chat', members: [INSTALLER] }) }), + }); + User.findById.mockReturnValue({ select: () => ({ lean: async () => ({ username: 'sam' }) }) }); + PGMessage.create.mockResolvedValue({ id: 'pg-1' }); + PGMessage.findById.mockResolvedValue({ id: 'pg-1', content: 'x' }); + }); + + test('writes the proven sender’s line into the bound pod as the linked user', async () => { + const result = await relayDiscordMessageToPod({ integration: row(), message: discordMessage() }); + + expect(result).toEqual({ relayed: true }); + expect(writes()).toHaveLength(1); + const [podId, userId, content] = writes()[0]; + expect(podId).toBe('pod-1'); + expect(userId).toBe(INSTALLER); + // Mentions stay text, so an @agent in the line still wakes that agent. + expect(content).toBe('💬 Sam (via Discord): ship it @kai'); + }); + + test('does not write another guild member’s line live', async () => { + const result = await relayDiscordMessageToPod({ + integration: row(), + message: discordMessage({ author: { id: OTHER_DISCORD, username: 'mallory', bot: false } }), + }); + + expect(result).toEqual({ relayed: false }); + expect(writes()).toHaveLength(0); + // Not even a claim: nothing about an unproven line touches the claim store. + expect(WebhookDelivery.create).not.toHaveBeenCalled(); + }); + + test('a row with no proven discordUserId relays nothing live', async () => { + const result = await relayDiscordMessageToPod({ + integration: row({}, { discordUserId: undefined }), + message: discordMessage(), + }); + + expect(result).toEqual({ relayed: false }); + expect(writes()).toHaveLength(0); + }); + + test('a row whose linked user is not the installer relays nothing live', async () => { + // The Discord id was proven for the row's creator. Authoring that account's + // lines as anyone else would be the misattribution this whole rule is for. + const result = await relayDiscordMessageToPod({ + integration: row({}, { linkedUserId: 'user-9' }), + message: discordMessage(), + }); + + expect(result).toEqual({ relayed: false }); + expect(writes()).toHaveLength(0); + }); + + test.each([ + ['live relay off', row({}, { liveRelay: false })], + ['an admin pause', row({}, { adminPause: { reason: 'abuse' } })], + ['an inactive row', row({ isActive: false })], + ['a row in error', row({ status: 'error' })], + ['a row bound to another channel', row({}, { channelId: '567890123456789012' })], + ])('relays nothing for %s', async (_label, integration) => { + await relayDiscordMessageToPod({ integration, message: discordMessage() }); + expect(writes()).toHaveLength(0); + }); + + test.each([ + ['a bot author', { author: { id: INSTALLER_DISCORD, username: 'bot', bot: true } }], + // Our own channel webhook echoes every outbound line back through the gateway. + ['a webhook author', { webhookId: '678901234567890123' }], + ['a system message', { system: true }], + ['a pin notice', { type: 6 }], + ])('ignores %s', async (_label, overrides) => { + const result = await relayDiscordMessageToPod({ integration: row(), message: discordMessage(overrides) }); + expect(result).toEqual({ relayed: false }); + expect(writes()).toHaveLength(0); + }); + + test('a reply relays like a plain line', async () => { + await relayDiscordMessageToPod({ integration: row(), message: discordMessage({ type: 19 }) }); + expect(writes()).toHaveLength(1); + }); + + test('attachments become links after the text', async () => { + await relayDiscordMessageToPod({ + integration: row(), + message: discordMessage({ + content: 'the deck', + attachments: new Map([['a1', { url: 'https://cdn.discordapp.com/attachments/1/2/deck.pdf' }]]), + }), + }); + + expect(writes()[0][2]).toBe('💬 Sam (via Discord): the deck\nhttps://cdn.discordapp.com/attachments/1/2/deck.pdf'); + }); + + test('the same Discord message delivered twice writes one pod message', async () => { + const message = discordMessage(); + + const results = await Promise.all([ + relayDiscordMessageToPod({ integration: row(), message }), + relayDiscordMessageToPod({ integration: row(), message }), + ]); + + expect(writes()).toHaveLength(1); + expect(results.filter((r) => r.relayed)).toHaveLength(1); + expect(results.filter((r) => r.duplicate)).toHaveLength(1); + }); + + test('the claim is per row: one message reaches each row bound to its channel once', async () => { + const message = discordMessage(); + + await relayDiscordMessageToPod({ integration: row(), message }); + await relayDiscordMessageToPod({ integration: row({ _id: 'integration-2', podId: 'pod-1' }), message }); + + expect(writes()).toHaveLength(2); + }); + + test('fails closed when the claim store is unavailable', async () => { + WebhookDelivery.__unavailable.value = true; + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + const message = discordMessage(); + const result = await relayDiscordMessageToPod({ integration: row(), message }); + expect(result).toEqual({ relayed: false }); + expect(writes()).toHaveLength(0); + // The operator is the audience for this exception (review rule 19): the + // log names the row and the message so the missed line can be found. + const logged = JSON.stringify(warn.mock.calls); + expect(logged).toContain('integration-1'); + expect(logged).toContain(message.id); + } finally { + warn.mockRestore(); + error.mockRestore(); + } + }); + + describe('a transient failure loading the author, while the claim is held', () => { + const podOk = { select: () => ({ lean: async () => ({ _id: 'pod-1', type: 'chat', members: [INSTALLER] }) }) }; + const podThrows = () => { throw new Error('mongo blip'); }; + + test('is retried, and the line is written once', async () => { + Pod.findById.mockImplementationOnce(podThrows).mockReturnValue(podOk); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = await relayDiscordMessageToPod({ integration: row(), message: discordMessage() }); + expect(result).toEqual({ relayed: true }); + expect(writes()).toHaveLength(1); + } finally { + warn.mockRestore(); + } + }); + + test('three throws write nothing, and say so with the row and message', async () => { + Pod.findById.mockImplementation(podThrows); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + const message = discordMessage(); + const result = await relayDiscordMessageToPod({ integration: row(), message }); + expect(result).toEqual({ relayed: false }); + expect(writes()).toHaveLength(0); + expect(Pod.findById).toHaveBeenCalledTimes(3); + const logged = JSON.stringify(error.mock.calls); + expect(logged).toContain('integration-1'); + expect(logged).toContain(message.id); + } finally { + warn.mockRestore(); + error.mockRestore(); + } + }); + + test('the write itself is never retried', async () => { + PGMessage.create.mockRejectedValueOnce(new Error('pg down')); + await expect(relayDiscordMessageToPod({ integration: row(), message: discordMessage() })).rejects.toThrow('pg down'); + expect(writes()).toHaveLength(1); + }); + }); + + test('does not write when the linked user has left the pod, and says nothing in the channel', async () => { + Pod.findById.mockReturnValue({ + select: () => ({ lean: async () => ({ _id: 'pod-1', type: 'chat', members: ['user-7'] }) }), + }); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = await relayDiscordMessageToPod({ integration: row(), message: discordMessage() }); + expect(result).toEqual({ relayed: false }); + expect(writes()).toHaveLength(0); + } finally { + warn.mockRestore(); + } + }); + + describe('the gateway', () => { + const target = (overrides = {}) => ({ + integrationId: 'integration-1', webhookListenerEnabled: true, maxBufferSize: 50, liveRelay: true, ...overrides, + }); + + beforeEach(() => { + discordGatewayService.channelCache = new Map(); + jest.spyOn(discordGatewayService, 'ensureChannelCache').mockResolvedValue(); + Integration.findByIdAndUpdate.mockResolvedValue({}); + Integration.findById.mockReturnValue({ select: () => ({ lean: async () => row() }) }); + }); + + afterEach(() => { + discordGatewayService.ensureChannelCache.mockRestore(); + }); + + test('buffers every line and relays the proven one, re-reading the row', async () => { + discordGatewayService.channelCache.set(`${GUILD}:${CHANNEL}`, [target()]); + + await discordGatewayService.handleMessageCreate(discordMessage()); + + expect(Integration.findByIdAndUpdate).toHaveBeenCalledWith('integration-1', expect.objectContaining({ + $push: { 'config.messageBuffer': expect.anything() }, + })); + expect(Integration.findById).toHaveBeenCalledWith('integration-1'); + expect(writes()).toHaveLength(1); + }); + + test('still buffers another member’s line, and writes nothing live', async () => { + discordGatewayService.channelCache.set(`${GUILD}:${CHANNEL}`, [target()]); + + await discordGatewayService.handleMessageCreate( + discordMessage({ author: { id: OTHER_DISCORD, username: 'mallory', bot: false } }), + ); + + expect(Integration.findByIdAndUpdate).toHaveBeenCalledTimes(1); + expect(writes()).toHaveLength(0); + }); + + test('does not hand a non-live row to the bridge', async () => { + discordGatewayService.channelCache.set(`${GUILD}:${CHANNEL}`, [target({ liveRelay: false })]); + + await discordGatewayService.handleMessageCreate(discordMessage()); + + expect(Integration.findById).not.toHaveBeenCalled(); + expect(writes()).toHaveLength(0); + }); + + test('two replicas’ gateways receiving one message write it once', async () => { + discordGatewayService.channelCache.set(`${GUILD}:${CHANNEL}`, [target()]); + const message = discordMessage(); + + await Promise.all([ + discordGatewayService.handleMessageCreate(message), + discordGatewayService.handleMessageCreate(message), + ]); + + expect(writes()).toHaveLength(1); + }); + }); + + test('relayDiscordMessageForIntegration answers nothing for a missing row', async () => { + Integration.findById.mockReturnValue({ select: () => ({ lean: async () => null }) }); + await expect(relayDiscordMessageForIntegration('gone', discordMessage())).resolves.toEqual({ relayed: false }); + }); + + describe('live-relay rule (the adapter’s declaration)', () => { + test('refuses a row without a proven Discord user', () => { + expect(discordInboundAdapter.liveRelayRefusal({ config: {}, createdBy: INSTALLER }, INSTALLER)).toMatch(/Discord account/); + }); + + test('refuses anyone but the installer, whose id linkedUserId would be stamped from', () => { + const proven = { config: { discordUserId: INSTALLER_DISCORD }, createdBy: { toString: () => INSTALLER } }; + expect(discordInboundAdapter.liveRelayRefusal(proven, 'pod-creator')).toMatch(/Only the person who added the bot/); + expect(discordInboundAdapter.liveRelayRefusal(proven, INSTALLER)).toBeNull(); + }); + }); +}); diff --git a/backend/__tests__/unit/services/slackBridgeService.test.js b/backend/__tests__/unit/services/slackBridgeService.test.js index c831ab930f..6ed9053d59 100644 --- a/backend/__tests__/unit/services/slackBridgeService.test.js +++ b/backend/__tests__/unit/services/slackBridgeService.test.js @@ -211,6 +211,34 @@ describe('Slack installable bridge', () => { expect(PGMessage.create.mock.calls[0][0]).toBe('pod-1'); }); + test('a plain DM from a Slack user other than the bound one writes nothing (F131 review)', async () => { + Pod.findById.mockReturnValue(podWithMembers('Alpha')); + PGMessage.create.mockResolvedValue({ id: 'pg-1' }); + User.findById.mockReturnValue({ + select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ username: 'sam' }) }), + }); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = await relaySlackMessageToPod({ + integration: { + ...integration, + scope: 'user', + config: { + ...integration.config, linkedUserId: 'user-1', slackUserId: 'U1', gates: {}, + }, + }, + // No thread, no mention: the plain-DM path, with a sender who is not U1. + event: { text: 'hello', user: 'U2' }, + }); + + expect(result).toEqual({ relayed: false }); + expect(PGMessage.create).not.toHaveBeenCalled(); + expect(deliverMessageToAgents).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + test('addresses an unquoted plain message to the active pod lead', async () => { Pod.findById.mockReturnValue(podWithMembers('Alpha', ['user-1', 'bot-1'])); User.findOne.mockResolvedValue({ _id: 'bot-1', username: 'kai', isBot: true }); diff --git a/backend/models/Integration.ts b/backend/models/Integration.ts index 5f7e14637d..18c12606f8 100644 --- a/backend/models/Integration.ts +++ b/backend/models/Integration.ts @@ -145,6 +145,12 @@ export interface IIntegration extends Document { teamName?: string; slackUserId?: string; slackUserName?: string; + /** + * The Discord user id of the person who installed the bot, proven by the + * OAuth callback's `users/@me` read (F131). Live relay authors only that + * sender's channel messages as `linkedUserId`; server-owned. + */ + discordUserId?: string; pendingBind?: { teamId: string; teamName?: string; @@ -345,6 +351,8 @@ const IntegrationSchema = new Schema( teamName: String, slackUserId: String, slackUserName: String, + // Declared, or the strict subdocument drops the bind's write (F131). + discordUserId: String, pendingBind: { teamId: String, teamName: String, diff --git a/backend/models/integrationPublicConfig.ts b/backend/models/integrationPublicConfig.ts index 0bb1ed807d..7fbc987dfe 100644 --- a/backend/models/integrationPublicConfig.ts +++ b/backend/models/integrationPublicConfig.ts @@ -98,6 +98,9 @@ export const toPublicIntegration = ( export const INTEGRATION_ROUTING_STATE_CONFIG_KEYS = [ 'chatId', 'linkedUserId', + // The Discord installer's own user id (F131): whose channel lines are + // authored as `linkedUserId`. The creator's, like the line above. + 'discordUserId', 'messageBuffer', 'relayMap', ] as const; diff --git a/backend/routes/discord.ts b/backend/routes/discord.ts index d5cf693ce1..0ab68b060d 100644 --- a/backend/routes/discord.ts +++ b/backend/routes/discord.ts @@ -246,7 +246,9 @@ router.get('/install-link/:podId', listIntegrationsRateLimit, auth, async (req: // on prod 2026-11-04). const installUrl = new URL('https://discord.com/api/oauth2/authorize'); installUrl.searchParams.set('client_id', String(clientId)); - installUrl.searchParams.set('scope', 'bot applications.commands'); + // `identify` lets the callback read the installer's own Discord user id + // (users/@me), the proof a live relay on this shared channel needs (F131). + installUrl.searchParams.set('scope', 'bot applications.commands identify'); installUrl.searchParams.set('permissions', DISCORD_BOT_PERMISSIONS); installUrl.searchParams.set('response_type', 'code'); installUrl.searchParams.set('redirect_uri', discordRedirectUri()); @@ -397,11 +399,32 @@ router.get('/callback', discordCallbackLimit, async (req: AuthReq, res: Res) => return res.redirect(`${frontend}/discord/error?error=OAuth%20authorization%20failed`); } } + // F131: the installer's Discord user id, read with the user token this + // exchange just returned. It rides in the signed proof and nowhere else. A + // failed or malformed read leaves it out — the install still binds, and the + // row simply cannot relay live — never a guess. + let installerDiscordId: string | null = null; + const accessToken = (tokenResponse?.data as { access_token?: unknown } | undefined)?.access_token; + if (provenGuildId && typeof accessToken === 'string' && accessToken) { + try { + const me = await axios.get('https://discord.com/api/users/@me', { + headers: { Authorization: `Bearer ${accessToken}` }, + timeout: 10_000, + }); + const meId = (me?.data as { id?: unknown } | undefined)?.id; + installerDiscordId = isDiscordSnowflake(meId) ? String(meId) : null; + } catch { + // No provider data in the log: Discord may echo request credentials. + console.warn('Discord OAuth users/@me lookup failed; install proof carries no Discord user'); + } + } const successUrl = new URL(`${frontend}/discord/success`); successUrl.searchParams.append('pod_id', podId); successUrl.searchParams.append('guild_id', installedGuildId || ''); successUrl.searchParams.append('server_name', serverName); - if (provenGuildId) successUrl.searchParams.append('install', mintDiscordInstallProof({ podId, guildId: provenGuildId, userId: installerId })); + if (provenGuildId) successUrl.searchParams.append('install', mintDiscordInstallProof({ + podId, guildId: provenGuildId, userId: installerId, discordUserId: installerDiscordId, + })); res.redirect(successUrl.toString()); } catch { console.error('Discord OAuth callback error'); diff --git a/backend/routes/integrations.ts b/backend/routes/integrations.ts index bd166b3c3e..5b255dee00 100644 --- a/backend/routes/integrations.ts +++ b/backend/routes/integrations.ts @@ -61,6 +61,7 @@ import { readDiscordInstallProof } from '../utils/discordInstallProof'; import { assertCanBindSurface, isSurfaceBindType, retargetsSurface, SurfaceBindRefusedError, } from '../services/chatRelay/bindSurface'; +import { liveRelayRefusalFor } from '../services/chatRelay/adapters'; // Keep this as an ESM import: static analysis recognizes the rate limiter at // the route sink, while the middleware owns the shared token/IP bucket. import { @@ -545,6 +546,17 @@ router.post('/', writeIntegrationsRateLimit, auth, async (req: AuthReq, res: Res if (relay.invalid) return res.status(400).json(relayFlagError(relay.invalid)); const nextConfig: Record = relay.next; if (type === 'telegram') Object.assign(nextConfig, mintConnectCode()); + // F131: the installer's Discord user id, from the OAuth callback's proof and + // nowhere else (a body copy was stripped above). Only a proof for this pod, + // this guild and this caller counts; without one the row has no proven + // Discord author and cannot relay live. + if (type === 'discord') { + const proof = readDiscordInstallProof((req.body as { install?: unknown })?.install); + if (proof?.discordUserId && proof.podId === String(podId) + && proof.guildId === String(config.serverId) && proof.userId === String(req.user?.id)) { + nextConfig.discordUserId = proof.discordUserId; + } + } // First-run default: mirror. A fresh connector has no leadAgentUsername // and its agents use no escalation markers, so attention mode relays // NOTHING — a new user's first experience of the bridge would be silence. @@ -559,6 +571,14 @@ router.post('/', writeIntegrationsRateLimit, auth, async (req: AuthReq, res: Res // streaming outbound. The creator IS the authenticated caller, so the // defaulted case is stamped exactly like an explicit liveRelay:true. if (nextConfig.liveRelay === true) nextConfig.linkedUserId = req.user?.id; + // The platform's live-relay rule (its adapter's declaration), on the config + // as it will be stored, before the row or any webhook exists. + if (nextConfig.liveRelay === true) { + const refusal = liveRelayRefusalFor( + type, { config: nextConfig, createdBy: req.user?.id }, String(req.user?.id || ''), + ); + if (refusal) return res.status(400).json({ message: refusal }); + } const missingRequired = getMissingRequiredFields(type, nextConfig); if (type === 'discord' && missingRequired.length) return res.status(400).json({ message: `Missing required fields: ${missingRequired.join(', ')}`, missing: missingRequired }); // F143: a chat surface people outside the pod can read (a Slack channel, a @@ -957,11 +977,16 @@ router.patch('/:id', writeIntegrationsRateLimit, auth, async (req: AuthReq, res: }); } if (incoming && incoming.liveRelay === true) { - // Relay authors inbound as linkedUserId and streams outbound to chatId; - // both are only honest in a 1:1 private chat (#1289 inbound, F2 outbound). - if (nextConfig.chatId && nextConfig.chatType !== 'private') { - return res.status(400).json({ message: 'Live relay requires a private chat with the bot; this connector is bound to a group' }); - } + // Relay authors inbound as linkedUserId, which is stamped from this caller. + // Each platform declares when that is honest (its inbound adapter's + // liveRelayRefusal): a 1:1 private chat for Telegram and Slack (#1289 + // inbound, F2 outbound), the proven installer for Discord (F131). + const refusal = liveRelayRefusalFor( + integration.type, + { config: nextConfig, createdBy: integration.createdBy }, + requesterId, + ); + if (refusal) return res.status(400).json({ message: refusal }); nextConfig.linkedUserId = req.user?.id; } const missingRequired = getMissingRequiredFields(integration.type || '', nextConfig); diff --git a/backend/services/chatRelay/adapters.ts b/backend/services/chatRelay/adapters.ts index a7accba3c1..4f04a6d715 100644 --- a/backend/services/chatRelay/adapters.ts +++ b/backend/services/chatRelay/adapters.ts @@ -4,13 +4,17 @@ // `.relay` handlers, so a new platform is an adapter in its bridge // plus one line here — no dispatcher edit. -import { relayAgentMessageThroughAdapter } from './chatRelayCore'; -import type { ChatRelayAdapter, OutboundRelayOptions } from './chatRelayCore'; +import { privateChatLiveRelayRefusal, relayAgentMessageThroughAdapter } from './chatRelayCore'; +import type { + ChatRelayAdapter, InboundRelayAdapter, LiveRelayRow, OutboundRelayOptions, +} from './chatRelayCore'; // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require -const { telegramRelayAdapter } = require('../telegramBridgeService'); +const { telegramRelayAdapter, telegramInboundAdapter } = require('../telegramBridgeService'); // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require -const { slackRelayAdapter } = require('../slackBridgeService'); +const { slackRelayAdapter, slackInboundAdapter } = require('../slackBridgeService'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const { discordInboundAdapter } = require('../discordBridgeService'); export const chatRelayAdapters: Record> = { telegram: telegramRelayAdapter, @@ -27,3 +31,31 @@ export const chatRelayEventHandlers = (): Record) => relayAgentMessageThroughAdapter(adapter, opts), ])) ); + +// --------------------------------------------------------------------------- +// Inbound — one entry per platform whose bridge relays chat lines INTO a pod. +// A platform can relay inbound without relaying outbound (Discord, F131), so +// this registry is separate from the outbound one above. +// --------------------------------------------------------------------------- + +export const inboundRelayAdapters: Record> = { + telegram: telegramInboundAdapter, + slack: slackInboundAdapter, + discord: discordInboundAdapter, +}; + +/** + * Why `liveRelay` may not go on for a row of this type, or null. The platform's + * own declaration when it has one; otherwise the private-chat rule every chat + * connector had before adapters declared their own. + */ +export const liveRelayRefusalFor = ( + type: unknown, + row: LiveRelayRow, + requesterId: string, +): string | null => { + const adapter = typeof type === 'string' && Object.prototype.hasOwnProperty.call(inboundRelayAdapters, type) + ? inboundRelayAdapters[type] + : null; + return adapter ? adapter.liveRelayRefusal(row, requesterId) : privateChatLiveRelayRefusal(row); +}; diff --git a/backend/services/chatRelay/chatRelayCore.ts b/backend/services/chatRelay/chatRelayCore.ts index 9bc8ff8756..d19caa2f49 100644 --- a/backend/services/chatRelay/chatRelayCore.ts +++ b/backend/services/chatRelay/chatRelayCore.ts @@ -11,10 +11,15 @@ // - how a command is spelled and how its reply reads; // - how an inbound sender is proven to be the linked user. // -// The last two are deliberately not abstracted here: reply text is -// byte-pinned per platform, and attribution proof is a security argument each -// bridge makes in its own terms (a private chat on Telegram, a matched DM user -// on Slack). The core takes the semantic outcome; the adapter writes the words. +// Reply text is not abstracted here: it is byte-pinned per platform. The core +// takes the semantic outcome; the adapter writes the words. +// +// Attribution proof is a security argument each bridge makes in its own terms +// (a private chat on Telegram, a matched DM user on Slack, a matched proven +// author on Discord), declared as an InboundRelayAdapter. The core owns the +// other half: the only way to reach `writeInboundAsLinkedUser` is a +// ProvenSender, and only `proveInboundSender` mints one, after the adapter's +// proof has passed. // // Adding a platform is one adapter in its bridge plus one entry in // ./adapters.ts — the dispatcher's selector reads the registry's keys. @@ -306,6 +311,112 @@ export const relayAgentMessageThroughAdapter = async | null; + createdBy?: unknown; +} + +/** + * One platform's inbound half. Both members are security arguments the + * platform makes in its own terms; the core never branches on the provider. + */ +export interface InboundRelayAdapter { + provider: string; + /** + * True only when the platform itself guarantees that the external sender + * of `evidence` IS `integration.config.linkedUserId`. Every relayed line is + * AUTHORED as that user, so a false positive here puts words in their mouth. + */ + senderIsLinkedUser: (integration: I, evidence: E) => boolean; + /** + * Why turning `liveRelay` on for this row would be dishonest, or null when + * it may go on. Read by both integration write routes, after the row's + * server-owned fields are settled. `requesterId` is the caller whose id the + * route stamps into `linkedUserId`. + */ + liveRelayRefusal: (row: LiveRelayRow, requesterId: string) => string | null; +} + +/** + * The rule every chat connector had before adapters declared their own: a + * live relay authors inbound as `linkedUserId` and streams outbound to + * `chatId`, and both are honest only in a 1:1 private chat (#1289 inbound, + * F2 outbound). A row with no `chatId` yet has nothing to judge. + */ +export const PRIVATE_CHAT_LIVE_RELAY_REFUSAL = 'Live relay requires a private chat with the bot; ' + + 'this connector is bound to a group'; +export const privateChatLiveRelayRefusal = (row: LiveRelayRow): string | null => { + const config = row.config || {}; + return config.chatId && config.chatType !== 'private' ? PRIVATE_CHAT_LIVE_RELAY_REFUSAL : null; +}; + +declare const provenSenderBrand: unique symbol; + +/** + * An inbound sender the platform has proven to be `linkedUserId`. Opaque: + * the brand is a type no module can name, and the runtime registry below + * refuses an object `proveInboundSender` did not mint, so a JS caller cannot + * forge one with a literal either. + */ +export type ProvenSender = { + readonly provider: string; + readonly linkedUserId: string; + readonly [provenSenderBrand]: true; +}; + +const mintedSenders = new WeakSet(); + +/** + * The only constructor of a ProvenSender: the adapter's proof, then a minted + * token naming the linked user. Null when the proof fails or the row names no + * linked user. + */ +export const proveInboundSender = ( + adapter: InboundRelayAdapter, + integration: I, + evidence: E, +): ProvenSender | null => { + const linkedUserId = integration?.config?.linkedUserId; + if (!linkedUserId) return null; + let proven = false; + try { + proven = adapter.senderIsLinkedUser(integration, evidence) === true; + } catch { + proven = false; + } + if (!proven) return null; + const sender = Object.freeze({ provider: adapter.provider, linkedUserId: String(linkedUserId) }) as ProvenSender; + mintedSenders.add(sender); + return sender; +}; + +const assertProvenSender = (sender: ProvenSender): string => { + if (!sender || typeof sender !== 'object' || !mintedSenders.has(sender)) { + throw new Error('chat-relay inbound write refused: the sender was not proven by an adapter'); + } + return sender.linkedUserId; +}; + +/** + * Claim one inbound platform message, once, across every backend replica. A + * platform that pushes the same message to more than one listener (a gateway + * socket per replica) calls this before the write; the WebhookDelivery unique + * index is the boundary the HTTP bridges' redelivery dedupe already uses. + * Keyed by the integration as well, because one message reaches every row + * bound to its channel and each row's pod should get it once. + */ +export const INBOUND_CLAIM_TTL_MS = 24 * 60 * 60 * 1000; +export const claimInboundMessage = async ( + provider: string, + integrationId: unknown, + externalMessageId: string, +): Promise<'claimed' | 'duplicate' | 'unavailable'> => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const { claimDelivery } = require('../webhookDeliveryService'); + return claimDelivery(provider, `inbound:${String(integrationId)}:${externalMessageId}`, INBOUND_CLAIM_TTL_MS); +}; + export interface InboundAuthor { pod: { type?: string; members?: unknown[] }; linkedUser: { username?: string; profilePicture?: string }; @@ -313,19 +424,20 @@ export interface InboundAuthor { /** * The membership half of inbound authorship: the linked user must still be a - * listed member of the pod the line lands in. `onRefused` is the adapter's + * listed member of the pod the line lands in. `onNotMember` is the adapter's * in-chat notice. The other half — that the external sender IS the linked user - * — each bridge proves before calling, in its platform's terms. + * — is the ProvenSender this takes. */ export const loadInboundAuthor = async (opts: { podId: string; - linkedUserId: string; + sender: ProvenSender; logPrefix: string; onNotMember: () => Promise; }): Promise => { const { - podId, linkedUserId, logPrefix, onNotMember, + podId, sender, logPrefix, onNotMember, } = opts; + const linkedUserId = assertProvenSender(sender); // 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 @@ -352,12 +464,13 @@ export const loadInboundAuthor = async (opts: { * repair, and nothing exists yet to duplicate. Failures AFTER it are swallowed: * a throw would reach the webhook, return non-2xx, and the platform would * redeliver — re-running the write and duplicating the message and every wake - * it triggers, because nothing dedupes on the platform's message id. + * it triggers. (The HTTP bridges dedupe the delivery at their routes; a + * pushed platform claims the message first with `claimInboundMessage`.) */ export const writeInboundAsLinkedUser = async (opts: { podId: string; podType?: string; - linkedUserId: string; + sender: ProvenSender; linkedUser: { username?: string; profilePicture?: string }; content: string; replyToMessageId: string | null; @@ -365,8 +478,11 @@ export const writeInboundAsLinkedUser = async (opts: { logPrefix: string; }): Promise => { const { - podId, podType, linkedUserId, linkedUser, content, replyToMessageId, lateReplyThreadRootId, logPrefix, + podId, podType, sender, linkedUser, content, replyToMessageId, lateReplyThreadRootId, logPrefix, } = opts; + // First, before anything is loaded or written: an unproven sender is a + // programming error, and it must fail where nothing exists yet to duplicate. + const linkedUserId = assertProvenSender(sender); // 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 diff --git a/backend/services/discordBridgeService.ts b/backend/services/discordBridgeService.ts new file mode 100644 index 0000000000..ea10400a5a --- /dev/null +++ b/backend/services/discordBridgeService.ts @@ -0,0 +1,239 @@ +// Discord live relay, inbound (D249, F131): a bound guild channel's messages +// reach its pod as real pod messages, through the shared chat-relay core. +// +// A guild channel is a SHARED surface: anyone in the guild can post there. So +// this follows Slack's model, not Telegram's private-chat one. A line is relayed +// live, authored as the row's `linkedUserId`, only when its Discord author is +// the person who added the bot, proven by the OAuth callback's `users/@me` read +// and stored server-side as `config.discordUserId`. Everyone else's lines keep +// the gateway's buffer-for-summary path, which this module does not touch. +// +// Outbound is not here: Discord is not in the outbound relay registry, and an +// agent's pod message does not reach the channel through this bridge. + +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const Integration = require('../models/Integration'); +import { + claimInboundMessage, + loadInboundAuthor, + proveInboundSender, + writeInboundAsLinkedUser, +} from './chatRelay/chatRelayCore'; +import type { InboundRelayAdapter, LiveRelayRow } from './chatRelay/chatRelayCore'; + +const LOG = '[discord-bridge]'; +const AUTHOR_LOAD_ATTEMPTS = 3; + +// discord.js MessageType: Default and Reply are what a person types. Everything +// else (joins, pins, boosts, thread starters, …) is the system talking. +const PERSON_MESSAGE_TYPES = new Set([0, 19]); + +export interface DiscordIntegrationDoc { + _id: unknown; + podId?: unknown; + type?: string; + isActive?: boolean; + status?: string; + createdBy?: unknown; + config?: { + serverId?: string; + channelId?: string; + liveRelay?: boolean; + linkedUserId?: string; + discordUserId?: string; + adminPause?: unknown; + }; +} + +export interface DiscordInboundMessage { + id?: string; + type?: number; + system?: boolean; + webhookId?: string | null; + guild?: { id?: string } | null; + channel?: { id?: string } | null; + author?: { id?: string; username?: string; globalName?: string | null; bot?: boolean } | null; + member?: { displayName?: string } | null; + content?: string; + attachments?: Map | { values: () => Iterable<{ url?: string }> }; +} + +const idOf = (value: unknown): string => { + if (!value) return ''; + const nested = (value as { _id?: unknown })._id; + return String(nested ?? value); +}; + +/** + * Lines that are never a person speaking: any bot (our own channel webhook's + * echo of an outbound line included, so nothing loops), any webhook, and any + * system message. + */ +export const isIgnoredDiscordMessage = (message: DiscordInboundMessage | null | undefined): boolean => ( + !message + || !message.id + || message.author?.bot === true + || Boolean(message.webhookId) + || message.system === true + || (message.type !== undefined && !PERSON_MESSAGE_TYPES.has(Number(message.type))) +); + +// The row is live for inbound, and the message came from the channel it binds. +// The gateway's channel cache already matched guild and channel, but it is up to +// a minute stale, so the row read here is the authority. +const isInboundRelayableIntegration = ( + integration: DiscordIntegrationDoc, + message: DiscordInboundMessage, +): boolean => ( + integration.type === 'discord' + && integration.isActive === true + && integration.status !== 'error' + && integration.config?.liveRelay === true + && !integration.config?.adminPause + && Boolean(integration.podId) + && Boolean(integration.config?.serverId) + && Boolean(integration.config?.channelId) + && String(integration.config?.serverId) === String(message.guild?.id || '') + && String(integration.config?.channelId) === String(message.channel?.id || '') +); + +const LIVE_RELAY_NEEDS_PROOF = 'Live relay on a Discord channel needs the Discord account of the person who ' + + 'added the bot, which the install proves. Add the bot again from Commonly to link it.'; +const LIVE_RELAY_INSTALLER_ONLY = 'Only the person who added the bot can turn on live relay for this channel: ' + + 'it posts their own Discord messages into the pod as them.'; + +export const discordInboundAdapter: InboundRelayAdapter = { + provider: 'discord', + // The author is the Discord account the install proved, and the Commonly user + // the line is authored as is the one that account was proven for: the row's + // creator, whose install proof carried the id. A guild channel guarantees + // nothing else about who is speaking. + senderIsLinkedUser: (integration, message) => { + const discordUserId = integration.config?.discordUserId; + const linkedUserId = integration.config?.linkedUserId; + return Boolean(discordUserId) + && message?.author?.bot !== true + && String(message?.author?.id || '') === String(discordUserId) + && Boolean(linkedUserId) + && String(linkedUserId) === idOf(integration.createdBy); + }, + // `linkedUserId` is stamped from whoever turns live relay on, so that person + // must be the one whose Discord id the row holds. + liveRelayRefusal: (row: LiveRelayRow, requesterId: string) => { + if (!row.config?.discordUserId) return LIVE_RELAY_NEEDS_PROOF; + if (!requesterId || idOf(row.createdBy) !== String(requesterId)) return LIVE_RELAY_INSTALLER_ONLY; + return null; + }, +}; + +const attachmentUrls = (message: DiscordInboundMessage): string[] => { + const attachments = message.attachments; + if (!attachments || typeof (attachments as { values?: unknown }).values !== 'function') return []; + return Array.from((attachments as { values: () => Iterable<{ url?: string }> }).values()) + .map((attachment) => String(attachment?.url || '')) + .filter(Boolean); +}; + +/** The pod line: the text as typed (mentions stay text), then each attachment as a link. */ +export const renderDiscordInboundContent = (message: DiscordInboundMessage): string => { + const body = [String(message.content || '').trim(), ...attachmentUrls(message)].filter(Boolean).join('\n'); + if (!body) return ''; + const name = message.member?.displayName || message.author?.globalName || message.author?.username; + return name ? `💬 ${name} (via Discord): ${body}` : `💬 (via Discord): ${body}`; +}; + +/** + * One Discord channel message → its pod, when the row relays live and the + * author is proven. Resolves `{ relayed: false }` for every line that is not + * relayed, and `duplicate: true` when another replica's gateway claimed it. + */ +export const relayDiscordMessageToPod = async (opts: { + integration: DiscordIntegrationDoc; + message: DiscordInboundMessage; +}): Promise<{ relayed: boolean; duplicate?: boolean }> => { + const { integration, message } = opts; + if (isIgnoredDiscordMessage(message)) return { relayed: false }; + if (!isInboundRelayableIntegration(integration, message)) return { relayed: false }; + const sender = proveInboundSender(discordInboundAdapter, integration, message); + if (!sender) return { relayed: false }; + const content = renderDiscordInboundContent(message); + if (!content) return { relayed: false }; + + // Each backend replica holds its own gateway socket, so each receives this + // message. One claim per (row, message) across all of them. + const claim = await claimInboundMessage('discord', integration._id, String(message.id)); + if (claim === 'duplicate') return { relayed: false, duplicate: true }; + if (claim === 'unavailable') { + // Fail closed: two replicas writing the same line is the defect the claim + // exists for, and the line is still in the summary buffer. + // A deliberate exception to review rule 19 (INTEGRATION_CONTRACT.md, F131): + // the operator is the audience, so name the row and the message. + console.warn( + `${LOG} inbound not relayed integration=${String(integration._id)} message=${String(message.id)}: ` + + 'claim store unavailable; the line is in the summary buffer only', + ); + return { relayed: false }; + } + + const podId = String(integration.podId); + // The claim is held from here on, and no other replica will write this line. + // So a transient read failure would lose it for good: the author load is + // read-only and retried. The write below is not — a retried write is the + // duplicate the claim exists to prevent. + let author: Awaited> = null; + for (let attempt = 1; ; attempt += 1) { + try { + // eslint-disable-next-line no-await-in-loop + author = await loadInboundAuthor({ + podId, + sender, + logPrefix: LOG, + // A shared channel gets no notice: the refusal is about one person's pod + // membership, and everyone in the guild would read it. + onNotMember: async () => {}, + }); + break; + } catch (error) { + if (attempt >= AUTHOR_LOAD_ATTEMPTS) { + console.error( + `${LOG} inbound not relayed integration=${String(integration._id)} message=${String(message.id)}: ` + + `author load failed ${AUTHOR_LOAD_ATTEMPTS} times: ${(error as Error).message}`, + ); + return { relayed: false }; + } + } + } + if (!author) return { relayed: false }; + await writeInboundAsLinkedUser({ + podId, + podType: author.pod.type, + sender, + linkedUser: author.linkedUser, + content, + replyToMessageId: null, + logPrefix: LOG, + }); + return { relayed: true }; +}; + +/** The gateway's entry: re-read the row (the gateway's cache is stale by design) and relay. */ +export const relayDiscordMessageForIntegration = async ( + integrationId: unknown, + message: DiscordInboundMessage, +): Promise<{ relayed: boolean; duplicate?: boolean }> => { + if (isIgnoredDiscordMessage(message)) return { relayed: false }; + const integration = await Integration.findById(integrationId) + .select('_id podId type isActive status createdBy config.serverId config.channelId config.liveRelay ' + + 'config.linkedUserId config.discordUserId config.adminPause') + .lean() as DiscordIntegrationDoc | null; + if (!integration) return { relayed: false }; + return relayDiscordMessageToPod({ integration, message }); +}; + +module.exports = { + discordInboundAdapter, + isIgnoredDiscordMessage, + renderDiscordInboundContent, + relayDiscordMessageToPod, + relayDiscordMessageForIntegration, +}; diff --git a/backend/services/discordGatewayService.ts b/backend/services/discordGatewayService.ts index a2ff61d5a9..ca672eaf77 100644 --- a/backend/services/discordGatewayService.ts +++ b/backend/services/discordGatewayService.ts @@ -16,6 +16,8 @@ interface ChannelTarget { integrationId: unknown; webhookListenerEnabled: boolean; maxBufferSize: number; + // Only a hint for which rows to hand the bridge; the bridge re-reads the row. + liveRelay?: boolean; } interface IntegrationDoc { @@ -25,13 +27,17 @@ interface IntegrationDoc { serverId?: string; webhookListenerEnabled?: boolean; maxBufferSize?: number; + liveRelay?: boolean; }; } interface DiscordMessage { guild?: { id: string }; channel?: { id: string }; - author?: { id?: string; username?: string; bot?: boolean }; + author?: { id?: string; username?: string; globalName?: string | null; bot?: boolean }; + type?: number; + system?: boolean; + webhookId?: string | null; member?: { displayName?: string }; content?: string; id?: string; @@ -117,7 +123,8 @@ class DiscordGatewayService { type: 'discord', isActive: true, }) - .select('_id config.channelId config.serverId config.webhookListenerEnabled config.maxBufferSize') + .select('_id config.channelId config.serverId config.webhookListenerEnabled config.maxBufferSize ' + + 'config.liveRelay') .lean() as IntegrationDoc[]; this.channelCache.clear(); @@ -133,6 +140,7 @@ class DiscordGatewayService { integrationId: integration._id, webhookListenerEnabled: !!integration?.config?.webhookListenerEnabled, maxBufferSize: integration?.config?.maxBufferSize || 1000, + liveRelay: integration?.config?.liveRelay === true, }); this.channelCache.set(key, existing); }); @@ -190,6 +198,24 @@ class DiscordGatewayService { }, })), ); + + // F131: a live-relay row also gets the line in its pod, when the bridge + // proves the author. The buffer above is unchanged for every line, relayed + // or not, so channel summaries still see the whole channel. + const liveTargets = targets.filter((target) => target.liveRelay); + if (!liveTargets.length) return; + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const { relayDiscordMessageForIntegration } = require('./discordBridgeService'); + await Promise.all(liveTargets.map(async (target) => { + try { + await relayDiscordMessageForIntegration(target.integrationId, message); + } catch (error) { + console.error( + `[discord-bridge] live relay failed integration=${String(target.integrationId)}:`, + (error as Error).message, + ); + } + })); } } diff --git a/backend/services/slackBridgeService.ts b/backend/services/slackBridgeService.ts index c584e3653f..7f26d1181d 100644 --- a/backend/services/slackBridgeService.ts +++ b/backend/services/slackBridgeService.ts @@ -17,10 +17,11 @@ import { resolveDecisionCardReply } from './decisionCardReply'; import type { ChannelCardEntry } from './decisionCardReply'; import { loadInboundAuthor, + proveInboundSender, relayAgentMessageThroughAdapter, writeInboundAsLinkedUser, } from './chatRelay/chatRelayCore'; -import type { ChatRelayAdapter, OutboundRelayOptions } from './chatRelay/chatRelayCore'; +import type { ChatRelayAdapter, InboundRelayAdapter, OutboundRelayOptions } from './chatRelay/chatRelayCore'; // One implementation, in slackApi.ts one step above the call that posts it. This // file and decisionCardReconcileService each carried their own copy, which is how @@ -281,6 +282,31 @@ export const routeSlackReplyContent = (opts: { : { content: `${mention} ${content}`, routedAgent: hit.agentUsername, podId: routedPodId }; }; +const SLACK_LIVE_RELAY_REFUSAL = 'Live relay requires a direct message with the bot; ' + + 'this connector is bound to a channel'; + +// Slack's inbound proof: the event's sender is the Slack user the OAuth bind +// proved (`config.slackUserId`), never a value a request supplied. +export const slackInboundAdapter: InboundRelayAdapter = { + provider: 'slack', + senderIsLinkedUser: (integration, event) => { + const slackUserId = integration.config?.slackUserId; + return Boolean(slackUserId) && event?.user === slackUserId; + }, + // Slack's own audience test, not the shared private-chat one: the OAuth bind + // stores the installer's DM as chatType 'im', which that rule read as a group + // and refused. A DM (`im`) with no channel is 1:1; a channelId, or a bound + // chat of any other type, is a channel. The same reading as F143's + // surfaceBindDeclarations.slack and isRelayableIntegration above. A row with + // no chat yet has nothing to judge. + liveRelayRefusal: (row) => { + const config = row.config || {}; + if (config.channelId) return SLACK_LIVE_RELAY_REFUSAL; + if (config.chatId && config.chatType !== 'im') return SLACK_LIVE_RELAY_REFUSAL; + return null; + }, +}; + // 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 @@ -307,12 +333,13 @@ export const relaySlackMessageToPod = async (opts: { return { relayed: false }; } const config = integration.config || {}; - if ( - !isInboundRelayableIntegration(integration, String(integration.podId)) - || !config.linkedUserId - || !config.slackUserId - || event.user !== config.slackUserId - ) { + // The proof is slackInboundAdapter.senderIsLinkedUser: the DM sender is the + // Slack user the OAuth bind proved. The core mints the ProvenSender every + // write below requires only when it passes. + const sender = isInboundRelayableIntegration(integration, String(integration.podId)) + ? proveInboundSender(slackInboundAdapter, integration, event) + : null; + if (!sender) { console.warn( `[slack-bridge] inbound dropped integration=${String(integration._id)}: ` + 'unbound or mismatched DM sender', @@ -399,7 +426,6 @@ export const relaySlackMessageToPod = async (opts: { // neither addresses an agent, so they must not turn a plain DM into a mention. && extractMentions(String(rawText).replace(/<[^<>]*>/g, ' ')).length === 0; const replyToMessageId = cardReply.lateReply?.messageId || null; - const linkedUserId = String(config.linkedUserId); const senderName = event.user_profile?.display_name || event.user_profile?.real_name; // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require @@ -407,7 +433,7 @@ export const relaySlackMessageToPod = async (opts: { const author = await loadInboundAuthor({ podId, - linkedUserId, + sender, logPrefix: '[slack-bridge]', onNotMember: () => replyNoActivePod(integration), }); @@ -432,7 +458,7 @@ export const relaySlackMessageToPod = async (opts: { await writeInboundAsLinkedUser({ podId, podType: pod.type, - linkedUserId, + sender, linkedUser, content, replyToMessageId, @@ -453,4 +479,5 @@ module.exports = { isRelayableIntegration, isInboundRelayableIntegration, slackRelayAdapter, + slackInboundAdapter, }; diff --git a/backend/services/telegramBridgeService.ts b/backend/services/telegramBridgeService.ts index 1aa5eb73af..6ca7d179ad 100644 --- a/backend/services/telegramBridgeService.ts +++ b/backend/services/telegramBridgeService.ts @@ -33,10 +33,12 @@ import { resolveDecisionCardReply } from './decisionCardReply'; import type { ChannelCardEntry } from './decisionCardReply'; import { loadInboundAuthor, + privateChatLiveRelayRefusal, + proveInboundSender, relayAgentMessageThroughAdapter, writeInboundAsLinkedUser, } from './chatRelay/chatRelayCore'; -import type { ChatRelayAdapter, OutboundRelayOptions } from './chatRelay/chatRelayCore'; +import type { ChatRelayAdapter, InboundRelayAdapter, OutboundRelayOptions } from './chatRelay/chatRelayCore'; const OUTBOUND_TEXT_CAP = 900; const CARD_OUTBOUND_TEXT_CAP = 1_900; @@ -306,6 +308,21 @@ export const relayAgentMessageToTelegram = async ( opts: OutboundRelayOptions, ): Promise => relayAgentMessageThroughAdapter(telegramRelayAdapter, opts); +// Telegram's inbound proof: a `private` chat is 1:1 between the bot and one +// person, so the sender is the chat's counterpart. Fails closed on an unknown +// chatType, on the row's or on the message's. The reasoning is at the call site +// in relayTelegramMessageToPod. +export const telegramInboundAdapter: InboundRelayAdapter< + TelegramIntegrationDoc, { chat?: { type?: string } } +> = { + provider: 'telegram', + senderIsLinkedUser: (integration, message) => ( + integration.config?.chatType === 'private' + && !(message?.chat && message.chat.type !== 'private') + ), + liveRelayRefusal: privateChatLiveRelayRefusal, +}; + // Inbound: Telegram message from a linked live-relay chat → pod message, // attributed to the integration's linked user, delivered through the same // seam both human routes use so mentions and wake behave identically. @@ -374,8 +391,12 @@ export const relayTelegramMessageToPod = async (opts: { // // Widening this needs a real Telegram-sender → Commonly-user mapping, not a // longer list of accepted chat types. - const chatType = integration.config?.chatType; - if (chatType !== 'private' || (telegramMessage.chat && telegramMessage.chat.type !== 'private')) { + // + // The check itself is telegramInboundAdapter.senderIsLinkedUser; the core + // mints the ProvenSender every write below requires only when it passes. + const sender = proveInboundSender(telegramInboundAdapter, integration, telegramMessage); + if (!sender) { + const chatType = integration.config?.chatType; console.warn( `[tg-bridge] inbound dropped — relay authors as the linked user and chatType=${chatType || 'unknown'} ` + 'cannot guarantee the sender is them', @@ -470,7 +491,7 @@ export const relayTelegramMessageToPod = async (opts: { const author = await loadInboundAuthor({ podId, - linkedUserId: String(linkedUserId), + sender, logPrefix: '[tg-bridge]', onNotMember: () => replyNoActivePod(integration), }); @@ -482,7 +503,7 @@ export const relayTelegramMessageToPod = async (opts: { await writeInboundAsLinkedUser({ podId, podType: author.pod.type, - linkedUserId: String(linkedUserId), + sender, linkedUser: author.linkedUser, content, replyToMessageId, @@ -502,6 +523,7 @@ module.exports = { relayAgentMessageToTelegram, relayTelegramMessageToPod, telegramRelayAdapter, + telegramInboundAdapter, }; export {}; diff --git a/backend/utils/discordInstallProof.ts b/backend/utils/discordInstallProof.ts index 7c5e69f354..5fe0e3d3bd 100644 --- a/backend/utils/discordInstallProof.ts +++ b/backend/utils/discordInstallProof.ts @@ -12,6 +12,12 @@ * * The payload carries neither `id` nor `user`, so `middleware/auth` never reads * it as a session. + * + * F131: it also carries the installer's Discord user id when the callback read + * it from `users/@me` with the token the code exchange returned. That id is + * what makes a live relay honest on a shared channel (only that author's lines + * are written as the installer), so it comes from Discord or not at all — the + * proof is the only way it reaches `POST /api/integrations`. */ import jwt, { type JwtPayload } from 'jsonwebtoken'; @@ -26,6 +32,8 @@ interface DiscordInstallProofPayload extends JwtPayload { gid: string; // The person whose install this is: the state's user. Only they may spend it. uid: string; + // Their Discord user id, from `users/@me` (F131). Absent when that read failed. + did?: string; } const secret = (): string => { @@ -35,10 +43,18 @@ const secret = (): string => { }; export const mintDiscordInstallProof = ( - { podId, guildId, userId }: { podId: string; guildId: string; userId: string }, + { + podId, guildId, userId, discordUserId, + }: { podId: string; guildId: string; userId: string; discordUserId?: string | null }, ttlSeconds: number = DISCORD_INSTALL_PROOF_TTL_SECONDS, ): string => { - const payload: DiscordInstallProofPayload = { pur: PURPOSE, pod: podId, gid: guildId, uid: userId }; + const payload: DiscordInstallProofPayload = { + pur: PURPOSE, + pod: podId, + gid: guildId, + uid: userId, + ...(isDiscordSnowflake(discordUserId) ? { did: String(discordUserId) } : {}), + }; return jwt.sign(payload, secret(), { expiresIn: ttlSeconds }); }; @@ -46,13 +62,20 @@ export const mintDiscordInstallProof = ( * The proof's (pod, guild, user), or null for anything that is not a live proof. * A proof without a user (minted before the state was signed) is not a proof. */ -export const readDiscordInstallProof = (token: unknown): { podId: string; guildId: string; userId: string } | null => { +export const readDiscordInstallProof = (token: unknown): { + podId: string; guildId: string; userId: string; discordUserId?: string; +} | null => { if (typeof token !== 'string' || !token) return null; try { const decoded = jwt.verify(token, secret()) as DiscordInstallProofPayload; if (decoded.pur !== PURPOSE || typeof decoded.pod !== 'string' || !isDiscordSnowflake(decoded.gid)) return null; if (typeof decoded.uid !== 'string' || !decoded.uid) return null; - return { podId: decoded.pod, guildId: decoded.gid, userId: decoded.uid }; + return { + podId: decoded.pod, + guildId: decoded.gid, + userId: decoded.uid, + ...(isDiscordSnowflake(decoded.did) ? { discordUserId: String(decoded.did) } : {}), + }; } catch { return null; } diff --git a/backend/utils/serverOwnedConfigKeys.ts b/backend/utils/serverOwnedConfigKeys.ts index f89e17449a..e494695d86 100644 --- a/backend/utils/serverOwnedConfigKeys.ts +++ b/backend/utils/serverOwnedConfigKeys.ts @@ -29,6 +29,10 @@ export const SERVER_OWNED_CONFIG_KEYS = [ // 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', + // The Discord installer's user id (F131), Discord's twin of `slackUserId`: + // live relay authors that author's channel lines as `linkedUserId`. Written + // only from the OAuth callback's signed install proof. + 'discordUserId', // The token itself, one layer in from the opaque ref above: it is read as a // credential by `providers/slackProvider`, by `routes/registry/helpers` (Slack // and Telegram, env-only since TASK-140) and by the Discord resolver's legacy diff --git a/docs/integrations/INTEGRATION_CONTRACT.md b/docs/integrations/INTEGRATION_CONTRACT.md index c915901ab1..c81a4cef8c 100644 --- a/docs/integrations/INTEGRATION_CONTRACT.md +++ b/docs/integrations/INTEGRATION_CONTRACT.md @@ -114,6 +114,53 @@ Rows bound by plain members before the rule keep working. The only runtime gates are the three outbound summary commands above, which answer with a short refusal and no summary on such a row until the pod's creator reconnects it. +## Who a relayed line is written as (F131) +A live relay writes an external line into the pod **as `config.linkedUserId`**. +That is honest only when the platform proves the sender is that person, so each +platform declares its proof as an inbound adapter (`InboundRelayAdapter` in +`backend/services/chatRelay/chatRelayCore.ts`, registered in +`chatRelay/adapters.ts` as `inboundRelayAdapters`): +- **Telegram**: the chat is `private`, so the sender is its one counterpart. +- **Slack**: the DM's `event.user` is the `slackUserId` the OAuth bind proved. +- **Discord**: a guild channel proves nothing, so the author must be the + installer's own Discord account. The install link asks for `identify`, the + OAuth callback reads `users/@me` with the exchange's user token, the id rides + in the signed install proof, and `POST /api/integrations` stores it as the + server-owned `config.discordUserId`. A line relays live only when + `author.id === discordUserId` and `linkedUserId` is the row's creator; every + other line stays in the summary buffer. Bots, webhooks (our own echo) and + system messages are ignored. + +`writeInboundAsLinkedUser` and `loadInboundAuthor` take a `ProvenSender`, which +only `proveInboundSender` mints, after the adapter's proof passes. The same +adapter declares `liveRelayRefusal`, which both integration write routes read +before `liveRelay` goes on: the private-chat rule for Telegram and Slack (and +any undeclared type), the proven installer for Discord. + +A platform that pushes one message to every backend replica (Discord's gateway +socket) claims it with `claimInboundMessage(provider, integrationId, messageId)` +before writing; that is the WebhookDelivery unique index the HTTP bridges' +redelivery dedupe already uses. + +**An explicit exception to review rule 19** (`docs/development/review-checklist.md`: +a guard that cannot do its job fails toward the noisy outcome, and "noisy" names +an audience). When the claim store is unavailable, Discord relays the line +**nothing live**: it fails closed, under-delivering. Failing open would write +the same line once per replica, each copy authored in the installer's name and +each waking the pod's agents. That is worse than a missed live line, and it +can't be undone. The line is not lost: the gateway buffered it for the +channel's summaries before the bridge ran. The audience is the operator: the +warning names the integration and the Discord message id. The sender isn't +told: on a shared channel a notice would reach everyone in the guild. Telegram +fails open at the same point, because its HTTP redelivery gives it one +delivery per update. A gateway socket per replica doesn't. + +Once the claim is held, no other replica writes the line. So the read-only +author load (`loadInboundAuthor`) is retried up to three times on a thrown +error, then logged with the same two ids. The write itself is single-attempt. +Inbound and outbound are separate registries: +Discord relays inbound only, and is not in `chatRelayAdapters`. + ## Testing contract - Shared Jest contract tests under `backend/__tests__/contracts/integrationProvider.test.js`: - validates `ingestEvent` produces required fields