From 7c0fa608c59deb508ef341cb2530a7b4c34e5ccc Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:19:45 -0700 Subject: [PATCH] fix(reactions): human pod-access falls back to Mongo membership (PG drift) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit callerHasPodAccess gated human reactions ONLY on PG pod_members, but that table is a lazily-synced mirror of Mongo pod.members — community auto-join (ensureUserInCommunityPod) and other join paths write Mongo only. Result: members whose PG row never synced got 403, silently (frontend swallows it), so reaction counts appeared stuck at 1. 2026-07-24: Commonly HQ had 66 Mongo members but 1 in PG pod_members → 65/66 could not react. Fix: after the PG fast-path misses, fall back to Mongo pod.members (the source of truth) — the same fallback the agent path already had. (Also backfilled HQ's PG pod_members out-of-band for the immediate fix; this makes it robust for all pods + future joins + users not yet in PG.) Test: two new cases — human in Mongo-but-not-PG is allowed; human in neither → 403. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DMeWzgFxsfBcjoLVLewEES --- .../controllers/reactionController.test.js | 32 +++++++++++++++++++ backend/controllers/reactionController.ts | 13 +++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/backend/__tests__/unit/controllers/reactionController.test.js b/backend/__tests__/unit/controllers/reactionController.test.js index 9c8f1b520..4c3469bb1 100644 --- a/backend/__tests__/unit/controllers/reactionController.test.js +++ b/backend/__tests__/unit/controllers/reactionController.test.js @@ -183,6 +183,38 @@ describe('reactionController.addReaction — agent runtime path', () => { expect(MessageReaction.add).toHaveBeenCalledWith('11', 'human-1', '👀'); }); + test('human NOT in pg pod_members but IN mongo pod.members is still allowed (dual-DB drift, 2026-07-24)', async () => { + pool.query + .mockResolvedValueOnce(podLookup('pod-drift')) // loadPodIdForMessage + .mockResolvedValueOnce(memberLookup(0)); // pg pod_members MISS → must fall back to Mongo + Pod.findById.mockReturnValue({ + select: () => ({ lean: () => Promise.resolve({ members: [{ toString: () => 'human-2' }] }) }), + }); + + const req = { params: { messageId: '12' }, body: { emoji: '👍' }, user: { _id: 'human-2' } }; + const res = buildRes(); + + await reactionController.addReaction(req, res); + + expect(res.status).not.toHaveBeenCalledWith(403); + expect(MessageReaction.add).toHaveBeenCalledWith('12', 'human-2', '👍'); + }); + + test('human in neither pg pod_members nor mongo members → 403', async () => { + pool.query + .mockResolvedValueOnce(podLookup('pod-x')) + .mockResolvedValueOnce(memberLookup(0)); + Pod.findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve({ members: [] }) }) }); + + const req = { params: { messageId: '13' }, body: { emoji: '👍' }, user: { _id: 'stranger' } }; + const res = buildRes(); + + await reactionController.addReaction(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(MessageReaction.add).not.toHaveBeenCalled(); + }); + test('unauthenticated request (no user, no agentUser) → 401', async () => { const req = { params: { messageId: '1' }, body: { emoji: '👍' } }; const res = buildRes(); diff --git a/backend/controllers/reactionController.ts b/backend/controllers/reactionController.ts index b67d21c56..98d41e8da 100644 --- a/backend/controllers/reactionController.ts +++ b/backend/controllers/reactionController.ts @@ -63,13 +63,24 @@ async function callerHasPodAccess(podId: string, userId: string, req: AuthedReq) } return false; } + // Human path. PG pod_members is the fast check, but it is a *lazily-synced + // mirror* of Mongo pod.members — community auto-join (ensureUserInCommunityPod) + // and other join paths write Mongo only. So fall back to Mongo membership, the + // source of truth, or we 403 real members whose row never reached PG. 2026-07-24: + // HQ had 66 Mongo members but 1 in PG pod_members, so 65/66 could not react and + // the failure was silent — reaction counts appeared stuck at 1. Mirrors the + // agent path above, which already checks Mongo. // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require const { pool } = require('../config/db-pg'); const result = await pool.query( 'SELECT 1 FROM pod_members WHERE pod_id = $1 AND user_id = $2 LIMIT 1', [podId, userId], ); - return (result.rowCount || 0) > 0; + if ((result.rowCount || 0) > 0) return true; + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const Pod = require('../models/Pod'); + const pod = await Pod.findById(podId).select('members').lean(); + return Boolean(pod?.members?.some((mem: any) => String(mem?.userId?.toString?.() || mem) === userId)); } async function emitReactionChange(messageId: string | number, podId: string, reactions: unknown): Promise {