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-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 runs
tiptap-renderer.tsper 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 |