Last Updated: 2026-05-06 Status: Active
1. Architecture
2. Tech Stack
| Layer | Technology |
|---|---|
| Runtime | Cloudflare Workers |
| Framework | Hono v4 |
| Database | Neon PostgreSQL (serverless) |
| ORM | Drizzle ORM |
| Validation | Zod + @hono/zod-validator |
| Migrations | drizzle-kit |
| Content | TipTap JSON (stored) → HTML (rendered server-side for public API) |
3. Middleware Pipeline
Requests pass through 5 middleware layers in order:
1. Logger → hono/logger — logs all requests
2. DB Middleware → Creates/caches Neon DB connection from BLOG_DATABASE_URL
3. Gateway Guard → Validates x-gateway-key against GATEWAY_SECRET (comma-separated)
→ Hydrates RequestContext from trusted headers:
x-tenant-id, x-user-id, x-user-permissions
4. requireTenant → Rejects requests without tenant_id (400)
5. requirePermission("blog:posts.*") → Post admin routes — checks permissions array
requirePermission("blog:posts.read/create/update/delete") → Category admin routes (reuses post scopes)
requirePermission("blog:posts.read/create/update/delete") → Tag admin routes (reuses post scopes)
"system:owner" auto-bypasses all4. Database Schema
blog_posts
| Column | Type | Notes |
|---|---|---|
id |
text PK |
Format: post_{uuid} |
tenant_id |
text NOT NULL |
Multi-tenant isolation |
author_id |
text |
Nullable; set from x-user-id |
category_id |
text FK |
References blog_categories.id, onDelete: 'set null' |
title |
text NOT NULL |
|
slug |
text NOT NULL |
Auto-generated, unique per tenant |
content_json |
jsonb NOT NULL |
TipTap editor JSON |
excerpt |
text |
Optional summary |
featured_image_url |
text |
Optional; URL to featured image |
status |
text NOT NULL |
"draft", "published", or "scheduled" |
seo_title |
text |
Optional |
seo_description |
text |
Optional |
scheduled_for |
timestamp |
When to auto-publish (null = not scheduled) |
published_at |
timestamp |
Set on publish, cleared on unpublish |
created_at |
timestamp |
Auto now() |
updated_at |
timestamp |
Auto now(), updated on every save |
Indexes:
blog_posts_tenant_slug_unique— unique on(tenant_id, slug)blog_posts_tenant_idx— ontenant_idblog_posts_tenant_status_idx— on(tenant_id, status)blog_posts_tenant_slug_idx— on(tenant_id, slug)
blog_categories
| Column | Type | Notes |
|---|---|---|
id |
text PK |
|
tenant_id |
text NOT NULL |
|
name |
text NOT NULL |
|
slug |
text NOT NULL |
Unique per tenant |
created_at |
timestamp |
blog_tags
| Column | Type | Notes |
|---|---|---|
id |
text PK |
Format: tag_{uuid} |
tenant_id |
text NOT NULL |
|
name |
text NOT NULL |
|
slug |
text NOT NULL |
Unique per tenant |
blog_post_tags (Junction Table)
| Column | Type | Notes |
|---|---|---|
post_id |
text FK |
References blog_posts.id, cascade delete |
tag_id |
text FK |
References blog_tags.id, cascade delete |
Primary Key: Composite (post_id, tag_id)
5. API Endpoints
Admin Routes (/admin/posts) — Requires blog:posts.* permissions
| Method | Path | Body/Query | Response | Description |
|---|---|---|---|---|
POST |
/admin/posts |
{ title, content?, excerpt?, seoTitle?, seoDescription?, featuredImageUrl?, categoryId?, tags?[], scheduledFor? } |
{ id, status: "draft" } |
Create a new draft post |
GET |
/admin/posts |
`?page=1&limit=10&category=slug&tag=slug&status=draft | published | scheduled&search=text&sort=publishedAt |
GET |
/admin/posts/:id |
— | Full post object | Get post by ID |
PUT |
/admin/posts/:id |
{ title?, content?, slug?, excerpt?, seoTitle?, seoDescription?, featuredImageUrl?, categoryId?, tags?[], scheduledFor? } |
{ status: "saved" } |
Update post (at least 1 field required) |
POST |
/admin/posts/:id/publish |
— | { status: "published" } |
Publish post (sets publishedAt) |
POST |
/admin/posts/:id/unpublish |
— | { status: "draft" } |
Unpublish (clears publishedAt) |
DELETE |
/admin/posts/:id |
— | { status: "deleted" } |
Permanently delete post |
Admin Routes (/admin/categories) — Requires blog:posts.* permissions
| Method | Path | Body/Query | Response | Description |
|---|---|---|---|---|
GET |
/admin/categories |
— | [{ id, name, slug, postCount, createdAt }] |
List all tenant categories with post counts |
POST |
/admin/categories |
{ name } |
{ id, name, slug } |
Create a category (slug auto-generated) |
PUT |
/admin/categories/:id |
{ name } |
{ id, name, slug } |
Update category name (slug regenerated) |
DELETE |
/admin/categories/:id |
— | { status: "deleted" } |
Delete category; affected posts get categoryId = null |
Admin Routes (/admin/tags) — Requires blog:posts.* permissions
| Method | Path | Body/Query | Response | Description |
|---|---|---|---|---|
GET |
/admin/tags |
— | [{ id, name, slug, postCount }] |
List all tenant tags with post counts |
POST |
/admin/tags |
{ name } |
{ id, name, slug } |
Create a tag (slug auto-generated) |
PUT |
/admin/tags/:id |
{ name } |
{ id, name, slug } |
Update tag name (slug regenerated) |
DELETE |
/admin/tags/:id |
— | { status: "deleted" } |
Delete tag; cascade-deletes junction rows |
Public Routes (/public/posts) — Requires API Key (via gateway)
| Method | Path | Body/Query | Response | Description |
|---|---|---|---|---|
GET |
/public/posts |
`?page=1&limit=10&category=slug&tag=slug&search=text&sort=publishedAt | title | createdAt&order=asc |
GET |
/public/posts/:slug |
— | { title, slug, excerpt, content (HTML), seoTitle, seoDescription, featuredImageUrl, publishedAt, category, tags } |
Get published post by slug — content is server-rendered HTML |
Public Routes (/public/categories & /public/tags) — Requires API Key
| Method | Path | Body/Query | Response | Description |
|---|---|---|---|---|
GET |
/public/categories |
— | [{ name, slug, postCount }] |
List categories with published post counts |
GET |
/public/tags |
— | [{ name, slug, postCount }] |
List tags with published post counts |
6. Validation Schemas (Zod)
// Create Post
{
title: string (1-255 chars, required),
content: any (optional, defaults to {}),
seoTitle: string (optional),
seoDescription: string (optional),
excerpt: string (optional),
featuredImageUrl: string URL | null (optional),
categoryId: string (optional),
tags: string[] (optional, max 10),
scheduledFor: string datetime | null (optional),
}
// Update Post
{
title?: string (1-255 chars),
content?: any,
slug?: string (lowercase, hyphens, 1-200 chars),
seoTitle?: string,
seoDescription?: string,
excerpt?: string,
featuredImageUrl?: string URL | null,
categoryId?: string | null,
tags?: string[] (max 10),
scheduledFor?: string datetime | null,
}
// Refinement: at least one field must be provided
// List Posts
{
page: number (min 1, default 1),
limit: number (min 1, max 100, default 10),
category: string (optional, filter by category slug),
tag: string (optional, filter by tag slug),
search: string (optional, max 200, case-insensitive title+excerpt match),
sort: "publishedAt" | "title" | "createdAt" (default: "publishedAt"),
order: "asc" | "desc" (default: "desc"),
status: "draft" | "published" | "scheduled" (optional),
}
// Create/Update Category
{
name: string (1-100 chars, required),
}
// Create/Update Tag
{
name: string (1-50 chars, required),
}7. Key Behaviors
Slug Generation
- Title is slugified: lowercased → non-alphanumeric replaced with
-→ leading/trailing-stripped - Checked for uniqueness within the same tenant
- On collision: appends
-1,-2, etc. (up to 100 attempts) - Fallback: appends 8-char UUID fragment
- On title update: slug is re-generated, old URLs will break
TipTap → HTML Rendering
The public API (GET /public/posts/:slug) converts TipTap JSON to HTML server-side via tiptap-renderer.ts.
Supported nodes: paragraph, heading (1-6), bulletList, orderedList, listItem, blockquote, codeBlock (with lowlight syntax highlighting), horizontalRule, hardBreak, image (with <figure>/<figcaption> when caption is set), table / tableRow / tableHeader / tableCell (colspan/rowspan supported), taskList / taskItem (with <input type="checkbox" disabled>), youtube (normalized to youtube-nocookie.com/embed/...), callout (<div class="callout callout-{info,warning,tip,danger}">), toggle (<details open><summary>...</summary>)
Supported marks: bold, italic, strike, underline, code, highlight, link
Security:
- All text content is HTML-escaped (
&,<,>,",') - URLs are sanitized — only
http://,https://,mailto:,/,#protocols allowed javascript:,data:,vbscript:are blocked (replaced with#)- Raw
styleattributes are NOT passed through (prevents CSS injection) - Image alignment only allows
left,center,right
Not rendered (silently passes through inner content): carousel — known bug, see Known Gaps #1
Content Storage
- Admin API: Returns raw TipTap JSON (for editor consumption)
- Public API: Returns rendered HTML (for end-user display)
- Content is stored as
jsonbin PostgreSQL
8. Gateway Integration
The gateway (apps/gateway) mounts the blog service at /blog/*:
/blog/admin/* → authMiddleware (JWT) → blog.proxy → blog-service /admin/*
/blog/public/* → apiKeyMiddleware → blog.proxy → blog-service /public/*Headers forwarded to blog-service:
x-gateway-key— shared secret for service-to-service authx-tenant-id— from JWT or API key contextx-user-id— from JWTx-user-permissions— JSON array from JWT (e.g.,["blog:posts.update", "system:owner"])
Seller Dashboard proxy (apps/seller-dashboard/src/modules/blog/api/proxy.ts):
- All frontend calls go to
/api/blog/*on Next.js - Next.js route handler extracts JWT via
next-auth/jwt - Forwards to Gateway with
Bearertoken - Gateway processes auth, then forwards to blog-service
9. Environment Variables
| Variable | Required | Description |
|---|---|---|
BLOG_DATABASE_URL |
✅ | Neon PostgreSQL connection string |
GATEWAY_SECRET |
✅ | Shared secret(s) for gateway auth (comma-separated) |
DEBUG |
❌ | Set to "true" to enable verbose context logging |
10. File Structure
apps/blog-service/
├── src/
│ ├── index.ts # Hono app + middleware pipeline
│ ├── context.ts # RequestContext type augmentation
│ ├── db/
│ │ ├── client.ts # Neon serverless + Drizzle setup
│ │ └── schema.ts # 3 tables: blog_posts, blog_categories, blog_tags
│ ├── middleware/
│ │ ├── auth.middleware.ts # requirePermission + requireTenant
│ │ └── db.middleware.ts # DB connection middleware (cached)
│ ├── routes/
│ │ ├── admin/posts.ts # 7 admin endpoints (CRUD + publish/unpublish + category/tag wiring)
│ │ ├── admin/categories.ts # 4 admin endpoints (CRUD)
│ │ ├── admin/tags.ts # 4 admin endpoints (CRUD)
│ │ ├── public/posts.ts # 2 public endpoints (list + get-by-slug, with category/tag)
│ │ ├── public/categories.ts # 1 public endpoint (list with published post counts)
│ │ └── public/tags.ts # 1 public endpoint (list with published post counts)
│ ├── schemas/
│ │ ├── post.schema.ts # Post validation schemas (with filter params)
│ │ ├── category.schema.ts # Category validation schemas
│ │ └── tag.schema.ts # Tag validation schemas
│ ├── services/
│ │ └── post.utils.ts # ID generation + slug uniqueness
│ └── utils/
│ └── tiptap-renderer.ts # TipTap JSON → sanitized HTML
├── drizzle/ # Migration files
│ ├── 0000_wild_sage.sql
│ ├── 0001_fluffy_hedge_knight.sql
│ └── 0002_new_maelstrom.sql
├── drizzle.config.ts
├── wrangler.toml # Worker config (port 8791)
└── package.json11. Scripts
npm run dev # wrangler dev --env-file=.env (local on :8791)
npm run deploy # wrangler deploy --minify (production)
npm run generate # drizzle-kit generate (create migration)
npm run migrate # drizzle-kit migrate (apply migration)
npm run db:push # drizzle-kit push (push schema to DB)
npm run studio # drizzle-kit studio (DB browser)12. Known Gaps
| # | Gap | Impact |
|---|---|---|
| 1 | Carousel node not rendered — posts with carousels render as flat figure stacks | The <div data-type="carousel"> wrapper the SDK expects is never emitted; SDK carousel runtime never mounts |
| 2 | Slug breaks on title update | Old URLs return 404 after title change; no redirect handling |
| 3 | No scheduled publishing | Schema supports scheduled_for but no cron trigger to auto-publish |
| 4 | No soft-delete | Posts are permanently deleted |
| 5 | blog_tags missing timestamps |
No created_at or updated_at columns unlike blog_categories |