From 14ec8c2b4e6e829b7d3d94491594124ba860888e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:11:08 -0700 Subject: [PATCH 1/7] feat(grants): list owner grants across all pods Co-authored-by: forge Agent-Model: codex/gpt-6-sol/medium Co-authored-by: kai Agent-Model: claude/claude-haiku-5-5/default --- .../__tests__/unit/routes/grants.read.test.js | 70 ++++++++++++++++++- backend/routes/grants.ts | 43 ++++++++++++ .../v2/__tests__/V2ConnectorTools.test.tsx | 49 +++++++++---- .../v2/__tests__/V2ConnectorsPage.test.tsx | 2 +- .../src/v2/components/V2ConnectorTools.tsx | 37 ++++------ 5 files changed, 159 insertions(+), 42 deletions(-) diff --git a/backend/__tests__/unit/routes/grants.read.test.js b/backend/__tests__/unit/routes/grants.read.test.js index 36c78b289d..7726ce9274 100644 --- a/backend/__tests__/unit/routes/grants.read.test.js +++ b/backend/__tests__/unit/routes/grants.read.test.js @@ -23,8 +23,8 @@ jest.mock('../../../middleware/agentRuntimeAuth', () => (req, res, next) => { req.agentUser = { _id: id }; return next(); }); -jest.mock('../../../models/Pod', () => ({ findById: jest.fn() })); -jest.mock('../../../models/Integration', () => ({ findById: jest.fn(), findOne: jest.fn() })); +jest.mock('../../../models/Pod', () => ({ findById: jest.fn(), find: jest.fn() })); +jest.mock('../../../models/Integration', () => ({ findById: jest.fn(), findOne: jest.fn(), find: jest.fn() })); jest.mock('../../../models/ToolCall', () => ({ listForGrant: jest.fn(), countsForGrant: jest.fn(), @@ -121,10 +121,76 @@ beforeEach(async () => { Integration.findOne.mockImplementation(async ({ installationId }) => (installationId === 'conn-1' ? { _id: 'conn-1', type: 'github-app', status: 'connected', createdBy: OWNER } : null)); + Integration.find.mockImplementation(() => ({ select: () => ({ lean: async () => [ + { _id: 'conn-1', installationId: 'conn-1', createdBy: OWNER }, + ] }) })); ToolCall.listForGrant.mockResolvedValue([]); ToolCall.countsForGrant.mockResolvedValue({ total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 }); }); +describe('GET /api/grants?connectionOwner=me', () => { + test('includes grants backed by both an external installation id and a hosted connection row id', async () => { + const hostedId = 'eeeeeeeeeeeeeeeeeeeeee01'; + const githubGrant = await RoomGrant.create(grant()); + const hostedGrant = await RoomGrant.create(grant({ connectionId: hostedId })); + Integration.find.mockImplementation(() => ({ select: () => ({ lean: async () => [ + { _id: 'ffffffffffffffffffffff01', installationId: 'conn-1', createdBy: OWNER }, + { _id: hostedId, createdBy: OWNER }, + ] }) })); + Pod.find.mockImplementation(() => ({ select: () => ({ lean: async () => [ + { _id: POD, members: [OWNER, SEAT] }, + ] }) })); + + const res = await request(app).get('/api/grants?connectionOwner=me').set('x-test-user', OWNER); + expect(res.status).toBe(200); + expect(res.body.grants.map((row) => row.grantId).sort()).toEqual([ + githubGrant.grantId, hostedGrant.grantId, + ].sort()); + }); + + test('lists the owner’s grants beyond 20 pods, preserves dead grants, and excludes another owner’s connection', async () => { + const podIds = Array.from({ length: 21 }, (_, index) => `aaaaaaaaaaaaaaaaaaaaaa${String(index + 1).padStart(2, '0')}`); + const owned = await RoomGrant.create(podIds.map((id) => grant({ target: { kind: 'pod', id } }))); + const revokedAt = new Date('2026-09-11T00:00:00.000Z'); + await RoomGrant.updateOne({ grantId: owned[0].grantId }, { $set: { revokedAt, revokedBy: OWNER } }); + await RoomGrant.create(grant({ connectionId: 'conn-other', target: { kind: 'pod', id: podIds[20] } })); + Integration.find.mockImplementation(() => ({ select: () => ({ lean: async () => [ + { _id: 'conn-1', installationId: 'conn-1', createdBy: OWNER }, + { _id: 'conn-other', createdBy: STRANGER }, + ] }) })); + Pod.find.mockImplementation(({ _id }) => ({ select: () => ({ lean: async () => _id.$in.map((id) => ( + { _id: id, members: [OWNER, SEAT] } + )) }) })); + + const res = await request(app).get('/api/grants?connectionOwner=me').set('x-test-user', OWNER); + expect(res.status).toBe(200); + expect(res.body.grants).toHaveLength(21); + expect(res.body.grants.map((row) => row.grantId)).toEqual(expect.arrayContaining(owned.map((row) => row.grantId))); + expect(res.body.grants.find((row) => row.target.id === podIds[20])).toMatchObject({ + grantId: owned[20].grantId, effectiveAudience: [SEAT], grantedBy: OWNER, + }); + const revoked = res.body.grants.find((row) => row.grantId === owned[0].grantId); + expect(revoked).toMatchObject({ revokedBy: OWNER, revokedAt: revokedAt.toISOString() }); + expect(res.body.grants.every((row) => row.expiresAt && Object.keys(row).sort().join() === [...FIELDS].sort().join())).toBe(true); + expect(JSON.stringify(res.body)).not.toContain('conn-other'); + expect(Integration.find).toHaveBeenCalledWith({ createdBy: OWNER }); + }); + + test('requires a signed-in owner and never gives a different user or admin the owner’s grants', async () => { + await RoomGrant.create(grant()); + Pod.find.mockImplementation(() => ({ select: () => ({ lean: async () => [] }) })); + Integration.find.mockImplementation(({ createdBy }) => ({ select: () => ({ lean: async () => ( + createdBy === OWNER ? [{ _id: 'conn-1', createdBy: OWNER }] : [] + ) }) })); + expect((await request(app).get('/api/grants?connectionOwner=me')).status).toBe(401); + expect((await request(app).get('/api/grants').set('x-test-user', OWNER)).status).toBe(400); + const other = await request(app).get('/api/grants?connectionOwner=me') + .set('x-test-user', STRANGER).set('x-test-role', 'admin'); + expect(other.status).toBe(200); + expect(other.body.grants).toEqual([]); + }); +}); + describe('GET /api/pods/:podId/grants', () => { test('GET /api/pods/:podId/grants is reachable with the pod routes mounted', async () => { await RoomGrant.create(grant()); diff --git a/backend/routes/grants.ts b/backend/routes/grants.ts index ab7de77c67..9cec214419 100644 --- a/backend/routes/grants.ts +++ b/backend/routes/grants.ts @@ -280,6 +280,13 @@ const connectionOwnerId = (connection: any): string => String( connection?.createdBy || connection?.config?.linkedUserId || '', ); +/** Connection ids are stored as either the row id or its external installation id. */ +const connectionIdsOf = (connection: any): string[] => [ + connection?._id, + connection?.installationId, + connection?.config?.installationId, +].filter(Boolean).map(String); + const getTargetMembers = async (target: { kind: 'pod' | 'seat'; id: string }): Promise => { // Seat grants already identify their single target; there is no pod census // to intersect for them. The broker still checks the explicit audience. @@ -591,6 +598,42 @@ router.get('/:grantId', dualGrantCredential, grantRateLimit, dualAuth, async (re } }); +/** The Tools page lists the caller's own connection grants across every pod. */ +router.get('/', grantRateLimit, auth, async (req: AuthenticatedRequest, res: express.Response) => { + try { + const userId = callerId(req); + if (!userId) return res.status(401).json({ error: 'unauthorized' }); + if (req.query.connectionOwner !== 'me') return res.status(400).json({ error: 'invalid_connection_owner' }); + + const connections = await Integration.find({ createdBy: userId }) + .select('_id installationId config.installationId createdBy') + .lean(); + const connectionIds = Array.from(new Set(connections + .filter((connection: any) => connectionOwnerId(connection) === userId) + .flatMap(connectionIdsOf))); + if (!connectionIds.length) return res.json({ grants: [] }); + + const grants = await RoomGrant.find({ connectionId: { $in: connectionIds } }) + .sort({ createdAt: -1 }).lean(); + const podIds = Array.from(new Set(grants + .filter((grant) => grant.target.kind === 'pod' && Types.ObjectId.isValid(grant.target.id)) + .map((grant) => grant.target.id))); + const [pods, confinements] = await Promise.all([ + podIds.length ? Pod.find({ _id: { $in: podIds } }).select('members').lean() : [], + seatGrantRefusals(grants), + ]); + const membersByPod = new Map(pods.map((pod: any) => [String(pod._id), memberIdsOf(pod)])); + return res.json({ grants: grants.map((grant) => projectGrant( + grant, + grant.target.kind === 'seat' ? grant.audience : membersByPod.get(grant.target.id) || [], + userId, + confinements.get(grant.grantId), + )) }); + } catch (error) { + return handleError(res, error); + } +}); + /** * GET /api/pods/:podId/grants — every grant whose target is the pod or a seat * in it, gated by canViewPod, each row projected. Mounted under /api/pods by diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index 22695150ab..1ce8ac14bd 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -91,8 +91,7 @@ test('Atlassian shows its read-in-testing state on an existing grant', async () const mockTwoEntries = ({ catalog = [githubEntry, linearEntry], grants = { p1: [grantLinear], p2: [grantRevoked] } } = {}) => { axios.get.mockImplementation((url) => { if (url === '/api/installables') return Promise.resolve({ data: { installables: catalog } }); - const pod = /^\/api\/pods\/(p\d)\/grants$/.exec(url); - if (pod) return Promise.resolve({ data: { podId: pod[1], grants: grants[pod[1]] || [] } }); + if (url === '/api/grants') return Promise.resolve({ data: { grants: Object.values(grants).flat() } }); if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } }); if (url === '/api/registry/pods/p2/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } }); if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'g', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); @@ -103,8 +102,7 @@ const mockTwoEntries = ({ catalog = [githubEntry, linearEntry], grants = { p1: [ const mockApi = (catalog = []) => { axios.get.mockImplementation((url) => { if (url === '/api/installables') return Promise.resolve({ data: { installables: [{ installableId: 'telegram', list: 'channels' }, ...catalog] } }); - if (url === '/api/pods/p1/grants') return Promise.resolve({ data: { podId: 'p1', grants: [grantLive] } }); - if (url === '/api/pods/p2/grants') return Promise.resolve({ data: { podId: 'p2', grants: [grantRevoked] } }); + if (url === '/api/grants') return Promise.resolve({ data: { grants: [grantLive, grantRevoked] } }); if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } }); if (url === '/api/registry/pods/p2/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } }); if (url === '/api/grants/grant_live/calls') return Promise.resolve({ data: trail }); @@ -122,6 +120,31 @@ const renderTools = (props = {}) => render( beforeEach(() => { jest.clearAllMocks(); mockApi([githubEntry]); }); +test('one owner grant read shows the 21st pod and keeps revoked and expired labels', async () => { + const manyPods = Array.from({ length: 21 }, (_, index) => ({ + _id: `p${index + 1}`, name: `Pod ${index + 1}`, members: [{ _id: 'u1', username: 'sam' }], + })); + const inLastPod = { ...grantLive, grantId: 'grant_21', target: { kind: 'pod', id: 'p21' } }; + const revoked = { ...inLastPod, grantId: 'grant_21_revoked', revokedAt: iso(-600000), revokedBy: 'u1' }; + const expired = { ...inLastPod, grantId: 'grant_21_expired', expiresAt: iso(-600000) }; + axios.get.mockImplementation((url) => { + if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); + if (url === '/api/grants') return Promise.resolve({ data: { grants: [inLastPod, revoked, expired] } }); + if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } }); + if (url.includes('/calls')) return Promise.resolve({ data: { calls: [], counts: {} } }); + return Promise.reject(new Error(`unmocked ${url}`)); + }); + renderTools({ pods: manyPods }); + + expect(await screen.findAllByRole('button', { name: 'View GitHub in Pod 21' })).toHaveLength(3); + expect(screen.getByText(/revoked by sam 10m/)).toBeInTheDocument(); + expect(screen.getByText(/expired 10m/)).toBeInTheDocument(); + const grantReads = axios.get.mock.calls.filter(([url]) => url === '/api/grants'); + expect(grantReads).toHaveLength(1); + expect(grantReads[0][1]).toMatchObject({ params: { connectionOwner: 'me' } }); + expect(axios.get.mock.calls.some(([url]) => /\/api\/pods\/.*\/grants/.test(url))).toBe(false); +}); + test('a sole join-policy refusal offers the owner an in-place fix and then allows retry', async () => { axios.post.mockRejectedValueOnce({ response: { data: { error: 'outsider_grant_pod_joinable', message: 'Agents can self-install into this pod. Make it invite-only to use this connector.', @@ -171,7 +194,7 @@ test('TASK-131: a grant age advances in place, and a returning tab re-reads, wit await act(async () => { jest.advanceTimersByTime(60 * 60_000); }); expect(screen.getByText(/granted 2h$/)).toBeInTheDocument(); - const reads = () => axios.get.mock.calls.filter(([url]) => url === '/api/pods/p1/grants').length; + const reads = () => axios.get.mock.calls.filter(([url]) => url === '/api/grants').length; const before = reads(); document.dispatchEvent(new Event('visibilitychange')); await waitFor(() => expect(reads()).toBeGreaterThan(before)); @@ -187,7 +210,7 @@ test('TASK-131: a grant expiring with the page open moves its tool back to Not y const expiring = { ...grantLive, grantId: 'grant_soon', expiresAt: iso(60_000) }; axios.get.mockImplementation((url) => { if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); - if (url.endsWith('/grants')) return Promise.resolve({ data: { grants: url.includes('p1') ? [expiring] : [] } }); + if (url === '/api/grants') return Promise.resolve({ data: { grants: [expiring] } }); if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } }); if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'grant_soon', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); return Promise.reject(new Error(`unmocked ${url}`)); @@ -278,8 +301,7 @@ test('TASK-179: the grant line is one sentence per language, and an empty audien const emptyAudience = { ...grantLive, grantId: 'grant_empty', target: { kind: 'pod', id: 'p2' }, effectiveAudience: [] }; axios.get.mockImplementation((url) => { if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); - if (url === '/api/pods/p1/grants') return Promise.resolve({ data: { podId: 'p1', grants: [grantLive] } }); - if (url === '/api/pods/p2/grants') return Promise.resolve({ data: { podId: 'p2', grants: [emptyAudience] } }); + if (url === '/api/grants') return Promise.resolve({ data: { grants: [grantLive, emptyAudience] } }); if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } }); if (url === '/api/registry/pods/p2/agents') return Promise.resolve({ data: { agents: [] } }); if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'g', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); @@ -325,8 +347,7 @@ test('TASK-179: a seat grant names the seat in its sentence and the pod in the r const seatGrant = { ...grantLive, grantId: 'grant_seat', target: { kind: 'seat', id: 'a1' }, effectiveAudience: ['a1'] }; axios.get.mockImplementation((url) => { if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); - if (url === '/api/pods/p1/grants') return Promise.resolve({ data: { podId: 'p1', grants: [] } }); - if (url === '/api/pods/p2/grants') return Promise.resolve({ data: { podId: 'p2', grants: [seatGrant] } }); + if (url === '/api/grants') return Promise.resolve({ data: { grants: [seatGrant] } }); if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } }); if (url === '/api/registry/pods/p2/agents') return Promise.resolve({ data: { agents: [{ name: 'reed', displayName: 'Reed', userId: 'a1' }] } }); if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'g', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); @@ -388,7 +409,7 @@ test('TASK-050: a human member in the audience is named, not called "an agent"', const withPeople = { ...grantLive, effectiveAudience: ['u1', 'a1'] }; axios.get.mockImplementation((url) => { if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); - if (url === '/api/pods/p1/grants') return Promise.resolve({ data: { podId: 'p1', grants: [withPeople] } }); + if (url === '/api/grants') return Promise.resolve({ data: { grants: [withPeople] } }); if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } }); if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'grant_live', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); return Promise.resolve({ data: {} }); @@ -457,7 +478,7 @@ test('Revoke is two-click and posts the revoke verb, then reloads; a dead grant expect(axios.post).not.toHaveBeenCalled(); fireEvent.click(within(aside).getByRole('button', { name: 'Yes, revoke it' })); await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants/grant_live/revoke', undefined, expect.any(Object))); - await waitFor(() => expect(axios.get.mock.calls.filter(([url]) => url === '/api/pods/p1/grants')).toHaveLength(2)); + await waitFor(() => expect(axios.get.mock.calls.filter(([url]) => url === '/api/grants')).toHaveLength(2)); fireEvent.click(screen.getByRole('button', { name: 'View GitHub in Ops' })); const gone = await screen.findByRole('complementary', { name: 'Grant details' }); @@ -471,7 +492,7 @@ test('the catalogue draws the not-yet row, and Add mints the grant exactly as th // No live grant anywhere → a not-yet row; the two dead/live fixtures are replaced by a revoked one only. axios.get.mockImplementation((url) => { if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); - if (url.endsWith('/grants')) return Promise.resolve({ data: { grants: url.includes('p2') ? [grantRevoked] : [] } }); + if (url === '/api/grants') return Promise.resolve({ data: { grants: [grantRevoked] } }); if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }, { name: 'hosted-smoke', userId: 'x9', internal: true }] } }); if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } }); if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'g', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); @@ -507,7 +528,7 @@ test('the catalogue draws the not-yet row, and Add mints the grant exactly as th const days = (new Date(body.expiresAt).getTime() - Date.now()) / 86400000; expect(days).toBeGreaterThan(29.9); expect(days).toBeLessThan(30.1); - await waitFor(() => expect(axios.get.mock.calls.filter(([u]) => u === '/api/pods/p1/grants')).toHaveLength(2)); + await waitFor(() => expect(axios.get.mock.calls.filter(([u]) => u === '/api/grants')).toHaveLength(2)); }); test('a not-yet row without a Connection or on an unconfigured instance offers no Add', async () => { diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index f759dc89e9..0b623e3c61 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -166,7 +166,7 @@ describe('V2ConnectorsPage', () => { if (url === '/api/integrations/user/all') return Promise.resolve({ data: [connectors[1]] }); if (url === '/api/pods') return Promise.resolve({ data: [{ _id: 'p1', name: 'Launch pod', type: 'chat' }] }); if (url === '/api/installables') return Promise.resolve({ data: { installables: [toolsEntry] } }); - if (url === '/api/pods/p1/grants') return Promise.resolve({ data: { podId: 'p1', grants: [grant] } }); + if (url === '/api/grants') return Promise.resolve({ data: { grants: [grant] } }); if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } }); if (url.includes('/calls')) { return Promise.resolve({ data: { grantId: 'grant_live', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index 6dfbe5a669..40ba8d0405 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -132,7 +132,6 @@ interface GithubAppIntegrationResponse { } const USED_RECENTLY_MS = 10 * 60 * 1000; -const MAX_PODS = 20; const MODE_RANK: Record = { read: 0, 'write-with-confirm': 1, write: 2 }; // The two option sets the aside renders as buttons. They live here, not inline in the // JSX, because eslint's i18next/no-literal-string (jsx-only) reads an array literal in @@ -234,36 +233,24 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) const isAdmin = currentUser?.role === 'admin'; - const podIds = useMemo(() => pods.slice(0, MAX_PODS).map((pod) => String(pod._id)), [pods]); + const podIds = useMemo(() => pods.map((pod) => String(pod._id)), [pods]); const load = useCallback(async () => { const catalogRes = await api.get<{ installables?: ToolCatalogEntry[]; hiddenToolConnections?: HiddenToolConnection[] }>('/api/installables').catch(() => null); setCatalog((catalogRes?.installables || []).filter((entry) => entry.list === 'tools')); setHiddenConnections(catalogRes?.hiddenToolConnections || []); - if (podIds.length === 0) { setGrants([]); return; } - const results = await Promise.all(podIds.map(async (podId) => { - const [grantsRes, seatsRes] = await Promise.all([ - api.get<{ grants: ToolGrant[] }>(`/api/pods/${podId}/grants`).catch(() => null), - api.get<{ agents?: PodSeat[] }>(`/api/registry/pods/${podId}/agents`).catch(() => null), - ]); - return { podId, grants: grantsRes?.grants ?? [], seats: (seatsRes?.agents ?? []).filter((seat) => !seat.internal) }; - })); - const seen = new Set(); - const merged: ToolGrant[] = []; - const seatMap: Record = {}; - for (const result of results) { - seatMap[result.podId] = result.seats; - for (const grant of result.grants) { - if (seen.has(grant.grantId)) continue; // a seat grant can surface from two pods - seen.add(grant.grantId); - merged.push(grant); - } - } - merged.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - setGrants(merged); - setSeats(seatMap); + const [grantRes, seatResults] = await Promise.all([ + api.get<{ grants: ToolGrant[] }>('/api/grants', { params: { connectionOwner: 'me' } }).catch(() => null), + Promise.all(podIds.map(async (podId) => { + const res = await api.get<{ agents?: PodSeat[] }>(`/api/registry/pods/${podId}/agents`).catch(() => null); + return [podId, (res?.agents ?? []).filter((seat) => !seat.internal)] as const; + })), + ]); + const ownedGrants = grantRes?.grants || []; + setGrants(ownedGrants); + setSeats(Object.fromEntries(seatResults)); // "used in the last 10 min" reads the newest trail row's `at` (§6). - const uses = await Promise.all(merged.filter((grant) => !isDead(grant)).map(async (grant) => { + const uses = await Promise.all(ownedGrants.filter((grant) => !isDead(grant)).map(async (grant) => { const res = await api.get(`/api/grants/${grant.grantId}/calls`, { params: { limit: 1 } }).catch(() => null); return [grant.grantId, res?.calls?.[0]?.at ?? null] as const; })); From d9b7936fba73fe30f16bee9914a6687b215590fa Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:13:04 -0700 Subject: [PATCH 2/7] test(tools): pin 21st pod as a grant target Co-authored-by: forge Agent-Model: codex/gpt-6-sol/medium Co-authored-by: kai Agent-Model: claude/claude-haiku-5-5/default --- frontend/src/v2/__tests__/V2ConnectorTools.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index 1ce8ac14bd..54e31e4cf4 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -139,6 +139,8 @@ test('one owner grant read shows the 21st pod and keeps revoked and expired labe expect(await screen.findAllByRole('button', { name: 'View GitHub in Pod 21' })).toHaveLength(3); expect(screen.getByText(/revoked by sam 10m/)).toBeInTheDocument(); expect(screen.getByText(/expired 10m/)).toBeInTheDocument(); + fireEvent.click(screen.getAllByRole('button', { name: 'Grant again' })[0]); + expect(await screen.findByRole('option', { name: 'Pod 21' })).toBeInTheDocument(); const grantReads = axios.get.mock.calls.filter(([url]) => url === '/api/grants'); expect(grantReads).toHaveLength(1); expect(grantReads[0][1]).toMatchObject({ params: { connectionOwner: 'me' } }); From a78de7ded18b172509131ee401f741c66ef624a8 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:15:20 -0700 Subject: [PATCH 3/7] test(grants): assert owner fence for admin caller Co-authored-by: forge Agent-Model: codex/gpt-6-sol/medium Co-authored-by: kai Agent-Model: claude/claude-haiku-5-5/default --- backend/__tests__/unit/routes/grants.read.test.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/__tests__/unit/routes/grants.read.test.js b/backend/__tests__/unit/routes/grants.read.test.js index 7726ce9274..22182e921b 100644 --- a/backend/__tests__/unit/routes/grants.read.test.js +++ b/backend/__tests__/unit/routes/grants.read.test.js @@ -179,15 +179,18 @@ describe('GET /api/grants?connectionOwner=me', () => { test('requires a signed-in owner and never gives a different user or admin the owner’s grants', async () => { await RoomGrant.create(grant()); Pod.find.mockImplementation(() => ({ select: () => ({ lean: async () => [] }) })); - Integration.find.mockImplementation(({ createdBy }) => ({ select: () => ({ lean: async () => ( - createdBy === OWNER ? [{ _id: 'conn-1', createdBy: OWNER }] : [] - ) }) })); + // Deliberately return a foreign row even for the admin caller. The route + // must enforce ownership after the query as well as in its predicate. + Integration.find.mockImplementation(() => ({ select: () => ({ lean: async () => [ + { _id: 'conn-1', createdBy: OWNER }, + ] }) })); expect((await request(app).get('/api/grants?connectionOwner=me')).status).toBe(401); expect((await request(app).get('/api/grants').set('x-test-user', OWNER)).status).toBe(400); const other = await request(app).get('/api/grants?connectionOwner=me') .set('x-test-user', STRANGER).set('x-test-role', 'admin'); expect(other.status).toBe(200); expect(other.body.grants).toEqual([]); + expect(Integration.find).toHaveBeenCalledWith({ createdBy: STRANGER }); }); }); From a4794ea7febeabb5de66d60277731220e4df00c7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:47:41 -0700 Subject: [PATCH 4/7] test(grants): align fold fixtures with owner grant endpoint Co-authored-by: forge Agent-Model: codex/gpt-6-sol/medium Co-authored-by: kai Agent-Model: claude/claude-haiku-5-5/default --- frontend/src/v2/__tests__/V2ConnectorTools.fold.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.fold.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.fold.test.tsx index 04a59ae0a9..b85f038367 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.fold.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.fold.test.tsx @@ -37,7 +37,7 @@ const offers = ['GitHub', 'Linear', 'Atlassian', 'Airtable', 'Sentry'].map((labe const mockTools = (entries: typeof catalog, grants: ReturnType[]) => { axios.get.mockImplementation((url: string) => { if (url === '/api/installables') return Promise.resolve({ data: { installables: entries } }); - if (url === '/api/pods/p1/grants') return Promise.resolve({ data: { grants } }); + if (url === '/api/grants') return Promise.resolve({ data: { grants } }); if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } }); if (url.includes('/calls')) return Promise.resolve({ data: { calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); return Promise.reject(new Error(`unmocked ${url}`)); @@ -85,7 +85,7 @@ test('two live grants stay visible and nine revoked or expired grants start in o ...Array.from({ length: 5 }, (_, i) => ({ ...grant(`expired-${i}`, null), expiresAt: yesterday }))]; axios.get.mockImplementation((url: string) => { if (url === '/api/installables') return Promise.resolve({ data: { installables: catalog } }); - if (url === '/api/pods/p1/grants') return Promise.resolve({ data: { grants } }); + if (url === '/api/grants') return Promise.resolve({ data: { grants } }); if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } }); if (url.includes('/calls')) return Promise.resolve({ data: { calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); return Promise.reject(new Error(`unmocked ${url}`)); @@ -112,7 +112,7 @@ test('search keeps a matching catalogue offer visible and folds only ended conne if (url === '/api/installables') return Promise.resolve({ data: { installables: entries, hiddenToolConnections: [ { connectionId: 'hidden-1', entryId: 'atlassian', label: 'Atlassian', status: 'inactive', revokePage: null }, ] } }); - if (url === '/api/pods/p1/grants') return Promise.resolve({ data: { grants: [grant('live', null), grant('revoked', yesterday)] } }); + if (url === '/api/grants') return Promise.resolve({ data: { grants: [grant('live', null), grant('revoked', yesterday)] } }); if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } }); if (url.includes('/calls')) return Promise.resolve({ data: { calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); return Promise.reject(new Error(`unmocked ${url}`)); From 880b41778ac616f088ae5b21267e5b444248a082 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:30:14 -0700 Subject: [PATCH 5/7] test(tools): repoint TASK-624 grant fixtures to the owner grant route The two TASK-624 cases on main still mocked the per-pod grants route that this change replaces, so the grant row never rendered once the page reads GET /api/grants?connectionOwner=me. Serve the grant from that route. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: kai Agent-Model: claude/claude-haiku-5-5/default --- frontend/src/v2/__tests__/V2ConnectorTools.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index 54e31e4cf4..52fb1c21bc 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -439,7 +439,7 @@ test('TASK-624: a per-user bot member with no roster row is named by its curated }]; axios.get.mockImplementation((url) => { if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); - if (url === '/api/pods/p1/grants') return Promise.resolve({ data: { podId: 'p1', grants: [grantLive] } }); + if (url === '/api/grants') return Promise.resolve({ data: { grants: [grantLive] } }); if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } }); if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'grant_live', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); return Promise.resolve({ data: {} }); @@ -461,7 +461,7 @@ test('TASK-624: a bot member with a blank displayName falls back to its non-defa }]; axios.get.mockImplementation((url) => { if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); - if (url === '/api/pods/p1/grants') return Promise.resolve({ data: { podId: 'p1', grants: [{ ...grantLive, effectiveAudience: ['a1', 'a2'] }] } }); + if (url === '/api/grants') return Promise.resolve({ data: { grants: [{ ...grantLive, effectiveAudience: ['a1', 'a2'] }] } }); if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } }); if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'grant_live', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); return Promise.resolve({ data: {} }); From 4fc026b9f9c4db10a4173b5248b5ee3e5f7777da Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:42:04 -0700 Subject: [PATCH 6/7] fix(grants): check pod visibility before listing owner grants GET /api/grants?connectionOwner=me projected pod-target grants without DMService.canViewPod, so a connection owner who had left a private pod still saw that pod's grants and its audience. Load each target pod and keep only pods the caller can view. Mutation check: removing the filter turns the new private-pod case red. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: kai Agent-Model: claude/claude-haiku-5-5/default --- .../__tests__/unit/routes/grants.read.test.js | 33 +++++++++++++++---- backend/routes/grants.ts | 23 ++++++++----- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/backend/__tests__/unit/routes/grants.read.test.js b/backend/__tests__/unit/routes/grants.read.test.js index 22182e921b..20e6d99799 100644 --- a/backend/__tests__/unit/routes/grants.read.test.js +++ b/backend/__tests__/unit/routes/grants.read.test.js @@ -137,9 +137,9 @@ describe('GET /api/grants?connectionOwner=me', () => { { _id: 'ffffffffffffffffffffff01', installationId: 'conn-1', createdBy: OWNER }, { _id: hostedId, createdBy: OWNER }, ] }) })); - Pod.find.mockImplementation(() => ({ select: () => ({ lean: async () => [ - { _id: POD, members: [OWNER, SEAT] }, - ] }) })); + Pod.findById.mockImplementation((id) => ({ select: () => ({ lean: async () => ( + { _id: id, type: 'public', members: [OWNER, SEAT] } + ) }) })); const res = await request(app).get('/api/grants?connectionOwner=me').set('x-test-user', OWNER); expect(res.status).toBe(200); @@ -158,9 +158,9 @@ describe('GET /api/grants?connectionOwner=me', () => { { _id: 'conn-1', installationId: 'conn-1', createdBy: OWNER }, { _id: 'conn-other', createdBy: STRANGER }, ] }) })); - Pod.find.mockImplementation(({ _id }) => ({ select: () => ({ lean: async () => _id.$in.map((id) => ( - { _id: id, members: [OWNER, SEAT] } - )) }) })); + Pod.findById.mockImplementation((id) => ({ select: () => ({ lean: async () => ( + { _id: id, type: 'public', members: [OWNER, SEAT] } + ) }) })); const res = await request(app).get('/api/grants?connectionOwner=me').set('x-test-user', OWNER); expect(res.status).toBe(200); @@ -176,9 +176,28 @@ describe('GET /api/grants?connectionOwner=me', () => { expect(Integration.find).toHaveBeenCalledWith({ createdBy: OWNER }); }); + test('drops a pod grant once the owner can no longer view that pod', async () => { + const hidden = '0000000000000000000000ab'; + const visible = '0000000000000000000000ac'; + const [hiddenGrant, visibleGrant] = await RoomGrant.create([ + grant({ target: { kind: 'pod', id: hidden } }), + grant({ target: { kind: 'pod', id: visible } }), + ]); + Pod.findById.mockImplementation((id) => ({ select: () => ({ lean: async () => ( + { _id: id, type: 'private', members: id === hidden ? [SEAT] : [OWNER, SEAT] } + ) }) })); + + const res = await request(app).get('/api/grants?connectionOwner=me').set('x-test-user', OWNER); + expect(res.status).toBe(200); + const ids = res.body.grants.map((row) => row.grantId); + expect(ids).toContain(visibleGrant.grantId); + expect(ids).not.toContain(hiddenGrant.grantId); + expect(JSON.stringify(res.body)).not.toContain(hidden); + }); + test('requires a signed-in owner and never gives a different user or admin the owner’s grants', async () => { await RoomGrant.create(grant()); - Pod.find.mockImplementation(() => ({ select: () => ({ lean: async () => [] }) })); + Pod.findById.mockImplementation(() => ({ select: () => ({ lean: async () => null }) })); // Deliberately return a foreign row even for the admin caller. The route // must enforce ownership after the query as well as in its predicate. Integration.find.mockImplementation(() => ({ select: () => ({ lean: async () => [ diff --git a/backend/routes/grants.ts b/backend/routes/grants.ts index 9cec214419..563f44e2a2 100644 --- a/backend/routes/grants.ts +++ b/backend/routes/grants.ts @@ -619,16 +619,23 @@ router.get('/', grantRateLimit, auth, async (req: AuthenticatedRequest, res: exp .filter((grant) => grant.target.kind === 'pod' && Types.ObjectId.isValid(grant.target.id)) .map((grant) => grant.target.id))); const [pods, confinements] = await Promise.all([ - podIds.length ? Pod.find({ _id: { $in: podIds } }).select('members').lean() : [], + Promise.all(podIds.map(async (podId) => [podId, await loadPod(podId)] as const)), seatGrantRefusals(grants), ]); - const membersByPod = new Map(pods.map((pod: any) => [String(pod._id), memberIdsOf(pod)])); - return res.json({ grants: grants.map((grant) => projectGrant( - grant, - grant.target.kind === 'seat' ? grant.audience : membersByPod.get(grant.target.id) || [], - userId, - confinements.get(grant.grantId), - )) }); + // A pod grant is returned only while the caller can still view its pod: an owner + // who has left a private pod does not see that pod's grants. + const visiblePods = new Map(); + for (const [podId, pod] of pods) { + if (pod && await DMService.canViewPod(userId, pod)) visiblePods.set(podId, memberIdsOf(pod)); + } + return res.json({ grants: grants + .filter((grant) => grant.target.kind !== 'pod' || visiblePods.has(grant.target.id)) + .map((grant) => projectGrant( + grant, + grant.target.kind === 'seat' ? grant.audience : visiblePods.get(grant.target.id) || [], + userId, + confinements.get(grant.grantId), + )) }); } catch (error) { return handleError(res, error); } From 9236dfe377695702c483e084b71eb1b0c0f78123 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:23:43 -0700 Subject: [PATCH 7/7] fix(grants): owner grant list follows the binding, not the connection holder GET /api/grants?connectionOwner=me took the caller's connections by their current createdBy and listed grants on those connection ids. After a connection id changed hands, the new holder would see the earlier owner's grants. The list now reads grants by the ownerId fixed at mint. An unbound grant is listed for nobody. Drops the now-unused connectionIdsOf helper. Tests: a grant bound to the owner stays with the owner after the connection changed hands; the holder and an unbound grant get nothing; the foreign-owner fixture binds to its own owner. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: kai Agent-Model: claude/claude-haiku-5-5/default --- .../__tests__/unit/routes/grants.read.test.js | 27 ++++++++++++------- backend/routes/grants.ts | 19 +++---------- 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/backend/__tests__/unit/routes/grants.read.test.js b/backend/__tests__/unit/routes/grants.read.test.js index 1aa6f9c68f..16839e5384 100644 --- a/backend/__tests__/unit/routes/grants.read.test.js +++ b/backend/__tests__/unit/routes/grants.read.test.js @@ -156,11 +156,7 @@ describe('GET /api/grants?connectionOwner=me', () => { const owned = await RoomGrant.create(podIds.map((id) => grant({ target: { kind: 'pod', id } }))); const revokedAt = new Date('2026-09-11T00:00:00.000Z'); await RoomGrant.updateOne({ grantId: owned[0].grantId }, { $set: { revokedAt, revokedBy: OWNER } }); - await RoomGrant.create(grant({ connectionId: 'conn-other', target: { kind: 'pod', id: podIds[20] } })); - Integration.find.mockImplementation(() => ({ select: () => ({ lean: async () => [ - { _id: 'conn-1', installationId: 'conn-1', createdBy: OWNER }, - { _id: 'conn-other', createdBy: STRANGER }, - ] }) })); + await RoomGrant.create(grant({ connectionId: 'conn-other', ownerId: STRANGER, target: { kind: 'pod', id: podIds[20] } })); Pod.findById.mockImplementation((id) => ({ select: () => ({ lean: async () => ( { _id: id, type: 'public', members: [OWNER, SEAT] } ) }) })); @@ -176,7 +172,22 @@ describe('GET /api/grants?connectionOwner=me', () => { expect(revoked).toMatchObject({ revokedBy: OWNER, revokedAt: revokedAt.toISOString() }); expect(res.body.grants.every((row) => row.expiresAt && Object.keys(row).sort().join() === [...FIELDS].sort().join())).toBe(true); expect(JSON.stringify(res.body)).not.toContain('conn-other'); - expect(Integration.find).toHaveBeenCalledWith({ createdBy: OWNER }); + }); + + // The connection id now names a row another user holds. The list follows the + // binding fixed at mint: the bound owner keeps the grant, the holder does not + // get it, and an unbound grant is listed for nobody. + test('follows the binding, not the connection holder, and lists unbound grants for nobody', async () => { + const bound = await RoomGrant.create(grant({ target: { kind: 'pod', id: POD } })); + await RoomGrant.create(grant({ ownerId: null, connectionRowId: null, target: { kind: 'pod', id: POD } })); + Integration.find.mockImplementation(() => ({ select: () => ({ lean: async () => [ + { _id: 'conn-1', installationId: 'conn-1', createdBy: STRANGER }, + ] }) })); + + const owner = await request(app).get('/api/grants?connectionOwner=me').set('x-test-user', OWNER); + expect(owner.body.grants.map((row) => row.grantId)).toEqual([bound.grantId]); + const holder = await request(app).get('/api/grants?connectionOwner=me').set('x-test-user', STRANGER); + expect(holder.body.grants).toEqual([]); }); test('drops a pod grant once the owner can no longer view that pod', async () => { @@ -203,16 +214,12 @@ describe('GET /api/grants?connectionOwner=me', () => { Pod.findById.mockImplementation(() => ({ select: () => ({ lean: async () => null }) })); // Deliberately return a foreign row even for the admin caller. The route // must enforce ownership after the query as well as in its predicate. - Integration.find.mockImplementation(() => ({ select: () => ({ lean: async () => [ - { _id: 'conn-1', createdBy: OWNER }, - ] }) })); expect((await request(app).get('/api/grants?connectionOwner=me')).status).toBe(401); expect((await request(app).get('/api/grants').set('x-test-user', OWNER)).status).toBe(400); const other = await request(app).get('/api/grants?connectionOwner=me') .set('x-test-user', STRANGER).set('x-test-role', 'admin'); expect(other.status).toBe(200); expect(other.body.grants).toEqual([]); - expect(Integration.find).toHaveBeenCalledWith({ createdBy: STRANGER }); }); }); diff --git a/backend/routes/grants.ts b/backend/routes/grants.ts index afbaf02e0e..414cc7128d 100644 --- a/backend/routes/grants.ts +++ b/backend/routes/grants.ts @@ -269,13 +269,6 @@ const connectionOwnerId = (connection: any): string => String( connection?.createdBy || connection?.config?.linkedUserId || '', ); -/** Connection ids are stored as either the row id or its external installation id. */ -const connectionIdsOf = (connection: any): string[] => [ - connection?._id, - connection?.installationId, - connection?.config?.installationId, -].filter(Boolean).map(String); - const getTargetMembers = async (target: { kind: 'pod' | 'seat'; id: string }): Promise => { // Seat grants already identify their single target; there is no pod census // to intersect for them. The broker still checks the explicit audience. @@ -601,15 +594,9 @@ router.get('/', grantRateLimit, auth, async (req: AuthenticatedRequest, res: exp if (!userId) return res.status(401).json({ error: 'unauthorized' }); if (req.query.connectionOwner !== 'me') return res.status(400).json({ error: 'invalid_connection_owner' }); - const connections = await Integration.find({ createdBy: userId }) - .select('_id installationId config.installationId createdBy') - .lean(); - const connectionIds = Array.from(new Set(connections - .filter((connection: any) => connectionOwnerId(connection) === userId) - .flatMap(connectionIdsOf))); - if (!connectionIds.length) return res.json({ grants: [] }); - - const grants = await RoomGrant.find({ connectionId: { $in: connectionIds } }) + // The owner is the binding fixed at mint, not whoever holds the connection id + // now. An unbound grant is listed for nobody here. + const grants = await RoomGrant.find({ ownerId: userId }) .sort({ createdAt: -1 }).lean(); const podIds = Array.from(new Set(grants .filter((grant) => grant.target.kind === 'pod' && Types.ObjectId.isValid(grant.target.id))