Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions backend/__tests__/service/pods.community-scope.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
process.env.PG_HOST = '';
process.env.JWT_SECRET = 'community-scope-test-secret';

// This suite exercises route ownership and controller filtering, not JWT
// cryptography. Keep it runnable on Node 26, where jsonwebtoken's transitive
// SlowBuffer dependency is not yet compatible.
jest.mock('jsonwebtoken', () => ({
sign: ({ id }) => `test-token:${id}`,
verify: (token) => ({ id: token.replace('test-token:', '') }),
}));

const express = require('express');
const jwt = require('jsonwebtoken');
const request = require('supertest');
const Pod = require('../../models/Pod');
const User = require('../../models/User');
const podRoutes = require('../../routes/pods');
const {
setupMongoDb,
closeMongoDb,
clearMongoDb,
} = require('../utils/testUtils');

describe('GET /api/pods community scope', () => {
let app;
let viewer;
let otherUser;
let viewerToken;
let memberPod;
let communityPod;
let privatePod;
let forcedPersonalPods;

beforeAll(async () => {
await setupMongoDb();

app = express();
app.use(express.json());
app.use('/api/pods', podRoutes);

viewer = await User.create({
username: 'community-viewer',
email: 'community-viewer@test.com',
password: 'Password123!',
isVerified: true,
});
otherUser = await User.create({
username: 'community-owner',
email: 'community-owner@test.com',
password: 'Password123!',
isVerified: true,
});
viewerToken = jwt.sign({ id: viewer._id }, process.env.JWT_SECRET, { expiresIn: '1h' });

memberPod = await Pod.create({
name: 'My private team',
type: 'team',
createdBy: viewer._id,
members: [viewer._id],
publicRead: false,
});
communityPod = await Pod.create({
name: 'Public community pod',
type: 'team',
createdBy: otherUser._id,
members: [otherUser._id],
publicRead: true,
});
privatePod = await Pod.create({
name: 'Other private team',
type: 'team',
createdBy: otherUser._id,
members: [otherUser._id],
publicRead: false,
});
forcedPersonalPods = await Promise.all(['agent-room', 'agent-dm', 'agent-admin'].map((type) => (
Pod.create({
name: `Forced public ${type}`,
type,
createdBy: otherUser._id,
members: [otherUser._id],
// These rows bypass the admin toggle deliberately. The discovery
// query must remain safe even if legacy/manual data is malformed.
publicRead: true,
})
)));
});

afterAll(async () => {
await clearMongoDb();
await closeMongoDb();
});

it('returns non-member public pods while excluding every personal pod type', async () => {
const res = await request(app)
.get('/api/pods?scope=community')
.set('Authorization', `Bearer ${viewerToken}`);

expect(res.status).toBe(200);
const ids = res.body.map((pod) => pod._id);
expect(ids).toEqual([communityPod._id.toString()]);
expect(ids).not.toContain(privatePod._id.toString());
forcedPersonalPods.forEach((pod) => {
expect(ids).not.toContain(pod._id.toString());
});
});

it('keeps the default listing membership-only for the same fixtures', async () => {
const res = await request(app)
.get('/api/pods')
.set('Authorization', `Bearer ${viewerToken}`);

expect(res.status).toBe(200);
expect(res.body.map((pod) => pod._id)).toEqual([memberPod._id.toString()]);
expect(res.body.map((pod) => pod._id)).not.toContain(communityPod._id.toString());
});

it('still requires authentication', async () => {
const res = await request(app).get('/api/pods?scope=community');

expect(res.status).toBe(401);
});
});
19 changes: 16 additions & 3 deletions backend/controllers/podController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ if (process.env.PG_HOST) {
}

