logicspike/docs

Blog Engine

Blog System — Architecture

Last Updated: 2026-05-06 Status: Active

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


1. System Overview

Two 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/*

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 runs tiptap-renderer.ts per request — HTML is never persisted, always generated fresh
  • Storing TipTap JSON avoids lossy round-trips; storing HTML would destroy mark/block fidelity

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