logicspike/docs

Blog Engine

Blog Service — Technical Documentation

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 all

4. 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 — on tenant_id
  • blog_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 style attributes 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 jsonb in 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 auth
  • x-tenant-id — from JWT or API key context
  • x-user-id — from JWT
  • x-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 Bearer token
  • 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.json

11. 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
Blog Engine