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 → BrowserPublic 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 endpoints3. 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:
- Is the user logged in? → Check browser cookies for
next-auth.session-token - Does the session have access_token? → Call
GET /api/auth/sessionin browser - Is the proxy reaching the gateway? → Check terminal for
[Blog Proxy] Error XXXlogs - Which URL is the proxy using? → Check
NEXT_PUBLIC_GATEWAY_URLin.env.local - Is the gateway running? → Try
curl http://127.0.0.1:8788/health(if using local gateway) - Does the gateway have secrets? → Check
apps/gateway/.dev.varsforJWT_PUBLIC_KEY,GATEWAY_SECRET - Is the blog-service running? → Check
turbo run devoutput for blog-service logs - Does blog-service have its DB? → Check
apps/blog-service/.envforBLOG_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