Skip to content

Commit e04dd53

Browse files
feat(copilot-app): no-access screens, MCP deep-links, ticket refresh (#315) (#3215)
* feat(copilot-app): no-access screens, MCP deep-links, ticket refresh * fix(copilot-app): guard refresh paths against stale-fetch races * fix(copilot-app): address PR review feedback (race, errors, tests) * chore(agents-api): regenerate OpenAPI snapshot for AppListResponse role + tenantHasAnyApps * fix(copilot-app): mount-path UX polish, fetchApps tests, tenantHasAnyApps consistency GitOrigin-RevId: e348e84a8a92bb565474c5c2bad6c5ec37ec3e71 Co-authored-by: omar-inkeep <omar@inkeep.com>
1 parent 5318d54 commit e04dd53

5 files changed

Lines changed: 227 additions & 1 deletion

File tree

.changeset/retail-blush-trout.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@inkeep/agents-core": patch
3+
"@inkeep/agents-api": patch
4+
---
5+
6+
Return tenant role on apps list response

agents-api/__snapshots__/openapi.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1251,6 +1251,17 @@
12511251
},
12521252
"pagination": {
12531253
"$ref": "#/components/schemas/Pagination"
1254+
},
1255+
"role": {
1256+
"enum": [
1257+
"owner",
1258+
"admin",
1259+
"member"
1260+
],
1261+
"type": "string"
1262+
},
1263+
"tenantHasAnyApps": {
1264+
"type": "boolean"
12541265
}
12551266
},
12561267
"required": [

agents-api/src/__tests__/manage/routes/tenantAppsAuthz.test.ts

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,14 @@ describe('GET /manage/tenants/:tenantId/apps — project access filtering', () =
100100
const res = await app.request('/manage/tenants/tenant-1/apps');
101101

102102
expect(res.status).toBe(200);
103-
expect(listAppsPaginatedMock).not.toHaveBeenCalled();
103+
// The main scoped listing is short-circuited; only the unscoped
104+
// tenantHasAnyApps probe runs (one call, with `limit: 1`).
105+
expect(listAppsPaginatedMock).toHaveBeenCalledTimes(1);
106+
expect(listAppsPaginatedMock).toHaveBeenCalledWith({
107+
scopes: { tenantId: 'tenant-1' },
108+
pagination: { page: 1, limit: 1 },
109+
type: undefined,
110+
});
104111

105112
const body = await res.json();
106113
expect(body.data).toEqual([]);
@@ -151,3 +158,149 @@ describe('GET /manage/tenants/:tenantId/apps — project access filtering', () =
151158
});
152159
});
153160
});
161+
162+
describe('GET /manage/tenants/:tenantId/apps — role and tenantHasAnyApps response fields', () => {
163+
beforeEach(() => {
164+
vi.clearAllMocks();
165+
listAppsPaginatedMock.mockResolvedValue(emptyPage);
166+
});
167+
168+
afterEach(() => {
169+
vi.restoreAllMocks();
170+
});
171+
172+
it('returns role and tenantHasAnyApps=true when a member with no project access is in a tenant with apps elsewhere', async () => {
173+
listUsableProjectIdsMock.mockResolvedValue([]);
174+
// The handler's early-return path runs the unscoped tenantHasAnyApps probe.
175+
listAppsPaginatedMock.mockResolvedValueOnce({
176+
data: [{ id: 'app-elsewhere' }],
177+
pagination: { page: 1, limit: 1, total: 1, pages: 1 },
178+
});
179+
180+
const app = buildHarness({ userId: 'user-no-projects', tenantRole: 'member' });
181+
const res = await app.request('/manage/tenants/tenant-1/apps?type=support_copilot');
182+
183+
expect(res.status).toBe(200);
184+
const body = await res.json();
185+
expect(body.data).toEqual([]);
186+
expect(body.role).toBe('member');
187+
expect(body.tenantHasAnyApps).toBe(true);
188+
// Only the unscoped probe runs — main listing is short-circuited.
189+
expect(listAppsPaginatedMock).toHaveBeenCalledTimes(1);
190+
expect(listAppsPaginatedMock).toHaveBeenCalledWith({
191+
scopes: { tenantId: 'tenant-1' },
192+
pagination: { page: 1, limit: 1 },
193+
type: 'support_copilot',
194+
});
195+
});
196+
197+
it('returns tenantHasAnyApps=false when a member with no project access is in a tenant with zero apps', async () => {
198+
listUsableProjectIdsMock.mockResolvedValue([]);
199+
listAppsPaginatedMock.mockResolvedValueOnce(emptyPage);
200+
201+
const app = buildHarness({ userId: 'user-no-projects', tenantRole: 'member' });
202+
const res = await app.request('/manage/tenants/tenant-1/apps');
203+
204+
expect(res.status).toBe(200);
205+
const body = await res.json();
206+
expect(body.data).toEqual([]);
207+
expect(body.role).toBe('member');
208+
expect(body.tenantHasAnyApps).toBe(false);
209+
});
210+
211+
it('fires a second unscoped query when a member has projects but the scoped result is empty', async () => {
212+
listUsableProjectIdsMock.mockResolvedValue(['proj-a']);
213+
// First call (scoped to user's projects) returns empty.
214+
listAppsPaginatedMock.mockResolvedValueOnce(emptyPage);
215+
// Second call (unscoped tenant probe) finds apps elsewhere.
216+
listAppsPaginatedMock.mockResolvedValueOnce({
217+
data: [{ id: 'app-elsewhere' }],
218+
pagination: { page: 1, limit: 1, total: 1, pages: 1 },
219+
});
220+
221+
const app = buildHarness({ userId: 'user-non-admin', tenantRole: 'member' });
222+
const res = await app.request('/manage/tenants/tenant-1/apps');
223+
224+
expect(res.status).toBe(200);
225+
const body = await res.json();
226+
expect(body.data).toEqual([]);
227+
expect(body.role).toBe('member');
228+
expect(body.tenantHasAnyApps).toBe(true);
229+
expect(listAppsPaginatedMock).toHaveBeenCalledTimes(2);
230+
expect(listAppsPaginatedMock).toHaveBeenNthCalledWith(1, {
231+
scopes: { tenantId: 'tenant-1', projectIds: ['proj-a'] },
232+
pagination: { page: 1, limit: 10 },
233+
type: undefined,
234+
});
235+
expect(listAppsPaginatedMock).toHaveBeenNthCalledWith(2, {
236+
scopes: { tenantId: 'tenant-1' },
237+
pagination: { page: 1, limit: 1 },
238+
type: undefined,
239+
});
240+
});
241+
242+
it('skips the second query and returns tenantHasAnyApps=true when the scoped result is non-empty', async () => {
243+
listUsableProjectIdsMock.mockResolvedValue(['proj-a']);
244+
listAppsPaginatedMock.mockResolvedValueOnce({
245+
data: [
246+
{
247+
id: 'app-a',
248+
tenantId: 'tenant-1',
249+
projectId: 'proj-a',
250+
name: 'A',
251+
type: 'support_copilot',
252+
enabled: true,
253+
config: { type: 'support_copilot', supportCopilot: {} },
254+
defaultAgentId: null,
255+
defaultProjectId: 'proj-a',
256+
lastUsedAt: null,
257+
createdAt: '2025-01-01T00:00:00.000Z',
258+
updatedAt: '2025-01-01T00:00:00.000Z',
259+
},
260+
],
261+
pagination: { page: 1, limit: 10, total: 1, pages: 1 },
262+
});
263+
264+
const app = buildHarness({ userId: 'user-non-admin', tenantRole: 'member' });
265+
const res = await app.request('/manage/tenants/tenant-1/apps');
266+
267+
expect(res.status).toBe(200);
268+
const body = await res.json();
269+
expect(body.data).toHaveLength(1);
270+
expect(body.role).toBe('member');
271+
expect(body.tenantHasAnyApps).toBe(true);
272+
// No second probe — non-empty result is authoritative.
273+
expect(listAppsPaginatedMock).toHaveBeenCalledTimes(1);
274+
});
275+
276+
it('returns role=admin and tenantHasAnyApps=false without a second query when an admin sees an empty list', async () => {
277+
listAppsPaginatedMock.mockResolvedValue(emptyPage);
278+
279+
const app = buildHarness({ userId: 'user-admin', tenantRole: 'admin' });
280+
const res = await app.request('/manage/tenants/tenant-1/apps');
281+
282+
expect(res.status).toBe(200);
283+
const body = await res.json();
284+
expect(body.data).toEqual([]);
285+
expect(body.role).toBe('admin');
286+
// Admins see the whole tenant, so empty is authoritative — no extra DB hit.
287+
expect(body.tenantHasAnyApps).toBe(false);
288+
expect(listAppsPaginatedMock).toHaveBeenCalledTimes(1);
289+
});
290+
291+
it('returns role=owner and tenantHasAnyApps=true for an owner with apps', async () => {
292+
listAppsPaginatedMock.mockResolvedValueOnce({
293+
data: [{ id: 'app-a' }],
294+
pagination: { page: 1, limit: 10, total: 1, pages: 1 },
295+
});
296+
297+
const app = buildHarness({ userId: 'user-owner', tenantRole: 'owner' });
298+
const res = await app.request('/manage/tenants/tenant-1/apps');
299+
300+
expect(res.status).toBe(200);
301+
const body = await res.json();
302+
expect(body.role).toBe('owner');
303+
expect(body.tenantHasAnyApps).toBe(true);
304+
expect(listAppsPaginatedMock).toHaveBeenCalledTimes(1);
305+
});
306+
});

