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
4 changes: 2 additions & 2 deletions backend/__tests__/service/pods.community-scope.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ describe('GET /api/pods community scope', () => {
});
});

it('discovers only listed, readable, joinable pods the caller has not joined', async () => {
it('discovers only listed, readable, directly joinable pods the caller has not joined', async () => {
const res = await request(app)
.get('/api/pods?scope=discover')
.set('Authorization', `Bearer ${viewerToken}`);
Expand All @@ -178,8 +178,8 @@ describe('GET /api/pods community scope', () => {
studyCommunityPod._id.toString(),
]));
expect(ids).toHaveLength(2);
expect(ids).not.toContain(joinedCommunityPod._id.toString());
expect(ids).not.toContain(inviteOnlyPod._id.toString());
expect(ids).not.toContain(joinedCommunityPod._id.toString());
expect(ids).not.toContain(showcasePod._id.toString());
expect(ids).not.toContain(nonPublicListedPod._id.toString());
forcedPersonalPods.forEach((pod) => {
Expand Down
135 changes: 135 additions & 0 deletions backend/__tests__/service/pods.discover-join.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ const jwt = require('jsonwebtoken');
const request = require('supertest');
const Pod = require('../../models/Pod');
const User = require('../../models/User');
const AuditLog = require('../../models/AuditLog');
const { PodInvite } = require('../../models/PodInvite');
const podRoutes = require('../../routes/pods');
const podInvitesRoutes = require('../../routes/podInvites');
const adminPodsRoutes = require('../../routes/admin/pods');
const {
setupMongoDb,
closeMongoDb,
Expand All @@ -36,6 +38,7 @@ describe('POST /api/pods/:id/join discovery gate', () => {
// catch-all so /api/pods/:podId/invites remains reachable (#712).
app.use('/api', podInvitesRoutes);
app.use('/api/pods', podRoutes);
app.use('/api/admin/pods', adminPodsRoutes);
});

beforeEach(async () => {
Expand Down Expand Up @@ -83,6 +86,16 @@ describe('POST /api/pods/:id/join discovery gate', () => {
.post(`/api/pods/${podId}/join`)
.set('Authorization', `Bearer ${token}`);

const setListing = (podId, communityListed, token = adminToken) => request(app)
.post(`/api/admin/pods/${podId}/listing`)
.set('Authorization', `Bearer ${token}`)
.send({ communityListed });

const setShowcase = (podId, publicRead, token = adminToken) => request(app)
.post(`/api/admin/pods/${podId}/showcase`)
.set('Authorization', `Bearer ${token}`)
.send({ publicRead });

it('joins a listed open pod atomically and makes repeat joins idempotent', async () => {
const pod = await createPod();

Expand All @@ -105,6 +118,16 @@ describe('POST /api/pods/:id/join discovery gate', () => {
expect(fresh.members.map(String)).not.toContain(String(viewer._id));
});

it('refuses direct self-join to a listed pod that is not publicly readable', async () => {
const pod = await createPod({ publicRead: false, communityListed: true });

const res = await join(pod._id).expect(403);

expect(res.body.code).toBe('join_refused');
const fresh = await Pod.findById(pod._id).lean();
expect(fresh.members.map(String)).not.toContain(String(viewer._id));
});

it('returns 200 for an existing member even when the pod is not directly joinable', async () => {
const pod = await createPod({
communityListed: false,
Expand All @@ -127,6 +150,19 @@ describe('POST /api/pods/:id/join discovery gate', () => {
expect(res.body.code).toBe('join_refused');
});

it('keeps a listed invite-only pod out of Discover and refuses direct self-join', async () => {
const pod = await createPod({ joinPolicy: 'invite-only' });

const discover = await request(app)
.get('/api/pods?scope=discover')
.set('Authorization', `Bearer ${viewerToken}`)
.expect(200);

expect(discover.body.map((candidate) => candidate._id)).not.toContain(String(pod._id));
const joinResponse = await join(pod._id).expect(403);
expect(joinResponse.body.code).toBe('join_refused');
});

it('keeps the personal-DM refusal ahead of discovery eligibility', async () => {
const pod = await createPod({ type: 'agent-dm' });

Expand Down Expand Up @@ -158,4 +194,103 @@ describe('POST /api/pods/:id/join discovery gate', () => {
const fresh = await Pod.findById(pod._id).lean();
expect(fresh.members.map(String)).toContain(String(viewer._id));
});

describe('admin community listing toggle', () => {
it('requires a global admin', async () => {
const pod = await createPod({ communityListed: false });

await setListing(pod._id, true, viewerToken).expect(403);

const fresh = await Pod.findById(pod._id).lean();
expect(fresh.communityListed).toBe(false);
});

it('lists and unlists a publicly readable pod with an audit record', async () => {
const pod = await createPod({ communityListed: false });

const listed = await setListing(pod._id, true).expect(200);
expect(listed.body).toEqual({
id: String(pod._id),
publicRead: true,
communityListed: true,
});

const unlisted = await setListing(pod._id, false).expect(200);
expect(unlisted.body.communityListed).toBe(false);

const fresh = await Pod.findById(pod._id).lean();
expect(fresh.communityListed).toBe(false);
const actions = await AuditLog.find({ target: String(pod._id) }).sort({ createdAt: 1 }).lean();
expect(actions.map((entry) => entry.action)).toEqual(['community.list', 'community.unlist']);
});

it('rejects a non-boolean body', async () => {
const pod = await createPod({ communityListed: false });

const res = await request(app)
.post(`/api/admin/pods/${pod._id}/listing`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ communityListed: 'yes' })
.expect(400);

expect(res.body.error).toMatch(/boolean/);
});

it('returns 404 when the pod does not exist', async () => {
await setListing('0123456789abcdef01234567', true).expect(404);
});

it('refuses to list a pod that is not publicly readable', async () => {
const pod = await createPod({ publicRead: false, communityListed: false });

const res = await setListing(pod._id, true).expect(409);

expect(res.body).toMatchObject({
error: 'listing_requires_public_read',
message: expect.stringContaining(`/api/admin/pods/${pod._id}/showcase`),
});
const fresh = await Pod.findById(pod._id).lean();
expect(fresh.publicRead).toBe(false);
expect(fresh.communityListed).toBe(false);
});

it('allows unlisting a non-public pod without requiring it to be republished', async () => {
const pod = await createPod({ publicRead: false, communityListed: true });

const res = await setListing(pod._id, false).expect(200);

expect(res.body).toMatchObject({ publicRead: false, communityListed: false });
});

it.each(['agent-room', 'agent-dm', 'agent-admin'])(
'refuses listing changes for personal pod type %s',
async (type) => {
const pod = await createPod({ type, communityListed: false });

await setListing(pod._id, true).expect(400);

const fresh = await Pod.findById(pod._id).lean();
expect(fresh.communityListed).toBe(false);
},
);
});

it('unpublishing a listed pod cascades the community unlist', async () => {
const pod = await createPod();

const res = await setShowcase(pod._id, false).expect(200);

expect(res.body).toEqual({
id: String(pod._id),
publicRead: false,
communityListed: false,
});
const fresh = await Pod.findById(pod._id).lean();
expect(fresh).toMatchObject({ publicRead: false, communityListed: false });
const audit = await AuditLog.findOne({
target: String(pod._id),
action: 'showcase.unpublish',
}).lean();
expect(audit.detail).toContain('communityListed=false cascade=unlisted');
});
});
1 change: 1 addition & 0 deletions backend/__tests__/unit/controllers/podController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ describe('podController', () => {
],
createdBy: { toString: () => 'creator-id' },
joinPolicy: 'open',
publicRead: true,
communityListed: true,
};
Pod.findById
Expand Down
26 changes: 11 additions & 15 deletions backend/controllers/podController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ const Integration = require('../models/Integration');
const { AgentRegistry, AgentInstallation } = require('../models/AgentRegistry');
const AgentProfile = require('../models/AgentProfile');
const AgentIdentityService = require('../services/agentIdentityService');
const {
COMMUNITY_LISTING_QUERY,
NON_LISTABLE_POD_TYPES,
communityDiscoverQuery,
isDirectlyJoinable,
} = require('../services/podListing');
const User = require('../models/User');
// Add PGPod at the top level if it's available
let PGPod: any;
Expand All @@ -18,7 +24,6 @@ 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 @@ -177,25 +182,16 @@ exports.getAllPods = async (req: any, res: any) => {
// Keep the default query semantically identical to the privacy-hardened
// membership listing below.
const query = isDiscoverScope
? {
publicRead: true,
communityListed: true,
joinPolicy: { $ne: 'invite-only' },
members: { $ne: scopedCallerId },
type: type
? { $eq: type, $nin: COMMUNITY_EXCLUDED_POD_TYPES }
: { $nin: COMMUNITY_EXCLUDED_POD_TYPES },
}
? communityDiscoverQuery({ callerId: scopedCallerId, type })
: isCommunityScope
? {
// Listed is opt-in and distinct from readable: showcase rooms keep
// publicRead for anonymous viewing without appearing in Community.
publicRead: true,
communityListed: true,
...COMMUNITY_LISTING_QUERY,
members: scopedCallerId,
type: type
? { $eq: type, $nin: COMMUNITY_EXCLUDED_POD_TYPES }
: { $nin: COMMUNITY_EXCLUDED_POD_TYPES },
? { $eq: type, $nin: NON_LISTABLE_POD_TYPES }
: { $nin: NON_LISTABLE_POD_TYPES },
}
: (type ? { type } : { type: { $ne: 'agent-admin' } });

Expand Down Expand Up @@ -492,7 +488,7 @@ exports.joinPod = async (req: any, res: any) => {
// keeps optimistic clients and retried requests idempotent.
if (!isMember) {
const isAdmin = await isGlobalAdminRequest(req);
const isJoinable = pod.communityListed === true && pod.joinPolicy !== 'invite-only';
const isJoinable = isDirectlyJoinable(pod);
if (!isAdmin && !isJoinable) {
return res.status(403).json({
code: 'join_refused',
Expand Down
86 changes: 79 additions & 7 deletions backend/routes/admin/pods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const adminAuth = require('../../middleware/adminAuth');
const Pod = require('../../models/Pod');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const AuditLog = require('../../models/AuditLog');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { NON_LISTABLE_POD_TYPES } = require('../../services/podListing');

const router = express.Router();

Expand All @@ -26,10 +28,6 @@ const adminPodsRateLimit = rateLimit({
handler: (_req: any, res: any) => res.status(429).json({ error: 'rate_limited' }),
});

// Personal / DM pod types that must NEVER be published to the public
// showcase, regardless of admin intent. A 1:1 DM is private by definition.
const PERSONAL_POD_TYPES = new Set(['agent-dm', 'agent-room', 'agent-admin']);

// POST /api/admin/pods/:podId/showcase { publicRead: boolean }
// Admin-only toggle for the anonymous showcase read path. Rejects personal
// pod types so a private DM can never be flipped public.
Expand Down Expand Up @@ -59,13 +57,17 @@ router.post(
return res.status(404).json({ error: 'Pod not found' });
}

if (PERSONAL_POD_TYPES.has(String(pod.type))) {
if (NON_LISTABLE_POD_TYPES.includes(String(pod.type))) {
return res.status(400).json({
error: `Cannot publish a personal pod type (${pod.type}) to the public showcase`,
});
}

const cascadedCommunityUnlist = publicRead === false && pod.communityListed === true;
pod.publicRead = publicRead;
if (cascadedCommunityUnlist) {
pod.communityListed = false;
}
await pod.save();

// Audit the world-readable state change (security review F6): who/when/
Expand All @@ -74,7 +76,11 @@ router.post(
await AuditLog.create({
action: publicRead ? 'showcase.publish' : 'showcase.unpublish',
target: pod._id.toString(),
detail: `publicRead=${publicRead} type=${pod.type}`,
detail: [
`publicRead=${publicRead}`,
`type=${pod.type}`,
cascadedCommunityUnlist ? 'communityListed=false cascade=unlisted' : null,
].filter(Boolean).join(' '),
userId: req.userId || req.user?.id,
ip: req.ip,
userAgent: req.headers?.['user-agent'],
Expand All @@ -83,12 +89,78 @@ router.post(
console.warn('[admin/pods] audit log write failed (non-fatal):', (auditErr as Error).message);
}

return res.json({ id: pod._id.toString(), publicRead: pod.publicRead });
return res.json({
id: pod._id.toString(),
publicRead: pod.publicRead,
communityListed: pod.communityListed,
});
} catch (err) {
console.error('[admin/pods] showcase toggle error:', (err as Error).message);
return res.status(500).json({ error: 'Server Error' });
}
},
);

// POST /api/admin/pods/:podId/listing { communityListed: boolean }
// Admin-only curation toggle for Community discovery. Listing refines public
// readability: publishing a pod remains a separate, explicitly audited action.
router.post(
'/:podId/listing',
adminPodsRateLimit,
auth,
adminAuth,
async (req: any, res: any) => {
try {
const { podId } = req.params;
const { communityListed } = req.body || {};
if (typeof communityListed !== 'boolean') {
return res.status(400).json({ error: 'communityListed (boolean) is required' });
}

const pod = await Pod.findById(podId);
if (!pod) {
return res.status(404).json({ error: 'Pod not found' });
}

if (NON_LISTABLE_POD_TYPES.includes(String(pod.type))) {
return res.status(400).json({
error: `Cannot list a personal pod type (${pod.type}) in Community`,
});
}

if (communityListed && pod.publicRead !== true) {
return res.status(409).json({
error: 'listing_requires_public_read',
message: `Publish the pod first with POST /api/admin/pods/${pod._id}/showcase`,
});
}

pod.communityListed = communityListed;
await pod.save();

try {
await AuditLog.create({
action: communityListed ? 'community.list' : 'community.unlist',
target: pod._id.toString(),
detail: `communityListed=${communityListed} publicRead=${pod.publicRead} type=${pod.type}`,
userId: req.userId || req.user?.id,
ip: req.ip,
userAgent: req.headers?.['user-agent'],
});
} catch (auditErr) {
console.warn('[admin/pods] audit log write failed (non-fatal):', (auditErr as Error).message);
}

return res.json({
id: pod._id.toString(),
publicRead: pod.publicRead,
communityListed: pod.communityListed,
});
} catch (err) {
console.error('[admin/pods] community listing toggle error:', (err as Error).message);
return res.status(500).json({ error: 'Server Error' });
}
},
);

module.exports = router;
Loading
Loading