logicspike/docs

Blog Engine

Blog System — Complete Workflow & Debugging Guide

Last Updated: 2026-05-06 Status: Active


1. Architecture Overview

Two doors into the blog system:

Door Who uses it Auth method Path
Admin Seller Dashboard (logged-in users) JWT token from NextAuth session /blog/admin/*
Public External consumers via SDK API Key (x-api-key / Bearer header) /blog/public/*

2. Request Flow

Admin Request (e.g., "List all posts")

1. Browser → GET /dashboard/blog

2. BlogList.tsx calls fetchBlogPosts()
   │  → GET /api/blog/admin/posts  (Next.js API route)

3. /api/blog/[...slug]/route.ts
   │  → imports proxy.ts handler
   │  → getToken({ req }) extracts JWT from NextAuth session
   │  → Builds headers:
   │     - Sets Authorization: Bearer <access_token>
   │     - Deletes host, cookie, connection headers
   │  → Forwards to: ${NEXT_PUBLIC_GATEWAY_URL}/blog/admin/posts

4. Gateway (blog.proxy.ts)
   │  → authMiddleware verifies JWT via verifyAccessToken(token, JWT_PUBLIC_KEY)
   │  → Extracts tenant_id, user_id, permissions from JWT payload
   │  → Injects headers: x-tenant-id, x-user-id, x-user-permissions, x-gateway-key
   │  → Deletes host header
   │  → Forwards via BLOG_SERVICE binding to: /admin/posts

5. Blog Service (index.ts middleware chain)
   │  → Logger (logs all requests)
   │  → dbMiddleware (creates DB connection from BLOG_DATABASE_URL)
   │  → Gateway secret check (validates x-gateway-key against GATEWAY_SECRET)
   │  → Context hydration (reads x-tenant-id, x-user-id, x-user-permissions)
   │  → requireTenant middleware (ensures tenant_id is present)
   │  → requirePermission("blog:posts.update") on /admin/* routes

6. Route handler (routes/admin/posts.ts)
   │  → Queries DB with tenant_id scope
   │  → Returns JSON response

7. Response flows back: Blog Service → Gateway → Proxy → Browser

Public Request (via SDK)

1. Consumer App calls VloziClient.blog.list()
   │  → GET ${baseUrl}/blog/public/posts?page=1&limit=10
   │  → Headers: Authorization: Bearer <api_key>

2. Gateway (blog.proxy.ts)
   │  → apiKeyMiddleware("blog") validates API key + scope
   │  → Extracts tenant_id from API key record
   │  → Injects: x-tenant-id, x-gateway-key
   │  → Forwards via BLOG_SERVICE binding to: /public/posts

3. Blog Service processes request (same middleware chain, minus permission check)
   │  → Only returns published posts with publishedAt != null
   │  → Converts TipTap JSON → HTML for single post endpoints

3. Environment Variables

seller-dashboard (apps/seller-dashboard/.env.local)

Variable Purpose Local Dev Value Production
NEXT_PUBLIC_GATEWAY_URL Where the proxy sends blog requests Must be production gateway URL (e.g., https://logicspike-gateway.xxx.workers.dev) OR local gateway (http://127.0.0.1:8788) if gateway .dev.vars is configured Set via Vercel
NEXTAUTH_SECRET Signs NextAuth session cookies Any stable string Same as deployed
NEXTAUTH_URL NextAuth callback URL http://localhost:3000 Production URL
GOOGLE_CLIENT_ID OAuth login Google Console value Same
GOOGLE_CLIENT_SECRET OAuth login Google Console value Same

WARNING

If using local gateway (127.0.0.1:8788), the gateway MUST have .dev.vars with JWT_PUBLIC_KEY, GATEWAY_SECRET, and DATABASE_URL configured. Without these, all requests will fail with 401/403.

Gateway (apps/gateway/.dev.vars — for local dev)

Variable Purpose Where to get it
JWT_PUBLIC_KEY Verifies JWT access tokens Must match the private key used by the manager service to sign tokens
GATEWAY_SECRET Shared secret passed to blog-service via x-gateway-key Any secure string, must match blog-service's GATEWAY_SECRET
DATABASE_URL Gateway's own DB for logging/API keys Neon connection string
DEBUG Enables verbose logging true to enable

Blog Service (apps/blog-service/.env and .dev.vars)

Variable Purpose File
BLOG_DATABASE_URL Blog posts database .env
GATEWAY_SECRET Validates x-gateway-key from gateway .dev.vars
DEBUG Enables verbose logging .dev.vars or env

4. All Endpoints

Admin Endpoints (require JWT + blog:posts.* permissions)

Method Path (from blog-service) Gateway Path Proxy Path (browser) Description
GET /admin/posts /blog/admin/posts /api/blog/admin/posts List all posts (paginated)
GET /admin/posts/:id /blog/admin/posts/:id /api/blog/admin/posts/:id Get single post by ID
POST /admin/posts /blog/admin/posts /api/blog/admin/posts Create new post
PUT /admin/posts/:id /blog/admin/posts/:id /api/blog/admin/posts/:id Update post
POST /admin/posts/:id/publish /blog/admin/posts/:id/publish /api/blog/admin/posts/:id/publish Publish post
POST /admin/posts/:id/unpublish /blog/admin/posts/:id/unpublish /api/blog/admin/posts/:id/unpublish Unpublish post
DELETE /admin/posts/:id /blog/admin/posts/:id /api/blog/admin/posts/:id Delete post

Request/Response Contracts

POST /admin/posts — Create

// Request body (validated by createPostSchema)
{
  "title": "My Post",             // required, 1-255 chars
  "content": {},                   // optional, TipTap JSON
  "excerpt": "...",                // optional
  "seoTitle": "...",               // optional
  "seoDescription": "...",        // optional
  "featuredImageUrl": "https://...", // optional
  "categoryId": "cat_uuid",       // optional
  "tags": ["tag_uuid"],           // optional, max 10
  "scheduledFor": "2026-06-01T10:00:00Z" // optional ISO datetime
}
 
// Response
{ "id": "post_uuid", "status": "draft" }

PUT /admin/posts/:id — Update

// Request body (validated by updatePostSchema — at least 1 field required)
{
  "title": "Updated Title",        // optional
  "content": {},                    // optional
  "slug": "custom-slug",           // optional
  "excerpt": "...",                 // optional
  "seoTitle": "...",                // optional
  "seoDescription": "...",         // optional
  "featuredImageUrl": "https://...", // optional
  "categoryId": "cat_uuid",        // optional
  "tags": ["tag_uuid"],            // optional, max 10
  "scheduledFor": null             // optional, null to clear
}
 
// Response
{ "status": "saved" }

GET /admin/posts — List

// Query params (validated by listPostsSchema)
?page=1&limit=10&category=slug&tag=slug&status=draft|published|scheduled&search=text&sort=publishedAt|title|createdAt&order=asc|desc
 
// Response
{
  "data": [
    { "id": "...", "title": "...", "status": "draft|published|scheduled", "seoDescription": "...", "updatedAt": "..." }
  ],
  "meta": { "page": 1, "limit": 10, "total": 42, "totalPages": 5 }
}

GET /admin/posts/:id — Get by ID

// Response — full post object
{
  "id": "...", "tenantId": "...", "authorId": "...",
  "title": "...", "slug": "...", "content": { /* TipTap JSON */ },
  "excerpt": "...", "featuredImageUrl": "...",
  "status": "draft|published|scheduled",
  "categoryId": "...", "seoTitle": "...", "seoDescription": "...",
  "scheduledFor": null, "publishedAt": null,
  "createdAt": "...", "updatedAt": "..."
}

