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
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ alwaysApply: true

# BTST Monorepo - Agent Rules

## Agent Working Principles

1. **Think before coding.** State your assumptions out loud. If the request is ambiguous, ask. If a simpler approach exists, push back. Stop when you are confused — name what is unclear; do not just pick one interpretation and run.
2. **Simplicity first.** Write the minimum code that solves the problem. No speculative abstractions. No flexibility nobody asked for. The test: would a senior engineer call this overcomplicated?
3. **Surgical changes.** Touch only what the task requires. Do not improve neighboring code. Do not refactor what is not broken. Every changed line should trace back to the request.
4. **Goal-driven execution.** Turn vague instructions into verifiable targets before writing a line. "Add validation" becomes "write tests for invalid inputs, then make them pass."

## Environment Setup

### Node.js Version
Expand Down
4 changes: 2 additions & 2 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
"fumadocs-ui": "16.0.9",
"lucide-react": "^0.522.0",
"next": "16.0.10",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-icons": "^5.5.0",
"shiki": "^3.12.2",
"ts-morph": "^27.0.2"
Expand Down
8 changes: 7 additions & 1 deletion e2e/tests/smoke.chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@ test.describe("AI Chat Plugin", () => {
await page.keyboard.press("Enter");

// 4. Verify user message appears - increase timeout to account for slower state updates
await expect(page.getByText("Hello, world!")).toBeVisible({
// Scope to the chat interface: once the assistant responds, the sidebar also
// shows a conversation titled "Hello, world!" (and the in-memory adapter keeps
// conversations from previous retries), so an unscoped getByText violates
// strict mode.
await expect(
page.locator('[data-testid="chat-interface"]').getByText("Hello, world!"),
).toBeVisible({
timeout: 30000,
});

Expand Down
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,21 @@
"vitest": "catalog:"
},
"resolutions": {
"zod": "^4.2.0",
"zod": "4.4.3",
"miniflare>zod": "^3.25.1",
"vinxi>zod": "^3.24.3",
"@tanstack/router-generator>zod": "^3.25.1",
"@tanstack/router-plugin>zod": "^3.25.1",
"@tanstack/start-plugin-core>zod": "^3.25.1",
"react": "^19.2.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"@tanstack/react-query": "^5.90.2",
"sonner": "^2.0.7"
},
"pnpm": {
"overrides": {
"react": "^19.2.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"@tanstack/react-query": "^5.90.2",
"sonner": "^2.0.7"
}
Expand Down
6 changes: 3 additions & 3 deletions packages/stack/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@btst/stack",
"version": "2.12.1",
"version": "2.12.2",
"description": "A composable, plugin-based library for building full-stack applications.",
"repository": {
"type": "git",
Expand Down Expand Up @@ -823,8 +823,8 @@
"ai": "^5.0.94",
"better-call": "catalog:",
"knip": "^5.61.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-error-boundary": "^4.1.2",
"rollup-plugin-preserve-directives": "0.4.0",
"rollup-plugin-visualizer": "^5.12.0",
Expand Down
4 changes: 2 additions & 2 deletions packages/stack/registry/btst-cms.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/stack/registry/btst-form-builder.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/stack/registry/btst-media.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
{
"path": "btst/media/client/components/media-picker/url-tab.tsx",
"type": "registry:component",
"content": "import { useState } from \"react\";\nimport { useRegisterAsset } from \"@btst/stack/plugins/media/client/hooks\";\nimport type { SerializedAsset } from \"../../../types\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { Loader2, Check } from \"lucide-react\";\n\nexport function UrlTab({\n\tfolderId,\n\tonRegistered,\n}: {\n\tfolderId: string | null;\n\tonRegistered: (asset: SerializedAsset) => void;\n}) {\n\tconst [url, setUrl] = useState(\"\");\n\tconst [error, setError] = useState<string | null>(null);\n\tconst { mutateAsync: registerAsset, isPending } = useRegisterAsset();\n\n\tconst handleSubmit = async (e: React.FormEvent) => {\n\t\te.preventDefault();\n\t\tsetError(null);\n\t\tconst trimmed = url.trim();\n\t\tif (!trimmed) return;\n\t\ttry {\n\t\t\tconst filename = trimmed.split(\"/\").pop() ?? \"asset\";\n\t\t\tconst asset = await registerAsset({\n\t\t\t\turl: trimmed,\n\t\t\t\tfilename,\n\t\t\t\tfolderId: folderId ?? undefined,\n\t\t\t});\n\t\t\tsetUrl(\"\");\n\t\t\tonRegistered(asset);\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : \"Failed to register URL\");\n\t\t}\n\t};\n\n\treturn (\n\t\t<div className=\"flex h-full flex-col gap-3 pt-2\">\n\t\t\t<p className=\"text-sm text-muted-foreground\">\n\t\t\t\tPaste a public URL to register it as an asset without uploading a file.\n\t\t\t</p>\n\t\t\t<form onSubmit={handleSubmit} className=\"flex flex-col gap-2\">\n\t\t\t\t<div className=\"flex flex-col gap-2 sm:flex-row\">\n\t\t\t\t\t<Input\n\t\t\t\t\t\ttype=\"url\"\n\t\t\t\t\t\tvalue={url}\n\t\t\t\t\t\tonChange={(e) => setUrl(e.target.value)}\n\t\t\t\t\t\tplaceholder=\"https://example.com/image.png\"\n\t\t\t\t\t\tclassName=\"flex-1\"\n\t\t\t\t\t\tdata-testid=\"media-url-input\"\n\t\t\t\t\t\tautoFocus\n\t\t\t\t\t/>\n\t\t\t\t\t<Button\n\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\tdisabled={isPending || !url.trim()}\n\t\t\t\t\t\tclassName=\"w-full sm:w-auto\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{isPending ? (\n\t\t\t\t\t\t\t<Loader2 className=\"mr-1 size-4 animate-spin\" />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<Check className=\"mr-1 size-4\" />\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tUse URL\n\t\t\t\t\t</Button>\n\t\t\t\t</div>\n\t\t\t\t{error && <p className=\"text-sm text-destructive\">{error}</p>}\n\t\t\t</form>\n\t\t</div>\n\t);\n}\n",
"content": "import { useState } from \"react\";\nimport { useRegisterAsset } from \"@btst/stack/plugins/media/client/hooks\";\nimport type { SerializedAsset } from \"../../../types\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { Loader2, Check } from \"lucide-react\";\n\nexport function UrlTab({\n\tfolderId,\n\tonRegistered,\n}: {\n\tfolderId: string | null;\n\tonRegistered: (asset: SerializedAsset) => void;\n}) {\n\tconst [url, setUrl] = useState(\"\");\n\tconst [error, setError] = useState<string | null>(null);\n\tconst { mutateAsync: registerAsset, isPending } = useRegisterAsset();\n\n\tconst handleSubmit = async (e: React.FormEvent) => {\n\t\te.preventDefault();\n\t\te.stopPropagation();\n\t\tsetError(null);\n\t\tconst trimmed = url.trim();\n\t\tif (!trimmed) return;\n\t\ttry {\n\t\t\tconst filename = trimmed.split(\"/\").pop() ?? \"asset\";\n\t\t\tconst asset = await registerAsset({\n\t\t\t\turl: trimmed,\n\t\t\t\tfilename,\n\t\t\t\tfolderId: folderId ?? undefined,\n\t\t\t});\n\t\t\tsetUrl(\"\");\n\t\t\tonRegistered(asset);\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : \"Failed to register URL\");\n\t\t}\n\t};\n\n\treturn (\n\t\t<div className=\"flex h-full flex-col gap-3 pt-2\">\n\t\t\t<p className=\"text-sm text-muted-foreground\">\n\t\t\t\tPaste a public URL to register it as an asset without uploading a file.\n\t\t\t</p>\n\t\t\t<form onSubmit={handleSubmit} className=\"flex flex-col gap-2\">\n\t\t\t\t<div className=\"flex flex-col gap-2 sm:flex-row\">\n\t\t\t\t\t<Input\n\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\tinputMode=\"url\"\n\t\t\t\t\t\tautoCapitalize=\"none\"\n\t\t\t\t\t\tautoCorrect=\"off\"\n\t\t\t\t\t\tvalue={url}\n\t\t\t\t\t\tonChange={(e) => setUrl(e.target.value)}\n\t\t\t\t\t\tplaceholder=\"https://example.com/image.png\"\n\t\t\t\t\t\tclassName=\"flex-1\"\n\t\t\t\t\t\tdata-testid=\"media-url-input\"\n\t\t\t\t\t\tautoFocus\n\t\t\t\t\t/>\n\t\t\t\t\t<Button\n\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\tdisabled={isPending || !url.trim()}\n\t\t\t\t\t\tclassName=\"w-full sm:w-auto\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{isPending ? (\n\t\t\t\t\t\t\t<Loader2 className=\"mr-1 size-4 animate-spin\" />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<Check className=\"mr-1 size-4\" />\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tUse URL\n\t\t\t\t\t</Button>\n\t\t\t\t</div>\n\t\t\t\t{error && <p className=\"text-sm text-destructive\">{error}</p>}\n\t\t\t</form>\n\t\t</div>\n\t);\n}\n",
"target": "src/components/btst/media/client/components/media-picker/url-tab.tsx"
},
{
Expand Down
13 changes: 10 additions & 3 deletions packages/stack/scripts/test-registry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -259,13 +259,17 @@ console.log('tsconfig.json patched');
fi

# ------------------------------------------------------------------
step "7b — Pinning tiptap packages to 3.20.1"
step "7b — Pinning tiptap packages to 3.20.1 and react-day-picker to v9"
# ------------------------------------------------------------------
# Must run AFTER all `shadcn add` calls so that tiptap packages are already
# present as direct dependencies — setting npm overrides for packages that
# are not yet direct deps and then having shadcn add them afterwards causes
# EOVERRIDE, which silently aborts the shadcn install and leaves plugin
# files (boards-list-page, page-list-page, …) unwritten.
#
# react-day-picker is pinned to ^9 because shadcn's calendar.tsx still uses
# deprecated v8 ClassNames keys (e.g. `table`) that were removed from the
# ClassNames type in react-day-picker v10, breaking `next build` type checks.
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
Expand Down Expand Up @@ -294,10 +298,13 @@ for (const p of pkgs) {
if (pkg.dependencies?.[p]) pkg.dependencies[p] = V;
pkg.overrides[p] = V;
}
const RDP = '^9.13.2';
if (pkg.dependencies?.['react-day-picker']) pkg.dependencies['react-day-picker'] = RDP;
pkg.overrides['react-day-picker'] = RDP;
fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2));
console.log('package.json updated with tiptap overrides');
console.log('package.json updated with tiptap + react-day-picker overrides');
"
success "Tiptap overrides written (npm install runs in step 8)"
success "Tiptap and react-day-picker overrides written (npm install runs in step 8)"

# ------------------------------------------------------------------
step "7c — Patching external registry files with known type errors"
Expand Down
46 changes: 46 additions & 0 deletions packages/stack/src/plugins/media/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,52 @@ describe("mediaBackendPlugin create-asset URL validation", () => {
"https://cdn.example.com/uploads/photo.jpg",
);
});

it("allows any HTTP(S) URL when protocol prefixes are explicitly configured", async () => {
const backend = createVercelBlobBackend({
allowedUrlPrefixes: ["https://", "http://"],
});

const response = await backend.handler(
new Request("http://localhost/api/media/assets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
createAssetRequestBody(
"https://images.example.net/affiliate/photo.jpg",
),
),
}),
);

expect(response.status).toBe(200);

const assets = await backend.api.media.listAssets();
expect(assets.items).toHaveLength(1);
expect(assets.items[0]?.url).toBe(
"https://images.example.net/affiliate/photo.jpg",
);

const httpResponse = await backend.handler(
new Request("http://localhost/api/media/assets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
createAssetRequestBody(
"http://images.example.net/affiliate/photo.jpg",
),
),
}),
);

expect(httpResponse.status).toBe(200);

const updatedAssets = await backend.api.media.listAssets();
expect(updatedAssets.items).toHaveLength(2);
expect(
updatedAssets.items.some((asset) => asset.url.startsWith("http://")),
).toBe(true);
});
});

