Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/stack/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@btst/stack",
"version": "2.12.0",
"version": "2.12.1",
"description": "A composable, plugin-based library for building full-stack applications.",
"repository": {
"type": "git",
Expand Down
6 changes: 3 additions & 3 deletions packages/stack/registry/btst-cms.json

Large diffs are not rendered by default.

203 changes: 203 additions & 0 deletions packages/stack/src/plugins/cms/__tests__/schema-roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -804,3 +804,206 @@ describe("Zod to JSON Schema roundtrip", () => {
});
});
});

/**
* These tests confirm and protect against a bug where `useFieldArray` in
* AutoFormArray corrupts primitive string values in arrays like
* `structuredContraindications: z.array(z.string()).default([])`.
*
* When react-hook-form's `useFieldArray` processes a primitive array
* (e.g. `["pregnancy", "active malignancy"]`), it wraps each element as a
* tracking object `{ id: "rhf_generated_id" }`, discarding the original
* string value. When `form.watch()` fires, it returns these objects. The
* `handleValuesChange` callback then calls `setFormData(values)` which stores
* the corrupted objects. On form submit, `zodResolver` validates the corrupted
* values against `z.array(z.string())` and FAILS — causing the Save button
* to do nothing (no error shown, no API request made).
*
* The fix: AutoFormArray must NOT use `useFieldArray` for primitive (non-object)
* arrays. Instead it uses `form.watch` + `form.setValue` directly so primitive
* values are always preserved in the form state.
*/
describe("Primitive string array — useFieldArray corruption bug", () => {
/**
* Simulates what zodToFormSchema + formSchemaToZod does:
* Zod schema → JSON Schema (stored in DB) → reconstructed Zod schema.
*/
function roundtripSchema(schema: z.ZodType): z.ZodType {
const jsonSchema = z.toJSONSchema(schema, { unrepresentable: "any" });
return z.fromJSONSchema(jsonSchema as z.core.JSONSchema.JSONSchema);
}

it("z.array(z.string()) survives JSON Schema roundtrip and accepts string values", () => {
const schema = z.object({
structuredContraindications: z.array(z.string()).default([]),
});

const reconstructed = roundtripSchema(schema);

// Actual compound data — should PASS
expect(
reconstructed.safeParse({
structuredContraindications: [
"pregnancy",
"active malignancy",
"active cancer",
"trying to conceive",
],
}).success,
).toBe(true);

// Empty array — should PASS (default)
expect(
reconstructed.safeParse({ structuredContraindications: [] }).success,
).toBe(true);
});

it("useFieldArray-corrupted values (objects) fail z.array(z.string()) validation — this is the root cause of the silent save failure", () => {
const schema = z.object({
structuredContraindications: z.array(z.string()).default([]),
});

const reconstructed = roundtripSchema(schema);

// Simulates what react-hook-form's useFieldArray returns when used with
// primitive string arrays: each string is replaced by a tracking object
// { id: "rhf_generated_id" } and the original value is lost.
const corruptedByUseFieldArray = {
structuredContraindications: [
{ id: "rhf_internal_id_1" },
{ id: "rhf_internal_id_2" },
{ id: "rhf_internal_id_3" },
{ id: "rhf_internal_id_4" },
],
};

// This is the actual validation error that occurs when Save is clicked:
// zodResolver validates the corrupted objects against z.array(z.string())
// and FAILS, so onSubmit is never called → no API request → "nothing happens"
const result = reconstructed.safeParse(corruptedByUseFieldArray);
expect(result.success).toBe(false);
if (!result.success) {
// Confirm the error is specifically about the string array elements
const paths = result.error.issues.map((i) => i.path.join("."));
expect(
paths.some((p) => p.startsWith("structuredContraindications")),
).toBe(true);
}
});

it("primitive string array values are preserved correctly (NOT corrupted) when form state is managed without useFieldArray", () => {
// After the fix, AutoFormArray uses form.watch + form.setValue for primitive
// arrays. The values remain as strings throughout the lifecycle:
// initialData → formData → form state → zodResolver → submit

const schema = z.object({
structuredContraindications: z.array(z.string()).default([]),
});

const reconstructed = roundtripSchema(schema);

// The correctly-preserved values (no useFieldArray wrapping)
const preservedValues = {
structuredContraindications: [
"pregnancy",
"active malignancy",
"active cancer",
"trying to conceive",
],
};

// With the fix applied, these values pass validation → Save works
expect(reconstructed.safeParse(preservedValues).success).toBe(true);
});

it("compound schema with structuredContraindications passes full validation with string array values", () => {
// Representative subset of CompoundSchema fields that appear on the
// Epitalon compound page — verifies the full schema round-trip for the
// fields that are relevant to the reported bug.
const compoundSchema = z.object({
name: z.string().min(1),
compoundType: z.enum([
"healing-peptide",
"gh-axis",
"metabolic-peptide",
"sarm",
"steroid",
"nootropic",
"supplement",
"ancillary-pct",
"longevity",
"hair",
"skin",
"sexual",
"other",
]),
researchStatus: z.enum([
"research-only",
"approved",
"banned",
"grey-market",
"supplement",
]),
legalStatus: z.enum([
"OTC",
"Research",
"Grey-Market",
"Rx-Only",
"Schedule-III",
"Banned",
]),
doseUnit: z.enum(["mcg", "mg", "IU", "ml", "g"]),
doseFrequency: z.enum([
"once-daily",
"twice-daily",
"three-times-daily",
"every-other-day",
"weekly",
"twice-weekly",
"three-times-weekly",
"as-needed",
"custom",
]),
structuredContraindications: z.array(z.string()).default([]),
affiliates: z
.array(
z.object({
partnerId: z.object({ id: z.string() }).optional(),
title: z.string().optional(),
url: z.string().min(1),
}),
)
.default([]),
});

const reconstructed = roundtripSchema(compoundSchema);

// Epitalon-like data — all values as they come from parsedData
const epitalon = {
name: "Epitalon",
compoundType: "longevity",
researchStatus: "research-only",
legalStatus: "Research",
doseUnit: "mg",
doseFrequency: "once-daily",
// The 4 string items that were causing the silent save failure
structuredContraindications: [
"pregnancy",
"active malignancy",
"active cancer",
"trying to conceive",
],
affiliates: [
{
partnerId: { id: "v6yAqOSO_example_id" },
title: "Buy Epitalon 10mg",
url: "https://swisschems.is/product/epitalon-10mg-price-is-per-vial/",
},
],
};

const result = reconstructed.safeParse(epitalon);
// After the fix, this should PASS (the save button works)
expect(result.success).toBe(true);
});
});
1 change: 1 addition & 0 deletions packages/stack/src/plugins/cms/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export {
type CreateCMSContentItemOptions,
} from "./mutations";
export { CMS_QUERY_KEYS } from "./query-key-defs";
export { createCMSQueryKeys } from "../query-keys";
49 changes: 33 additions & 16 deletions packages/stack/src/plugins/cms/api/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import type {
RelationValue,
InverseRelation,
} from "../types";
import { listContentQuerySchema } from "../schemas";
import {
createListContentQuerySchema,
DEFAULT_MAX_PAGE_SIZE,
} from "../schemas";
import { slugify } from "../utils";
import {
getAllContentTypes,
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 — Low] offset has no upper bound. A client can supply an arbitrarily large value (e.g. offset=9999999999), which may cause runaway DB scans, integer overflow in the adapter layer, or expose adapter error details in the response.