agents-api/src/domains/manage/routes/tenantApps.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
commonGetErrorResponses,
55
listAppsPaginated,
66
listUsableProjectIds,
7+
type OrgRole,
78
OrgRoles,
89
PaginationQueryParamsSchema,
910
sanitizeAppConfig,
@@ -63,9 +64,28 @@ app.openapi(
6364
if (!isOrgAdmin) {
6465
projectIds = userId ? await listUsableProjectIds({ userId, tenantId }) : [];
6566
if (projectIds.length === 0) {
67+
// Member with no project memberships. Empty list either way; check
68+
// whether the tenant has any apps of this type so the client can
69+
// distinguish "you don't have access to existing apps" from "no
70+
// apps exist yet".
71+
//
72+
// Intentional information-disclosure tradeoff: a member with zero
73+
// project access can learn whether the tenant has any apps of this
74+
// type via `tenantHasAnyApps`. Within their own tenant, that single
75+
// boolean is acceptable disclosure to drive correct empty-state UX
76+
// (ask admin to add me vs ask admin to set up Copilot). If a future
77+
// deploy needs strict project-boundary silos, this branch should
78+
// be revisited.
79+
const tenantWide = await listAppsPaginated(runDbClient)({
80+
scopes: { tenantId },
81+
pagination: { page: 1, limit: 1 },
82+
type,
83+
});
6684
return c.json({
6785
data: [],
6886
pagination: { page, limit, total: 0, pages: 0 },
87+
role: tenantRole as OrgRole,
88+
tenantHasAnyApps: tenantWide.pagination.total > 0,
6989
});
7090
}
7191
}
@@ -78,9 +98,30 @@ app.openapi(
7898

7999
const sanitizedData = result.data.map((app) => sanitizeAppConfig(app));
80100

101+
// Skip the second query when we already know: a non-empty list means
102+
// apps exist, and an admin's empty result is authoritative because
103+
// their scope is the whole tenant.
104+
let tenantHasAnyApps: boolean;
105+
if (result.pagination.total > 0) {
106+
// Use total, not sanitizedData.length: page 2+ with results on page 1
107+
// would spuriously fall through to the unscoped query below otherwise.
108+
tenantHasAnyApps = true;
109+
} else if (isOrgAdmin) {
110+
tenantHasAnyApps = false;
111+
} else {
112+
const tenantWide = await listAppsPaginated(runDbClient)({
113+
scopes: { tenantId },
114+
pagination: { page: 1, limit: 1 },
115+
type,
116+
});
117+
tenantHasAnyApps = tenantWide.pagination.total > 0;
118+
}
119+
81120
return c.json({
82121
data: sanitizedData,
83122
pagination: result.pagination,
123+
role: tenantRole as OrgRole,
124+
tenantHasAnyApps,
84125
});
85126
}
86127
);

packages/agents-core/src/validation/schemas.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2883,6 +2883,21 @@ export const AppListResponse = z
28832883
.object({
28842884
data: z.array(AppApiResponseSelectSchema),
28852885
pagination: PaginationSchema,
2886+
/**
2887+
* The caller's organization role. Set on tenant-wide list responses so
2888+
* the client can branch UX (admin "create one" vs member "ask admin")
2889+
* when `data` is empty. Omitted on project-scoped list responses where
2890+
* role isn't a meaningful signal.
2891+
*/
2892+
role: z.enum(['owner', 'admin', 'member']).optional(),
2893+
/**
2894+
* Whether the tenant has ANY apps of the requested type, regardless of
2895+
* the caller's project memberships. Lets a member with an empty list
2896+
* distinguish "I don't have access to existing apps" from "no apps
2897+
* exist anywhere yet". Set on tenant-wide responses; omitted on
2898+
* project-scoped responses.
2899+
*/
2900+
tenantHasAnyApps: z.boolean().optional(),
28862901
})
28872902
.openapi('AppListResponse');
28882903
export const CredentialReferenceListResponse = z

0 commit comments

Comments
 (0)