describe("mediaBackendPlugin S3 URL validation", () => {
Expand Down
23 changes: 22 additions & 1 deletion packages/stack/src/plugins/media/api/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,19 @@ function sanitizeS3KeySegment(s: string): string {
}

function matchesUrlPrefix(url: string, prefix: string): boolean {
const normalizedPrefix = `${prefix.replace(/\/+$/, "")}/`;
const trimmedPrefix = prefix.trim();
if (!trimmedPrefix) return false;

if (/^[a-z][a-z\d+.-]*:\/\//i.test(trimmedPrefix)) {
if (trimmedPrefix.endsWith("://")) {
return url.startsWith(trimmedPrefix);
Comment thread
olliethedev marked this conversation as resolved.
}

const normalizedPrefix = trimmedPrefix.replace(/\/+$/, "");
return url === normalizedPrefix || url.startsWith(`${normalizedPrefix}/`);
}

const normalizedPrefix = `${trimmedPrefix.replace(/\/+$/, "")}/`;
return url.startsWith(normalizedPrefix);
}

Expand Down Expand Up @@ -181,6 +193,15 @@ export interface MediaBackendConfig {
* Provide this option only when you need to override the automatic default (e.g. to allow
* assets from a CDN in front of your storage that uses a different domain). When using
* `localAdapter`, setting `allowedUrlPrefixes` explicitly opts `POST /media/assets` back in.
*
* @remarks
* Protocol-only prefixes such as `"https://"` (and especially `"http://"`) disable
* origin-based restrictions entirely — any URL with that scheme will be accepted.
* Only use them when you intentionally want to allow assets from arbitrary hosts:
* `"http://"` URLs will be blocked as mixed content on HTTPS pages, and if your
* `onAfterUpload` hook fetches asset URLs server-side, accepting arbitrary hosts
* can expose an SSRF surface. Prefer full origin prefixes like
* `"https://cdn.example.com"` whenever possible.
*/
allowedUrlPrefixes?: string[];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function UrlTab({

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
e.stopPropagation();
setError(null);
const trimmed = url.trim();
if (!trimmed) return;
Expand All @@ -43,7 +44,10 @@ export function UrlTab({
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<div className="flex flex-col gap-2 sm:flex-row">
<Input
type="url"
type="text"
Comment thread
olliethedev marked this conversation as resolved.
inputMode="url"
autoCapitalize="none"
autoCorrect="off"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/image.png"
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@
"next-themes": "^0.4.6",
"object-hash": "^3.0.0",
"radix-ui": "^1.4.3",
"react": "^19.1.1",
"react": "^19.2.7",
"react-day-picker": "^9.13.2",
"react-dom": "^19.1.1",
"react-dom": "^19.2.7",
"react-error-boundary": "^4.1.2",
"react-hook-form": "^7.66.1",
"react-markdown": "^9.1.0",
Expand Down
9 changes: 7 additions & 2 deletions packages/ui/src/components/auto-form/fields/object.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,11 +253,16 @@ export default function AutoFormObject<
const defaultValue = fieldConfigItem.inputProps?.defaultValue;
const value = field.value ?? defaultValue ?? "";

// Destructure defaultValue out of inputProps so it isn't spread
// alongside value — having both makes React warn about a mixed
// controlled/uncontrolled input.
const { defaultValue: _omitDV, ...safeInputProps } =
(fieldConfigItem.inputProps ?? {}) as Record<string, unknown>;
const fieldProps = {
...zodToHtmlInputProps(item),
...field,
...fieldConfigItem.inputProps,
disabled: fieldConfigItem.inputProps?.disabled || isDisabled,
...safeInputProps,
disabled: (safeInputProps.disabled as boolean | undefined) || isDisabled,
ref: undefined,
value: value,
};
Expand Down
10 changes: 3 additions & 7 deletions packages/ui/src/components/auto-form/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -389,13 +389,9 @@ export function buildFieldConfigFromJsonSchema(
) {
config.fieldType = fieldType;
}
// 3. Unknown custom type without a component - log warning and skip
else {
console.warn(
`CMS: Unknown fieldType "${fieldType}" for field "${key}". ` +
`Provide a component via fieldComponents override or use a built-in type.`,
);
}
// 3. Unknown custom type — no component registered here; a higher-level
// wrapper (e.g. the CMS content-form) is expected to inject it via
// injectCustomFieldTypes. No warning needed; this is by design.
}

// Reserved FieldConfigItem property names that should not be overwritten by nested field configs.
Expand Down
5 changes: 4 additions & 1 deletion packages/ui/src/components/calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,10 @@ function Calendar({
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
month_grid: cn(
"w-full border-collapse",
defaultClassNames.month_grid
),
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
Expand Down
15 changes: 13 additions & 2 deletions packages/ui/src/lib/schema-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,12 +217,23 @@ function attachStepsMetadata(
export function formSchemaToZod(jsonSchema: FormJsonSchema): z.ZodType {
// 1. Create base schema from JSON Schema
let schema = z.fromJSONSchema(jsonSchema as z.core.JSONSchema.JSONSchema);

// 2. z.fromJSONSchema creates strict ZodObjects that reject unknown keys with
// an 'unrecognized_keys' error. This breaks form save when parsedData
// contains fields added to the Zod schema after the content type was last
// synced to the DB. Apply passthrough() so unknown keys are preserved
// rather than rejected. We deliberately do NOT use strip() here: stripping
// would silently drop values for fields that exist in the live Zod schema
// but not yet in the stale stored JSON schema, losing data on save.
if (schema && typeof (schema as z.ZodObject<z.ZodRawShape>).passthrough === "function") {
schema = (schema as z.ZodObject<z.ZodRawShape>).passthrough();
Comment thread
olliethedev marked this conversation as resolved.
}

// 2. Add date constraint validations
// 3. Add date constraint validations
const dateFieldsWithConstraints = findDateFieldsWithConstraints(jsonSchema);
schema = addDateValidations(schema, dateFieldsWithConstraints);

// 3. Re-attach steps metadata so SteppedAutoForm can extract it
// 4. Re-attach steps metadata so SteppedAutoForm can extract it
schema = attachStepsMetadata(schema, jsonSchema);

return schema;
Expand Down
4 changes: 2 additions & 2 deletions playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
"next": "16.0.10",
"next-themes": "^0.4.6",
"nuqs": "^2.8.9",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"shadcn": "^4.1.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
Expand Down
Loading
Loading