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-keyon 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_idis never derived from the request body — only from the trustedx-tenant-idheaderrequireTenantmiddleware rejects any request wherex-tenant-idis 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_htmlcache when version matches (RENDERER_VERSION). On cache miss, live-renders viatiptap-renderer.tsand backfills the cache inwaitUntil(). - 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. |