Skip to content

feat(cms): phase-2 sweep onto core primitives - #143

Merged
olliethedev merged 3 commits into
v3from
feat/cms-phase2-sweep
Aug 18, 2026
Merged

feat(cms): phase-2 sweep onto core primitives#143
olliethedev merged 3 commits into
v3from
feat/cms-phase2-sweep

Conversation

@olliethedev

Copy link
Copy Markdown
Collaborator

Summary

Part of the v3 Phase 2 per-plugin adoption sweep (#136). Migrates the CMS plugin onto the core primitives, mirroring the blog sweep (#142):

  • Declare cmsResources via createResource/createResourceQueryKeys; cms-hooks.tsx becomes thin public wrappers over the factory-generated hooks.
  • Editor page uses resource useForm with inline server field errors (the API now emits Zod issues so StackError.errors populates correctly) and save toasts.
  • RelationField uses resource useSelect with debounced server-side search; MultipleSelector internal filtering disabled.
  • Server-side search param on the content list API (slug + data values, case-insensitive) with matching SSG discriminator normalization.
  • URL-synced ?q= search box on the content list via useListState, with a non-suspense query path while searching to avoid full-page suspense flashes.
  • useNotify() replaces direct sonner usage; all UI strings converted to useTranslate() with localization override precedence (new cms-relations.ts catalog).
  • ComposedRoute permission prop on the editor route and CanAccess around New/Edit/Delete controls (cms:content resource).
  • Dead code removed: pagination.tsx, local error helpers, SHARED_QUERY_CONFIG copies.

Core changes (needed by the sweep)

  • ResourceQueryDef gains params (declared path parameters, e.g. /content/:typeSlug) and nextPageParam (custom pagination for { items, total } page envelopes).

Test plan

  • pnpm typecheck and pnpm lint clean
  • Unit: 392 tests / 34 files pass, incl. new cms-query-keys parity, getters search, and client-sweep (notify, field errors, CanAccess, useListState, i18n precedence) tests
  • E2E (Next.js): 37 CMS + relations tests pass, incl. new URL-synced search spec
  • E2E (Next.js): SSG + ui-builder specs pass (ui-builder consumes the CMS query-key factory)
  • Registry regenerated (btst-cms.json)

Made with Cursor

Migrate the CMS plugin to the v3 core primitives: declare cmsResources
via createResource/createResourceQueryKeys and rewrite cms-hooks.tsx as
thin wrappers; move the editor onto resource useForm with inline server
field errors; move RelationField onto useSelect with server-side search;
replace sonner with useNotify; convert all UI strings to useTranslate
with localization overrides; gate create/edit/delete with ComposedRoute
permission + CanAccess; add a URL-synced search box via useListState
backed by a new search param on the list API.

Core gains params (path parameters) and nextPageParam (envelope
pagination) on resource query declarations to support the sweep.

Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel

vercel Bot commented Aug 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
better-stack-docs Ready Ready Preview Aug 18, 2026 8:58pm
better-stack-playground Ready Ready Preview Aug 18, 2026 8:58pm

Request Review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 61ee7de. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Review

Summary

One confirmed medium-severity vulnerability (DoS via unbounded full-table scan) and two informational items.


🔴 Medium — Unbounded full-table scan via search parameter

Files: packages/stack/src/plugins/cms/schemas.ts · packages/stack/src/plugins/cms/api/getters.ts

The new search query parameter is declared as z.string().optional() with no length limit. When any non-empty search value is received, getAllContentItems removes the limit and offset from the DB query and fetches the entire contentItem table into memory before filtering:

// getters.ts lines 215-216
limit: !needsInMemoryFilter ? params?.limit : undefined,   // ← limit dropped
offset: !needsInMemoryFilter ? params?.offset : undefined, // ← offset dropped

Any authenticated user (or any caller with access to the /content/:typeSlug?search=x endpoint) can trigger a complete DB table read on every request. With a large content dataset this exhausts server memory and CPU — a straightforward DoS vector.

An unbounded string also wastes CPU in contentItemMatchesSearch, which runs String.prototype.includes across every string value in every item's parsedData.

Remediation — two independent fixes, both needed:

  1. Cap the search string length in the schema:
search: z.string().max(200).optional(),
  1. Cap the DB fetch even when searching — either set a hard ceiling (e.g. config.maxPageSize ?? DEFAULT_MAX_PAGE_SIZE) or require a per-type upper bound to be configured:
// in getters.ts: always pass a limit to findMany
limit: params?.limit ?? DEFAULT_MAX_PAGE_SIZE,

Without a DB-level cap, a legitimate search across a type with 100 k items still loads everything into memory on every keystroke.


🟡 Informational — Fail-open permission gate (useCan / <CanAccess>)

File: packages/stack/src/context/auth.tsx

// When no auth provider is configured, or provider has no can():
if (!auth || !canFn) {
    return { can: true, isPending: false }; // ← always allowed
}

This is documented and intentional (backward-compatible opt-in), but it creates a silent footgun: a developer who wires <CanAccess resource="admin:panel" action="view"> in their UI expecting access control gets zero protection if they forget to pass auth to StackProvider. No warning, no runtime error, everything renders.

Recommendation: At minimum, add a console.warn in development (process.env.NODE_ENV !== 'production') when <CanAccess> is rendered without a configured auth provider, so the oversight is surfaced during development.


🟡 Informational — curl | sh install in devcontainer Dockerfile

File: .devcontainer/Dockerfile

RUN curl https://mise.run | sh

Mise is installed by piping an unversioned remote URL into sh with no checksum verification. If mise.run is compromised or returns different content, the entire dev image build is compromised. This affects developer machines, not production, but it is still a supply-chain risk.

Recommendation: Pin to a specific mise release and verify the checksum, or install from the Arch package repository (pacman -S mise) if it is available in the package set used:

RUN curl -fsSL https://github.com/jdx/mise/releases/download/v2025.x.y/mise-v2025.x.y-linux-x64.tar.gz \
    | tar -xz -C /usr/local/bin --strip-components=1 \
  && echo "<expected-sha256> /usr/local/bin/mise" | sha256sum -c
Open in Web View Automation 

Sent by Cursor Automation: Find vulnerabilities

) {
return z.object({
slug: z.string().optional(),
search: z.string().optional(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DoS — no length limit on search.

With search non-empty, getAllContentItems removes limit/offset from the DB query and loads the entire table into memory before filtering. Add a length cap here:

search: z.string().max(200).optional(),

Also add a hard DB-fetch ceiling in getAllContentItems (e.g. config.maxPageSize) so a search against a large type cannot exhaust server memory.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cfe0d80: search is now bounded to 200 chars, matching the blog plugin's query bound.

where: whereConditions,
limit: params?.limit,
offset: params?.offset,
limit: !needsInMemoryFilter ? params?.limit : undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DoS — DB query runs without limit when search is set.

When needsInMemoryFilter is true, both limit and offset are passed as undefined to findMany, loading every row in the type. Even with a short search string, a content type with thousands of items causes a full table scan on every request.

// Fix: always apply a cap
limit: params?.limit ?? config.maxPageSize ?? DEFAULT_MAX_PAGE_SIZE,
offset: !needsInMemoryFilter ? params?.offset : 0,

Pagination after the in-memory filter can then trim to the caller-requested window.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cfe0d80: the DB scan is capped at DEFAULT_MAX_PAGE_SIZE (1000) when search forces the in-memory filter, with pagination still applied after filtering. Items beyond the cap are not searched — acceptable for this fallback until search can be pushed to the adapter.

The ejected registry copy of RelationField imported the cms resource
instance from cms-resource.ts, which isn't exported by the package's
cms/client/hooks entry point, failing registry validation. Wrap the
useSelect call in a useContentOptions hook exported from the hooks
barrel so the ejected component stays fully typed.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

Copy link
Copy Markdown
Contributor

Shadcn registry validated — no registry changes detected.

@olliethedev
olliethedev changed the base branch from main to v3 August 18, 2026 20:39
- The list page re-seeds the search input when `?q=` changes externally
  (hydration after SSR-empty search params, back/forward), so the
  debounced URL write only ever reflects user edits instead of
  clobbering the query (Bugbot).
- Bound the `search` param to 200 chars and cap the DB scan at
  DEFAULT_MAX_PAGE_SIZE when search forces the in-memory filter, so a
  search over a large content type cannot exhaust server memory
  (security review).

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant