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);
}