Skip to content

Commit 2aecbae

Browse files
authored
Merge pull request #124 from better-stack-ai/fix/cms-admin-form
fix: CMS media URL and schema handling
2 parents a6f48d5 + b702b6a commit 2aecbae

26 files changed

Lines changed: 8728 additions & 1887 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ alwaysApply: true
55

66
# BTST Monorepo - Agent Rules
77

8+
## Agent Working Principles
9+
10+
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.
11+
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?
12+
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.
13+
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."
14+
815
## Environment Setup
916

1017
### Node.js Version

docs/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
"fumadocs-ui": "16.0.9",
2020
"lucide-react": "^0.522.0",
2121
"next": "16.0.10",
22-
"react": "^19.2.0",
23-
"react-dom": "^19.2.0",
22+
"react": "^19.2.7",
23+
"react-dom": "^19.2.7",
2424
"react-icons": "^5.5.0",
2525
"shiki": "^3.12.2",
2626
"ts-morph": "^27.0.2"

e2e/tests/smoke.chat.spec.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,13 @@ test.describe("AI Chat Plugin", () => {
6060
await page.keyboard.press("Enter");
6161

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

package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,21 @@
3030
"vitest": "catalog:"
3131
},
3232
"resolutions": {
33-
"zod": "^4.2.0",
33+
"zod": "4.4.3",
3434
"miniflare>zod": "^3.25.1",
3535
"vinxi>zod": "^3.24.3",
3636
"@tanstack/router-generator>zod": "^3.25.1",
3737
"@tanstack/router-plugin>zod": "^3.25.1",
3838
"@tanstack/start-plugin-core>zod": "^3.25.1",
39-
"react": "^19.2.0",
39+
"react": "19.2.7",
40+
"react-dom": "19.2.7",
4041
"@tanstack/react-query": "^5.90.2",
4142
"sonner": "^2.0.7"
4243
},
4344
"pnpm": {
4445
"overrides": {
45-
"react": "^19.2.0",
46+
"react": "19.2.7",
47+
"react-dom": "19.2.7",
4648
"@tanstack/react-query": "^5.90.2",
4749
"sonner": "^2.0.7"
4850
}

packages/stack/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@btst/stack",
3-
"version": "2.12.1",
3+
"version": "2.12.2",
44
"description": "A composable, plugin-based library for building full-stack applications.",
55
"repository": {
66
"type": "git",
@@ -823,8 +823,8 @@
823823
"ai": "^5.0.94",
824824
"better-call": "catalog:",
825825
"knip": "^5.61.2",
826-
"react": "^19.1.1",
827-
"react-dom": "^19.1.1",
826+
"react": "^19.2.7",
827+
"react-dom": "^19.2.7",
828828
"react-error-boundary": "^4.1.2",
829829
"rollup-plugin-preserve-directives": "0.4.0",
830830
"rollup-plugin-visualizer": "^5.12.0",

packages/stack/registry/btst-cms.json

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

packages/stack/registry/btst-form-builder.json

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

packages/stack/registry/btst-media.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
{
6868
"path": "btst/media/client/components/media-picker/url-tab.tsx",
6969
"type": "registry:component",
70-
"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",
70+
"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",
7171
"target": "src/components/btst/media/client/components/media-picker/url-tab.tsx"
7272
},
7373
{

packages/stack/scripts/test-registry.sh

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,13 +259,17 @@ console.log('tsconfig.json patched');
259259
fi
260260

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

302309
# ------------------------------------------------------------------
303310
step "7c — Patching external registry files with known type errors"

packages/stack/src/plugins/media/__tests__/plugin.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,52 @@ describe("mediaBackendPlugin create-asset URL validation", () => {
293293
"https://cdn.example.com/uploads/photo.jpg",
294294
);
295295
});
296+
297+
it("allows any HTTP(S) URL when protocol prefixes are explicitly configured", async () => {
298+
const backend = createVercelBlobBackend({
299+
allowedUrlPrefixes: ["https://", "http://"],
300+
});
301+
302+
const response = await backend.handler(
303+
new Request("http://localhost/api/media/assets", {
304+
method: "POST",
305+
headers: { "Content-Type": "application/json" },
306+
body: JSON.stringify(
307+
createAssetRequestBody(
308+
"https://images.example.net/affiliate/photo.jpg",
309+
),
310+
),
311+
}),
312+
);
313+
314+
expect(response.status).toBe(200);
315+
316+
const assets = await backend.api.media.listAssets();
317+
expect(assets.items).toHaveLength(1);
318+
expect(assets.items[0]?.url).toBe(
319+
"https://images.example.net/affiliate/photo.jpg",
320+
);
321+
322+
const httpResponse = await backend.handler(
323+
new Request("http://localhost/api/media/assets", {
324+
method: "POST",
325+
headers: { "Content-Type": "application/json" },
326+
body: JSON.stringify(
327+
createAssetRequestBody(
328+
"http://images.example.net/affiliate/photo.jpg",
329+
),
330+
),
331+
}),
332+
);
333+
334+
expect(httpResponse.status).toBe(200);
335+
336+
const updatedAssets = await backend.api.media.listAssets();
337+
expect(updatedAssets.items).toHaveLength(2);
338+
expect(
339+
updatedAssets.items.some((asset) => asset.url.startsWith("http://")),
340+
).toBe(true);
341+
});
296342
});
297343

298344
describe("mediaBackendPlugin S3 URL validation", () => {

0 commit comments

Comments
 (0)