Public Endpoints (require API Key with blog scope)

Method Path (from blog-service) Gateway Path SDK Method
GET /public/posts /blog/public/posts client.blog.list()
GET /public/posts/:slug /blog/public/posts/:slug client.blog.get(slug)

NOTE

Public endpoints only return published posts where publishedAt IS NOT NULL. The single-post endpoint converts TipTap JSON → sanitized HTML.

Utility Endpoints

Method Path Auth Purpose
GET /blog/debug Gateway (DEBUG mode only) Check blog-service connectivity
GET /blog/admin/test-log JWT + DEBUG mode Test gateway logging
GET /health Gateway secret Blog service health check
GET /debug Gateway secret Blog service env status

5. Authentication Flow

How the JWT gets into the session

1. User clicks "Login with Google" → NextAuth handles OAuth flow
2. NextAuth callback in [...nextauth]/route.ts:
   │  → Calls ${NEXT_PUBLIC_GATEWAY_URL}/auth/google with the Google token
   │  → Gateway's manager service validates & returns { access_token, refresh_token }
   │  → Tokens stored in NextAuth session: session.access_token = access_token
3. On subsequent requests:
   │  → getToken({ req }) reads the encrypted NextAuth cookie
   │  → Extracts token.access_token (the JWT signed by manager service)
   │  → This JWT contains: tenant_id, user_id, permissions[]

What the JWT payload looks like

{
  "tenant_id": "tenant_xxx",
  "user_id": "user_xxx",
  "permissions": ["blog:posts.update", "team:members.invite", ...],
  "iat": 1709300000,
  "exp": 1709386400
}

6. Common Failures & Debugging

🔴 401 Unauthorized

Symptom Cause Fix
[Blog Proxy] No access token found in session! User not logged in, or session expired Log in again. Check that NEXTAUTH_SECRET matches between restarts.
{ error: "Missing token" } from gateway Proxy didn't send Authorization header Check proxy.ts → token?.access_token path
{ error: "Invalid token" } from gateway JWT verification failed Check that gateway's JWT_PUBLIC_KEY matches the key used to sign tokens

🔴 403 Forbidden

Symptom Cause Fix
{ error: "Direct access forbidden. Use the gateway." } Blog service received request without valid x-gateway-key Check GATEWAY_SECRET matches in both gateway and blog-service
{ error: "Missing required permission: blog:write" } User's JWT doesn't include blog:write permission Check user's role/permissions in the manager service

🔴 500 Internal Server Error

