diff --git a/backend/__tests__/unit/integrations/groupmeProvider.test.js b/backend/__tests__/unit/integrations/groupmeProvider.test.js index 78b68bce2e..812584676c 100644 --- a/backend/__tests__/unit/integrations/groupmeProvider.test.js +++ b/backend/__tests__/unit/integrations/groupmeProvider.test.js @@ -8,6 +8,8 @@ jest.mock('../../../services/agentEventService', () => ({ enqueue: jest.fn(), })); jest.mock('../../../services/groupmeService', () => ({ sendMessage: jest.fn() })); +jest.mock('../../../models/Pod', () => ({ findById: jest.fn() })); +jest.mock('../../../models/User', () => ({ findById: jest.fn() })); const Integration = require('../../../models/Integration'); const Summary = require('../../../models/Summary'); @@ -126,10 +128,16 @@ describe('GroupMe provider commands', () => { }); it('sends latest pod summary on !pod-summary', async () => { + // Bound by the pod's creator: a GroupMe group is a shared surface (F143). const latest = { ...integration, + createdBy: 'creator-1', config: { ...integration.config, messageBuffer: [] }, }; + require('../../../models/Pod').findById.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ _id: 'pod-1', createdBy: 'creator-1', members: ['creator-1'] }), + }); + require('../../../models/User').findById.mockResolvedValue({ _id: 'creator-1', role: 'user' }); Integration.findById.mockReturnValue({ lean: jest.fn().mockResolvedValue(latest), }); diff --git a/backend/__tests__/unit/integrations/groupmeProvider.webhook.test.js b/backend/__tests__/unit/integrations/groupmeProvider.webhook.test.js index 2e94d73f2f..571c4b5c99 100644 --- a/backend/__tests__/unit/integrations/groupmeProvider.webhook.test.js +++ b/backend/__tests__/unit/integrations/groupmeProvider.webhook.test.js @@ -2,6 +2,10 @@ const createGroupMeProvider = require('../../../integrations/providers/groupmePr const Integration = require('../../../models/Integration'); jest.mock('../../../models/Integration'); +jest.mock('../../../models/Pod', () => ({ findById: jest.fn() })); +jest.mock('../../../models/User', () => ({ findById: jest.fn() })); +jest.mock('../../../models/Summary', () => ({ findOne: jest.fn() })); +jest.mock('../../../services/groupmeService', () => ({ sendMessage: jest.fn() })); beforeEach(() => { jest.clearAllMocks(); @@ -50,3 +54,62 @@ describe('groupmeProvider', () => { }); }); +// F143: `!pod` is what a GroupMe binding sends OUT — the pod's latest summary, +// to everyone in the group. A group bound by someone without authority over the +// pod (its creator or an instance admin) gets nothing. +describe('groupmeProvider !pod in a shared group', () => { + const Pod = require('../../../models/Pod'); + const User = require('../../../models/User'); + const Summary = require('../../../models/Summary'); + const groupmeService = require('../../../services/groupmeService'); + const row = (createdBy) => ({ + _id: 'int1', podId: 'pod-1', createdBy, config: { botId: 'bot', groupId: '123' }, + }); + const ask = async (createdBy) => { + Integration.findById.mockReturnValue({ lean: jest.fn().mockResolvedValue(row(createdBy)) }); + const { events } = createGroupMeProvider(row(createdBy)).getWebhookHandlers(); + const res = { sendStatus: jest.fn(), status: jest.fn().mockReturnThis(), send: jest.fn() }; + await events({ body: { group_id: '123', text: '!pod' } }, res); + return res; + }; + + beforeEach(() => { + Pod.findById.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ _id: 'pod-1', createdBy: 'creator-1', members: ['creator-1', 'member-1'] }), + }); + User.findById.mockImplementation(async (id) => ({ _id: String(id), role: String(id) === 'admin-1' ? 'admin' : 'user' })); + Summary.findOne.mockReturnValue({ + sort: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ title: 'T', content: 'pod secrets' }) }), + }); + }); + + test('withholds the pod summary from a group a plain member bound', async () => { + const res = await ask('member-1'); + expect(res.sendStatus).toHaveBeenCalledWith(200); + expect(Summary.findOne).not.toHaveBeenCalled(); + const text = groupmeService.sendMessage.mock.calls[0][1]; + expect(text).not.toContain('pod secrets'); + expect(text).toMatch(/only the pod's creator/i); + }); + + test('reads the summary from the pod it checked, the stored row', async () => { + // The live row was moved to pod-2 after this provider was built for pod-1. + const stale = { ...row('creator-1'), podId: 'pod-1' }; + const live = { ...row('creator-1'), podId: 'pod-2' }; + Integration.findById.mockReturnValue({ lean: jest.fn().mockResolvedValue(live) }); + const { events } = createGroupMeProvider(stale).getWebhookHandlers(); + const res = { sendStatus: jest.fn(), status: jest.fn().mockReturnThis(), send: jest.fn() }; + await events({ body: { group_id: '123', text: '!pod' } }, res); + expect(Pod.findById).toHaveBeenCalledWith('pod-2'); + expect(Summary.findOne).toHaveBeenCalledWith(expect.objectContaining({ podId: 'pod-2' })); + }); + + test.each([['the pod creator', 'creator-1'], ['an instance admin', 'admin-1']])( + 'sends the pod summary to a group %s bound', + async (_label, createdBy) => { + await ask(createdBy); + expect(groupmeService.sendMessage.mock.calls[0][1]).toContain('pod secrets'); + }, + ); +}); + diff --git a/backend/__tests__/unit/routes/discord.interactions.summaryVisibility.test.js b/backend/__tests__/unit/routes/discord.interactions.summaryVisibility.test.js new file mode 100644 index 0000000000..402e7aa721 --- /dev/null +++ b/backend/__tests__/unit/routes/discord.interactions.summaryVisibility.test.js @@ -0,0 +1,82 @@ +// F143 / CodeRabbit on #3451: when several pods share one Discord channel, the +// route folds their /commonly-summary results into one reply. A pod refused by +// the shared-surface bind rule shows only its short refusal, and that is not a +// failure for visibility: the reply stays public when every other pod's result +// succeeded, so channel members still see the allowed summaries. A real error, +// or a reply where every pod was refused, stays ephemeral. +const request = require('supertest'); +const express = require('express'); + +jest.mock('tweetnacl', () => ({ sign: { detached: { verify: jest.fn(() => true) } } })); +jest.mock('../../../models/Integration', () => ({ find: jest.fn(), findOne: jest.fn() })); +jest.mock('../../../models/DiscordIntegration', () => ({})); +jest.mock('../../../models/Pod', () => ({ findById: jest.fn(), find: jest.fn() })); +jest.mock('../../../models/User', () => ({ findById: jest.fn() })); +jest.mock('../../../services/discordService', () => jest.fn()); +// The real visibility rule; only the per-pod command runs are stubbed. +jest.mock('../../../services/discordMultiCommandService', () => ({ + ...jest.requireActual('../../../services/discordMultiCommandService'), + runDiscordCommandForIntegrations: jest.fn(), +})); + +process.env.DISCORD_PUBLIC_KEY = 'aa'.repeat(32); +const Integration = require('../../../models/Integration'); +const { runDiscordCommandForIntegrations } = require('../../../services/discordMultiCommandService'); +const discordRoutes = require('../../../routes/discord'); + +const app = express(); +app.use('/api/discord/interactions', express.raw({ type: '*/*' })); +app.use('/api/discord', discordRoutes); + +const integrations = [ + { _id: 'i-open', podId: { name: 'Open pod' } }, + { _id: 'i-member', podId: { name: 'Member-bound pod' } }, +]; +const ALLOWED = { success: true, content: 'Open pod summary text' }; +const REFUSED = { + success: false, + refused: true, + content: "Only the pod's creator can share this pod's summary with a channel. Ask them to reconnect it.", +}; +const ERROR = { success: false, content: '❌ An error occurred while fetching the summary.' }; + +const summarize = (results) => { + Integration.find.mockReturnValue({ populate: jest.fn().mockResolvedValue(integrations) }); + runDiscordCommandForIntegrations.mockResolvedValue(results.map((result, i) => ({ integration: integrations[i], result }))); + return request(app) + .post('/api/discord/interactions') + .set('Content-Type', 'application/json') + .set('X-Signature-Ed25519', 'ab') + .set('X-Signature-Timestamp', '1') + .send(JSON.stringify({ + type: 2, guild_id: 'g1', channel_id: 'c1', data: { type: 1, name: 'commonly-summary' }, + })); +}; + +describe('POST /api/discord/interactions — combined /commonly-summary visibility', () => { + beforeEach(() => jest.clearAllMocks()); + + it('stays public when one pod is refused and the other succeeds, without the refused summary', async () => { + const res = await summarize([ALLOWED, REFUSED]); + + expect(res.status).toBe(200); + expect(res.body.data.flags).toBe(0); + expect(res.body.data.content).toContain('Open pod summary text'); + expect(res.body.data.content).toContain(REFUSED.content); + }); + + it('is ephemeral when every pod is refused', async () => { + const res = await summarize([REFUSED, REFUSED]); + expect(res.body.data.flags).toBe(64); + }); + + it('stays ephemeral on a real error beside a success', async () => { + const res = await summarize([ALLOWED, ERROR]); + expect(res.body.data.flags).toBe(64); + }); + + it('is public when every pod succeeds', async () => { + const res = await summarize([ALLOWED, { ...ALLOWED, content: 'Second summary' }]); + expect(res.body.data.flags).toBe(0); + }); +}); diff --git a/backend/__tests__/unit/routes/discord.management-auth.test.js b/backend/__tests__/unit/routes/discord.management-auth.test.js index 74fce564ef..36cb43a8ea 100644 --- a/backend/__tests__/unit/routes/discord.management-auth.test.js +++ b/backend/__tests__/unit/routes/discord.management-auth.test.js @@ -113,6 +113,32 @@ describe('discord management route auth', () => { expect(res.body).toEqual({ error: 'Access denied' }); }); + // F143: the install link starts a Discord bind, so it asks the same rule + // POST /api/integrations does (services/chatRelay/bindSurface) and the two + // cannot disagree: the pod's creator, still a listed member, or an admin. + describe('install-link asks the shared-surface rule', () => { + beforeEach(() => { process.env.DISCORD_CLIENT_ID = '1111111111'; }); + afterEach(() => { delete process.env.DISCORD_CLIENT_ID; }); + const link = (token) => request(app).get('/api/discord/install-link/pod-1').set('Authorization', `Bearer ${token}`); + + it('serves the pod creator who is a member', async () => { + Pod.findById.mockResolvedValue({ _id: 'pod-1', createdBy: 'user-1', members: ['user-1'] }); + expect((await link('user-token')).status).toBe(200); + }); + + it('refuses a creator who has left the pod, as the bind would', async () => { + Pod.findById.mockResolvedValue({ _id: 'pod-1', createdBy: 'user-1', members: ['someone-else'] }); + const res = await link('user-token'); + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'Access denied' }); + }); + + it('serves an instance admin on a pod they did not create', async () => { + Pod.findById.mockResolvedValue({ _id: 'pod-1', createdBy: 'someone-else', members: ['someone-else'] }); + expect((await link('admin-token')).status).toBe(200); + }); + }); + it('requires integration ownership for command registration', async () => { Integration.findById.mockResolvedValue({ _id: 'integration-1', diff --git a/backend/__tests__/unit/routes/integrations.sharedSurfaceBind.test.js b/backend/__tests__/unit/routes/integrations.sharedSurfaceBind.test.js new file mode 100644 index 0000000000..d8d046ec1d --- /dev/null +++ b/backend/__tests__/unit/routes/integrations.sharedSurfaceBind.test.js @@ -0,0 +1,289 @@ +// F143 (Cofounder, 11-04): binding a pod to a chat surface people outside the +// pod can read — a Slack channel, a Discord guild channel, a Telegram group — +// takes authority over the POD: its creator or an instance admin. A Telegram +// private chat with the bot stays member-level. POST /api/integrations and the +// PATCH retarget paths consult the one rule in services/chatRelay/bindSurface. +const request = require('supertest'); +const express = require('express'); + +jest.mock('axios', () => ({ get: jest.fn(), post: jest.fn() })); +jest.mock('../../../middleware/auth', () => (req, res, next) => { + req.user = { id: req.header('x-test-user') || 'member-1' }; + next(); +}); +jest.mock('../../../middleware/adminAuth', () => (req, res, next) => next()); +jest.mock('../../../middleware/integrationRateLimit', () => ({ + writeIntegrationsRateLimit: (_req, _res, next) => next(), + listIntegrationsRateLimit: (_req, _res, next) => next(), + ingestIngressRateLimit: (_req, _res, next) => next(), + ingestIntegrationsRateLimit: (_req, _res, next) => next(), +})); +jest.mock('../../../models/Pod', () => ({ findById: jest.fn(), find: jest.fn() })); +jest.mock('../../../models/User', () => ({ findById: jest.fn() })); +jest.mock('../../../models/DiscordIntegration', () => function DiscordIntegration(data) { + Object.assign(this, data); + this.save = jest.fn().mockResolvedValue(this); +}); +jest.mock('../../../services/discordService', () => jest.fn()); +jest.mock('../../../models/Integration', () => { + function Integration(data) { + Object.assign(this, data); + this._id = this._id || 'integration-new'; + this.save = jest.fn().mockResolvedValue(this); + Integration.created.push(this); + } + Integration.created = []; + Integration.findById = jest.fn(); + Integration.findOne = jest.fn(); + Integration.findByIdAndUpdate = jest.fn(); + Integration.aggregate = jest.fn().mockResolvedValue([]); + return Integration; +}); + +const axios = require('axios'); +const Integration = require('../../../models/Integration'); +const User = require('../../../models/User'); +const Pod = require('../../../models/Pod'); +const integrationRoutes = require('../../../routes/integrations'); + +const app = express(); +app.use(express.json()); +app.use('/api/integrations', integrationRoutes); + +const CREATOR = 'creator-1'; +const MEMBER = 'member-1'; +const ADMIN = 'admin-1'; +const POD_1 = '64b64c1f7e5b8f0a00000001'; +const POD_2 = '64b64c1f7e5b8f0a00000002'; +const POD_3 = '64b64c1f7e5b8f0a00000003'; +const REFUSAL = 'Only the pod creator can connect a shared channel to this pod'; +const GUILD = '111111111111111111'; +const CHANNEL = '222222222222222222'; + +const pods = { + [POD_1]: { + _id: POD_1, type: 'chat', createdBy: CREATOR, members: [CREATOR, MEMBER, ADMIN], + }, + // MEMBER is a plain member of pod 2 as well; CREATOR made it. + [POD_2]: { + _id: POD_2, type: 'chat', createdBy: CREATOR, members: [CREATOR, MEMBER], + }, + // MEMBER's own pod. + [POD_3]: { + _id: POD_3, type: 'chat', createdBy: MEMBER, members: [MEMBER], + }, +}; +const users = { + [CREATOR]: { _id: CREATOR, role: 'user' }, + [MEMBER]: { _id: MEMBER, role: 'user' }, + [ADMIN]: { _id: ADMIN, role: 'admin' }, +}; + +const create = (as, body) => request(app).post('/api/integrations').set('x-test-user', as).send(body); +const patch = (as, body) => request(app) + .patch('/api/integrations/64b64c1f7e5b8f0a12345674') + .set('x-test-user', as) + .send(body); + +beforeEach(() => { + jest.clearAllMocks(); + Integration.created.length = 0; + process.env.DISCORD_BOT_TOKEN = 'bot-token'; + Pod.findById.mockImplementation(async (id) => pods[String(id)] || null); + User.findById.mockImplementation(async (id) => users[String(id)] || null); + Integration.findByIdAndUpdate.mockResolvedValue({ _id: 'integration-1' }); +}); + +describe('POST /api/integrations — a shared surface needs authority over the pod', () => { + it.each([ + ['an array', ['slack']], + ['an object', { $ne: 'telegram' }], + ['a number', 7], + ])('refuses a type that is %s with 400 before anything else', async (_label, type) => { + const res = await create(MEMBER, { podId: POD_1, type, config: { channelId: 'C123' } }); + + expect(res.status).toBe(400); + expect(res.body.message).toBe('type must be a string'); + expect(Pod.findById).not.toHaveBeenCalled(); + expect(Integration.created).toHaveLength(0); + }); + + it('refuses a plain member creating a Slack connector, and writes nothing', async () => { + const res = await create(MEMBER, { podId: POD_1, type: 'slack', config: { channelId: 'C123' } }); + + expect(res.status).toBe(403); + expect(res.body.message).toBe(REFUSAL); + expect(res.body.code).toBe('shared_surface_needs_pod_authority'); + expect(Integration.created).toHaveLength(0); + }); + + it('refuses a plain member even when the body claims a Slack DM (chatType is server-owned)', async () => { + const res = await create(MEMBER, { podId: POD_1, type: 'slack', config: { chatType: 'im', chatId: 'D1' } }); + + expect(res.status).toBe(403); + expect(Integration.created).toHaveLength(0); + }); + + it.each([ + ['the pod creator', CREATOR], + ['an instance admin who did not create the pod', ADMIN], + ])('lets %s create a Slack connector', async (_label, as) => { + const res = await create(as, { podId: POD_1, type: 'slack', config: { channelId: 'C123' } }); + + expect(res.status).toBe(201); + expect(Integration.created).toHaveLength(1); + }); + + it('refuses a plain member binding a Discord channel with the one shared message, before any Discord call', async () => { + const res = await create(MEMBER, { + podId: POD_1, type: 'discord', config: { serverId: GUILD, channelId: CHANNEL }, + }); + + expect(res.status).toBe(403); + expect(res.body.message).toBe(REFUSAL); + expect(axios.get).not.toHaveBeenCalled(); + expect(axios.post).not.toHaveBeenCalled(); + expect(Integration.created).toHaveLength(0); + }); + + it('refuses a plain member creating a GroupMe connector, and writes nothing', async () => { + const res = await create(MEMBER, { podId: POD_1, type: 'groupme', config: { groupId: '123', botId: 'b' } }); + + expect(res.status).toBe(403); + expect(res.body.message).toBe(REFUSAL); + expect(Integration.created).toHaveLength(0); + }); + + it.each([ + ['the pod creator', CREATOR], + ['an instance admin who did not create the pod', ADMIN], + ])('lets %s create a GroupMe connector', async (_label, as) => { + const res = await create(as, { podId: POD_1, type: 'groupme', config: { groupId: '123', botId: 'b' } }); + + expect(res.status).toBe(201); + expect(Integration.created).toHaveLength(1); + }); + + it('lets a plain member create a Telegram connector: the chat is judged where the code is redeemed', async () => { + const res = await create(MEMBER, { podId: POD_1, type: 'telegram', config: {} }); + + expect(res.status).toBe(201); + expect(Integration.created).toHaveLength(1); + expect(Integration.created[0].config.connectCode).toMatch(/^[0-9a-f]{32}$/); + }); +}); + +describe('PATCH /api/integrations/:id — a member cannot retarget onto a shared surface', () => { + const groupmeRow = (createdBy) => ({ + _id: 'integration-1', + type: 'groupme', + scope: 'pod', + podId: POD_1, + createdBy: { toString: () => createdBy }, + config: { toObject: () => ({ groupId: '123', botId: 'b' }) }, + }); + + it.each([['groupId', '999'], ['botId', 'other-bot']])( + 'refuses a member pointing their GroupMe row at another group via %s', + async (field, value) => { + Integration.findById.mockResolvedValue(groupmeRow(MEMBER)); + + const res = await patch(MEMBER, { config: { [field]: value } }); + + expect(res.status).toBe(403); + expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled(); + }, + ); + + it('lets the pod creator point a GroupMe row at another group', async () => { + Integration.findById.mockResolvedValue(groupmeRow(MEMBER)); + + const res = await patch(CREATOR, { config: { groupId: '999' } }); + + expect(res.status).toBe(200); + }); + + const slackDmRow = (createdBy) => ({ + _id: 'integration-1', + type: 'slack', + scope: 'pod', + podId: POD_1, + createdBy: { toString: () => createdBy }, + config: { toObject: () => ({ chatType: 'im', chatId: 'D1', teamId: 'T1' }) }, + }); + + it('refuses a member pointing their Slack DM row at a channel, and writes nothing', async () => { + Integration.findById.mockResolvedValue(slackDmRow(MEMBER)); + + const res = await patch(MEMBER, { config: { channelId: 'C999' } }); + + expect(res.status).toBe(403); + expect(res.body.message).toBe(REFUSAL); + expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + it('lets the same member change a field that is not the surface', async () => { + Integration.findById.mockResolvedValue(slackDmRow(MEMBER)); + + const res = await patch(MEMBER, { config: { relayAllAgentMessages: true } }); + + expect(res.status).toBe(200); + expect(Integration.findByIdAndUpdate).toHaveBeenCalledTimes(1); + }); + + it('lets the pod creator point a Slack row at a channel', async () => { + Integration.findById.mockResolvedValue(slackDmRow(MEMBER)); + + const res = await patch(CREATOR, { config: { channelId: 'C999' } }); + + expect(res.status).toBe(200); + const [, update] = Integration.findByIdAndUpdate.mock.calls[0]; + expect(update['config.channelId']).toBe('C999'); + }); + + const userScopedTelegram = (chatType) => ({ + _id: 'integration-1', + type: 'telegram', + scope: 'user', + podId: POD_1, + createdBy: { toString: () => MEMBER }, + config: { toObject: () => ({ chatId: '-100', chatType, linkedUserId: MEMBER }) }, + }); + + it('refuses a member moving a group-bound connector to another pod they did not create', async () => { + Integration.findById.mockResolvedValue(userScopedTelegram('supergroup')); + + const res = await patch(MEMBER, { podId: POD_2 }); + + expect(res.status).toBe(403); + expect(res.body.message).toBe(REFUSAL); + expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + it('lets a member move their private-chat connector to another pod', async () => { + Integration.findById.mockResolvedValue(userScopedTelegram('private')); + + const res = await patch(MEMBER, { podId: POD_2 }); + + expect(res.status).toBe(200); + const [, update] = Integration.findByIdAndUpdate.mock.calls[0]; + expect(update.podId).toBe(POD_2); + }); + + it('judges a channel retarget against the pod the row moves to, not the one it leaves', async () => { + Integration.findById.mockResolvedValue({ + _id: 'integration-1', + type: 'slack', + scope: 'user', + podId: POD_3, + createdBy: { toString: () => MEMBER }, + config: { toObject: () => ({ chatType: 'im', chatId: 'D1', linkedUserId: MEMBER }) }, + }); + + // MEMBER created POD_3, so the channel alone would pass there; POD_2 is not theirs. + const res = await patch(MEMBER, { podId: POD_2, config: { channelId: 'C999' } }); + + expect(res.status).toBe(403); + expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json b/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json index 74411092e0..155aafb283 100644 --- a/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json +++ b/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json @@ -53,7 +53,6 @@ "routes/dev.ts POST /agents/events [unlimited]", "routes/discord.ts GET /binding/:podId [unlimited]", "routes/discord.ts GET /health [unlimited]", - "routes/discord.ts GET /install-link/:podId [unlimited]", "routes/discord.ts POST /interactions [unlimited]", "routes/discord.ts POST /register-all [unlimited]", "routes/discord.ts POST /register-commands/:integrationId [unlimited]", diff --git a/backend/__tests__/unit/routes/telegram.webhook.connectCode.test.js b/backend/__tests__/unit/routes/telegram.webhook.connectCode.test.js index b464139a35..ddb47900f5 100644 --- a/backend/__tests__/unit/routes/telegram.webhook.connectCode.test.js +++ b/backend/__tests__/unit/routes/telegram.webhook.connectCode.test.js @@ -6,6 +6,7 @@ const express = require('express'); jest.mock('../../../models/Integration'); jest.mock('../../../models/Pod'); +jest.mock('../../../models/User', () => ({ findById: jest.fn() })); jest.mock('../../../models/Summary', () => ({ findOne: jest.fn() })); jest.mock('../../../services/integrationSummaryService', () => ({ createSummary: jest.fn() })); jest.mock('../../../services/agentEventService', () => ({ enqueue: jest.fn() })); @@ -37,6 +38,7 @@ jest.mock('../../../services/telegramConnectCode', () => { const Integration = require('../../../models/Integration'); const Pod = require('../../../models/Pod'); +const User = require('../../../models/User'); const WebhookDelivery = require('../../../models/WebhookDelivery'); const telegramService = require('../../../services/telegramService'); const { @@ -136,10 +138,18 @@ describe('/commonly-enable hardening', () => { expect(telegramService.sendMessage.mock.calls[0][2]).toMatch(/private chat/i); }); - it('still binds a legacy (buffer) integration from a group', async () => { + // F143 (Cofounder, 11-04) narrowed this arm: a legacy (buffer) row still + // binds a group, when the pod's creator minted it. + it('still binds a legacy (buffer) integration from a group, minted by the pod creator', async () => { Integration.findOne = jest.fn() - .mockResolvedValueOnce({ _id: 'i1', podId: 'p1', config: freshCode() }) + .mockResolvedValueOnce({ + _id: 'i1', podId: 'p1', createdBy: 'creator-1', config: freshCode(), + }) .mockResolvedValueOnce(null); + Pod.findById.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ name: 'Test Pod', createdBy: 'creator-1', members: ['creator-1'] }), + }); + User.findById.mockResolvedValue({ _id: 'creator-1', role: 'user' }); await enable('c'.repeat(32), { id: -100, type: 'group', title: 'Crew' }); expect(Integration.findByIdAndUpdate).toHaveBeenCalled(); }); @@ -236,3 +246,84 @@ describe('/commonly-enable hardening', () => { expect(Integration.findByIdAndUpdate).toHaveBeenCalled(); }); }); + +// F143 (Cofounder, 11-04): a Telegram group, supergroup or channel is read by +// people outside the pod, so binding one takes authority over the POD. The chat +// type is only known here, at redemption, so the rule runs here, against the +// row's creator — the person whose code it is (connect-code re-mints keep it). +describe('/commonly-enable in a shared chat needs authority over the pod', () => { + const CREATOR = 'creator-1'; + const MEMBER = 'member-1'; + const ADMIN = 'admin-1'; + const users = { + [CREATOR]: { _id: CREATOR, role: 'user' }, + [MEMBER]: { _id: MEMBER, role: 'user' }, + [ADMIN]: { _id: ADMIN, role: 'admin' }, + }; + const rowMintedBy = (createdBy) => ({ + _id: 'i1', podId: 'p1', createdBy, config: freshCode(), + }); + + beforeEach(() => { + jest.clearAllMocks(); + resetEnableAttempts(); + process.env.TELEGRAM_BOT_TOKEN = 'bot-token'; + process.env.TELEGRAM_SECRET_TOKEN = SECRET; + WebhookDelivery.create.mockResolvedValue({}); + WebhookDelivery.deleteOne.mockResolvedValue({}); + Integration.findByIdAndUpdate = jest.fn().mockResolvedValue({}); + Pod.findById.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + name: 'Test Pod', createdBy: CREATOR, members: [CREATOR, MEMBER, ADMIN], + }), + }); + User.findById.mockImplementation(async (id) => users[String(id)] || null); + }); + + it.each(['group', 'supergroup', 'channel'])( + 'refuses a plain member\'s code redeemed in a %s, binds nothing, and keeps the code', + async (type) => { + Integration.findOne = jest.fn().mockResolvedValueOnce(rowMintedBy(MEMBER)).mockResolvedValueOnce(null); + await enable('c'.repeat(32), { id: -100, type, title: 'Crew' }); + expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled(); + expect(Integration.updateOne).not.toHaveBeenCalled(); + expect(telegramService.sendMessage.mock.calls[0][2]).toMatch(/made by someone who can't connect a group/i); + }, + ); + + it('tells a pod creator redeeming a member\'s code that the code is the problem, not them', async () => { + Integration.findOne = jest.fn().mockResolvedValueOnce(rowMintedBy(MEMBER)).mockResolvedValueOnce(null); + await enable('c'.repeat(32), { id: -100, type: 'group', title: 'Crew' }); + const text = telegramService.sendMessage.mock.calls[0][2]; + expect(text).toBe( + "⚠️ This code was made by someone who can't connect a group. " + + "The pod's creator or an admin needs to set up the Telegram connection. " + + 'This code still works in a private chat with this bot.', + ); + expect(text).not.toMatch(/^⚠️ Only the pod's creator/); + }); + + it('fails closed on a chat with no type', async () => { + Integration.findOne = jest.fn().mockResolvedValueOnce(rowMintedBy(MEMBER)).mockResolvedValueOnce(null); + await enable('c'.repeat(32), { id: -100, title: 'Crew' }); + expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + it.each([ + ['the pod creator', CREATOR], + ['an instance admin who did not create the pod', ADMIN], + ])('binds a group on a code minted by %s', async (_label, minter) => { + Integration.findOne = jest.fn().mockResolvedValueOnce(rowMintedBy(minter)).mockResolvedValueOnce(null); + await enable('c'.repeat(32), { id: -100, type: 'supergroup', title: 'Crew' }); + const [, update] = Integration.findByIdAndUpdate.mock.calls[0]; + expect(update.$set['config.chatType']).toBe('supergroup'); + }); + + it('still binds a plain member\'s code in a private chat, without a role lookup', async () => { + Integration.findOne = jest.fn().mockResolvedValueOnce(rowMintedBy(MEMBER)).mockResolvedValueOnce(null); + await enable('c'.repeat(32)); + const [, update] = Integration.findByIdAndUpdate.mock.calls[0]; + expect(update.$set['config.chatType']).toBe('private'); + expect(User.findById).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/__tests__/unit/routes/telegram.webhook.test.js b/backend/__tests__/unit/routes/telegram.webhook.test.js index 6b1febe19e..4e9a5d189b 100644 --- a/backend/__tests__/unit/routes/telegram.webhook.test.js +++ b/backend/__tests__/unit/routes/telegram.webhook.test.js @@ -3,6 +3,7 @@ const express = require('express'); jest.mock('../../../models/Integration'); jest.mock('../../../models/Pod'); +jest.mock('../../../models/User', () => ({ findById: jest.fn() })); jest.mock('../../../models/Summary', () => ({ findOne: jest.fn() })); jest.mock('../../../services/integrationSummaryService', () => ({ createSummary: jest.fn() })); jest.mock('../../../services/agentEventService', () => ({ enqueue: jest.fn() })); @@ -24,6 +25,7 @@ jest.mock('../../../models/WebhookDelivery', () => ({ const Integration = require('../../../models/Integration'); const Pod = require('../../../models/Pod'); +const User = require('../../../models/User'); const Summary = require('../../../models/Summary'); const IntegrationSummaryService = require('../../../services/integrationSummaryService'); const AgentEventService = require('../../../services/agentEventService'); @@ -55,18 +57,21 @@ describe('Telegram webhook routes', () => { }); it('handles /commonly-enable and links chat', async () => { + // Minted by the pod's creator: a group is a shared surface (F143). const integration = { _id: 'integration-1', podId: 'pod-1', + createdBy: 'creator-1', config: { connectCode: 'abc12300000000000000000000000000', connectCodeExpiresAt: new Date(Date.now() + 60000) }, }; + User.findById.mockResolvedValue({ _id: 'creator-1', role: 'user' }); Integration.findOne = jest.fn() .mockResolvedValueOnce(integration) // connectCode lookup .mockResolvedValueOnce(null); // chatId claim lookup Integration.findByIdAndUpdate = jest.fn().mockResolvedValue({}); Pod.findById.mockReturnValue({ - lean: jest.fn().mockResolvedValue({ name: 'Test Pod' }), + lean: jest.fn().mockResolvedValue({ name: 'Test Pod', createdBy: 'creator-1', members: ['creator-1'] }), }); const res = await telegramPost() @@ -110,15 +115,18 @@ describe('Telegram webhook routes', () => { }); it('escapes a pod name in the bind confirmation, which Telegram parses as HTML', async () => { + // Minted by the pod's creator: a group is a shared surface (F143). const integration = { _id: 'integration-1', podId: 'pod-1', + createdBy: 'creator-1', config: { connectCode: 'abc12300000000000000000000000000', connectCodeExpiresAt: new Date(Date.now() + 60000) }, }; + User.findById.mockResolvedValue({ _id: 'creator-1', role: 'user' }); Integration.findOne = jest.fn().mockResolvedValueOnce(integration).mockResolvedValueOnce(null); Integration.findByIdAndUpdate = jest.fn().mockResolvedValue({ config: { chatId: '42' } }); Pod.findById.mockReturnValue({ - lean: jest.fn().mockResolvedValue({ name: 'R&D ops' }), + lean: jest.fn().mockResolvedValue({ name: 'R&D ops', createdBy: 'creator-1', members: ['creator-1'] }), }); const res = await telegramPost() @@ -396,6 +404,51 @@ describe('bridge command surface (/mode /status /mute /help)', () => { expect(text).toContain('Agent said <b>ship it</b> & left'); }); + // F143: what a group binding still sends OUT is /tldr — the pod's latest + // summary, to everyone in the group. A group bound before the rule by someone + // without authority over the pod no longer gets it; one bound by the pod's + // creator still does. + describe('/tldr in a shared chat', () => { + const groupRow = (createdBy) => ({ + _id: 'integration-1', + podId: 'pod-1', + type: 'telegram', + createdBy, + config: { chatId: '42', chatType: 'supergroup' }, + }); + const groupPost = () => telegramPost().send({ + message: { + text: '/tldr', message_id: 901, chat: { id: 42, type: 'supergroup' }, from: { id: 7 }, + }, + }); + + beforeEach(() => { + Pod.findById.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ name: 'Pod', createdBy: 'creator-1', members: ['creator-1', 'member-1'] }), + }); + User.findById.mockImplementation(async (id) => ({ _id: String(id), role: 'user' })); + Summary.findOne.mockReturnValue({ + sort: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ title: 'T', content: 'pod secrets' }) }), + }); + }); + + it('withholds the pod summary from a group a plain member bound', async () => { + Integration.findOne = jest.fn().mockResolvedValue(groupRow('member-1')); + const res = await groupPost(); + expect(res.status).toBe(200); + expect(Summary.findOne).not.toHaveBeenCalled(); + const text = telegramService.sendMessage.mock.calls[0][2]; + expect(text).not.toContain('pod secrets'); + expect(text).toMatch(/only the pod's creator/i); + }); + + it('sends the pod summary to a group the pod creator bound', async () => { + Integration.findOne = jest.fn().mockResolvedValue(groupRow('creator-1')); + await groupPost(); + expect(telegramService.sendMessage.mock.calls[0][2]).toContain('pod secrets'); + }); + }); + it('commands never fall through to the live relay', async () => { const bridge = require('../../../services/telegramBridgeService'); await post('/status'); diff --git a/backend/__tests__/unit/services/chatRelay.bindSurface.test.js b/backend/__tests__/unit/services/chatRelay.bindSurface.test.js new file mode 100644 index 0000000000..3f6438247e --- /dev/null +++ b/backend/__tests__/unit/services/chatRelay.bindSurface.test.js @@ -0,0 +1,187 @@ +// F143 (Cofounder, 11-04): binding a pod to a chat surface people outside the +// pod can read takes authority over the POD — its creator or an instance +// admin. A private 1:1 with the bot stays member-level. One rule, consulted by +// every bind and retarget path; these arms pin the rule itself. +jest.mock('../../../models/User', () => ({ findById: jest.fn() })); +jest.mock('../../../models/Integration', () => ({ + findByIdAndUpdate: jest.fn(), + findOneAndUpdate: jest.fn(), + aggregate: jest.fn(), +})); + +const User = require('../../../models/User'); +const { + surfaceAudienceOf, + surfaceBindDeclarations, + canBindSurface, + assertCanBindSurface, + retargetsSurface, + SurfaceBindRefusedError, + SHARED_SURFACE_REFUSAL, +} = require('../../../services/chatRelay/bindSurface'); +const { chatRelayIntegrationTypes } = require('../../../services/chatRelay/adapters'); + +const CREATOR = 'creator-1'; +const MEMBER = 'member-1'; +const ADMIN = 'admin-1'; +const pod = () => ({ _id: 'pod-1', createdBy: CREATOR, members: [CREATOR, MEMBER] }); +const creator = { _id: CREATOR, role: 'user' }; +const member = { _id: MEMBER, role: 'user' }; +const admin = { _id: ADMIN, role: 'admin' }; + +// Every shared surface the ruling names, and the one private surface. +const SHARED = [ + ['a Slack channel (legacy channelId)', 'slack', { channelId: 'C123' }], + ['a Slack connector with no surface named', 'slack', {}], + ['a Slack DM that also names a channel', 'slack', { chatType: 'im', chatId: 'D1', channelId: 'C123' }], + ['a Discord guild channel', 'discord', { serverId: '1'.repeat(18), channelId: '2'.repeat(18) }], + ['a Telegram group', 'telegram', { chatId: '-1', chatType: 'group' }], + ['a Telegram supergroup', 'telegram', { chatId: '-100', chatType: 'supergroup' }], + ['a Telegram channel', 'telegram', { chatId: '-100', chatType: 'channel' }], + ['a GroupMe group', 'groupme', { groupId: '123', botId: 'b' }], +]; + +describe('surfaceAudienceOf', () => { + it('governs every chat bridge the ruling names, GroupMe included', () => { + expect(Object.keys(surfaceBindDeclarations).sort()).toEqual(['discord', 'groupme', 'slack', 'telegram']); + }); + + it.each(SHARED)('%s is a shared surface', (_label, type, config) => { + expect(surfaceAudienceOf(type, config)).toBe('shared-surface'); + }); + + it('a Telegram private chat and a Slack DM are private 1:1', () => { + expect(surfaceAudienceOf('telegram', { chatId: '42', chatType: 'private' })).toBe('private-1to1'); + expect(surfaceAudienceOf('slack', { chatId: 'D1', chatType: 'im' })).toBe('private-1to1'); + }); + + it.each([ + ['an unknown type', 'carrier-pigeon', { chatType: 'private' }], + ['an unknown Telegram chatType', 'telegram', { chatId: '9', chatType: 'secret-room' }], + ['a missing Telegram chatType', 'telegram', { chatId: '9' }], + ['a non-string type', undefined, { chatType: 'private' }], + ])('fails closed on %s', (_label, type, config) => { + expect(surfaceAudienceOf(type, config)).toBe('shared-surface'); + }); +}); + +describe('canBindSurface', () => { + it.each(SHARED)('refuses a plain member binding %s', (_label, type, config) => { + expect(canBindSurface({ + user: member, pod: pod(), type, config, + })).toBe(false); + }); + + it.each(SHARED)('lets the pod creator bind %s', (_label, type, config) => { + expect(canBindSurface({ + user: creator, pod: pod(), type, config, + })).toBe(true); + }); + + it.each(SHARED)('lets an instance admin bind %s on a pod they did not create', (_label, type, config) => { + expect(canBindSurface({ + user: admin, pod: pod(), type, config, + })).toBe(true); + }); + + it('refuses a creator who has left the pod (TASK-168)', () => { + const left = { ...pod(), members: [MEMBER] }; + expect(canBindSurface({ + user: creator, pod: left, type: 'discord', config: {}, + })).toBe(false); + }); + + it('refuses an unknown type even for the pod creator\'s co-member', () => { + expect(canBindSurface({ + user: member, pod: pod(), type: 'carrier-pigeon', config: { chatType: 'private' }, + })).toBe(false); + expect(canBindSurface({ + user: creator, pod: pod(), type: 'carrier-pigeon', config: {}, + })).toBe(true); + }); + + it('lets a plain member bind a Telegram private chat and a Slack DM', () => { + expect(canBindSurface({ + user: member, pod: pod(), type: 'telegram', config: { chatId: '42', chatType: 'private' }, + })).toBe(true); + expect(canBindSurface({ + user: member, pod: pod(), type: 'slack', config: { chatId: 'D1', chatType: 'im' }, + })).toBe(true); + }); + + it('defers a Telegram row with no chat yet to the redemption', () => { + expect(canBindSurface({ + user: member, pod: pod(), type: 'telegram', config: {}, + })).toBe(true); + }); + + it('refuses with no user or no pod', () => { + expect(canBindSurface({ + user: null, pod: pod(), type: 'discord', config: {}, + })).toBe(false); + expect(canBindSurface({ + user: admin, pod: null, type: 'discord', config: {}, + })).toBe(false); + expect(canBindSurface({ + user: creator, pod: null, type: 'discord', config: {}, + })).toBe(false); + }); +}); + +describe('assertCanBindSurface', () => { + beforeEach(() => jest.clearAllMocks()); + + it('throws one 403 with one message for a plain member on a shared surface', async () => { + User.findById.mockResolvedValue(member); + const refusal = await assertCanBindSurface({ + userId: MEMBER, pod: pod(), type: 'slack', config: { channelId: 'C1' }, + }).catch((error) => error); + expect(refusal).toBeInstanceOf(SurfaceBindRefusedError); + expect(refusal.status).toBe(403); + expect(refusal.body).toEqual(SHARED_SURFACE_REFUSAL); + expect(refusal.body.message).toBe('Only the pod creator can connect a shared channel to this pod'); + }); + + it('reads the role from the database, not the caller', async () => { + User.findById.mockResolvedValue({ _id: MEMBER, role: 'admin' }); + await expect(assertCanBindSurface({ + userId: MEMBER, pod: pod(), type: 'discord', config: {}, + })).resolves.toBeUndefined(); + expect(User.findById).toHaveBeenCalledWith(MEMBER); + }); + + it('refuses a user the database does not know', async () => { + User.findById.mockResolvedValue(null); + await expect(assertCanBindSurface({ + userId: CREATOR, pod: pod(), type: 'discord', config: {}, + })).rejects.toBeInstanceOf(SurfaceBindRefusedError); + }); + + it('settles a private surface without a user lookup', async () => { + await assertCanBindSurface({ + userId: MEMBER, pod: pod(), type: 'telegram', config: { chatId: '42', chatType: 'private' }, + }); + expect(User.findById).not.toHaveBeenCalled(); + }); +}); + +describe('retargetsSurface', () => { + it('sees a Slack channel added to a DM row', () => { + expect(retargetsSurface('slack', { chatType: 'im', chatId: 'D1' }, { chatType: 'im', chatId: 'D1', channelId: 'C1' })).toBe(true); + }); + + it('ignores a write that leaves the surface fields alone', () => { + expect(retargetsSurface('slack', { chatType: 'im', chatId: 'D1' }, { chatType: 'im', chatId: 'D1', liveRelay: true })).toBe(false); + }); + + it('treats any write on an unknown type as a retarget', () => { + expect(retargetsSurface('carrier-pigeon', {}, {})).toBe(true); + }); +}); + +describe('every relay adapter declares its audience', () => { + it('covers each type the dispatcher relays through', () => { + const declared = Object.keys(surfaceBindDeclarations); + expect(chatRelayIntegrationTypes().filter((type) => !declared.includes(type))).toEqual([]); + }); +}); diff --git a/backend/__tests__/unit/services/discordCommandService.test.js b/backend/__tests__/unit/services/discordCommandService.test.js index 017d4652b0..e558971b5d 100644 --- a/backend/__tests__/unit/services/discordCommandService.test.js +++ b/backend/__tests__/unit/services/discordCommandService.test.js @@ -3,6 +3,8 @@ jest.mock('../../../models/DiscordSummaryHistory', () => jest.fn()); jest.mock('../../../models/Summary', () => ({ findOne: jest.fn() })); jest.mock('../../../services/discordService'); jest.mock('../../../services/summarizerService'); +jest.mock('../../../models/Pod', () => ({ findById: jest.fn() })); +jest.mock('../../../models/User', () => ({ findById: jest.fn() })); const Integration = require('../../../models/Integration'); const DiscordCommandService = require('../../../services/discordCommandService'); @@ -112,3 +114,51 @@ describe('DiscordCommandService', () => { expect(summary.content).toBe('⚠️ AI summary is temporarily unavailable. Please try again shortly.'); }); }); + +// F143: /commonly-summary posts the pod's latest summary into the guild channel +// for everyone in it — the Discord twin of Telegram /tldr and GroupMe !pod. +// Rows plain members bound before #3436 get a refusal and no summary. +describe('DiscordCommandService /commonly-summary on a shared channel', () => { + const Pod = require('../../../models/Pod'); + const User = require('../../../models/User'); + const Summary = require('../../../models/Summary'); + const row = (createdBy) => ({ + _id: 'integration123', podId: 'pod-1', createdBy, config: { serverId: 'g', channelId: 'c' }, + }); + const summarize = async (createdBy) => { + Integration.findOne.mockResolvedValue(row(createdBy)); + const service = new DiscordCommandService({ integrationId: 'integration123' }); + await service.initialize(); + return service.handleSummaryCommand(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + Pod.findById.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ _id: 'pod-1', createdBy: 'creator-1', members: ['creator-1', 'member-1'] }), + }); + User.findById.mockImplementation(async (id) => ({ _id: String(id), role: String(id) === 'admin-1' ? 'admin' : 'user' })); + Summary.findOne.mockReturnValue({ + sort: jest.fn().mockResolvedValue({ title: 'T', content: 'pod secrets', type: 'chats' }), + }); + }); + + it('withholds the summary from a channel a plain member bound', async () => { + const result = await summarize('member-1'); + expect(Summary.findOne).not.toHaveBeenCalled(); + expect(result.content).not.toContain('pod secrets'); + expect(result.content).toMatch(/only the pod's creator/i); + // Marked as a refusal, not an error: the multi-pod reply reads it. + expect(result).toMatchObject({ success: false, refused: true }); + }); + + it.each([['the pod creator', 'creator-1'], ['an instance admin', 'admin-1']])( + 'posts the summary to a channel %s bound', + async (_label, createdBy) => { + const result = await summarize(createdBy); + expect(result.success).toBe(true); + expect(result.content).toContain('pod secrets'); + }, + ); +}); + diff --git a/backend/__tests__/unit/services/discordService.test.js b/backend/__tests__/unit/services/discordService.test.js index ee62a88c2f..4eaffdb7f1 100644 --- a/backend/__tests__/unit/services/discordService.test.js +++ b/backend/__tests__/unit/services/discordService.test.js @@ -117,6 +117,19 @@ describe('DiscordService', () => { jest.clearAllMocks(); }); + // F143: on a single-pod channel, a bind-rule refusal of /commonly-summary is + // the whole reply, so it stays ephemeral (only the caller sees it). + describe('handleInteraction for a refused /commonly-summary', () => { + it('answers ephemerally', async () => { + discordService.handleSlashCommand = jest.fn().mockResolvedValue({ + success: false, refused: true, content: "Only the pod's creator can share this pod's summary with a channel.", + }); + const response = await discordService.handleInteraction({ type: 2, data: { name: 'commonly-summary' } }); + expect(response.data.flags).toBe(64); + expect(response.data.content).toMatch(/only the pod's creator/i); + }); + }); + describe('initialization', () => { it('should initialize successfully with valid integration', async () => { const result = await discordService.initialize(); diff --git a/backend/integrations/providers/groupmeProvider.ts b/backend/integrations/providers/groupmeProvider.ts index 8be394c489..8d12a496a8 100644 --- a/backend/integrations/providers/groupmeProvider.ts +++ b/backend/integrations/providers/groupmeProvider.ts @@ -12,6 +12,9 @@ const groupmeService = require('../../services/groupmeService'); const Summary = require('../../models/Summary'); // eslint-disable-next-line global-require const { manifests } = require('../manifests'); +// eslint-disable-next-line global-require +const Pod = require('../../models/Pod'); +import { assertCanBindSurface, SurfaceBindRefusedError } from '../../services/chatRelay/bindSurface'; interface GroupMePayload { system?: boolean; @@ -208,9 +211,29 @@ function createGroupMeProvider(integration: { _id: unknown; config?: Record; + const commandResults = await runDiscordCommandForIntegrations({ commandName: data.name, integrations, guildId, channelId }) as Array<{ integration: typeof integrations[0]; result: { content: string; success: boolean; refused?: boolean } }>; const header = `🔗 ${integrations.length} pods linked to this Discord channel`; const blocks = commandResults.map(({ integration, result }) => { const podName = (integration.podId as { name?: string })?.name || integration.config?.channelName || `Pod ${integration.podId || integration._id}`; return `**${podName}**\n${result.content}`; }); - const response: Record = { type: 4, data: { content: [header, ...blocks].join('\n\n'), flags: commandResults.every(({ result }) => result.success) ? 0 : 64 } }; + const response: Record = { type: 4, data: { content: [header, ...blocks].join('\n\n'), flags: combinedReplyIsPublic(commandResults) ? 0 : 64 } }; response.interactionToken = interactionToken; response.interactionId = interactionId; return res.json(response); @@ -219,14 +220,25 @@ router.post('/interactions', async (req: AuthReq & { body: Buffer }, res: Res) = return res.json({ type: 4, data: { content: 'This interaction type is not supported.', flags: 64 } }); }); -router.get('/install-link/:podId', auth, async (req: AuthReq, res: Res) => { +router.get('/install-link/:podId', listIntegrationsRateLimit, auth, async (req: AuthReq, res: Res) => { try { const { podId } = req.params || {}; // The instance's own application only. A caller-chosen client id would send // the bot add to an app whose callback is not ours. const clientId = process.env.DISCORD_CLIENT_ID; - const canManage = await canManagePod(podId || '', req.user?.id || ''); - if (!canManage) return res.status(403).json({ error: 'Access denied' }); + // The link starts a Discord bind, so it asks the rule the bind itself runs + // (F143, services/chatRelay/bindSurface): a guild channel is a shared + // surface, which takes the pod's creator or an instance admin. + const pod = podId ? await Pod.findById(podId) : null; + if (!pod) return res.status(403).json({ error: 'Access denied' }); + try { + await assertCanBindSurface({ + userId: req.user?.id, pod, type: 'discord', config: {}, + }); + } catch (error) { + if (error instanceof SurfaceBindRefusedError) return res.status(403).json({ error: 'Access denied' }); + throw error; + } if (!clientId) return res.status(400).json({ error: 'Discord client ID is required' }); // A code grant with our callback as the redirect. Without `response_type` // and `redirect_uri`, Discord adds the bot and stops on its own page, so the diff --git a/backend/routes/integrations.ts b/backend/routes/integrations.ts index 0293b86269..bd166b3c3e 100644 --- a/backend/routes/integrations.ts +++ b/backend/routes/integrations.ts @@ -58,6 +58,9 @@ import { malformedDiscordBindingField, serverOwnedConfigError, } from '../utils/discordBinding'; import { readDiscordInstallProof } from '../utils/discordInstallProof'; +import { + assertCanBindSurface, isSurfaceBindType, retargetsSurface, SurfaceBindRefusedError, +} from '../services/chatRelay/bindSurface'; // 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 { @@ -491,7 +494,10 @@ router.get('/:podId', listIntegrationsRateLimit, auth, async (req: AuthReq, res: router.post('/', writeIntegrationsRateLimit, auth, async (req: AuthReq, res: Res) => { try { - const { podId, type, config } = (req.body || {}) as { podId?: string; type?: string; config?: Record }; + const { podId, type, config } = (req.body || {}) as { podId?: string; type?: unknown; config?: Record }; + // First, so every check below compares a string: an array or object `type` + // must not reach the shared-surface gate or a Mongoose cast (F143). + if (type !== undefined && typeof type !== 'string') return res.status(400).json({ message: 'type must be a string' }); if (!podId || !type || !config) return res.status(400).json({ message: 'Missing required fields' }); if (type === 'github-app') return res.status(400).json({ message: 'github-app connections require the administrator route' }); // `hosted-mcp` is refused by NAME for the reason the type exists (TASK-172, @@ -555,18 +561,21 @@ router.post('/', writeIntegrationsRateLimit, auth, async (req: AuthReq, res: Res if (nextConfig.liveRelay === true) nextConfig.linkedUserId = req.user?.id; 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 + // Discord channel, a Telegram group) takes authority over the POD — its + // creator or an instance admin — not membership. Before the row or any + // Discord webhook exists. `nextConfig` is past the server-owned strip, so a + // body cannot claim a private chat; a Telegram row's chat is judged where + // its code is redeemed (routes/webhooks/telegram.ts). + if (isSurfaceBindType(type)) { + await assertCanBindSurface({ + userId: req.user?.id, pod: targetPod, type, config: nextConfig, + }); + } if (type === 'discord') { - // Authority, then the channel: both before the row or the webhook exist. - // One answer for both refusals, as on the channel list. - // A channel bridge is a pod-level setting: binding one takes authority - // over the POD, the same as the install link (routes/discord.ts - // canManagePod: the pod's creator or an instance admin). Guild authority - // alone is not enough: the prior-binding arm below proves the guild, and - // on its own let a plain member bridge a pod they did not create. - const binder = await User.findById(String(req.user?.id)) as { role?: string } | null; - if (binder?.role !== 'admin' && targetPod.createdBy?.toString() !== String(req.user?.id)) { - return res.status(403).json({ message: 'Only the pod creator can connect Discord to this pod' }); - } + // Guild authority, then the channel. One answer for both refusals, as on + // the channel list. The prior-binding arm proves the guild, not the pod: + // the rule above is what keeps a plain member from bridging a pod. const guildId = String(config.serverId); const install = (req.body as { install?: unknown })?.install; if (!await hasDiscordGuildAuthority(req.user?.id, String(podId), guildId, install) @@ -628,6 +637,7 @@ router.post('/', writeIntegrationsRateLimit, auth, async (req: AuthReq, res: Res } res.status(201).json({ integration, platformIntegration }); } catch (error) { + if (error instanceof SurfaceBindRefusedError) return res.status(403).json(error.body); console.error('Error creating integration:', error); res.status(500).json({ message: 'Server error' }); } @@ -852,6 +862,12 @@ router.patch('/:id', writeIntegrationsRateLimit, auth, async (req: AuthReq, res: if (!activePod || !isConnectorTargetPod(activePod, requesterId)) { return res.status(403).json({ message: 'Access denied' }); } + // Moving a connector bound to a shared chat (F143) binds THAT pod to it. + if (isSurfaceBindType(integration.type)) { + await assertCanBindSurface({ + userId: requesterId, pod: activePod, type: integration.type, config: currentConfig, + }); + } } const requestedGates = config?.gates; if (requestedGates !== undefined) { @@ -926,6 +942,20 @@ router.patch('/:id', writeIntegrationsRateLimit, auth, async (req: AuthReq, res: return res.status(400).json({ message: 'config keys must be field names, not update paths' }); } const nextConfig = incoming ? { ...currentConfig, ...incoming } : currentConfig; + // F143: a write that changes which chat a row points at is a bind, judged + // against the pod the row will serve (the new one when podId moves too). + // Fields that do not select the surface (relay flags, lead, gates) are not. + if (incoming && isSurfaceBindType(integration.type) + && retargetsSurface(integration.type, currentConfig, nextConfig)) { + await assertCanBindSurface({ + userId: requesterId, + // String(): podId is validated as an id string above, and the rule must + // never be handed a query object in its place. + pod: await Pod.findById(String(podId !== undefined ? podId : integration.podId)), + type: integration.type, + config: nextConfig, + }); + } 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). @@ -972,6 +1002,7 @@ router.patch('/:id', writeIntegrationsRateLimit, auth, async (req: AuthReq, res: // pod read withholds (chatId, linkedUserId, relayMap, messageBuffer). return res.json(projectIntegrationForViewer(updated, await resolveConnectorViewer(req.user?.id))); } catch (error) { + if (error instanceof SurfaceBindRefusedError) return res.status(403).json(error.body); console.error('Error updating integration:', error); return res.status(500).json({ message: 'Server error' }); } diff --git a/backend/routes/webhooks/telegram.ts b/backend/routes/webhooks/telegram.ts index 10e7b51be1..3dd96d2470 100644 --- a/backend/routes/webhooks/telegram.ts +++ b/backend/routes/webhooks/telegram.ts @@ -20,6 +20,7 @@ const { // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require const { cloudflareIpRateLimitKeyGenerator } = require('../../middleware/ipRateLimit'); import { applyRelayCommand } from '../../services/chatRelay/chatRelayCore'; +import { assertCanBindSurface, SurfaceBindRefusedError, surfaceAudienceOf } from '../../services/chatRelay/bindSurface'; import type { RelayCommand, RelayCommandOutcome } from '../../services/chatRelay/chatRelayCore'; const router = express.Router({ mergeParams: true }); @@ -185,6 +186,32 @@ const handleEnableCommand = async (chat: any, code: any) => { return; } + // F143: a group, supergroup or channel is read by people outside the pod, so + // binding one takes authority over the pod (its creator or an instance admin). + // The chat type is only known here, so the rule runs here, against the row's + // creator: the code is theirs (a re-mint on /:id/connect-code keeps the row's + // creator, so an admin re-minting a member's row cannot lend it authority). + // The code is not spent, so the same person can still redeem it in a private + // chat with the bot. + const pod = await Pod.findById(integration.podId).lean(); + try { + await assertCanBindSurface({ + userId: integration.createdBy, pod, type: 'telegram', config: { chatId, chatType }, + }); + } catch (error) { + if (!(error instanceof SurfaceBindRefusedError)) throw error; + await telegramService.sendMessage( + botToken, + chatId, + // About the code, not the sender: a pod creator redeeming a code minted on + // a member's row is refused too, and must not be told they lack authority. + "⚠️ This code was made by someone who can't connect a group. " + + "The pod's creator or an admin needs to set up the Telegram connection. " + + 'This code still works in a private chat with this bot.', + ); + return; + } + // `errorMessage: null` because the working bind is the other state of the // field the failure writes, and only a bind knows the connector works again // (wren 73779). `new: true` is what the confirmation below classifies against: @@ -207,7 +234,6 @@ const handleEnableCommand = async (chat: any, code: any) => { }, }, { new: true }); - const pod = await Pod.findById(integration.podId).lean(); const podName = pod?.name || 'your pod'; // Escaped because parse_mode is HTML: a pod named `A ` made Telegram reject @@ -319,6 +345,26 @@ const handlePodSummaryCommand = async (chat: any, integration: any) => { return; } + // F143: /tldr is what a group binding sends OUT — the pod's summary, to + // everyone in the chat. A shared chat bound before the rule by someone without + // authority over the pod gets nothing until the pod's creator reconnects it. + if (surfaceAudienceOf('telegram', integration.config) === 'shared-surface') { + const pod = await Pod.findById(integration.podId).lean(); + try { + await assertCanBindSurface({ + userId: integration.createdBy, pod, type: 'telegram', config: integration.config, + }); + } catch (error) { + if (!(error instanceof SurfaceBindRefusedError)) throw error; + await telegramService.sendMessage( + botToken, + chatId, + "Only the pod's creator can share this pod's summary with a group or channel. Ask them to reconnect it.", + ); + return; + } + } + const latestSummary = await Summary.findOne({ type: 'chats', podId: integration.podId, diff --git a/backend/services/chatRelay/bindSurface.ts b/backend/services/chatRelay/bindSurface.ts new file mode 100644 index 0000000000..9f277fa18a --- /dev/null +++ b/backend/services/chatRelay/bindSurface.ts @@ -0,0 +1,173 @@ +// Who may bind a pod to an external chat surface (F143, Cofounder 11-04). +// +// One rule, in one place, for every chat bridge: +// - a SHARED surface (a Slack channel, a Discord guild channel, a Telegram +// group, supergroup or channel, a GroupMe group) is read by people outside the pod, so +// binding one takes authority over the POD: its creator (still a listed +// member) or an instance admin (`User.role === 'admin'`). Plain membership +// is not enough; +// - a PRIVATE 1:1 chat with the bot is read only by the person who bound it, +// so it stays member-level. Membership itself is the caller's own check +// (`isConnectorTargetPod`); this rule only adds the shared-surface clause. +// +// Each platform declares its audience below. It is the same knowledge the +// bridges' `isRelayableIntegration` encodes (Telegram relays only `private`, +// Slack only `im`), kept here rather than on the relay adapters so the bind +// paths can read it without loading a bridge. `chatRelayIntegrationTypes()` +// must be a subset of these keys; a unit test holds that. +// +// Fail closed: an unknown type, or a chat type the declaration does not name as +// private, is a shared surface. + +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const { isListedPodMember } = require('../../utils/isPodMember'); + +export type SurfaceAudience = 'private-1to1' | 'shared-surface'; + +type SurfaceConfig = Record; + +export interface SurfaceBindDeclaration { + /** Who can read the chat this config names. */ + audience: (config: SurfaceConfig) => SurfaceAudience; + /** + * Where the surface is chosen. `request`: the create request names it, so + * creating the row is the bind. `redemption`: a code is redeemed in the chat + * itself (Telegram), so a row with no chat yet has nothing to judge and the + * rule runs when the code is redeemed. + */ + chosenAt: 'request' | 'redemption'; + /** Config keys that select the surface; changing one on a live row is a retarget. */ + surfaceFields: string[]; +} + +export const surfaceBindDeclarations: Record = { + telegram: { + audience: (config) => (config.chatType === 'private' ? 'private-1to1' : 'shared-surface'), + chosenAt: 'redemption', + surfaceFields: ['chatId', 'chatType'], + }, + // The OAuth bind (routes/installables.ts) only ever writes the installer's + // own DM (`im`). A legacy per-row connector names a `channelId`, and the + // legacy webhook route buffers that channel into the pod, so a channel id + // makes the row shared even beside an `im`. + slack: { + audience: (config) => (config.chatType === 'im' && !config.channelId ? 'private-1to1' : 'shared-surface'), + chosenAt: 'request', + surfaceFields: ['chatId', 'chatType', 'channelId'], + }, + // Every Discord connector is a guild channel. + discord: { + audience: () => 'shared-surface', + chosenAt: 'request', + surfaceFields: ['serverId', 'channelId'], + }, + // A GroupMe connector bridges a group chat. `botId` counts as a surface field + // because the bot is what posts, and a bot belongs to one group. + groupme: { + audience: () => 'shared-surface', + chosenAt: 'request', + surfaceFields: ['groupId', 'botId'], + }, +}; + +export const SHARED_SURFACE_REFUSAL = { + code: 'shared_surface_needs_pod_authority', + message: 'Only the pod creator can connect a shared channel to this pod', +} as const; + +export class SurfaceBindRefusedError extends Error { + readonly status = 403; + + readonly body = { ...SHARED_SURFACE_REFUSAL }; + + constructor() { + super(SHARED_SURFACE_REFUSAL.message); + this.name = 'SurfaceBindRefusedError'; + } +} + +/** Types whose binds this rule governs. */ +export const isSurfaceBindType = (type: unknown): boolean => ( + typeof type === 'string' && Object.prototype.hasOwnProperty.call(surfaceBindDeclarations, type) +); + +const declarationFor = (type: unknown): SurfaceBindDeclaration | null => ( + isSurfaceBindType(type) ? surfaceBindDeclarations[type as string] : null +); + +/** Who can read the surface. Unknown type or chat type: shared. */ +export const surfaceAudienceOf = (type: unknown, config: SurfaceConfig | null | undefined): SurfaceAudience => { + const declaration = declarationFor(type); + if (!declaration) return 'shared-surface'; + return declaration.audience(config || {}) === 'private-1to1' ? 'private-1to1' : 'shared-surface'; +}; + +/** True when this write changes which external chat a live row points at. */ +export const retargetsSurface = ( + type: unknown, + before: SurfaceConfig | null | undefined, + after: SurfaceConfig | null | undefined, +): boolean => { + const fields = declarationFor(type)?.surfaceFields; + // Unknown type: any config write could be a retarget. + if (!fields) return true; + return fields.some((field) => String(before?.[field] ?? '') !== String(after?.[field] ?? '')); +}; + +const idOf = (value: unknown): string => { + if (!value) return ''; + const nested = (value as { _id?: unknown })._id; + return String(nested ?? value); +}; + +/** The pod's creator, still a listed member (TASK-168), or an instance admin. */ +export const hasPodAuthority = ( + user: { _id?: unknown; id?: unknown; role?: string } | null | undefined, + pod: { createdBy?: unknown; members?: unknown[] } | null | undefined, +): boolean => { + if (!user || !pod) return false; + if (user.role === 'admin') return true; + const userId = idOf(user._id ?? user.id); + return !!userId && idOf(pod.createdBy) === userId && isListedPodMember(pod, userId); +}; + +/** Pure form of the rule, for a caller that already holds the user document. */ +export const canBindSurface = ({ + user, pod, type, config, +}: { + user: { _id?: unknown; id?: unknown; role?: string } | null | undefined; + pod: { createdBy?: unknown; members?: unknown[] } | null | undefined; + type: unknown; + config: SurfaceConfig | null | undefined; +}): boolean => { + const declaration = declarationFor(type); + // No chat chosen yet: nothing to judge until the code is redeemed. + if (declaration?.chosenAt === 'redemption' && !config?.chatId) return true; + if (surfaceAudienceOf(type, config) === 'private-1to1') return true; + return hasPodAuthority(user, pod); +}; + +/** + * Throws `SurfaceBindRefusedError` (403) when `userId` may not bind `pod` to + * the surface `config` names. The user is read from the database: the auth + * middleware sets `role` only on the JWT branch. + */ +export const assertCanBindSurface = async ({ + userId, pod, type, config, +}: { + userId: unknown; + pod: { createdBy?: unknown; members?: unknown[] } | null | undefined; + type: unknown; + config: SurfaceConfig | null | undefined; +}): Promise => { + // Answer without a lookup when the audience settles it. + if (canBindSurface({ + user: null, pod, type, config, + })) return; + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const User = require('../../models/User'); + const user = userId ? await User.findById(String(userId)) as { _id?: unknown; role?: string } | null : null; + if (!canBindSurface({ + user: user ? { _id: user._id ?? userId, role: user.role } : null, pod, type, config, + })) throw new SurfaceBindRefusedError(); +}; diff --git a/backend/services/discordCommandService.ts b/backend/services/discordCommandService.ts index 2e11d125f1..f18f5177fd 100644 --- a/backend/services/discordCommandService.ts +++ b/backend/services/discordCommandService.ts @@ -6,6 +6,8 @@ const Summary = require('../models/Summary'); const DiscordService = require('./discordService'); // eslint-disable-next-line global-require const summarizerService = require('./summarizerService'); +const Pod = require('../models/Pod'); +import { assertCanBindSurface, SurfaceBindRefusedError } from './chatRelay/bindSurface'; interface CommandServiceOptions { guildId?: string; @@ -16,6 +18,11 @@ interface CommandServiceOptions { interface CommandResult { success: boolean; content: string; + /** + * A shared-surface bind-rule refusal (F143): not an error, and its text is + * not sensitive, so a combined multi-pod reply may stay public around it. + */ + refused?: boolean; } interface SyncResult { @@ -42,6 +49,7 @@ interface SummaryLike { interface IntegrationDoc { _id?: unknown; + createdBy?: unknown; podId?: { name?: string; type?: string } | null; status?: string; lastSync?: unknown; @@ -146,9 +154,29 @@ class DiscordCommandService { }; } + // F143: the summary goes to everyone in the guild channel, so the row + // must have been bound by someone with authority over the pod (its + // creator or an admin). Rows plain members bound before #3436 get this + // refusal and no summary until the pod's creator reconnects them. + const podRef = this.integration.podId as unknown as { _id?: unknown } | null; + const podId = podRef?._id ?? podRef; + const pod = await Pod.findById(podId).lean(); + try { + await assertCanBindSurface({ + userId: this.integration.createdBy, pod, type: 'discord', config: this.integration.config || {}, + }); + } catch (error) { + if (!(error instanceof SurfaceBindRefusedError)) throw error; + return { + success: false, + refused: true, + content: "Only the pod's creator can share this pod's summary with a channel. Ask them to reconnect it.", + }; + } + const latestSummary = await Summary.findOne({ type: 'chats', - podId: this.integration.podId, + podId, }).sort({ createdAt: -1 }) as SummaryLike | null; if (!latestSummary) { diff --git a/backend/services/discordMultiCommandService.ts b/backend/services/discordMultiCommandService.ts index dd4761e797..a4412d5729 100644 --- a/backend/services/discordMultiCommandService.ts +++ b/backend/services/discordMultiCommandService.ts @@ -11,6 +11,8 @@ interface Integration { interface CommandResult { success: boolean; content: string; + /** A bind-rule refusal (F143), not an error; see `combinedReplyIsPublic`. */ + refused?: boolean; [key: string]: unknown; } @@ -128,4 +130,15 @@ async function runDiscordCommandForIntegrations({ return results; } -export { runCommandForIntegration, runDiscordCommandForIntegrations }; +/** + * Whether the one reply that folds several pods' results is public. A bind-rule + * refusal (F143) carries only a short, non-sensitive sentence, so it does not + * make the allowed pods' summaries private; a real error still does. A reply + * with no success at all stays ephemeral. + */ +const combinedReplyIsPublic = (results: Array<{ result: { success: boolean; refused?: boolean } }>): boolean => ( + results.some(({ result }) => result.success) + && results.every(({ result }) => result.success || result.refused === true) +); + +export { runCommandForIntegration, runDiscordCommandForIntegrations, combinedReplyIsPublic }; diff --git a/docs/integrations/INTEGRATION_CONTRACT.md b/docs/integrations/INTEGRATION_CONTRACT.md index 48a11d5440..c915901ab1 100644 --- a/docs/integrations/INTEGRATION_CONTRACT.md +++ b/docs/integrations/INTEGRATION_CONTRACT.md @@ -81,6 +81,39 @@ for summarization. Issue an `cm_int_*` ingest token through - Rate-limit webhook routes; strip PII beyond what’s needed for summaries. - Store secrets encrypted (reuse existing config patterns). +## Who may bind a pod to a chat (F143) +A chat that people outside the pod can read is a **shared surface**: a Slack +channel, a Discord guild channel, a Telegram group, supergroup or channel, and +a GroupMe group. Binding a pod to one takes authority over the pod: the pod's +creator (still a listed member) or an instance admin (`User.role === 'admin'`). +Pod membership is not enough. A **private 1:1** chat with the bot (a Telegram +`private` chat, the Slack OAuth DM) is read only by the person who bound it and +stays member-level. + +The rule lives in one place, `backend/services/chatRelay/bindSurface.ts`. Each +platform declares there who can read its chat; an unknown type, or a chat type +not declared private, counts as shared. The server refuses with 403 +`{ code: 'shared_surface_needs_pod_authority' }`, and the v2 pages map that code +to `connectors.sharedSurfaceCreatorOnly` (en and zh-CN). + +It is checked at: +- **create**: `POST /api/integrations` (Slack, Discord, GroupMe; a non-string + `type` is refused with 400 first), and Discord's + `GET /api/discord/install-link/:podId`; +- **retarget**: `PATCH /api/integrations/:id`, when a field that picks the chat + changes (Slack `channelId`, GroupMe `groupId`/`botId`; Discord's are fixed by + F90), or when a user-scoped connector's `podId` moves; +- **redemption**: Telegram's `/commonly-enable`, because the chat type is only + known there. It is judged against the row's `createdBy`, and a refused code is + left unspent for a private chat; +- **outbound summaries**, judged against the row's `createdBy`: Telegram + `/tldr` (`/pod_summary`), GroupMe `!pod`, and Discord `/commonly-summary`. + Each posts the pod's latest summary to everyone in the chat. + +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. + ## Testing contract - Shared Jest contract tests under `backend/__tests__/contracts/integrationProvider.test.js`: - validates `ingestEvent` produces required fields diff --git a/docs/integrations/README.md b/docs/integrations/README.md index a473326a1d..a0a55aa31f 100644 --- a/docs/integrations/README.md +++ b/docs/integrations/README.md @@ -11,6 +11,7 @@ Contents: - Code scaffold lives in `packages/integration-sdk/` for open-sourcing the contract and registry. - Integration catalog API: `GET /api/integrations/catalog` (manifest-driven metadata + per-user stats). - Integration create/update routes (`POST /api/integrations`, `PATCH /api/integrations/:id`) enforce manifest-required fields when marking an integration as connected; drafts remain pending until required config is present. +- Binding a pod to a shared chat (a Slack or Discord channel, a Telegram or GroupMe group) takes the pod's creator or an instance admin; a private 1:1 chat with the bot stays member-level. See "Who may bind a pod to a chat" in `INTEGRATION_CONTRACT.md`. - Integration summarization persists indexed pod memory via `PodAsset` so `/api/pods/:id/context` and LLM skill synthesis can reuse integration activity as structured context. - External provider services should forward to `POST /api/integrations/ingest` with `{ provider, integrationId, event | messages }`. - Issue ingest tokens via `POST /api/integrations/:id/ingest-tokens` and use them as `Authorization: Bearer cm_int_...`. diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 65c40802ab..810e873e5b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2522,6 +2522,7 @@ "availableTelegram": "one message", "availableDiscord": "add the bot to your server", "linkInstallCreatorOnly": "Only the person who created this pod can add the {{connector}} bot to it.", + "sharedSurfaceCreatorOnly": "Only the pod creator can connect a shared channel to this pod.", "cancel": "Cancel", "channelDetails": "Channel details", "closeDetails": "Close", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 3743b91784..31dee51427 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2510,6 +2510,7 @@ "availableTelegram": "发一条消息", "availableDiscord": "把机器人加进你的服务器", "linkInstallCreatorOnly": "只有这个 Pod 的创建者才能把 {{connector}} 机器人加进来。", + "sharedSurfaceCreatorOnly": "只有这个 Pod 的创建者才能把共享频道连接进来。", "cancel": "取消", "channelDetails": "频道详情", "closeDetails": "关闭", diff --git a/frontend/src/v2/__tests__/ConnectorsPage.channels.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.channels.test.tsx index 94ae37365f..11809462b1 100644 --- a/frontend/src/v2/__tests__/ConnectorsPage.channels.test.tsx +++ b/frontend/src/v2/__tests__/ConnectorsPage.channels.test.tsx @@ -242,6 +242,40 @@ describe('Connectors page: channels', () => { )); }); + // F143: a PATCH that would point a connector at a shared chat is refused with + // a code; the page names that refusal in the reader's language. Any other + // refusal keeps the action's own copy. + it.each([ + ['en', 'Only the pod creator can connect a shared channel to this pod.'], + ['zh-CN', '只有这个 Pod 的创建者才能把共享频道连接进来。'], + ])('names a shared-surface refusal from a PATCH in %s', async (lang, expected) => { + await i18nReady; + await act(async () => { await i18n.changeLanguage(lang); }); + try { + mockGets(); + axios.patch.mockRejectedValue({ + response: { status: 403, data: { code: 'shared_surface_needs_pod_authority', message: 'Server wording.' } }, + }); + renderPage(); + const drawer = await openDrawer('Slack'); + fireEvent.click(within(drawer).getAllByRole('checkbox')[0]); + expect(await screen.findByText(expected)).toBeInTheDocument(); + } finally { + await act(async () => { await i18n.changeLanguage('en'); }); + } + }); + + it('keeps the action\'s own copy for a PATCH refusal with any other code', async () => { + mockGets(); + axios.patch.mockRejectedValue({ response: { status: 403, data: { message: 'Access denied' } } }); + renderPage(); + const drawer = await openDrawer('Slack'); + fireEvent.click(within(drawer).getByRole('checkbox', { name: 'Relay' })); + await waitFor(() => expect(axios.patch).toHaveBeenCalled()); + expect(await screen.findByText(/Could not/)).toBeInTheDocument(); + expect(screen.queryByText('Only the pod creator can connect a shared channel to this pod.')).toBeNull(); + }); + it('PATCHes the mirror mode from the channel drawer', async () => { mockGets([{ ...connectors[1], diff --git a/frontend/src/v2/__tests__/V2DiscordCallback.test.tsx b/frontend/src/v2/__tests__/V2DiscordCallback.test.tsx index ad984ea99b..2808f9e482 100644 --- a/frontend/src/v2/__tests__/V2DiscordCallback.test.tsx +++ b/frontend/src/v2/__tests__/V2DiscordCallback.test.tsx @@ -154,6 +154,30 @@ describe('V2DiscordCallback (d215 s-handoff)', () => { expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); }); + // F143: binding a Discord channel takes authority over the pod. The server's + // refusal carries a code, and the page says it in the reader's language. + test.each([ + ['en', 'Only the pod creator can connect a shared channel to this pod.'], + ['zh-CN', '只有这个 Pod 的创建者才能把共享频道连接进来。'], + ])('a shared-surface refusal reads in %s, not as the server text', async (lang, expected) => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + await act(async () => { await i18n.changeLanguage(lang); }); + axios.get.mockResolvedValueOnce({ data: [{ id: 'c1', name: 'general' }] }); + axios.post.mockRejectedValueOnce({ + response: { + status: 403, + data: { code: 'shared_surface_needs_pod_authority', message: 'Server wording the page must not show.' }, + }, + }); + renderAt('/discord/success?pod_id=p1&guild_id=g1&server_name=S'); + const select = await screen.findByRole('combobox'); + await screen.findByRole('option', { name: '#general' }); + fireEvent.change(select, { target: { value: 'c1' } }); + await act(async () => { fireEvent.click(screen.getAllByRole('button').at(-1)); }); + expect(screen.getByRole('alert')).toHaveTextContent(expected); + expect(screen.getByRole('alert')).not.toHaveTextContent('Server wording'); + }); + // The pages are reached through the route tables, which pick the view by prop. // A swapped success/error mapping would render the wrong page for the backend's // redirect, so both tables are read and their mapping pinned. diff --git a/frontend/src/v2/components/V2DiscordCallback.tsx b/frontend/src/v2/components/V2DiscordCallback.tsx index 643a600778..e782b1937b 100644 --- a/frontend/src/v2/components/V2DiscordCallback.tsx +++ b/frontend/src/v2/components/V2DiscordCallback.tsx @@ -6,6 +6,7 @@ import V2AuthBrand, { V2HandoffState } from './V2AuthBrand'; import V2AuthNotice from './V2AuthNotice'; import { ShellGlyph } from '../icons/glyphs'; import { PlatformGlyph } from '../icons/platforms'; +import { sharedSurfaceRefusal } from '../connectors/channelModel'; // The notice's role, kept out of JSX for the i18n literal rule. const STATUS = 'status' as const; @@ -132,8 +133,11 @@ const V2DiscordCallback: React.FC = ({ type = 'callback' // The status only, as for the channel list: the request body now carries // the install proof (F90). console.error('Error creating integration:', (err as { response?: { status?: number } })?.response?.status ?? 'no response'); - const e = err as { response?: { data?: { message?: string } } }; - setError(e.response?.data?.message || t('auth.discordHandoff.createFailed')); + const e = err as { response?: { data?: { message?: string; code?: string } } }; + // A known refusal reads in the page's language (F143); any other keeps the + // server's sentence. + setError(sharedSurfaceRefusal(t, e.response?.data) + || e.response?.data?.message || t('auth.discordHandoff.createFailed')); } finally { setLoading(false); } diff --git a/frontend/src/v2/connectors/channelModel.ts b/frontend/src/v2/connectors/channelModel.ts index 4fc4fad7b5..02247bdb1b 100644 --- a/frontend/src/v2/connectors/channelModel.ts +++ b/frontend/src/v2/connectors/channelModel.ts @@ -228,6 +228,24 @@ export const installErrorResponse = (error: unknown): { status?: number; data?: (error as { response?: { status?: number; data?: InstallErrorResponse } })?.response || {} ); +// F143: binding a pod to a chat people outside it can read (a Slack or Discord +// channel, a Telegram or GroupMe group) takes the pod's creator or an admin. +// The server refuses with this code from POST and PATCH /api/integrations +// (backend/services/chatRelay/bindSurface.ts); the page says it in the reader's +// language. Null for any other refusal, so each caller keeps its own fallback. +export const SHARED_SURFACE_REFUSAL_CODE = 'shared_surface_needs_pod_authority'; + +export const sharedSurfaceRefusal = ( + t: (key: string, options: { defaultValue: string }) => string, + data: { code?: unknown } | null | undefined, +): string | null => ( + data?.code === SHARED_SURFACE_REFUSAL_CODE + ? t('connectors.sharedSurfaceCreatorOnly', { + defaultValue: 'Only the pod creator can connect a shared channel to this pod.', + }) + : null +); + // Row C: the Slack authorize and bind verbs refuse with a `code` the server // already sends (`slackError` in backend/routes/installables.ts), so the row // can say which refusal it was rather than one generic apology. An unlisted diff --git a/frontend/src/v2/connectors/useChannelConnectors.ts b/frontend/src/v2/connectors/useChannelConnectors.ts index e3d7b82d64..8ff03c1d69 100644 --- a/frontend/src/v2/connectors/useChannelConnectors.ts +++ b/frontend/src/v2/connectors/useChannelConnectors.ts @@ -20,6 +20,7 @@ import { TRANSIENT_STATUSES, installableLifecyclePath, installErrorResponse, + sharedSurfaceRefusal, SLACK_REFUSALS, connectorPodId, defaultPodForChannel, @@ -475,8 +476,9 @@ export const useChannelConnectors = () => { try { await api.patch(`/api/integrations/${connector._id}`, body); await load(); - } catch { - setError(t(errorKey, { defaultValue: errorDefault })); + } catch (requestError) { + setError(sharedSurfaceRefusal(t, installErrorResponse(requestError).data) + || t(errorKey, { defaultValue: errorDefault })); } finally { setBusyId(null); }