From bb6b53f0713a3657aeac7ee4821c8dcfc6230fae Mon Sep 17 00:00:00 2001 From: jarvis Date: Thu, 3 Sep 2026 17:54:50 -0400 Subject: [PATCH] fix(github): align authorization catalog pagination --- src/core/external-authorization-server.ts | 42 ++++++++++++++----- .../github/external-authorization.ts | 15 ++++--- .../external-authorization-server.test.ts | 16 ++++--- .../github-external-authorization.test.ts | 4 +- 4 files changed, 51 insertions(+), 26 deletions(-) diff --git a/src/core/external-authorization-server.ts b/src/core/external-authorization-server.ts index 8f5db96..e9c3760 100644 --- a/src/core/external-authorization-server.ts +++ b/src/core/external-authorization-server.ts @@ -32,13 +32,13 @@ export type ExternalProviderAuthorization = { authorizationDetailsTypes?: readonly string[] authorizationDetailsCatalog?: { scope: string - list(input: { subject: string; limit: number; offset: number }): Promise<{ + list(input: { subject: string; page: number; pageSize: number }): Promise<{ items: Array<{ authorizationDetail: Record grantedScopes?: string[] display: { label: string; description?: string; metadata?: Record } }> - pagination: { limit: number; offset: number; total: number; hasMore: boolean; nextOffset: number | null } + pagination: { page: number; pageSize: number; totalItems: number; totalPages: number } }> } authorizationDetailsSubset?(input: { @@ -410,13 +410,14 @@ export async function createExternalAuthorizationServer(input: { if (!scopes.includes(authorizationDetailsCatalog.scope)) { throw oauthError('insufficient_scope', 'The access token does not authorize catalog discovery.', 403) } - return c.json( - await authorizationDetailsCatalog.list({ - subject: String(verified.payload.sub), - limit: paginationInteger(c.req.query('limit'), 'limit', 50, 1), - offset: paginationInteger(c.req.query('offset'), 'offset', 0, 0), - }), - ) + const result = await authorizationDetailsCatalog.list({ + subject: String(verified.payload.sub), + page: paginationInteger(c.req.query('page'), 'page', 1, 1), + pageSize: paginationInteger(c.req.query('pageSize'), 'pageSize', 20, 1), + }) + const link = paginationLinkHeader(c.req.url, result.pagination) + if (link) c.header('Link', link) + return c.json(result) }) } }, @@ -684,12 +685,33 @@ function normalizeScopes(value: string) { function paginationInteger(value: string | undefined, name: string, fallback: number, minimum: number) { if (value === undefined) return fallback const parsed = Number(value) - if (!Number.isSafeInteger(parsed) || parsed < minimum || (name === 'limit' && parsed > 100)) { + if (!Number.isSafeInteger(parsed) || parsed < minimum || (name === 'pageSize' && parsed > 100)) { throw oauthError('invalid_request', `${name} is invalid.`) } return parsed } +function paginationLinkHeader(requestUrl: string, pagination: { page: number; pageSize: number; totalPages: number }) { + if (pagination.totalPages === 0) return null + const links: string[] = [] + if (pagination.page > 1) { + links.push(paginationLink(requestUrl, 1, pagination.pageSize, 'first')) + links.push(paginationLink(requestUrl, pagination.page - 1, pagination.pageSize, 'previous')) + } + if (pagination.page < pagination.totalPages) { + links.push(paginationLink(requestUrl, pagination.page + 1, pagination.pageSize, 'next')) + links.push(paginationLink(requestUrl, pagination.totalPages, pagination.pageSize, 'last')) + } + return links.length ? links.join(', ') : null +} + +function paginationLink(requestUrl: string, page: number, pageSize: number, relation: string) { + const url = new URL(requestUrl) + url.searchParams.set('page', String(page)) + url.searchParams.set('pageSize', String(pageSize)) + return `<${url.toString()}>; rel="${relation}"` +} + function validRedirectUri(value: string) { try { const url = new URL(value) diff --git a/src/providers/github/external-authorization.ts b/src/providers/github/external-authorization.ts index d21c7ff..98878b1 100644 --- a/src/providers/github/external-authorization.ts +++ b/src/providers/github/external-authorization.ts @@ -67,22 +67,21 @@ export function createGitHubExternalAuthorization(input: { authorizationDetailsTypes: [GITHUB_INSTALLATION_AUTHORIZATION_DETAIL_TYPE], authorizationDetailsCatalog: { scope: authorizationDetailsCatalogScope, - async list({ subject, limit, offset }) { + async list({ subject, page, pageSize }) { const contexts = (await input.connections.externalAuthorization(subject)).contexts - const items = contexts.slice(offset, offset + limit).map((context) => ({ + const offset = (page - 1) * pageSize + const items = contexts.slice(offset, offset + pageSize).map((context) => ({ authorizationDetail: githubInstallationAuthorizationDetail(context), grantedScopes: context.scopes, display: githubInstallationAuthorizationDetailDisplay(context), })) - const nextOffset = offset + limit < contexts.length ? offset + limit : null return { items, pagination: { - limit, - offset, - total: contexts.length, - hasMore: nextOffset !== null, - nextOffset, + page, + pageSize, + totalItems: contexts.length, + totalPages: Math.ceil(contexts.length / pageSize), }, } }, diff --git a/test/core/external-authorization-server.test.ts b/test/core/external-authorization-server.test.ts index 588d302..4d25050 100644 --- a/test/core/external-authorization-server.test.ts +++ b/test/core/external-authorization-server.test.ts @@ -53,7 +53,7 @@ describe('external authorization server', () => { expect(tokenResponse.status).toBe(200) const token = (await tokenResponse.json()) as { access_token: string } - const response = await app.request('/oauth/example/authorization-details?limit=10&offset=0', { + const response = await app.request('/oauth/example/authorization-details?page=2&pageSize=1', { headers: { authorization: `Bearer ${token.access_token}` }, }) @@ -65,12 +65,16 @@ describe('external authorization server', () => { display: { label: 'Project One' }, }, ], - pagination: { limit: 10, offset: 0, total: 1, hasMore: false, nextOffset: null }, + pagination: { page: 2, pageSize: 1, totalItems: 2, totalPages: 2 }, }) + expect(response.headers.get('link')).toBe( + '; rel="first", ' + + '; rel="previous"', + ) expect(provider.authorizationDetailsCatalog?.list).toHaveBeenCalledWith({ subject: 'provider-user-1', - limit: 10, - offset: 0, + page: 2, + pageSize: 1, }) }) @@ -289,14 +293,14 @@ async function testServer( authorizationDetailsTypes: ['example_context'], authorizationDetailsCatalog: { scope: 'authorization-details:read', - list: vi.fn(async ({ limit, offset }) => ({ + list: vi.fn(async ({ page, pageSize }) => ({ items: [ { authorizationDetail: { type: 'example_context', project_id: 'project-1' }, display: { label: 'Project One' }, }, ], - pagination: { limit, offset, total: 1, hasMore: false, nextOffset: null }, + pagination: { page, pageSize, totalItems: 2, totalPages: 2 }, })), }, begin: vi.fn(({ providerState }) => ({ diff --git a/test/providers/github-external-authorization.test.ts b/test/providers/github-external-authorization.test.ts index 1ae68da..924872f 100644 --- a/test/providers/github-external-authorization.test.ts +++ b/test/providers/github-external-authorization.test.ts @@ -29,7 +29,7 @@ describe('GitHub external authorization', () => { }) await expect( - external.authorization.authorizationDetailsCatalog?.list({ subject: '70', limit: 10, offset: 0 }), + external.authorization.authorizationDetailsCatalog?.list({ subject: '70', page: 1, pageSize: 10 }), ).resolves.toEqual({ items: [ { @@ -52,7 +52,7 @@ describe('GitHub external authorization', () => { }, }, ], - pagination: { limit: 10, offset: 0, total: 1, hasMore: false, nextOffset: null }, + pagination: { page: 1, pageSize: 10, totalItems: 1, totalPages: 1 }, }) })