From 3336761bdff394f18c3adc314a1cef308c55fa62 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Mon, 3 Aug 2026 18:45:15 +0000 Subject: [PATCH 1/5] feat(coderd): bound pagination limit in ParsePagination Resolve a limit of 0 or an omitted limit to MaxPaginationLimit (1000) and reject a larger explicit limit, so paginated endpoints can no longer issue an unbounded query. Refs PLAT-386. --- coderd/pagination.go | 26 ++++++++++++++++++++++---- coderd/pagination_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/coderd/pagination.go b/coderd/pagination.go index 011f8df9e7bd4..086c8336a78df 100644 --- a/coderd/pagination.go +++ b/coderd/pagination.go @@ -1,6 +1,7 @@ package coderd import ( + "fmt" "net/http" "github.com/google/uuid" @@ -9,6 +10,11 @@ import ( "github.com/coder/coder/v2/codersdk" ) +// MaxPaginationLimit is the largest page size ParsePagination accepts. An +// omitted limit resolves to this value. A limit that is set must be a positive +// integer no greater than this, so the resulting query is always bounded. +const MaxPaginationLimit = 100 + // ParsePagination extracts pagination query params from the http request. // If an error is encountered, the error is written to w and ok is set to false. func ParsePagination(w http.ResponseWriter, r *http.Request) (p codersdk.Pagination, ok bool) { @@ -17,10 +23,22 @@ func ParsePagination(w http.ResponseWriter, r *http.Request) (p codersdk.Paginat parser := httpapi.NewQueryParamParser() params := codersdk.Pagination{ AfterID: parser.UUID(queryParams, uuid.Nil, "after_id"), - // A limit of 0 should be interpreted by the SQL query as "null" or - // "no limit". Do not make this value anything besides 0. - Limit: int(parser.PositiveInt32(queryParams, 0, "limit")), - Offset: int(parser.PositiveInt32(queryParams, 0, "offset")), + Offset: int(parser.PositiveInt32(queryParams, 0, "offset")), + } + limitErrsBefore := len(parser.Errors) + params.Limit = int(parser.PositiveInt32(queryParams, 0, "limit")) + limitParsed := len(parser.Errors) == limitErrsBefore + // An omitted limit resolves to MaxPaginationLimit so the downstream query is + // never unbounded. A limit that is set must be a positive integer no greater + // than MaxPaginationLimit; otherwise it is rejected rather than clamped. + switch { + case queryParams.Get("limit") == "": + params.Limit = MaxPaginationLimit + case limitParsed && (params.Limit < 1 || params.Limit > MaxPaginationLimit): + parser.Errors = append(parser.Errors, codersdk.ValidationError{ + Field: "limit", + Detail: fmt.Sprintf("Query param \"limit\" must be a positive integer no greater than %d.", MaxPaginationLimit), + }) } if len(parser.Errors) > 0 { httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{ diff --git a/coderd/pagination_test.go b/coderd/pagination_test.go index f6e1aab7067f4..9eca6a719377c 100644 --- a/coderd/pagination_test.go +++ b/coderd/pagination_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strconv" "testing" "github.com/google/uuid" @@ -72,6 +73,11 @@ func TestPagination(t *testing.T) { Offset: "-1", ExpectedError: invalidValues, }, + { + Name: "LimitAboveMax", + Limit: strconv.Itoa(coderd.MaxPaginationLimit + 1), + ExpectedError: invalidValues, + }, // Valid values { @@ -93,11 +99,35 @@ func TestPagination(t *testing.T) { Limit: 50, }, }, + { + // An omitted limit resolves to the maximum page size so the + // query is never unbounded. + Name: "DefaultsToMaxWhenAbsent", + ExpectedParams: codersdk.Pagination{ + AfterID: uuid.Nil, + Limit: coderd.MaxPaginationLimit, + }, + }, + { + // An explicit limit must be positive; 0 is rejected. + Name: "ZeroLimitRejected", + Limit: "0", + ExpectedError: invalidValues, + }, + { + Name: "LimitAtMax", + Limit: strconv.Itoa(coderd.MaxPaginationLimit), + ExpectedParams: codersdk.Pagination{ + AfterID: uuid.Nil, + Limit: coderd.MaxPaginationLimit, + }, + }, { Name: "ValidOffset", Offset: "150", ExpectedParams: codersdk.Pagination{ AfterID: uuid.Nil, + Limit: coderd.MaxPaginationLimit, Offset: 150, }, }, @@ -106,6 +136,7 @@ func TestPagination(t *testing.T) { AfterID: "5f2005fc-acc4-4e5e-a7fa-be017359c60b", ExpectedParams: codersdk.Pagination{ AfterID: uuid.MustParse("5f2005fc-acc4-4e5e-a7fa-be017359c60b"), + Limit: coderd.MaxPaginationLimit, }, }, } From 197bbb19e6734e3137d3f9cd1dd9cd7a2ca73997 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 6 Aug 2026 22:58:02 +0000 Subject: [PATCH 2/5] chore: reconcile pagination docs and drop dead ai-gateway limit checks ParsePagination now defaults an omitted limit to MaxPaginationLimit and rejects anything outside 1..MaxPaginationLimit, so several doc strings no longer describe the behavior: - codersdk.Pagination.Limit no longer means "no limit" at <= 0. - paginated-members no longer returns all members at limit=0. - limit is no longer required on /audit and /connectionlog. The ai-gateway session, model, and client list handlers each defaulted a zero limit and rejected limits above 1000. ParsePagination guarantees both conditions, so the blocks and their constants are removed. The effective ceiling on those routes drops from 1000 to MaxPaginationLimit. Refs PLAT-386. --- coderd/apidoc/docs.go | 8 +++--- coderd/apidoc/swagger.json | 8 +++--- coderd/audit.go | 2 +- coderd/members.go | 2 +- codersdk/pagination.go | 8 +++--- docs/reference/api/audit.md | 4 +-- docs/reference/api/enterprise.md | 4 +-- docs/reference/api/members.md | 14 +++++------ enterprise/coderd/aibridge.go | 40 ------------------------------ enterprise/coderd/aibridge_test.go | 5 ++-- enterprise/coderd/connectionlog.go | 2 +- site/src/api/typesGenerated.ts | 8 +++--- 12 files changed, 33 insertions(+), 72 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 526fe4b48fad7..af2c11df80a02 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2078,8 +2078,7 @@ const docTemplate = `{ "type": "integer", "description": "Page limit", "name": "limit", - "in": "query", - "required": true + "in": "query" }, { "type": "integer", @@ -2239,8 +2238,7 @@ const docTemplate = `{ "type": "integer", "description": "Page limit", "name": "limit", - "in": "query", - "required": true + "in": "query" }, { "type": "integer", @@ -5854,7 +5852,7 @@ const docTemplate = `{ }, { "type": "integer", - "description": "Page limit, if 0 returns all members", + "description": "Page limit", "name": "limit", "in": "query" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a009b6e708658..c937721ba3137 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1835,8 +1835,7 @@ "type": "integer", "description": "Page limit", "name": "limit", - "in": "query", - "required": true + "in": "query" }, { "type": "integer", @@ -1974,8 +1973,7 @@ "type": "integer", "description": "Page limit", "name": "limit", - "in": "query", - "required": true + "in": "query" }, { "type": "integer", @@ -5187,7 +5185,7 @@ }, { "type": "integer", - "description": "Page limit, if 0 returns all members", + "description": "Page limit", "name": "limit", "in": "query" }, diff --git a/coderd/audit.go b/coderd/audit.go index 44ed30770bc3a..1654d25208931 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -37,7 +37,7 @@ const auditLogCountCap = 2000 // @Produce json // @Tags Audit // @Param q query string false "Search query" -// @Param limit query int true "Page limit" +// @Param limit query int false "Page limit" // @Param offset query int false "Page offset" // @Success 200 {object} codersdk.AuditLogResponse // @Router /api/v2/audit [get] diff --git a/coderd/members.go b/coderd/members.go index 7f1511bebb94c..e9adce1befd14 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -269,7 +269,7 @@ func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { // @Param organization path string true "Organization ID" // @Param q query string false "Member search query" // @Param after_id query string false "After ID" format(uuid) -// @Param limit query int false "Page limit, if 0 returns all members" +// @Param limit query int false "Page limit" // @Param offset query int false "Page offset" // @Success 200 {object} []codersdk.PaginatedMembersResponse // @Router /api/v2/organizations/{organization}/paginated-members [get] diff --git a/codersdk/pagination.go b/codersdk/pagination.go index 2201277aecaa8..d4036103e94e3 100644 --- a/codersdk/pagination.go +++ b/codersdk/pagination.go @@ -15,9 +15,11 @@ type Pagination struct { // set AfterID to the last UUID returned by the previous // request. AfterID uuid.UUID `json:"after_id,omitempty" format:"uuid"` - // Limit sets the maximum number of users to be returned - // in a single page. If the limit is <= 0, there is no limit - // and all users are returned. + // Limit sets the maximum number of results returned in a single page. + // When set, it must be a positive integer no greater than the server's + // maximum page size, otherwise the request is rejected. A value of 0 + // omits the query parameter entirely, and the server resolves the page + // size to its maximum. Limit int `json:"limit,omitempty"` // Offset is used to indicate which page to return. An offset of 0 // returns the first 'limit' number of users. diff --git a/docs/reference/api/audit.md b/docs/reference/api/audit.md index 06a7057fb7d0d..ec74cd2c99b4d 100644 --- a/docs/reference/api/audit.md +++ b/docs/reference/api/audit.md @@ -6,7 +6,7 @@ ```sh # Example request using curl -curl -X GET http://coder-server:8080/api/v2/audit?limit=0 \ +curl -X GET http://coder-server:8080/api/v2/audit \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` @@ -18,7 +18,7 @@ curl -X GET http://coder-server:8080/api/v2/audit?limit=0 \ | Name | In | Type | Required | Description | |----------|-------|---------|----------|--------------| | `q` | query | string | false | Search query | -| `limit` | query | integer | true | Page limit | +| `limit` | query | integer | false | Page limit | | `offset` | query | integer | false | Page offset | ### Example responses diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 92da32c83531f..b2a70978ba04a 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -455,7 +455,7 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```sh # Example request using curl -curl -X GET http://coder-server:8080/api/v2/connectionlog?limit=0 \ +curl -X GET http://coder-server:8080/api/v2/connectionlog \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` @@ -467,7 +467,7 @@ curl -X GET http://coder-server:8080/api/v2/connectionlog?limit=0 \ | Name | In | Type | Required | Description | |----------|-------|---------|----------|--------------| | `q` | query | string | false | Search query | -| `limit` | query | integer | true | Page limit | +| `limit` | query | integer | false | Page limit | | `offset` | query | integer | false | Page offset | ### Example responses diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index 6660b4a138d93..bb752049ecc72 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -777,13 +777,13 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/paginat ### Parameters -| Name | In | Type | Required | Description | -|----------------|-------|--------------|----------|--------------------------------------| -| `organization` | path | string | true | Organization ID | -| `q` | query | string | false | Member search query | -| `after_id` | query | string(uuid) | false | After ID | -| `limit` | query | integer | false | Page limit, if 0 returns all members | -| `offset` | query | integer | false | Page offset | +| Name | In | Type | Required | Description | +|----------------|-------|--------------|----------|---------------------| +| `organization` | path | string | true | Organization ID | +| `q` | query | string | false | Member search query | +| `after_id` | query | string(uuid) | false | After ID | +| `limit` | query | integer | false | Page limit | +| `offset` | query | integer | false | Page offset | ### Example responses diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index 06e305b7fec8d..126f3c2854ff2 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -33,12 +33,6 @@ import ( ) const ( - maxListSessionsLimit = 1000 - maxListModelsLimit = 1000 - maxListClientsLimit = 1000 - defaultListSessionsLimit = 100 - defaultListModelsLimit = 100 - defaultListClientsLimit = 100 // aiBridgeRateLimitWindow is the fixed duration for rate limiting AI Bridge // requests. This is hardcoded to keep configuration simple. aiBridgeRateLimitWindow = time.Second @@ -178,16 +172,6 @@ func (api *API) aiBridgeListSessions(rw http.ResponseWriter, r *http.Request) { }) return } - if page.Limit == 0 { - page.Limit = defaultListSessionsLimit - } - if page.Limit > maxListSessionsLimit || page.Limit < 1 { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid pagination limit value.", - Detail: fmt.Sprintf("Pagination limit must be in range (0, %d]", maxListSessionsLimit), - }) - return - } queryStr := r.URL.Query().Get("q") filter, errs := searchquery.AIBridgeSessions(ctx, api.Database, queryStr, page, apiKey.UserID, afterSessionID) @@ -518,18 +502,6 @@ func (api *API) aiBridgeListModels(rw http.ResponseWriter, r *http.Request) { return } - if page.Limit == 0 { - page.Limit = defaultListModelsLimit - } - - if page.Limit > maxListModelsLimit || page.Limit < 1 { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid pagination limit value.", - Detail: fmt.Sprintf("Pagination limit must be in range (0, %d]", maxListModelsLimit), - }) - return - } - queryStr := r.URL.Query().Get("q") filter, errs := searchquery.AIBridgeModels(queryStr, page) @@ -571,18 +543,6 @@ func (api *API) aiBridgeListClients(rw http.ResponseWriter, r *http.Request) { return } - if page.Limit == 0 { - page.Limit = defaultListClientsLimit - } - - if page.Limit > maxListClientsLimit || page.Limit < 1 { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid pagination limit value.", - Detail: fmt.Sprintf("Pagination limit must be in range (0, %d]", maxListClientsLimit), - }) - return - } - queryStr := r.URL.Query().Get("q") filter, errs := searchquery.AIBridgeClients(queryStr, page) diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index cba71e261786c..f69c896fe6f8d 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" aiblib "github.com/coder/coder/v2/aibridge" + agplcoderd "github.com/coder/coder/v2/coderd" agplaibridge "github.com/coder/coder/v2/coderd/aibridge" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" @@ -781,12 +782,12 @@ func TestAIBridgeListSessions(t *testing.T) { //nolint:gocritic // Owner role is irrelevant; testing pagination validation. res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{ Pagination: codersdk.Pagination{ - Limit: 1001, + Limit: agplcoderd.MaxPaginationLimit + 1, }, }) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) - require.Contains(t, sdkErr.Message, "Invalid pagination limit value.") + require.Contains(t, sdkErr.Message, "Query parameters have invalid values.") require.Empty(t, res.Sessions) }) diff --git a/enterprise/coderd/connectionlog.go b/enterprise/coderd/connectionlog.go index 9754abab5209f..c7734aea6d5df 100644 --- a/enterprise/coderd/connectionlog.go +++ b/enterprise/coderd/connectionlog.go @@ -25,7 +25,7 @@ const connectionLogCountCap = 2000 // @Produce json // @Tags Enterprise // @Param q query string false "Search query" -// @Param limit query int true "Page limit" +// @Param limit query int false "Page limit" // @Param offset query int false "Page offset" // @Success 200 {object} codersdk.ConnectionLogResponse // @Router /api/v2/connectionlog [get] diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0222095336d24..ddb145a773ad4 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6984,9 +6984,11 @@ export interface Pagination { */ readonly after_id?: string; /** - * Limit sets the maximum number of users to be returned - * in a single page. If the limit is <= 0, there is no limit - * and all users are returned. + * Limit sets the maximum number of results returned in a single page. + * When set, it must be a positive integer no greater than the server's + * maximum page size, otherwise the request is rejected. A value of 0 + * omits the query parameter entirely, and the server resolves the page + * size to its maximum. */ readonly limit?: number; /** From 4375e02062a702f0970fb6dd3577ed76ce503013 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Tue, 11 Aug 2026 15:16:11 +0000 Subject: [PATCH 3/5] refactor(codersdk): move MaxPaginationLimit into codersdk The bound describes a value clients need in order to size their own page requests, so it lives next to the Pagination type that carries it rather than in coderd. --- coderd/pagination.go | 17 ++++++----------- coderd/pagination_test.go | 12 ++++++------ codersdk/pagination.go | 11 ++++++++--- enterprise/coderd/aibridge_test.go | 3 +-- 4 files changed, 21 insertions(+), 22 deletions(-) diff --git a/coderd/pagination.go b/coderd/pagination.go index 086c8336a78df..399a1df0c1f49 100644 --- a/coderd/pagination.go +++ b/coderd/pagination.go @@ -10,11 +10,6 @@ import ( "github.com/coder/coder/v2/codersdk" ) -// MaxPaginationLimit is the largest page size ParsePagination accepts. An -// omitted limit resolves to this value. A limit that is set must be a positive -// integer no greater than this, so the resulting query is always bounded. -const MaxPaginationLimit = 100 - // ParsePagination extracts pagination query params from the http request. // If an error is encountered, the error is written to w and ok is set to false. func ParsePagination(w http.ResponseWriter, r *http.Request) (p codersdk.Pagination, ok bool) { @@ -28,16 +23,16 @@ func ParsePagination(w http.ResponseWriter, r *http.Request) (p codersdk.Paginat limitErrsBefore := len(parser.Errors) params.Limit = int(parser.PositiveInt32(queryParams, 0, "limit")) limitParsed := len(parser.Errors) == limitErrsBefore - // An omitted limit resolves to MaxPaginationLimit so the downstream query is - // never unbounded. A limit that is set must be a positive integer no greater - // than MaxPaginationLimit; otherwise it is rejected rather than clamped. + // An omitted limit resolves to codersdk.MaxPaginationLimit so the downstream + // query is never unbounded. A limit that is set must be a positive integer no + // greater than that maximum; otherwise it is rejected rather than clamped. switch { case queryParams.Get("limit") == "": - params.Limit = MaxPaginationLimit - case limitParsed && (params.Limit < 1 || params.Limit > MaxPaginationLimit): + params.Limit = codersdk.MaxPaginationLimit + case limitParsed && (params.Limit < 1 || params.Limit > codersdk.MaxPaginationLimit): parser.Errors = append(parser.Errors, codersdk.ValidationError{ Field: "limit", - Detail: fmt.Sprintf("Query param \"limit\" must be a positive integer no greater than %d.", MaxPaginationLimit), + Detail: fmt.Sprintf("Query param \"limit\" must be a positive integer no greater than %d.", codersdk.MaxPaginationLimit), }) } if len(parser.Errors) > 0 { diff --git a/coderd/pagination_test.go b/coderd/pagination_test.go index 9eca6a719377c..d4ce5e13f00bc 100644 --- a/coderd/pagination_test.go +++ b/coderd/pagination_test.go @@ -75,7 +75,7 @@ func TestPagination(t *testing.T) { }, { Name: "LimitAboveMax", - Limit: strconv.Itoa(coderd.MaxPaginationLimit + 1), + Limit: strconv.Itoa(codersdk.MaxPaginationLimit + 1), ExpectedError: invalidValues, }, @@ -105,7 +105,7 @@ func TestPagination(t *testing.T) { Name: "DefaultsToMaxWhenAbsent", ExpectedParams: codersdk.Pagination{ AfterID: uuid.Nil, - Limit: coderd.MaxPaginationLimit, + Limit: codersdk.MaxPaginationLimit, }, }, { @@ -116,10 +116,10 @@ func TestPagination(t *testing.T) { }, { Name: "LimitAtMax", - Limit: strconv.Itoa(coderd.MaxPaginationLimit), + Limit: strconv.Itoa(codersdk.MaxPaginationLimit), ExpectedParams: codersdk.Pagination{ AfterID: uuid.Nil, - Limit: coderd.MaxPaginationLimit, + Limit: codersdk.MaxPaginationLimit, }, }, { @@ -127,7 +127,7 @@ func TestPagination(t *testing.T) { Offset: "150", ExpectedParams: codersdk.Pagination{ AfterID: uuid.Nil, - Limit: coderd.MaxPaginationLimit, + Limit: codersdk.MaxPaginationLimit, Offset: 150, }, }, @@ -136,7 +136,7 @@ func TestPagination(t *testing.T) { AfterID: "5f2005fc-acc4-4e5e-a7fa-be017359c60b", ExpectedParams: codersdk.Pagination{ AfterID: uuid.MustParse("5f2005fc-acc4-4e5e-a7fa-be017359c60b"), - Limit: coderd.MaxPaginationLimit, + Limit: codersdk.MaxPaginationLimit, }, }, } diff --git a/codersdk/pagination.go b/codersdk/pagination.go index d4036103e94e3..514894f4278dc 100644 --- a/codersdk/pagination.go +++ b/codersdk/pagination.go @@ -7,6 +7,11 @@ import ( "github.com/google/uuid" ) +// MaxPaginationLimit is the largest page size the server accepts. An omitted +// limit resolves to this value. A limit that is set must be a positive integer +// no greater than this, so the resulting query is always bounded. +const MaxPaginationLimit = 100 + // Pagination sets pagination options for the endpoints that support it. type Pagination struct { // AfterID returns all or up to Limit results after the given @@ -16,10 +21,10 @@ type Pagination struct { // request. AfterID uuid.UUID `json:"after_id,omitempty" format:"uuid"` // Limit sets the maximum number of results returned in a single page. - // When set, it must be a positive integer no greater than the server's - // maximum page size, otherwise the request is rejected. A value of 0 + // When set, it must be a positive integer no greater than + // MaxPaginationLimit, otherwise the request is rejected. A value of 0 // omits the query parameter entirely, and the server resolves the page - // size to its maximum. + // size to MaxPaginationLimit. Limit int `json:"limit,omitempty"` // Offset is used to indicate which page to return. An offset of 0 // returns the first 'limit' number of users. diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index f69c896fe6f8d..47a2085af88d8 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -17,7 +17,6 @@ import ( "github.com/stretchr/testify/require" aiblib "github.com/coder/coder/v2/aibridge" - agplcoderd "github.com/coder/coder/v2/coderd" agplaibridge "github.com/coder/coder/v2/coderd/aibridge" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" @@ -782,7 +781,7 @@ func TestAIBridgeListSessions(t *testing.T) { //nolint:gocritic // Owner role is irrelevant; testing pagination validation. res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{ Pagination: codersdk.Pagination{ - Limit: agplcoderd.MaxPaginationLimit + 1, + Limit: codersdk.MaxPaginationLimit + 1, }, }) var sdkErr *codersdk.Error From 07789fba4ba707732cb8e4fe6400cc6ade6dcf36 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Tue, 11 Aug 2026 15:16:12 +0000 Subject: [PATCH 4/5] feat(cli): page through list endpoints until exhausted The server now caps a page at MaxPaginationLimit, so commands that relied on an omitted limit returning every row request successive pages instead. --- cli/list.go | 4 ++++ cli/templatepull.go | 2 +- cli/templateversions.go | 21 ++++++++++++++++++++- cli/userlist.go | 19 +++++++++++++++---- 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/cli/list.go b/cli/list.go index 09e15f2e66310..ff473be2b483a 100644 --- a/cli/list.go +++ b/cli/list.go @@ -178,3 +178,7 @@ func QueryConvertWorkspaces[T any](ctx context.Context, client *codersdk.Client, } return converted, nil } + +// pageLimit is the number of rows requested per page when the CLI fetches a +// list endpoint to exhaustion. +const pageLimit = codersdk.MaxPaginationLimit diff --git a/cli/templatepull.go b/cli/templatepull.go index 322a11d8e36d8..18de4f00fbcc4 100644 --- a/cli/templatepull.go +++ b/cli/templatepull.go @@ -62,7 +62,7 @@ func (r *RootCmd) templatePull() *serpent.Command { { // Determine the latest template version and compare with the // active version. If they aren't the same, warn the user. - versions, err := client.TemplateVersionsByTemplate(ctx, codersdk.TemplateVersionsByTemplateRequest{ + versions, err := allTemplateVersions(ctx, client, codersdk.TemplateVersionsByTemplateRequest{ TemplateID: template.ID, }) if err != nil { diff --git a/cli/templateversions.go b/cli/templateversions.go index 30d4a1ca82be8..11590d1b675d8 100644 --- a/cli/templateversions.go +++ b/cli/templateversions.go @@ -1,6 +1,7 @@ package cli import ( + "context" "fmt" "strings" "time" @@ -14,6 +15,24 @@ import ( "github.com/coder/serpent" ) +// allTemplateVersions fetches template versions page by page until a page +// returns fewer rows than requested, which marks the end of the result set. +func allTemplateVersions(ctx context.Context, client *codersdk.Client, req codersdk.TemplateVersionsByTemplateRequest) ([]codersdk.TemplateVersion, error) { + req.Limit = pageLimit + var versions []codersdk.TemplateVersion + for { + page, err := client.TemplateVersionsByTemplate(ctx, req) + if err != nil { + return nil, err + } + versions = append(versions, page...) + if len(page) < pageLimit { + return versions, nil + } + req.Offset += len(page) + } +} + func (r *RootCmd) templateVersions() *serpent.Command { cmd := &serpent.Command{ Use: "versions", @@ -110,7 +129,7 @@ func (r *RootCmd) templateVersionsList() *serpent.Command { IncludeArchived: includeArchived.Value(), } - versions, err := client.TemplateVersionsByTemplate(inv.Context(), req) + versions, err := allTemplateVersions(inv.Context(), client, req) if err != nil { return xerrors.Errorf("get template versions by template: %w", err) } diff --git a/cli/userlist.go b/cli/userlist.go index c8a6740a935c3..83c3dc1df4477 100644 --- a/cli/userlist.go +++ b/cli/userlist.go @@ -48,12 +48,23 @@ func (r *RootCmd) userList() *serpent.Command { req.Search = fmt.Sprintf("github_com_user_id:%d", githubUserID) } - res, err := client.Users(inv.Context(), req) - if err != nil { - return err + // Fetch pages until a page returns fewer rows than requested, + // which marks the end of the result set. + req.Limit = pageLimit + users := []codersdk.User{} + for { + res, err := client.Users(inv.Context(), req) + if err != nil { + return err + } + users = append(users, res.Users...) + if len(res.Users) < pageLimit { + break + } + req.Offset += len(res.Users) } - out, err := formatter.Format(inv.Context(), res.Users) + out, err := formatter.Format(inv.Context(), users) if err != nil { return err } From 546969fd92d062484c2efda97fc3835c17869835 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Tue, 11 Aug 2026 15:22:03 +0000 Subject: [PATCH 5/5] fix(site,support): adopt the bounded page size An explicit limit of 0 is now rejected, so the callers that used it either request a single row when they only read the count, or page to exhaustion. Workspace and support page sizes derive from MaxPaginationLimit. --- codersdk/workspaces.go | 2 +- site/src/api/api.ts | 28 ++++++++++++++++++- site/src/api/queries/organizations.ts | 24 ++++++++++++++++ site/src/api/typesGenerated.ts | 20 +++++++++++-- .../UserAutocomplete/UserAutocomplete.tsx | 4 +-- .../UserOrGroupAutocomplete.tsx | 4 +-- .../components/UsageIndicator.stories.tsx | 2 +- .../AgentsPage/components/UsageIndicator.tsx | 3 +- .../DisableWorkspaceSharingDialog.tsx | 3 +- support/support.go | 2 +- 10 files changed, 79 insertions(+), 13 deletions(-) diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 5bb268411ad44..16639100a651a 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -633,7 +633,7 @@ func (c *Client) Workspaces(ctx context.Context, filter WorkspaceFilter) (Worksp } // WorkspacePageSize is the number of rows AllWorkspaces requests per page. -const WorkspacePageSize = 100 +const WorkspacePageSize = MaxPaginationLimit // AllWorkspaces requests successive pages of workspaces matching the filter and // returns every row. Limit and Offset on the filter are ignored. diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 8d275769cbf3a..b3b782ede89e2 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -48,7 +48,7 @@ export const SessionTokenCookie = "coder_session_token"; /** * WORKSPACE_PAGE_SIZE is the number of rows getAllWorkspaces requests per page. */ -export const WORKSPACE_PAGE_SIZE = 100; +export const WORKSPACE_PAGE_SIZE = TypesGen.MaxPaginationLimit; /** * @param agentId @@ -674,6 +674,32 @@ class ApiMethods { return response.data; }; + /** + * Requests successive pages of organization members until the offset reaches + * the total the server reports. + * + * @param organization Can be the organization's ID or name + */ + getAllOrganizationMembers = async ( + organization: string, + options: Omit = {}, + ): Promise => { + const members: TypesGen.OrganizationMemberWithUserData[] = []; + let count = 0; + let offset = 0; + do { + const page = await this.getOrganizationPaginatedMembers(organization, { + ...options, + limit: TypesGen.MaxPaginationLimit, + offset, + }); + members.push(...page.members); + count = page.count; + offset += TypesGen.MaxPaginationLimit; + } while (offset < count); + return { members, count }; + }; + /** * @param organization Can be the organization's ID or name */ diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 1dcaac36596f6..7c2c731de6d2c 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -93,6 +93,30 @@ export const organizationMembers = (id: string, req: UsersRequest) => { }; }; +type AllOrganizationMembersRequest = Omit; + +export const allOrganizationMembersKey = ( + id: string, + req: AllOrganizationMembersRequest, +) => ["organization", id, "members", { ...req, all: true }]; + +/** + * Creates a query configuration that fetches every page of an organization's + * members. Prefer a server-side search filter and a single page when the caller + * can express one. + * + * @param id - The unique identifier of the organization + */ +export const allOrganizationMembers = ( + id: string, + req: AllOrganizationMembersRequest = {}, +) => { + return { + queryFn: () => API.getAllOrganizationMembers(id, req), + queryKey: allOrganizationMembersKey(id, req), + }; +}; + export const paginatedOrganizationMembers = ( id: string, searchParams: URLSearchParams, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ddb145a773ad4..3aa17a2503984 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5952,6 +5952,14 @@ export const MaxChatFileIDs = 50; */ export const MaxChatFileSizeBytes = 10485760; +// From codersdk/pagination.go +/** + * MaxPaginationLimit is the largest page size the server accepts. An omitted + * limit resolves to this value. A limit that is set must be a positive integer + * no greater than this, so the resulting query is always bounded. + */ +export const MaxPaginationLimit = 100; + // From codersdk/usersecretsimport.go /** * MaxSecretsFileBytes bounds the raw size of a secrets file before parsing. @@ -6985,10 +6993,10 @@ export interface Pagination { readonly after_id?: string; /** * Limit sets the maximum number of results returned in a single page. - * When set, it must be a positive integer no greater than the server's - * maximum page size, otherwise the request is rejected. A value of 0 + * When set, it must be a positive integer no greater than + * MaxPaginationLimit, otherwise the request is rejected. A value of 0 * omits the query parameter entirely, and the server resolves the page - * size to its maximum. + * size to MaxPaginationLimit. */ readonly limit?: number; /** @@ -11347,6 +11355,12 @@ export interface WorkspaceOptions { readonly include_deleted?: boolean; } +// From codersdk/workspaces.go +/** + * WorkspacePageSize is the number of rows AllWorkspaces requests per page. + */ +export const WorkspacePageSize = 100; + // From codersdk/workspaceproxy.go export interface WorkspaceProxy extends Region { readonly derp_enabled: boolean; diff --git a/site/src/components/UserAutocomplete/UserAutocomplete.tsx b/site/src/components/UserAutocomplete/UserAutocomplete.tsx index b04f4251eb868..a59e51594e5ae 100644 --- a/site/src/components/UserAutocomplete/UserAutocomplete.tsx +++ b/site/src/components/UserAutocomplete/UserAutocomplete.tsx @@ -1,7 +1,7 @@ import { type FC, useId, useState } from "react"; import { keepPreviousData, useQuery } from "react-query"; import { getErrorMessage } from "#/api/errors"; -import { organizationMembers } from "#/api/queries/organizations"; +import { allOrganizationMembers } from "#/api/queries/organizations"; import { users, workspaceAvailableUsers } from "#/api/queries/users"; import type { MinimalUser, @@ -79,7 +79,7 @@ export const MemberAutocomplete: FC = ({ const [filter, setFilter] = useState(); const membersQuery = useQuery({ - ...organizationMembers(organizationId, { limit: 0 }), + ...allOrganizationMembers(organizationId), enabled: filter !== undefined, placeholderData: keepPreviousData, }); diff --git a/site/src/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete.tsx b/site/src/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete.tsx index 4a0999871829c..1e5b0e930de36 100644 --- a/site/src/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete.tsx +++ b/site/src/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete.tsx @@ -2,7 +2,7 @@ import { CheckIcon } from "lucide-react"; import { type FC, useState } from "react"; import { keepPreviousData, useQuery } from "react-query"; import { groupsByOrganization } from "#/api/queries/groups"; -import { organizationMembers } from "#/api/queries/organizations"; +import { allOrganizationMembers } from "#/api/queries/organizations"; import type { Group, OrganizationMemberWithUserData, @@ -53,7 +53,7 @@ export const UserOrGroupAutocomplete: FC = ({ // This allows regular org members to see other members in their org // for workspace sharing, without needing site-wide user:read permission. const membersQuery = useQuery({ - ...organizationMembers(organizationId, { limit: 0 }), + ...allOrganizationMembers(organizationId), enabled: open, placeholderData: keepPreviousData, }); diff --git a/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx b/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx index 2b449c7e4163a..51c26809cc654 100644 --- a/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx +++ b/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx @@ -99,7 +99,7 @@ const noBudgetStatus = aiSpendStatus({ const userWorkspacesRequest = { q: `owner:me organization:${MockDefaultOrganization.name}`, - limit: 0, + limit: 1, }; const noWorkspaceQuota = { credits_consumed: 0, diff --git a/site/src/pages/AgentsPage/components/UsageIndicator.tsx b/site/src/pages/AgentsPage/components/UsageIndicator.tsx index 872e20b76a1ff..10173ec7c4322 100644 --- a/site/src/pages/AgentsPage/components/UsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/UsageIndicator.tsx @@ -69,7 +69,8 @@ export const UsageIndicator: FC = () => { const workspacesQuery = useQuery({ ...workspaces({ q: `owner:me organization:${organizationName}`, - limit: 0, + // Only the count is read, so request the smallest valid page. + limit: 1, }), enabled: hasWorkspaceQuotaUsage && organizationName !== "", }); diff --git a/site/src/pages/OrganizationSettingsPage/DisableWorkspaceSharingDialog.tsx b/site/src/pages/OrganizationSettingsPage/DisableWorkspaceSharingDialog.tsx index b95b9aea1d1c7..6e5c859dbabdd 100644 --- a/site/src/pages/OrganizationSettingsPage/DisableWorkspaceSharingDialog.tsx +++ b/site/src/pages/OrganizationSettingsPage/DisableWorkspaceSharingDialog.tsx @@ -39,7 +39,8 @@ export const DisableWorkspaceSharingDialog: FC< queryFn: async () => { const response = await API.getWorkspaces({ q: `organization:${organizationId} shared:true`, - limit: 0, // Avoid fetching workspaces as we only need the count. + // Only the count is read, so request the smallest valid page. + limit: 1, }); return response.count; }, diff --git a/support/support.go b/support/support.go index c46d80ad7fcd5..6ece4ac8631ab 100644 --- a/support/support.go +++ b/support/support.go @@ -266,7 +266,7 @@ func DeploymentInfo(ctx context.Context, client *codersdk.Client, log slog.Logge eg.Go(func() error { var ( offset int - limit = 200 + limit = codersdk.MaxPaginationLimit all []codersdk.Workspace count int )