Add .max(...) — a value proportional to maxPageSize is a reasonable choice.

Expand Down Expand Up @@ -492,6 +495,20 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
}),

routes: (adapter: Adapter) => {
// Build pagination schemas once — honours config.maxPageSize
const listContentQuerySchema = createListContentQuerySchema(
config.maxPageSize,
);
const paginationQuerySchema = z.object({
limit: z.coerce
.number()
.min(1)
.max(config.maxPageSize ?? DEFAULT_MAX_PAGE_SIZE)
.optional()
.default(20),
offset: z.coerce.number().min(0).optional().default(0),
});

// Helper to get content type by slug
const getContentType = async (
slug: string,
Expand Down Expand Up @@ -525,10 +542,10 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
sortBy: { field: "name", direction: "asc" },
});

// Get item counts for each content type
// Get item counts for each content type via adapter.count() (avoids N+1 scan)
const typesWithCounts = await Promise.all(
contentTypes.map(async (ct) => {
const items = await adapter.findMany<ContentItem>({
const itemCount: number = await adapter.count({
model: "contentItem",
where: [
{
Expand All @@ -540,7 +557,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
});
return {
...serializeContentType(ct),
itemCount: items.length,
itemCount,
};
}),
);
Expand Down Expand Up @@ -964,12 +981,12 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
{
method: "GET",
params: z.object({ typeSlug: z.string() }),
query: z.object({
field: z.string(),
targetId: z.string(),
limit: z.coerce.number().min(1).max(100).optional().default(20),
offset: z.coerce.number().min(0).optional().default(0),
}),
query: z
.object({
field: z.string(),
targetId: z.string(),
})
.merge(paginationQuerySchema),
},
async (ctx) => {
const { typeSlug } = ctx.params;
Expand Down Expand Up @@ -1144,12 +1161,12 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
slug: z.string(),
sourceType: z.string(),
}),
query: z.object({
itemId: z.string(),
fieldName: z.string(),
limit: z.coerce.number().min(1).max(100).optional().default(20),
offset: z.coerce.number().min(0).optional().default(0),
}),
query: z
.object({
itemId: z.string(),
fieldName: z.string(),
})
.merge(paginationQuerySchema),
},
async (ctx) => {
const { slug, sourceType } = ctx.params;
Expand Down
Loading