logicspike/docs

Blog Engine

Blog System — Architecture

Last Updated: 2026-06-28 Status: Active

How all the pieces fit together. Read this before touching any of the three codebases.


1. System Overview

Three doors, one service:

Door Who uses it Auth Gateway path
Admin Seller Dashboard (logged-in users) JWT from NextAuth session /blog/admin/*
Public Customer websites via SDK API Key (Authorization: Bearer) /blog/public/*
MCP AI agents (Claude, Cursor, etc.) JWT (same as admin) /blog/mcp/*

2. Service Inventory

2.1 Gateway (apps/gateway)

  • Port: 8788 (local) · api.vlozi.app (production)
  • Role: Authentication, authorization, and routing broker. Never touches the blog database directly.
  • Blog-specific file: src/routes/blog.proxy.ts

Admin door middleware chain:

Step Middleware Rejects with
1 authMiddleware — verify JWT, extract identity 401 Invalid token
2 accessMiddleware("blog") — check blog entitlement in tenant plan 403 Access denied
3 subscriptionGuard — check tenant subscription is active 402 Subscription required
4 Strips /blog prefix, injects identity headers, forwards via BLOG_SERVICE binding

Public door middleware chain:

Step Middleware Rejects with
1 apiKeyMiddleware("blog") — validate key + scope 401 Invalid API key
2 Extracts tenant_id from API key record, injects x-tenant-id
3 Forwards via BLOG_SERVICE binding

2.2 blog-service (apps/blog-service)

  • Port: 8791 (local) · Cloudflare Workers (production)
  • Framework: Hono v4
  • Role: Single source of truth for all blog data. Owns the database. Enforces permissions.
  • Entry point: src/index.ts
  • Only accepts requests from the gateway — validates x-gateway-key on every request

Middleware pipeline (in order):

Step Middleware Purpose
1 Request logger Structured JSON log per request
2 dbMiddleware Creates Neon DB connection, caches per request
3 Gateway guard Validates x-gateway-key against GATEWAY_SECRET
4 Context hydration Reads x-tenant-id, x-user-id, x-user-permissions into request context
5 requireTenant Rejects 400 if tenant_id is missing
6 requirePermission(...) Method + path specific permission check

2.3 seller-dashboard blog module (apps/seller-dashboard/src/modules/blog)

  • Port: 3000 (local)
  • Role: Authoring UI — editor, post list, categories, tags.
  • Proxy: src/modules/blog/api/proxy.ts (Next.js API route at /api/blog/*) extracts JWT from NextAuth session and forwards it to the gateway.
  • Does not call blog-service directly — always routes through the gateway.

2.4 @vlozi/blog SDK (packages/blog-sdk)

  • Published: @vlozi/blog@2.1.6
  • Role: Public consumption layer for customer websites.
  • Entry points: @vlozi/blog · @vlozi/blog/react · @vlozi/blog/server · @vlozi/blog/next
  • Calls: ${baseUrl}/blog/public/* via the gateway

3. Request Flows

3.1 Admin Request — Save a Draft

3.2 Public Request — Fetch a Post


4. Header Protocol

The gateway injects trusted identity headers before forwarding to blog-service. These must never come from the client — the gateway validates auth first, then sets them.

Header Set by Read by Content
x-gateway-key Gateway blog-service middleware step 3 Shared secret proving the request came through the gateway
x-tenant-id Gateway blog-service context hydration Tenant identity extracted from JWT or API key
x-user-id Gateway (admin only) blog-service context User identity from JWT
x-user-permissions Gateway (admin only) blog-service permission guards JSON-encoded string[] from JWT
x-user-role Gateway (admin only) blog-service context Role string from JWT
x-request-id Gateway or fresh UUID Echoed on response Correlation ID for log tracing

IMPORTANT

blog-service only trusts these headers because a valid x-gateway-key proves the full gateway middleware chain ran first. Without the matching secret, the request is rejected 403 before any identity header is read.


5. Permission Model

Permissions are stored in the JWT (permissions: string[]), forwarded as x-user-permissions, and checked after context hydration in blog-service.

Permission Operations
blog:posts.read GET /admin/posts, GET /admin/posts/:id, GET /admin/categories, GET /admin/tags
blog:posts.create POST /admin/posts, POST /admin/categories, POST /admin/tags
blog:posts.update PUT /admin/posts/:id, PUT /admin/categories/:id, PUT /admin/tags/:id
blog:posts.delete DELETE /admin/posts/:id, DELETE /admin/categories/:id, DELETE /admin/tags/:id
blog:posts.publish POST /admin/posts/:id/publish, /unpublish, /schedule, /unschedule
system:owner Bypasses all permission checks

NOTE

Categories and tags reuse the blog:posts.* permission family — there is no separate blog:categories.* scope.


6. Multi-Tenant Isolation

There is one blog-service serving all tenants. Isolation is enforced at the query layer — not by routing, separate Workers, or separate databases.

  • Every table has tenant_id TEXT NOT NULL
  • Every query includes WHERE tenant_id = ?
  • tenant_id is never derived from the request body — only from the trusted x-tenant-id header
  • requireTenant middleware rejects any request where x-tenant-id is absent or empty
  • Slug uniqueness is enforced per tenant — two tenants can share a slug

7. Content Storage Model

  • Admin API returns raw TipTap JSON — the editor re-hydrates it directly
  • Public API serves from content_html cache when version matches (RENDERER_VERSION). On cache miss, live-renders via tiptap-renderer.ts and backfills the cache in waitUntil().
  • Storing TipTap JSON avoids lossy round-trips; the HTML cache column (content_html) is an optimization, not the source of truth.

8. Key Design Decisions

Decision Rationale
Cloudflare Workers for blog-service Zero cold starts, global edge, service bindings make gateway→service communication in-process and free
Neon PostgreSQL Serverless HTTP driver works inside Workers (no TCP), Drizzle keeps queries type-safe, JSONB indexes handle blog-scale reads
Single service, tenant-scoped queries Simpler than per-tenant Workers. Neon branching available later for compliance requirements.
Gateway as trust boundary blog-service never verifies JWTs or API keys — that logic lives once in the gateway
JSONB content storage Schema migrations don't break stored content; round-trips to the editor are lossless
No direct blog-service exposure blog-service is only reachable via the Cloudflare service binding — not exposed to the public internet
Credit billing via Core DB Publish charges credits against CORE_DATABASE_URL using creditGuard middleware. Unpublish within 30 min triggers a refund. See billing.md.
Newsletter fan-out is fire-and-forget On publish, blog-service calls newsletter-service in waitUntil(). Failures are logged but don't block or roll back the publish.
Analytics Engine for metrics Per-request metrics (method, route, status, duration, tenantId) are written to the blog_service_metrics Analytics Engine dataset if the METRICS binding is present.
Blog Engine