Skip to content

feat: core createResource factory + blog reference migration#141

Merged
olliethedev merged 3 commits into
mainfrom
feat/129-resource-factory
Jul 5, 2026
Merged

feat: core createResource factory + blog reference migration#141
olliethedev merged 3 commits into
mainfrom
feat/129-resource-factory

Conversation

@olliethedev

Copy link
Copy Markdown
Collaborator

Part of #129

Summary

Adds the core resource-hook factory and migrates the blog plugin onto it as the reference consumer.

Core (packages/stack/src/plugins/client/resource/)

  • StackError contractisErrorResponse / toError / SHARED_QUERY_CONFIG moved from per-plugin copies into @btst/stack/plugins/client. toError now also normalizes statusCode and maps better-call Zod issues into a field-name → message(s) errors map.
  • Zod issue preservation (server) — better-call 1.3.6 discards ValidationError.issues when building the default 400 response. The createEndpoint re-export in @btst/stack/plugins/api is now a thin wrapper that injects a default onValidationError re-throwing the 400 APIError with a JSON-safe issues array. Endpoints with their own onValidationError are untouched; all plugins pick this up with no code changes.
  • createResourceQueryKeys (server-safe) — declaration-driven query-key factory with @lukemorales/query-key-factory-compatible _def/queryKey shapes, so SSR loaders and SSG prefetchForRoute keys keep matching. Declarations support HTTP query mapping, custom key discriminators, select/unwrap, offset-based infinite paging, and skip guards.
  • createResource (client-only, new @btst/stack/plugins/client/hooks entry) — generates per-query use / useSuspense (or useInfinite / useSuspenseInfinite) hooks with the shared query config and suspense error re-throw, plus mutations with declarative invalidates prefixes, optional detail-cache seeding (setData), and awaited refresh() — matching existing blog mutation semantics. Overrides (apiBaseURL/apiBasePath/headers/navigate/refresh) resolve via usePluginOverrides(plugin) at render time.
  • useForm (per resource) — create/edit lifecycle: fetches the record for edit (or accepts an external record, e.g. from a suspense hook), derives defaults, runs the right mutation, notifies via useNotify() (Notification provider: pluggable notify() instead of hardcoded sonner toasts in every plugin #131), redirects via the router adapter (Top-level router adapter on StackProvider (framework presets for Link/navigate/refresh/Image) #127), and exposes fieldErrors derived from StackError.errors. Field-level failures deliberately do not produce a toast.
  • useSelect (per resource) — debounced server-side search, current-value preloading via a configurable detail query, merged options and loading/searching flags.

Blog migration (reference consumer)

file before after
query-keys.ts 278 187 (declaration + factory call)
client/hooks/blog-hooks.tsx 708 383 (thin wrappers)
client/components/forms/post-forms.tsx 637 674 (now with server field-error mapping)
  • createBlogQueryKeys(client, headers) keeps its exact name/signature; key shapes are byte-identical (guarded by a new SSG key test against BLOG_QUERY_KEYS).
  • All public hooks (usePosts, useSuspensePost, useCreatePost, …) keep their names, signatures and return shapes; existing blog unit tests pass unmodified.
  • post-forms.tsx drives fetch/submit/toast/redirect through the resource useForm and maps server Zod issues onto react-hook-form via setError.

Deviations / notes for phase 2

  • Mutations now surface errors: blog mutations previously ignored API error responses (a failed create still toasted success and navigated). Factory mutations throw normalized StackErrors; the forms show inline/field errors instead. Public UseMutationResult shapes are unchanged.
  • useSelect not adopted in blog: the blog /tags endpoint has no search param, so there's no natural consumer yet. Core + tests only.
  • redirect takes a path string/function, not a named route — core has no knowledge of page URL structure; pages compute paths (as today).
  • useForm uses a plain query for edit fetch; blog's edit form passes the suspense-fetched post in as record to preserve its existing Suspense UX.
  • Docs: .agents/skills/btst-client-plugin-dev/SKILL.md now documents createResource as the default for new plugins.

Test plan

  • pnpm build, pnpm typecheck, pnpm lint, pnpm test all green (352 tests / 31 files)
  • New unit tests: factory key shapes + queryFn behavior, infinite paging, mutation invalidation/seeding/refresh, toError (incl. round-trip through the wrapped createEndpoint), useForm create/edit/field-error/notify/redirect, useSelect debounce/preload, SSG key guard vs BLOG_QUERY_KEYS
  • Existing blog tests (getters.test.ts, client-plugin-ssr-loaders.test.ts) pass unmodified
  • CI: watch the Next.js codegen E2E job for SSG regressions

Made with Cursor

Adds a core resource-hook factory to @btst/stack/plugins/client:

- StackError contract + shared isErrorResponse/toError/SHARED_QUERY_CONFIG,
  with toError mapping better-call Zod issues to field-level errors
- createEndpoint wrapper that preserves Zod validation issues in 400 bodies
- server-safe resource declarations + createResourceQueryKeys (SSR/SSG-safe
  query-key factory with @lukemorales-compatible shapes)
- createResource hooks layer (plain/suspense/infinite queries, mutations
  with declarative invalidation, cache seeding and refresh) via the new
  client-only @btst/stack/plugins/client/hooks entry
- per-resource useForm (create/edit lifecycle, notify, redirect, field
  errors) and useSelect (debounced search + current-value preload)

Migrates the blog plugin as the reference consumer: query-keys.ts and
blog-hooks.tsx are thin wrappers over the factory (public hook APIs
unchanged, query keys byte-identical), post-forms.tsx drives submit/toast/
field errors through useForm.

Part of #129

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

vercel Bot commented Jul 5, 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, Comment Jul 5, 2026 10:18pm
better-stack-playground Ready Ready Preview, Comment Jul 5, 2026 10:18pm

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.

Security Review — feat/129-resource-factory

Result: No high-confidence vulnerabilities found.

This PR introduces a client-side factory abstraction (createResource, createUseForm, createUseSelect) and migrates the blog plugin onto it. The changes are purely client-side library plumbing — no server execution path, no new external dependencies, no auth logic altered.


Threat-by-threat findings

Threat Verdict
SQL / command / path injection Not applicable — client-side library; no DB access or shell calls
Auth / permission bypass No bypass introduced; headers flow unchanged through plugin overrides
Secret / token leakage No new logging; no secrets hardcoded
Unsafe deserialization No eval, Function(), or innerHTML; React escapes all rendered strings
SSRF apiBaseURL is developer-configured, not user-supplied at runtime
XSS React string escaping covers all error-message render paths
Supply chain Zero new external dependencies

Low-confidence observation (not a vulnerability)

Object.assign(err, error) in errors.ts line 133 — unscoped property copy from server response

const err = new Error(message) as StackError;
Object.assign(err, error); // copies ALL enumerable own props

All enumerable own properties of the raw API error response body are merged onto the Error instance before statusCode and errors are explicitly normalised. This means unexpected server-defined keys (e.g. code, details, internal stack info) end up as own properties on the error object that React Query may serialize or components may inadvertently access.

Why it is not currently exploitable: the code runs client-side; modern Object.assign does not mutate Object.prototype (JSON "__proto__" keys land as own properties, not prototype setters); errors is explicitly reset to undefined if it does not conform; and the result is never passed to eval or innerHTML.

Recommendation: scope the copy to only the properties you actually need, or use an allowlist:

// Instead of: Object.assign(err, error)
const { code, status, statusCode } = errorObj;
if (typeof code === 'string') (err as any).code = code;

This is a hardening suggestion, not a required fix for merge.

Open in Web View Automation 

Sent by Cursor Automation: Find vulnerabilities

Comment thread packages/stack/src/plugins/client/resource/errors.ts

@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 68b73a3. Configure here.

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Shadcn registry validated — no registry changes detected.

Co-authored-by: Cursor <cursoragent@cursor.com>
@olliethedev olliethedev merged commit 9456691 into main Jul 5, 2026
11 of 12 checks passed
@olliethedev olliethedev deleted the feat/129-resource-factory branch July 5, 2026 23:31
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