feat: core createResource factory + blog reference migration#141
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
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 propsAll 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.
Sent by Cursor Automation: Find vulnerabilities
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ 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.
Co-authored-by: Cursor <cursoragent@cursor.com>
|
✅ Shadcn registry validated — no registry changes detected. |
Co-authored-by: Cursor <cursoragent@cursor.com>



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/)StackErrorcontract —isErrorResponse/toError/SHARED_QUERY_CONFIGmoved from per-plugin copies into@btst/stack/plugins/client.toErrornow also normalizesstatusCodeand maps better-call Zodissuesinto a field-name → message(s)errorsmap.ValidationError.issueswhen building the default 400 response. ThecreateEndpointre-export in@btst/stack/plugins/apiis now a thin wrapper that injects a defaultonValidationErrorre-throwing the 400APIErrorwith a JSON-safeissuesarray. Endpoints with their ownonValidationErrorare 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/queryKeyshapes, so SSR loaders and SSGprefetchForRoutekeys 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/hooksentry) — generates per-queryuse/useSuspense(oruseInfinite/useSuspenseInfinite) hooks with the shared query config and suspense error re-throw, plus mutations with declarativeinvalidatesprefixes, optional detail-cache seeding (setData), and awaitedrefresh()— matching existing blog mutation semantics. Overrides (apiBaseURL/apiBasePath/headers/navigate/refresh) resolve viausePluginOverrides(plugin)at render time.useForm(per resource) — create/edit lifecycle: fetches the record for edit (or accepts an externalrecord, e.g. from a suspense hook), derives defaults, runs the right mutation, notifies viauseNotify()(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 exposesfieldErrorsderived fromStackError.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)
query-keys.tsclient/hooks/blog-hooks.tsxclient/components/forms/post-forms.tsxcreateBlogQueryKeys(client, headers)keeps its exact name/signature; key shapes are byte-identical (guarded by a new SSG key test againstBLOG_QUERY_KEYS).usePosts,useSuspensePost,useCreatePost, …) keep their names, signatures and return shapes; existing blog unit tests pass unmodified.post-forms.tsxdrives fetch/submit/toast/redirect through the resourceuseFormand maps server Zod issues onto react-hook-form viasetError.Deviations / notes for phase 2
StackErrors; the forms show inline/field errors instead. PublicUseMutationResultshapes are unchanged.useSelectnot adopted in blog: the blog/tagsendpoint has no search param, so there's no natural consumer yet. Core + tests only.redirecttakes a path string/function, not a named route — core has no knowledge of page URL structure; pages compute paths (as today).useFormuses a plain query for edit fetch; blog's edit form passes the suspense-fetchedpostin asrecordto preserve its existing Suspense UX..agents/skills/btst-client-plugin-dev/SKILL.mdnow documentscreateResourceas the default for new plugins.Test plan
pnpm build,pnpm typecheck,pnpm lint,pnpm testall green (352 tests / 31 files)toError(incl. round-trip through the wrappedcreateEndpoint),useFormcreate/edit/field-error/notify/redirect,useSelectdebounce/preload, SSG key guard vsBLOG_QUERY_KEYSgetters.test.ts,client-plugin-ssr-loaders.test.ts) pass unmodifiedMade with Cursor