diff --git a/backend/__tests__/unit/routes/grants.read.test.js b/backend/__tests__/unit/routes/grants.read.test.js index ea055367eb..16839e5384 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(), @@ -124,10 +124,105 @@ 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.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); + 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', ownerId: STRANGER, target: { kind: 'pod', id: podIds[20] } })); + 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); + 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'); + }); + + // 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 () => { + 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.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. + 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 39ad988bea..414cc7128d 100644 --- a/backend/routes/grants.ts +++ b/backend/routes/grants.ts @@ -587,6 +587,43 @@ 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' }); + + // 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)) + .map((grant) => grant.target.id))); + const [pods, confinements] = await Promise.all([ + Promise.all(podIds.map(async (podId) => [podId, await loadPod(podId)] as const)), + seatGrantRefusals(grants), + ]); + // 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); + } +}); + /** * 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.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}`)); diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index 22695150ab..52fb1c21bc 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,33 @@ 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(); + 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' } }); + 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 +196,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 +212,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 +303,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 +349,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 +411,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: {} }); @@ -416,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: {} }); @@ -438,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: {} }); @@ -457,7 +480,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 +494,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 +530,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; }));