---
key: api.search-allitems-paging
title: 2026-07-18 — Search all-items is paged to cap DoS exposure; SPA add-all pages to completeness (#293)
status: active
since: '2026-07-18'
supersedes: none
superseded-by: none
rule: '`GET /api/v1/search/all-items` is paginated (capped page size, `Totals` field) to bound DoS exposure; the SPA add-all flow pages to completeness instead of relying on an unbounded response.'
signals: 'search all-items, pagination, DoS hardening · paths: `SearchController.SearchAllItems`, `LuceneSearchIndex`, `web/src/api/search.ts` · issues: #293, #285, #308, #384'
mechanics: '`MaxAllItemsPageSize`/`DefaultAllItemsPageSize` clamps; `getAllSearchItemIds`'
---

`GET /api/v1/search/all-items` (`SearchController.SearchAllItems` → `QuerySearchIndexAllItemsHandler`) fired
ten index searches with **`limit: 0`** (= "return every hit", `LuceneSearchIndex` line ~244), so a single
broad query (e.g. one matching the whole library) materialized *every* matching doc across all ten media
kinds into ten `List<int>` buckets and serialized them in one response — unbounded work per request. #285
closed the original *unauthenticated* exposure (the endpoint is now behind `Api:RequireKeyForReads`, default
true); the residual was DoS-hardening against an **authenticated** caller with a very broad query. Deferred
from #285 because the SPA "add all to collection/playlist" flow materializes the full id set before the add
POST, so a naive hard cap would silently truncate "add all".

**Decision (issue option (a), operator-confirmed): paginate the endpoint and teach the SPA add-all flow to
page to completeness** — rather than option (b) (a generous cap + truncation signal). Chosen because
"add all" must stay complete for real use, and it matches the sibling `GET /api/v1/search` /
`GET /api/v1/channels/auto-tune/members` (#384) paging convention already in the codebase.

- **Endpoint (additive).** `SearchAllItems` gains optional `pageNum` (0-based) + `pageSize`, clamped exactly
  like the §1 Logs / sibling `Search` precedent: `pageSize = Math.Clamp(pageSize, 1, MaxAllItemsPageSize)`
  with `MaxAllItemsPageSize = 1000`, `DefaultAllItemsPageSize = 500`. `pageNum` is clamped
  `Math.Clamp(pageNum, 0, MaxAllItemsPageNum)` with `MaxAllItemsPageNum = 2_000_000` — the upper bound keeps
  `pageNum * pageSize` (the search skip) inside `int` range so an absurd page number can't overflow to a 500
  (the sibling `Search` only floors at 0; the all-items endpoint hardens the upper bound too since this is a
  DoS-hardening change). The clamp is applied per media kind (a page returns ≤ `pageSize` ids of
  *each* of the ten kinds), so one response is bounded to ≤ 10 × `pageSize` ids. `QuerySearchIndexAllItems`
  carries `PageNum`/`PageSize`; the handler passes `skip = PageNum × PageSize`, `limit = PageSize` into
  `ISearchIndex.Search` (native skip/limit) and reads `SearchResult.TotalCount` (the true total, free) per
  kind.
- **Response (additive, frozen-v1-safe).** The ten `…Ids` buckets are unchanged; a new non-null nested
  `Totals` (`SearchResultAllItemsTotalsResponseModel`, ten `…Count` ints) is added so a client knows how many
  ids exist per kind and can page to completeness. Nothing is removed or retyped (#286 additive-only holds).
- **Deliberate default-behavior change.** A caller that sends no `pageSize` now gets one page (default 500 /
  kind) plus `Totals`, not the entire id set. This is the security change the issue asks for; it is safe here
  because the only in-repo consumer is the SPA (updated in the same PR) and any external/MCP caller can read
  `Totals` and page. Recorded as intentional, not a regression.
- **SPA pages to completeness.** `web/src/api/search.ts` `getSearchAllItems(query, pageNum, pageSize)` gains
  the params; a new `getAllSearchItemIds(query)` loops pages (requesting `pageSize = 1000`, the server max),
  accumulating every bucket until each kind has collected its `Totals` count (with an empty-page safety break
  against total-count drift), and returns the merged `SearchAllItemIds`. `SearchScreen.addAll` calls it
  instead of the single-shot fetch; the #221 stale-query guard and the single add POST are unchanged.
- **Out of scope (unchanged):** the add POST itself still accepts the full merged id set in one request body
  — bounding *that* surface is a separate concern (see #308 for the add path); #293 is the GET.