Symptom Cause Fix
{ error: "BLOG_DATABASE_URL not configured" } Blog service missing database connection string Add BLOG_DATABASE_URL to blog-service .env
{ error: "Configuration error: BLOG_SERVICE binding missing" } Gateway can't reach blog-service via Cloudflare binding Check wrangler.toml service bindings
{ error: "Configuration error: GATEWAY_SECRET missing" } Blog service missing gateway secret Add GATEWAY_SECRET to blog-service .dev.vars

🟡 Common Local Dev Issues

Issue Cause Fix
Blog proxy hits 127.0.0.1:8788 but fails Gateway not running locally, or missing .dev.vars Either: (a) Run gateway locally with proper .dev.vars, OR (b) Point NEXT_PUBLIC_GATEWAY_URL to production gateway
Env changes not picked up Next.js caches server-side env on startup Fully restart the dev server (stop + start, not hot reload)
Blog posts load in prod but not locally NEXT_PUBLIC_GATEWAY_URL mismatch Ensure .env.local points to a working gateway
Session works for other features but not blog Blog proxy uses different env var than other routes All routes use NEXT_PUBLIC_GATEWAY_URL — check consistency

Debug Checklist

When blog requests fail, check in this order:

  1. Is the user logged in? → Check browser cookies for next-auth.session-token
  2. Does the session have access_token? → Call GET /api/auth/session in browser
  3. Is the proxy reaching the gateway? → Check terminal for [Blog Proxy] Error XXX logs
  4. Which URL is the proxy using? → Check NEXT_PUBLIC_GATEWAY_URL in .env.local
  5. Is the gateway running? → Try curl http://127.0.0.1:8788/health (if using local gateway)
  6. Does the gateway have secrets? → Check apps/gateway/.dev.vars for JWT_PUBLIC_KEY, GATEWAY_SECRET
  7. Is the blog-service running? → Check turbo run dev output for blog-service logs
  8. Does blog-service have its DB? → Check apps/blog-service/.env for BLOG_DATABASE_URL

7. File Map

apps/
├── blog-service/
│   ├── src/
│   │   ├── index.ts               ← Entry point, middleware chain
│   │   ├── context.ts             ← Hono context type augmentation
│   │   ├── middleware/
│   │   │   ├── auth.middleware.ts  ← requirePermission, requireTenant
│   │   │   └── db.middleware.ts    ← DB connection + caching
│   │   ├── routes/
│   │   │   ├── admin/posts.ts      ← Post CRUD + publish/unpublish/delete
│   │   │   ├── admin/categories.ts ← Category CRUD
│   │   │   ├── admin/tags.ts       ← Tag CRUD
│   │   │   ├── public/posts.ts     ← List + get by slug (published only)
│   │   │   ├── public/categories.ts ← List categories (published post counts)
│   │   │   └── public/tags.ts      ← List tags (published post counts)
│   │   ├── schemas/
│   │   │   ├── post.schema.ts      ← Post validation schemas
│   │   │   ├── category.schema.ts  ← Category validation schemas
│   │   │   └── tag.schema.ts       ← Tag validation schemas
│   │   ├── services/post.utils.ts ← ID generation, slugify, unique slug
│   │   ├── utils/tiptap-renderer.ts ← TipTap JSON → sanitized HTML
│   │   └── db/
│   │       ├── schema.ts          ← Drizzle table definitions + indexes
│   │       └── client.ts          ← DB client factory
│   ├── .env                       ← BLOG_DATABASE_URL
│   ├── .dev.vars                  ← GATEWAY_SECRET (local dev)
│   └── wrangler.toml              ← CF Worker config

├── gateway/
│   └── src/routes/blog.proxy.ts   ← Admin + Public proxy handlers

└── seller-dashboard/
    ├── .env.local                 ← NEXT_PUBLIC_GATEWAY_URL
    └── src/modules/blog/
        ├── api/
        │   ├── proxy.ts           ← Next.js API proxy (JWT forwarding)
        │   └── blog.admin.api.ts  ← Frontend API functions
        ├── pages/
        │   ├── BlogList.tsx       ← Post list page
        │   └── BlogEditor.tsx     ← Create/edit post page
        ├── editor/
        │   ├── BlogEditorCore.tsx  ← TipTap editor wrapper
        │   ├── BlogToolbar.tsx     ← Editor toolbar
        │   └── ...                 ← Dialogs, menus, extensions
        ├── components/SeoPanel.tsx ← SEO input fields
        └── store/
            ├── blog.slice.ts      ← Redux slice
            └── blog.types.ts      ← TypeScript types
 
packages/
├── blog-sdk/src/
│   ├── client.ts                  ← VloziClient (public API)
│   ├── types.ts                   ← Post, PaginatedResponse, Config
│   ├── index.ts                   ← Package exports
│   └── react/
│       ├── context.tsx            ← VloziProvider
│       ├── hooks.ts               ← usePosts, usePost
│       ├── components/            ← BlogList, BlogCard, BlogPost
│       └── utils.ts               ← cn() utility

└── core-types/src/blog.ts         ← BlogPost, BlogPostSummary types
Blog Engine