logicspike/docs

Blog Engine

Blog Service — Technical Documentation

Last Updated: 2026-06-28 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 connections from BLOG_DATABASE_URL and CORE_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
GET /admin/posts/overview Overview payload (see §5a) Dashboard overview: counts, deltas, health flags, recent edits, upcoming scheduled, top categories/tags. SWR-cached 60s.
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&deleted=true&search=text&sort=publishedAt|title|createdAt&order=asc|desc&paginate=cursor&cursor=… { data: [...], meta } List posts (any status). ?deleted=true shows trash. Supports offset and keyset/cursor pagination.
GET /admin/posts/:id Full post object Get post by ID (including soft-deleted)
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 — charges credit, sets publishedAt, fans out newsletter webhook. See billing.md.
POST /admin/posts/:id/unpublish { status: "draft" } Unpublish — clears publishedAt. Refunds credit within 30-min grace window.
POST /admin/posts/:id/schedule { scheduledFor: ISO datetime } { status: "scheduled" } Schedule post for future publish (sets scheduled_for, status → "scheduled")
POST /admin/posts/:id/unschedule { status: "draft" } Cancel scheduled post (clears scheduled_for, status → "draft")
DELETE /admin/posts/:id { status: "deleted" } Soft-delete: sets deleted_at; post removed from all public and admin lists (unless ?deleted=true)
POST /admin/posts/:id/restore { status: "restored" } Restore soft-deleted post (clears deleted_at). Slug collision → auto-append suffix.

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 & HTML Cache

  • Admin API: Returns raw TipTap JSON (for editor consumption)
  • Public API: Returns rendered HTML (for end-user display)
  • Content is stored as jsonb in content_json column
  • content_html cache column: On every create/update, the rendered HTML is stored in content_html with content_html_version = RENDERER_VERSION (1). On public reads:
    1. If cache is present and version matches → serve cached HTML (no re-render)
    2. Otherwise → live-render and backfill cache asynchronously via waitUntil() (fallback: <p>Content could not be rendered.</p>)

Overview Payload (GET /admin/posts/overview)

Cached at the blog_overview_snapshots table with 60s SWR TTL. Response shape:

{
  "counts": { "total": 42, "draft": 10, "published": 30, "scheduled": 2 },
  "deltas": { "last7d": 5, "last30d": 18 },
  "healthFlags": { "hasUnpublishedDrafts": true, "hasScheduledPosts": true },
  "recentEdits": [ { "id", "title", "slug", "status", "updatedAt" } ],
  "upcoming": [ { "id", "title", "slug", "scheduledFor" } ],
  "topCategories": [ { "name", "slug", "postCount" } ],
  "topTags": [ { "name", "slug", "postCount" } ]
}

5a. MCP Tools (/mcp/tools/*)

All tools require the same JWT auth as admin routes. Called via GET or POST /mcp/tools/:tool-name.

Read tools (GET):

Tool Query params Description
list-posts status, page, limit, sort, order Paginated post list with status filter
get-post id or slug Fetch post by ID or slug
search-posts query, limit Full-text search on title + excerpt
list-categories limit All categories with post counts
list-tags limit All tags with post counts
get-analytics id Stub — returns { collected: false, views: 0, reads: 0, avgScrollDepth: null } — analytics ingestion not yet enabled

Write tools (POST):

Tool Body Description
create-draft same as POST /admin/posts New draft post
update-post { id, ...fields } Update any post fields
delete-post { id } Soft-delete
restore-post { id } Restore soft-deleted
publish-post { id, scheduledFor? } Publish or schedule. Note: does NOT fan out newsletter (unlike REST publish)
unpublish-post { id } Revert to draft. Note: no grace-window refund on MCP unpublish
unschedule-post { id } Cancel scheduled (409 if not scheduled)
create-category { name } New category
update-category { id, name } Update category
delete-category { id } Delete (posts → categoryId = null)
create-tag { name } New tag
update-tag { id, name } Update tag
delete-tag { id } Delete (cascade removes junction rows)

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 connection string for blog tables
CORE_DATABASE_URL Neon connection string for core DB (tenant_credits, credit_transactions). Required for publish to work.
GATEWAY_SECRET Shared secret(s) for gateway auth (comma-separated for rotation)
INTERNAL_KEY ⚠️ Shared secret for Newsletter Service internal API calls (x-internal-key header). Optional — newsletter dispatch degrades silently if missing.
NEWSLETTER_SERVICE ⚠️ Service binding to logicspike-newsletter. Optional — degrades silently if absent.
METRICS Cloudflare Analytics Engine dataset binding (blog_service_metrics). Per-request metrics.
DEBUG "true" enables verbose query/context logging
SLOW_REQUEST_MS Latency threshold in ms for warn-level logging (default: 1500)

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               # 5 tables: blog_posts, blog_categories, blog_tags, blog_post_tags, blog_overview_snapshots
│   ├── middleware/
│   │   ├── auth.middleware.ts       # requirePermission + requireTenant
│   │   ├── credit-guard.middleware.ts  # Charge credit before handler; refund on failure/idempotency
│   │   └── db.middleware.ts         # DB connection middleware (BLOG + CORE, cached per isolate)
│   ├── routes/
│   │   ├── admin/posts.ts           # 11 admin endpoints (CRUD + lifecycle + soft-delete/restore)
│   │   ├── admin/categories.ts      # 4 admin endpoints (CRUD)
│   │   ├── admin/tags.ts            # 4 admin endpoints (CRUD)
│   │   ├── mcp/                     # 20 MCP tool endpoints (read + write)
│   │   ├── 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 <div data-type="carousel"> wrapper the SDK expects is never emitted; SDK carousel runtime never mounts. Posts render as flat figure stacks.
2 Slug breaks on title update Old URLs return 404 after a title change; no redirect handling
3 Schedule → auto-publish not wired schedule / unschedule endpoints exist and store scheduled_for, but there is no background cron to flip status=scheduledstatus=published at the target time. Scheduled posts must be manually published.
4 blog_tags missing timestamps No created_at or updated_at columns, unlike blog_categories
5 Analytics stub GET /mcp/tools/get-analytics returns { collected: false, views: 0, reads: 0 } — analytics ingestion pipeline not yet wired
6 Newsletter fire-and-forget, no retry Publish fans out to newsletter-service but failures are logged and dropped; no DLQ or retry
7 MCP publish skips newsletter publish-post MCP tool does NOT dispatch newsletter (unlike REST POST /admin/posts/:id/publish). Intentional for now — agents should use the REST endpoint for full fan-out.
8 MCP unpublish skips grace refund unpublish-post MCP tool does not trigger the 30-min grace-window credit refund. Credits are not recovered when unpublishing via MCP.
Blog Engine