feat: add CRUD operations for blog post plugin#114
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…partial updates When tags field is omitted from UpdatePostInput, preserve existing tag associations instead of clearing them. Only delete and recreate tags when tags field is explicitly provided (including empty array).
|
✅ Shadcn registry validated — no registry changes detected. |
There was a problem hiding this comment.
Security Assessment
Two confirmed vulnerabilities and one data-integrity issue were found in this diff.
[HIGH] Authorization hooks bypassed via api factory — confirmed
createPost, updatePost, and deletePost are now wired into the api factory with no authorization hook invocations:
api: (adapter) => ({
createPost: (input) => createPostMutation(adapter, input),
updatePost: (id, input) => updatePostMutation(adapter, id, input),
deletePost: (id) => deletePostMutation(adapter, id),
}),The route handlers correctly call onBeforeCreatePost / onBeforeUpdatePost / onBeforeDeletePost before mutating data. These raw factory methods skip every one of those checks. Consumers who configure authorization hooks expecting that all mutation paths are protected will be wrong — any server-side caller that reaches through the api factory can create, modify, or delete posts with no access-control gate.
The JSDoc comment ("The caller is responsible for any access-control checks") is insufficient mitigation for a public API surface. It only defers the security burden to every downstream consumer.
Remediation options:
- Remove
createPost/updatePost/deletePostfrom theapifactory entirely; force consumers through the HTTP routes where hooks run. - If raw DB access is intentional (e.g. for seed scripts / SSG jobs), gate the factory behind a clearly distinct namespace (e.g.
api.unsafe.*) and add a runtime assertion that verifies the call site is server-only. - At minimum, accept and invoke optional authorization callbacks in the mutation functions themselves so they aren't guaranteed auth-free.
[MEDIUM] Caller-controlled createdAt / updatedAt on HTTP create route — confirmed
In the original code the HTTP create-post handler hardcoded both timestamps:
// original plugin.ts
createdAt: new Date(), // overrides any user-supplied value
updatedAt: new Date(),After this PR, postData is spread into createPostMutation and the mutation uses:
createdAt: postData.createdAt ?? new Date(),
updatedAt: postData.updatedAt ?? new Date(),The Zod schema (createPostSchema) accepts createdAt and updatedAt as optional Date fields, so any authenticated client can now backdate a post by submitting a past timestamp in the request body. The original defense (silently ignoring those fields at the persistence layer) has been removed.
Remediation: Strip createdAt and updatedAt from the HTTP-route call to createPostMutation — always pass new Date() for those fields at the route handler layer, not the mutation layer. The mutation's optional-date signature is fine for server-side/seed use; the route handler should enforce server-generated timestamps.
[LOW] findOrCreateTags uses outer adapter inside updatePost transaction — data integrity issue
Inside updatePost the entire operation runs in a transaction (adapter.transaction), but findOrCreateTags is invoked with the outer adapter, not the transaction handle tx:
return adapter.transaction(async (tx) => {
// ...
const resolvedTags = await findOrCreateTags(adapter, tagList); // ← outer adapter
for (const tag of resolvedTags) {
await tx.create({ model: "postTag", data: { ... } }); // ← tx
}
});If the transaction rolls back (e.g. the post update fails), any tags created by findOrCreateTags are already committed and will not be rolled back — leaving orphaned tag rows. This is a correctness / data-integrity bug; its security impact is limited to data pollution but it can be combined with a crafted payload to silently inject arbitrary tags.
Remediation: Pass tx instead of adapter to findOrCreateTags inside the transaction block, and update findOrCreateTags's signature to accept either.
No supply-chain, injection, secret-leakage, or deserialization risks were identified in this diff. The slugify call uses the well-vetted slug library and the adapter queries use structured field/value pairs rather than raw query strings.
Sent by Cursor Automation: Find vulnerabilities
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Tag operations escape transaction in
updatePost- Changed findOrCreateTags call to use tx instead of adapter, ensuring all tag operations stay within the transaction boundary.
Preview (4665fc0f1e)
diff --git a/packages/stack/package.json b/packages/stack/package.json
--- a/packages/stack/package.json
+++ b/packages/stack/package.json
@@ -1,6 +1,6 @@
{
"name": "@btst/stack",
- "version": "2.11.1",
+ "version": "2.11.2",
"description": "A composable, plugin-based library for building full-stack applications.",
"repository": {
"type": "git",
diff --git a/packages/stack/src/plugins/blog/api/index.ts b/packages/stack/src/plugins/blog/api/index.ts
--- a/packages/stack/src/plugins/blog/api/index.ts
+++ b/packages/stack/src/plugins/blog/api/index.ts
@@ -6,6 +6,13 @@
type PostListParams,
type PostListResult,
} from "./getters";
+export {
+ createPost,
+ updatePost,
+ deletePost,
+ type CreatePostInput,
+ type UpdatePostInput,
+} from "./mutations";
export { serializePost, serializeTag } from "./serializers";
export { BLOG_QUERY_KEYS } from "./query-key-defs";
export { createBlogQueryKeys } from "../query-keys";
diff --git a/packages/stack/src/plugins/blog/api/mutations.ts b/packages/stack/src/plugins/blog/api/mutations.ts
new file mode 100644
--- /dev/null
+++ b/packages/stack/src/plugins/blog/api/mutations.ts
@@ -1,0 +1,287 @@
+import type { DBAdapter as Adapter } from "@btst/db";
+import type { Post, Tag } from "../types";
+import { slugify } from "../utils";
+
+type TagInput = { name: string } | { id: string; name: string; slug: string };
+
+/**
+ * Find existing tags by slug or create missing ones, then return the resolved Tag records.
+ * Tags that already carry an `id` are returned as-is (after name normalisation).
+ */
+async function findOrCreateTags(
+ adapter: Adapter,
+ tagInputs: TagInput[],
+): Promise<Tag[]> {
+ if (tagInputs.length === 0) return [];
+
+ const normalizeTagName = (name: string): string => name.trim();
+
+ const tagsWithIds: Tag[] = [];
+ const tagsToFindOrCreate: Array<{ name: string }> = [];
+
+ for (const tagInput of tagInputs) {
+ if ("id" in tagInput && tagInput.id) {
+ tagsWithIds.push({
+ id: tagInput.id,
+ name: normalizeTagName(tagInput.name),
+ slug: tagInput.slug,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ } as Tag);
+ } else {
+ tagsToFindOrCreate.push({ name: normalizeTagName(tagInput.name) });
+ }
+ }
+
+ if (tagsToFindOrCreate.length === 0) {
+ return tagsWithIds;
+ }
+
+ const allTags = await adapter.findMany<Tag>({ model: "tag" });
+ const tagMapBySlug = new Map<string, Tag>();
+ for (const tag of allTags) {
+ tagMapBySlug.set(tag.slug, tag);
+ }
+
+ const tagSlugs = tagsToFindOrCreate.map((tag) => slugify(tag.name));
+ const foundTags: Tag[] = [];
+ for (const slug of tagSlugs) {
+ const tag = tagMapBySlug.get(slug);
+ if (tag) {
+ foundTags.push(tag);
+ }
+ }
+
+ const existingSlugs = new Set([
+ ...tagsWithIds.map((tag) => tag.slug),
+ ...foundTags.map((tag) => tag.slug),
+ ]);
+ const tagsToCreate = tagsToFindOrCreate.filter(
+ (tag) => !existingSlugs.has(slugify(tag.name)),
+ );
+
+ const createdTags: Tag[] = [];
+ for (const tag of tagsToCreate) {
+ const normalizedName = normalizeTagName(tag.name);
+ const newTag = await adapter.create<Tag>({
+ model: "tag",
+ data: {
+ name: normalizedName,
+ slug: slugify(normalizedName),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ });
+ createdTags.push(newTag);
+ }
+
+ return [...tagsWithIds, ...foundTags, ...createdTags];
+}
+
+/**
+ * Input for creating a new blog post.
+ * `slug` must already be slugified by the caller.
+ */
+export interface CreatePostInput {
+ title: string;
+ content: string;
+ excerpt: string;
+ /** Pre-slugified URL slug — use {@link slugify} before passing. */
+ slug: string;
+ image?: string;
+ published?: boolean;
+ publishedAt?: Date;
+ createdAt?: Date;
+ updatedAt?: Date;
+ tags?: TagInput[];
+}
+
+/**
+ * Input for updating an existing blog post.
+ * If `slug` is provided it must already be slugified by the caller.
+ */
+export interface UpdatePostInput {
+ title?: string;
+ content?: string;
+ excerpt?: string;
+ /** Pre-slugified URL slug — use {@link slugify} before passing. */
+ slug?: string;
+ image?: string;
+ published?: boolean;
+ publishedAt?: Date;
+ createdAt?: Date;
+ updatedAt?: Date;
+ tags?: TagInput[];
+}
+
+/**
+ * Create a new blog post with optional tag associations.
+ * Pure DB function — no hooks, no HTTP context. Safe for server-side and SSG use.
+ *
+ * @remarks **Security:** Authorization hooks (e.g. `onBeforeCreatePost`) are NOT
+ * called. The caller is responsible for any access-control checks before
+ * invoking this function.
+ *
+ * @param adapter - The database adapter
+ * @param input - Post data; `slug` must be pre-slugified
+ */
+export async function createPost(
+ adapter: Adapter,
+ input: CreatePostInput,
+): Promise<Post> {
+ const { tags: tagInputs, ...postData } = input;
+ const tagList = tagInputs ?? [];
+
+ const newPost = await adapter.create<Post>({
+ model: "post",
+ data: {
+ ...postData,
+ published: postData.published ?? false,
+ tags: [] as Tag[],
+ createdAt: postData.createdAt ?? new Date(),
+ updatedAt: postData.updatedAt ?? new Date(),
+ },
+ });
+
+ if (tagList.length > 0) {
+ const resolvedTags = await findOrCreateTags(adapter, tagList);
+
+ await adapter.transaction(async (tx) => {
+ for (const tag of resolvedTags) {
+ await tx.create<{ postId: string; tagId: string }>({
+ model: "postTag",
+ data: {
+ postId: newPost.id,
+ tagId: tag.id,
+ },
+ });
+ }
+ });
+
+ newPost.tags = resolvedTags.map((tag) => ({ ...tag }));
+ } else {
+ newPost.tags = [];
+ }
+
+ return newPost;
+}
+
+/**
+ * Update an existing blog post and reconcile its tag associations.
+ * Returns `null` if no post with the given `id` exists.
+ * Pure DB function — no hooks, no HTTP context. Safe for server-side use.
+ *
+ * @remarks **Security:** Authorization hooks (e.g. `onBeforeUpdatePost`) are NOT
+ * called. The caller is responsible for any access-control checks before
+ * invoking this function.
+ *
+ * @param adapter - The database adapter
+ * @param id - The post ID to update
+ * @param input - Partial post data to apply; `slug` must be pre-slugified if provided
+ */
+export async function updatePost(
+ adapter: Adapter,
+ id: string,
+ input: UpdatePostInput,
+): Promise<Post | null> {
+ const { tags: tagInputs, ...postData } = input;
+
+ return adapter.transaction(async (tx) => {
+ const updatedPost = await tx.update<Post>({
+ model: "post",
+ where: [{ field: "id", value: id }],
+ update: {
+ ...postData,
+ updatedAt: new Date(),
+ },
+ });
+
+ if (!updatedPost) return null;
+
+ if (tagInputs !== undefined) {
+ const existingPostTags = await tx.findMany<{
+ postId: string;
+ tagId: string;
+ }>({
+ model: "postTag",
+ where: [{ field: "postId", value: id, operator: "eq" as const }],
+ });
+
+ for (const postTag of existingPostTags) {
+ await tx.delete<{ postId: string; tagId: string }>({
+ model: "postTag",
+ where: [
+ {
+ field: "postId",
+ value: postTag.postId,
+ operator: "eq" as const,
+ },
+ {
+ field: "tagId",
+ value: postTag.tagId,
+ operator: "eq" as const,
+ },
+ ],
+ });
+ }
+
+ if (tagInputs.length > 0) {
+ const resolvedTags = await findOrCreateTags(tx, tagInputs);
+
+ for (const tag of resolvedTags) {
+ await tx.create<{ postId: string; tagId: string }>({
+ model: "postTag",
+ data: {
+ postId: id,
+ tagId: tag.id,
+ },
+ });
+ }
+
+ updatedPost.tags = resolvedTags.map((tag) => ({ ...tag }));
+ } else {
+ updatedPost.tags = [];
+ }
+ } else {
+ const existingPostTags = await tx.findMany<{
+ postId: string;
+ tagId: string;
+ }>({
+ model: "postTag",
+ where: [{ field: "postId", value: id, operator: "eq" as const }],
+ });
+
+ if (existingPostTags.length > 0) {
+ const tagIds = existingPostTags.map((pt) => pt.tagId);
+ const tags = await tx.findMany<Tag>({
+ model: "tag",
+ });
+ updatedPost.tags = tags
+ .filter((tag) => tagIds.includes(tag.id))
+ .map((tag) => ({ ...tag }));
+ } else {
+ updatedPost.tags = [];
+ }
+ }
+
+ return updatedPost;
+ });
+}
+
+/**
+ * Delete a blog post by ID.
+ * Pure DB function — no hooks, no HTTP context. Safe for server-side use.
+ *
+ * @remarks **Security:** Authorization hooks (e.g. `onBeforeDeletePost`) are NOT
+ * called. The caller is responsible for any access-control checks before
+ * invoking this function.
+ *
+ * @param adapter - The database adapter
+ * @param id - The post ID to delete
+ */
+export async function deletePost(adapter: Adapter, id: string): Promise<void> {
+ await adapter.delete<Post>({
+ model: "post",
+ where: [{ field: "id", value: id }],
+ });
+}
diff --git a/packages/stack/src/plugins/blog/api/plugin.ts b/packages/stack/src/plugins/blog/api/plugin.ts
--- a/packages/stack/src/plugins/blog/api/plugin.ts
+++ b/packages/stack/src/plugins/blog/api/plugin.ts
@@ -7,6 +7,13 @@
import { slugify } from "../utils";
import { createPostSchema, updatePostSchema } from "../schemas";
import { getAllPosts, getPostBySlug, getAllTags } from "./getters";
+import {
+ createPost as createPostMutation,
+ updatePost as updatePostMutation,
+ deletePost as deletePostMutation,
+ type CreatePostInput,
+ type UpdatePostInput,
+} from "./mutations";
import { BLOG_QUERY_KEYS } from "./query-key-defs";
import { serializePost, serializeTag } from "./serializers";
import type { QueryClient } from "@tanstack/react-query";
@@ -260,85 +267,15 @@
getPostBySlug: (slug: string) => getPostBySlug(adapter, slug),
getAllTags: () => getAllTags(adapter),
prefetchForRoute: createBlogPrefetchForRoute(adapter),
+ // Mutations
+ createPost: (input: CreatePostInput) =>
+ createPostMutation(adapter, input),
+ updatePost: (id: string, input: UpdatePostInput) =>
+ updatePostMutation(adapter, id, input),
+ deletePost: (id: string) => deletePostMutation(adapter, id),
}),
routes: (adapter: Adapter) => {
- const findOrCreateTags = async (
- tagInputs: Array<
- { name: string } | { id: string; name: string; slug: string }
- >,
- ): Promise<Tag[]> => {
- if (tagInputs.length === 0) return [];
-
- const normalizeTagName = (name: string): string => {
- return name.trim();
- };
-
- const tagsWithIds: Tag[] = [];
- const tagsToFindOrCreate: Array<{ name: string }> = [];
-
- for (const tagInput of tagInputs) {
- if ("id" in tagInput && tagInput.id) {
- tagsWithIds.push({
- id: tagInput.id,
- name: normalizeTagName(tagInput.name),
- slug: tagInput.slug,
- createdAt: new Date(),
- updatedAt: new Date(),
- } as Tag);
- } else {
- tagsToFindOrCreate.push({ name: normalizeTagName(tagInput.name) });
- }
- }
-
- if (tagsToFindOrCreate.length === 0) {
- return tagsWithIds;
- }
-
- const allTags = await adapter.findMany<Tag>({
- model: "tag",
- });
- const tagMapBySlug = new Map<string, Tag>();
- for (const tag of allTags) {
- tagMapBySlug.set(tag.slug, tag);
- }
-
- const tagSlugs = tagsToFindOrCreate.map((tag) => slugify(tag.name));
- const foundTags: Tag[] = [];
-
- for (const slug of tagSlugs) {
- const tag = tagMapBySlug.get(slug);
- if (tag) {
- foundTags.push(tag);
- }
- }
-
- const existingSlugs = new Set([
- ...tagsWithIds.map((tag) => tag.slug),
- ...foundTags.map((tag) => tag.slug),
- ]);
- const tagsToCreate = tagsToFindOrCreate.filter(
- (tag) => !existingSlugs.has(slugify(tag.name)),
- );
-
- const createdTags: Tag[] = [];
- for (const tag of tagsToCreate) {
- const normalizedName = normalizeTagName(tag.name);
- const newTag = await adapter.create<Tag>({
- model: "tag",
- data: {
- name: normalizedName,
- slug: slugify(normalizedName),
- createdAt: new Date(),
- updatedAt: new Date(),
- },
- });
- createdTags.push(newTag);
- }
-
- return [...tagsWithIds, ...foundTags, ...createdTags];
- };
-
const listPosts = createEndpoint(
"/posts",
{
@@ -394,11 +331,10 @@
);
}
- const { tags, ...postData } = ctx.body;
- const tagNames = tags || [];
+ const { tags, slug: rawSlug, ...postData } = ctx.body;
// Always slugify to ensure URL-safe slug, whether provided or generated from title
- const slug = slugify(postData.slug || postData.title);
+ const slug = slugify(rawSlug || postData.title);
// Validate that slugification produced a non-empty result
if (!slug) {
@@ -408,37 +344,12 @@
});
}
- const newPost = await adapter.create<Post>({
- model: "post",
- data: {
- ...postData,
- slug,
- tags: [],
- createdAt: new Date(),
- updatedAt: new Date(),
- },
+ const newPost = await createPostMutation(adapter, {
+ ...postData,
+ slug,
+ tags: tags ?? [],
});
- if (tagNames.length > 0) {
- const createdTags = await findOrCreateTags(tagNames);
-
- await adapter.transaction(async (tx) => {
- for (const tag of createdTags) {
- await tx.create<{ postId: string; tagId: string }>({
- model: "postTag",
- data: {
- postId: newPost.id,
- tagId: tag.id,
- },
- });
- }
- });
-
- newPost.tags = createdTags.map((tag) => ({ ...tag }));
- } else {
- newPost.tags = [];
- }
-
if (hooks?.onPostCreated) {
await hooks.onPostCreated(newPost, context);
}
@@ -476,7 +387,6 @@
}
const { tags, slug: rawSlug, ...restPostData } = ctx.body;
- const tagNames = tags || [];
// Sanitize slug if provided to ensure it's URL-safe
const slugified = rawSlug ? slugify(rawSlug) : undefined;
@@ -489,80 +399,16 @@
});
}
- const postData = {
+ const updated = await updatePostMutation(adapter, ctx.params.id, {
...restPostData,
...(slugified ? { slug: slugified } : {}),
- };
+ tags: tags ?? [],
+ });
- const updated = await adapter.transaction(async (tx) => {
- const existingPostTags = await tx.findMany<{
- postId: string;
- tagId: string;
- }>({
- model: "postTag",
- where: [
- {
- field: "postId",
- value: ctx.params.id,
- operator: "eq" as const,
- },
- ],
- });
+ if (!updated) {
+ throw ctx.error(404, { message: "Post not found" });
+ }
- const updatedPost = await tx.update<Post>({
- model: "post",
- where: [{ field: "id", value: ctx.params.id }],
- update: {
- ...postData,
- updatedAt: new Date(),
- },
- });
-
- if (!updatedPost) {
- throw ctx.error(404, {
- message: "Post not found",
- });
- }
-
- for (const postTag of existingPostTags) {
- await tx.delete<{ postId: string; tagId: string }>({
- model: "postTag",
- where: [
- {
- field: "postId",
- value: postTag.postId,
- operator: "eq" as const,
- },
- {
- field: "tagId",
- value: postTag.tagId,
- operator: "eq" as const,
- },
- ],
- });
- }
-
- if (tagNames.length > 0) {
- const createdTags = await findOrCreateTags(tagNames);
-
- for (const tag of createdTags) {
- await tx.create<{ postId: string; tagId: string }>({
- model: "postTag",
- data: {
- postId: ctx.params.id,
- tagId: tag.id,
- },
- });
- }
-
- updatedPost.tags = createdTags.map((tag) => ({ ...tag }));
- } else {
- updatedPost.tags = [];
- }
-
- return updatedPost;
- });
-
if (hooks?.onPostUpdated) {
await hooks.onPostUpdated(updated, context);
}
@@ -597,10 +443,7 @@
);
}
- await adapter.delete<Post>({
- model: "post",
- where: [{ field: "id", value: ctx.params.id }],
- });
+ await deletePostMutation(adapter, ctx.params.id);
// Lifecycle hook
if (hooks?.onPostDeleted) {You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 30b4932. Configure here.



Summary
Type of change
Checklist
pnpm buildpassespnpm typecheckpassespnpm lintpassesdocs/content/docs/) if consumer-facing types or behavior changedScreenshots
Note
Medium Risk
Adds new direct-to-DB blog post mutation APIs (create/update/delete) that bypass authorization hooks, increasing risk of misuse if called from user-facing code. Also refactors HTTP endpoints to share this logic, so regressions could affect existing create/update/delete behavior and tag associations.
Overview
Adds standalone server-side blog post mutation helpers (
createPost,updatePost,deletePost) that operate directly on the DB adapter, including tag creation/association and tag reconciliation on update.Exposes these mutations via
@btst/stack/plugins/blog/apiandstack().api.blog.*, and refactors the existing HTTP endpoints to delegate to the new mutation functions (while still running endpoint hooks and enforcing server-generated timestamps/slugification).Updates docs to describe the new server-side mutation pattern and explicitly warn that these direct APIs do not invoke authorization hooks, and bumps
@btst/stackto2.11.2.Reviewed by Cursor Bugbot for commit e5d3f7f. Bugbot is set up for automated code reviews on this repo. Configure here.