const VALID_POD_TYPES = ['chat', 'study', 'games', 'agent-ensemble', 'agent-admin', 'agent-room', 'team'];
const COMMUNITY_EXCLUDED_POD_TYPES = ['agent-room', 'agent-dm', 'agent-admin'];
const DEFAULT_POD_AGENT = process.env.DEFAULT_POD_AGENT_NAME || 'commonly-bot';
const DEFAULT_POD_AGENT_SCOPES = [
'context:read',
Expand Down Expand Up @@ -156,9 +157,22 @@ const installDefaultAgentForPod = async ({ pod, userId }: { pod: any; userId: an
exports.getAllPods = async (req: any, res: any) => {
try {
const { type } = req.query;
const scope = String(req.query?.scope || 'mine').toLowerCase();
const isCommunityScope = scope === 'community';
// Exclude agent-admin DM pods from default listing; only show when
// explicitly requested and the caller is a member.
const query = type ? { type } : { type: { $ne: 'agent-admin' } };
// Community is an explicit, additive discovery scope. Personal pod types
// stay excluded even if a malformed/admin-created row has publicRead=true.
// Keep the default query semantically identical to the privacy-hardened
// membership listing below.
const query = isCommunityScope
? {
publicRead: true,
type: type
? { $eq: type, $nin: COMMUNITY_EXCLUDED_POD_TYPES }
: { $nin: COMMUNITY_EXCLUDED_POD_TYPES },
}
: (type ? { type } : { type: { $ne: 'agent-admin' } });

let pods = await Pod.find(query)
.populate('createdBy', 'username profilePicture')
Expand All @@ -184,9 +198,8 @@ exports.getAllPods = async (req: any, res: any) => {
// their own pod list must be their own pods, otherwise every
// private DM in the instance leaks into their sidebar (which made
// xcjsam see — and try to post into — sam-demo's agent-rooms).
const scope = String(req.query?.scope || 'mine').toLowerCase();
const isPersonal = type === 'agent-admin' || type === 'agent-room' || type === 'agent-dm';
if (req.userId) {
if (req.userId && !isCommunityScope) {
const wantsAll = scope === 'all';
const isAdmin = wantsAll ? await isGlobalAdminRequest(req) : false;
const filterToMine = isPersonal || !wantsAll || !isAdmin;
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1242,7 +1242,8 @@
"filters": {
"all": "All",
"team": "Team",
"private": "Private"
"private": "Private",
"community": "Community"
},
"groups": {
"pinned": "Pinned",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1238,7 +1238,8 @@
"filters": {
"all": "全部",
"team": "团队",
"private": "私密"
"private": "私密",
"community": "社区"
},
"groups": {
"pinned": "已置顶",
Expand Down
64 changes: 60 additions & 4 deletions frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
// @ts-nocheck
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import i18n, { i18nReady } from '../../i18n';
import V2PodsSidebar from '../components/V2PodsSidebar';

const COMMUNITY_POD_ID = 'community-pod';
const mockJoinPod = jest.fn();
const mockSeedFromExisting = jest.fn();
const mockApiGet = jest.fn();
const mockApi = {
get: mockApiGet,
post: jest.fn(),
patch: jest.fn(),
del: jest.fn(),
};

jest.mock('../hooks/useV2Pods', () => ({
useV2Pods: () => ({
Expand All @@ -26,6 +34,10 @@ jest.mock('../hooks/useV2Pinned', () => ({
}),
}));

jest.mock('../hooks/useV2Api', () => ({
useV2Api: () => mockApi,
}));

jest.mock('../hooks/useV2Unread', () => ({
useV2Unread: () => ({
isUnread: () => false,
Expand All @@ -44,13 +56,14 @@ jest.mock('../../context/AuthContext', () => ({

const CurrentPath = () => <div data-testid="current-path">{useLocation().pathname}</div>;

const makePod = (id: string, memberIds: string[]) => ({
const makePod = (id: string, memberIds: string[], overrides = {}) => ({
_id: id,
name: id === COMMUNITY_POD_ID ? 'Commonly HQ' : 'My Workspace',
type: 'team',
members: memberIds.map((_id) => ({ _id, username: _id, isBot: false })),
createdAt: '2026-07-21T12:00:00.000Z',
updatedAt: '2026-07-21T12:00:00.000Z',
...overrides,
});

const renderSidebar = (pods) => {
Expand All @@ -72,17 +85,26 @@ const renderSidebar = (pods) => {
describe('V2PodsSidebar Community offer', () => {
const originalCommunityPodId = process.env.REACT_APP_COMMUNITY_POD_ID;

beforeEach(() => {
beforeAll(async () => {
await i18nReady;
});

beforeEach(async () => {
jest.clearAllMocks();
mockApiGet.mockResolvedValue([]);
process.env.REACT_APP_COMMUNITY_POD_ID = COMMUNITY_POD_ID;
await act(async () => {
await i18n.changeLanguage('en');
});
});

afterAll(() => {
afterAll(async () => {
if (originalCommunityPodId === undefined) {
delete process.env.REACT_APP_COMMUNITY_POD_ID;
} else {
process.env.REACT_APP_COMMUNITY_POD_ID = originalCommunityPodId;
}
await i18n.changeLanguage('en');
});

test('shows for a configured Community pod the human has not joined and navigates to the redirect', () => {
Expand All @@ -108,4 +130,38 @@ describe('V2PodsSidebar Community offer', () => {

expect(screen.queryByRole('button', { name: 'Join HQ' })).not.toBeInTheDocument();
});

test('Community shows public discovery and HQ, excludes personal pods, and leaves All personal', async () => {
mockApiGet.mockResolvedValue([
makePod('public-space', [], { name: 'Open Builders', publicRead: true }),
makePod(COMMUNITY_POD_ID, [], { publicRead: true }),
makePod('forced-public-dm', [], {
name: 'Private agent room',
type: 'agent-room',
publicRead: true,
}),
]);
renderSidebar([makePod('workspace', ['human-1'])]);

fireEvent.click(screen.getByRole('button', { name: 'Community' }));
expect(await screen.findByText('Open Builders')).toBeInTheDocument();
expect(screen.getByText('Commonly HQ')).toBeInTheDocument();
expect(screen.queryByText('Private agent room')).not.toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: 'All' }));
expect(screen.getByText('My Workspace')).toBeInTheDocument();
expect(screen.queryByText('Open Builders')).not.toBeInTheDocument();
expect(screen.queryByText('Commonly HQ')).not.toBeInTheDocument();
await waitFor(() => expect(mockApiGet).toHaveBeenCalledWith('/api/pods?scope=community'));
});

test('renders the Community tab from both locale catalogs', async () => {
renderSidebar([makePod('workspace', ['human-1'])]);
expect(screen.getByRole('button', { name: 'Community' })).toBeInTheDocument();

await act(async () => {
await i18n.changeLanguage('zh-CN');
});
expect(screen.getByRole('button', { name: '社区' })).toBeInTheDocument();
});
});
10 changes: 10 additions & 0 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(ruleBody(v2, '.v2-pods__community')).toContain('flex-shrink: 0');
});

test('the four pod filters stay on one row in the narrow sidebar', () => {
// The desktop sidebar leaves only ~240px inside its gutter (less than the
// mobile drawer). A four-column grid keeps Community beside the existing
// filters without horizontal scrolling or wrapping in either locale.
const rule = ruleBody(v2, '.v2-pods__filters');
expect(rule).toContain('display: grid');
expect(rule).toContain('grid-template-columns: minmax(0, 0.75fr) minmax(0, 1fr) minmax(0, 1.15fr) minmax(0, 1.7fr)');
expect(rule).toContain('overflow: visible');
});

test('starter prompts wrap within the mobile chat pane', () => {
// At 390px the rail leaves a narrow main pane. Both the row and each chip
// need explicit shrink/wrap rules or the longest prompt creates horizontal
Expand Down
Loading
Loading