1import BlogPost from "./model.js";
2import createRouter from "./routes.js";
3
4export async function init(core) {
5 return {
6 models: { BlogPost },
7 router: createRouter(core),
8 exports: {},
9 };
10}
11
1export default {
2 name: "blog",
3 version: "1.0.1",
4 builtFor: "TreeOS",
5 description:
6 "A land-level blog for publishing long-form content outside the tree structure. Trees " +
7 "are for structured knowledge. Blogs are for narrative. Announcements, changelogs, " +
8 "tutorials, essays. Content that has a publish date and an audience rather than a " +
9 "position in a hierarchy. " +
10 "\n\n" +
11 "Each post has a title, a URL-safe slug, markdown content, an optional summary, a " +
12 "publish date, and an author reference. The slug is unique and serves as the primary " +
13 "lookup key for public access. Posts can be marked as published or unpublished. Only " +
14 "published posts appear in the public listing. " +
15 "\n\n" +
16 "Publishing is restricted to land admins. Any authenticated admin can create, update, " +
17 "or delete posts through the API. The author's username is stored alongside the user " +
18 "ID so posts display correctly even if the author account is later modified. Reading " +
19 "is fully public. No authentication required to list posts or read a specific post " +
20 "by slug. " +
21 "\n\n" +
22 "The extension provides its own Mongoose model (BlogPost) stored in a dedicated " +
23 "collection separate from tree data. This keeps blog content out of the node/note " +
24 "system entirely. The CLI exposes two commands: 'blogs' to list all published posts " +
25 "and 'blog <slug>' to read a specific one. Slug collisions on create or update " +
26 "return a clear error rather than overwriting.",
27
28 needs: {
29 models: ["User"],
30 },
31
32 optional: {},
33
34 provides: {
35 models: { BlogPost: "./model.js" },
36 routes: "./routes.js",
37 tools: false,
38 jobs: false,
39 orchestrator: false,
40 energyActions: {},
41 sessionTypes: {},
42 cli: [
43 { command: "blogs", description: "List blog posts", method: "GET", endpoint: "/blog/posts" },
44 { command: "blog <slug>", description: "Read a blog post", method: "GET", endpoint: "/blog/posts/:slug" },
45 ],
46 },
47};
48
1import mongoose from "mongoose";
2import { v4 as uuidv4 } from "uuid";
3
4const BlogPostSchema = new mongoose.Schema({
5 _id: { type: String, default: uuidv4 },
6 title: { type: String, required: true },
7 slug: { type: String, required: true, unique: true },
8 content: { type: String, required: true },
9 summary: { type: String, default: "" },
10 publishedAt: { type: Date, default: Date.now },
11 createdAt: { type: Date, default: Date.now },
12 published: { type: Boolean, default: true },
13 author: { type: String, ref: "User" },
14 authorName: { type: String, default: "" },
15});
16
17const BlogPost = mongoose.model("BlogPost", BlogPostSchema);
18export default BlogPost;
19
1import log from "../../seed/log.js";
2import express from "express";
3import authenticate from "../../seed/middleware/authenticate.js";
4import { sendOk, sendError, ERR } from "../../seed/protocol.js";
5import BlogPost from "./model.js";
6
7export default function createRouter(core) {
8 const { User } = core.models;
9 const router = express.Router();
10
11 router.get("/blog/posts", async (req, res) => {
12 try {
13 const posts = await BlogPost.find({ published: true })
14 .select("title slug summary publishedAt authorName")
15 .sort({ publishedAt: -1 })
16 .lean();
17 sendOk(res, { posts });
18 } catch (err) {
19 log.error("Blog", "Blog list error:", err.message);
20 sendError(res, 500, ERR.INTERNAL, err.message);
21 }
22 });
23
24 router.get("/blog/posts/:slug", async (req, res) => {
25 try {
26 const post = await BlogPost.findOne({
27 slug: req.params.slug,
28 published: true,
29 }).lean();
30 if (!post) return sendError(res, 404, ERR.NOTE_NOT_FOUND, "Post not found");
31 sendOk(res, { post });
32 } catch (err) {
33 log.error("Blog", "Blog post error:", err.message);
34 sendError(res, 500, ERR.INTERNAL, err.message);
35 }
36 });
37
38 router.post("/blog/posts", authenticate, async (req, res) => {
39 try {
40 const user = await User.findById(req.userId)
41 .select("isAdmin username")
42 .lean();
43 if (!user || !user.isAdmin) {
44 return sendError(res, 403, ERR.FORBIDDEN, "Requires admin");
45 }
46
47 const { title, slug, content, summary, publishedAt, published } = req.body;
48 if (!title || !slug || !content) {
49 return sendError(res, 400, ERR.INVALID_INPUT, "title, slug, and content are required");
50 }
51
52 const post = await BlogPost.create({
53 title,
54 slug,
55 content,
56 summary: summary || "",
57 publishedAt: publishedAt ? new Date(publishedAt) : new Date(),
58 published: published !== undefined ? published : true,
59 author: req.userId,
60 authorName: user.username,
61 });
62
63 sendOk(res, { post }, 201);
64 } catch (err) {
65 if (err.code === 11000) {
66 return sendError(res, 400, ERR.INVALID_INPUT, "Slug already exists");
67 }
68 log.error("Blog", "Blog create error:", err.message);
69 sendError(res, 500, ERR.INTERNAL, err.message);
70 }
71 });
72
73 router.put("/blog/posts/:slug", authenticate, async (req, res) => {
74 try {
75 const user = await User.findById(req.userId).select("isAdmin").lean();
76 if (!user || !user.isAdmin) {
77 return sendError(res, 403, ERR.FORBIDDEN, "Requires admin");
78 }
79
80 const updates = {};
81 const allowed = ["title", "slug", "content", "summary", "publishedAt", "published"];
82 for (const key of allowed) {
83 if (req.body[key] !== undefined) {
84 updates[key] = key === "publishedAt" ? new Date(req.body[key]) : req.body[key];
85 }
86 }
87
88 const post = await BlogPost.findOneAndUpdate(
89 { slug: req.params.slug },
90 updates,
91 { new: true },
92 );
93 if (!post) return sendError(res, 404, ERR.NOTE_NOT_FOUND, "Post not found");
94 sendOk(res, { post });
95 } catch (err) {
96 if (err.code === 11000) {
97 return sendError(res, 400, ERR.INVALID_INPUT, "Slug already exists");
98 }
99 log.error("Blog", "Blog update error:", err.message);
100 sendError(res, 500, ERR.INTERNAL, err.message);
101 }
102 });
103
104 router.delete("/blog/posts/:slug", authenticate, async (req, res) => {
105 try {
106 const user = await User.findById(req.userId).select("isAdmin").lean();
107 if (!user || !user.isAdmin) {
108 return sendError(res, 403, ERR.FORBIDDEN, "Requires admin");
109 }
110
111 const post = await BlogPost.findOneAndDelete({ slug: req.params.slug });
112 if (!post) return sendError(res, 404, ERR.NOTE_NOT_FOUND, "Post not found");
113 sendOk(res, { deleted: req.params.slug });
114 } catch (err) {
115 log.error("Blog", "Blog delete error:", err.message);
116 sendError(res, 500, ERR.INTERNAL, err.message);
117 }
118 });
119
120 return router;
121}
122
Loading comments...