logicspike/docs

Blog Engine

Plan — Publish‑to‑Live Hosted Blog (no redeploy)

Status: Design complete. Implementation gated on two infra checks (§9). Created: 2026-06-02 Scope: Sequenced spec to execute or hand off. Related vision: vision/hosted-blog-vision.md, vision/layouts-vision.md, vision/blog-engine-vision.md


1. Context — why this exists

Symptom: publishing a post via dashboard/MCP did nothing on vlozi.app until someone ran next build && wrangler pages deploy (and a stale Next data cache even hid it after that). For a product whose audience is non‑technical creators who do everything from the dashboard, "you must redeploy to publish" is a dealbreaker.

Core issue — it's not vlozi.app, it's a missing product surface. The only ways a customer blog can reach the public web today are: (a) install the @vlozi/blog React SDK into their own Next.js site, or (b) call the API themselves. A Webflow / Carrd / "no website yet" customer — the actual target audience — cannot publish a visible blog at all. apps/website (vlozi.app) is a static, single‑tenant marketing site with a hardcoded API key; it was never the customer template.

Architecture problem: there is no dynamic, multi‑tenant delivery surface that turns a published post into live HTML on request. Rendering is locked to build time on a single‑tenant static export.

Intended outcome: a customer publishes in the dashboard → their blog is live within seconds, SEO‑indexable, on their own subdomain (later their own domain), zero technical activity, zero redeploy — for any tenant.

Decision: prioritize now, override the vision's "validate layouts first" gate, ship with a single default layout.


2. Goals / non‑goals

Goals

  • Any tenant's published content is live on the public web within seconds of publish, no rebuild.
  • SEO‑ready server‑rendered HTML (meta, OG, canonical, RSS, sitemap).
  • <tenant-slug>.vlozi.app/blog for every tenant, free.
  • Reuse the existing blog-service rendering + public API; no duplicated query/render logic.
  • Incremental, low‑risk rollout (no flag‑day; degrade gracefully if the new worker is absent).

Non‑goals (designed‑in seams, deferred)

  • The 6‑layout themable catalog (layouts-vision.md) — ship one default layout.
  • Custom domains via Cloudflare for SaaS + cert provisioning.
  • Dynamic OG image generation (MVP redirects to featured image).
  • Migrating vlozi.app's own blog onto the new surface (Phase 4; needs custom‑domain support).

3. Solution overview

Build apps/blog-host — a Cloudflare Worker that renders any tenant's blog as SEO‑ready HTML at the edge on every request, reading from the existing blog-service public API over a service binding, caching at the edge, and purging that cache on publish via a fan‑out from blog-service (mirroring the existing newsletter waitUntil pattern). Strict subset of hosted-blog-vision.md.

Dashboard/MCP ──publish──▶ blog-service (DB flip)
                               │ waitUntil fan-out (x-internal-key)

                         blog-host /internal/invalidate ──▶ Cloudflare purge_by_tag(tenant:{id}:posts)
 
Reader ──▶ <slug>.vlozi.app/blog ──▶ blog-host (edge)
                                        │ cache hit → HTML
                                        │ miss → BLOG_SERVICE binding (x-gateway-key + x-tenant-id)
                                        ▼  render default layout + cache w/ Cache-Tag

4. Ground truth that shapes the design (verified)

  • Tenant slug already exists. packages/core-database/src/schema.ts:64-73tenants.slug is unique().notNull() ("url-friendly"); status and planId on the same row. <slug>.vlozi.app → tenant is one indexed lookup. No new table for Phase 1.
  • blog-service trusts x-gateway-key + x-tenant-id (apps/blog-service/src/index.ts:106-164); it does not re-derive tenant from an API key. blog-host calls it over a service binding with those two headers and reuses public routes unchanged.
  • Post body is already server-rendered HTML (apps/blog-service/src/routes/public/posts.ts:189 via utils/tiptap-renderer.ts). blog-host wraps it in a page shell → no React/RSC in the worker for MVP (plain HTML template strings; removes the biggest bundling risk).
  • Publish fan-out to mirror: apps/blog-service/src/routes/admin/posts.ts:611-646 (c.executionCtx.waitUntil(NEWSLETTER_SERVICE.fetch(...x-internal-key...))). Idempotent early-return at :599-605 is the one place to skip emitting.
  • SDK feed helpers are worker-safepackages/blog-sdk/src/feeds.ts generateRSS/generateSitemap take a client, return XML; reuse via a thin adapter. (The SDK VloziClient is NOT reused for fetching — it hardcodes Authorization: Bearer pk_… against the public gateway URL, wrong over a binding.)

5. Component design — apps/blog-host

Framework: Hono, compatibility_date = "2024-04-01", compatibility_flags = ["nodejs_compat"] (matches the other workers).

Module layout (apps/blog-host/src/):

index.ts                 Hono app: middleware chain + route table
bindings.ts              Env type: BLOG_SERVICE (Fetcher), CORE_DATABASE_URL,
                         GATEWAY_SECRET, INTERNAL_KEY, CF_PURGE_TOKEN, CF_ZONE_ID
middleware/
  tenant.ts              Host → slug → {tenantId, plan, status}; 404 unknown/inactive
  cache.ts               Cache API read-through + Cache-Tag + Cache-Control
data/
  blog-client.ts         Thin fetcher over BLOG_SERVICE binding (NOT the SDK client)
  tenant-repo.ts         tenants.slug lookup (Drizzle, per-isolate 5-min cache)
routes/
  index.ts               GET /blog               (post list, paginated)
  post.ts                GET /blog/:slug
  category.ts            GET /blog/category/:slug
  tag.ts                 GET /blog/tag/:slug
  feed.ts                GET /feed.xml           (SDK generateRSS via adapter)
  sitemap.ts             GET /sitemap.xml        (SDK generateSitemap via adapter)
  og.ts                  GET /og/:slug.png       (MVP stub → featured image)
  internal.ts            POST /internal/invalidate  (binding-only, x-internal-key)
render/
  layout.ts              renderPage({head, body, tenant, plan}) → full <!doctype> string
  surfaces.ts            renderList / renderPost / renderCategory / renderTag
  seo.ts                 title/desc/OG/Twitter/canonical/RSS-alternate
  styles.ts              inlined CSS — MUST cover tiptap-renderer classes
  hydrate.client.ts      separate esbuild entry → /blog/_hydrate.js
wrangler.toml

wrangler.toml essentials:

name = "logicspike-blog-host"
main = "src/index.ts"
compatibility_date = "2024-04-01"
compatibility_flags = ["nodejs_compat"]
routes = [
  { pattern = "*.vlozi.app/blog",      zone_name = "vlozi.app" },
  { pattern = "*.vlozi.app/blog/*",    zone_name = "vlozi.app" },
  { pattern = "*.vlozi.app/feed.xml",  zone_name = "vlozi.app" },
  { pattern = "*.vlozi.app/sitemap.xml", zone_name = "vlozi.app" },
]
[[services]]
binding = "BLOG_SERVICE"
service = "logicspike-blog-service"
# secrets: GATEWAY_SECRET, CORE_DATABASE_URL, INTERNAL_KEY, CF_PURGE_TOKEN, CF_ZONE_ID

Routes are path-scoped to /blog, /feed.xml, /sitemap.xml and host-scoped to *.vlozi.app (does not match bare apex), so the marketing site and api./mcp./go. subdomains are never intercepted.

5.1 Tenant resolution (middleware/tenant.ts)

  1. Read Host; for *.vlozi.app, slug = host.split(".")[0].
  2. Deny reserved labels: www, api, app, go, mcp, admin → 404.
  3. Look up tenants by slug via @repo/core-database (createDb, eq(tenants.slug, slug)), per-isolate Map cache w/ 5-min TTL — mirrors apps/gateway/src/middleware/api-key.middleware.ts.
  4. 404 if missing or status !== "active". Set c.set("tenant", {tenantId, plan, slug}).

5.2 Data fetching (data/blog-client.ts) — service binding, not the SDK:

this.svc.fetch(`https://blog-service.internal${path}`, {
  headers: { "x-gateway-key": GATEWAY_SECRET, "x-tenant-id": tenantId },
})

Methods: listPosts({page,limit,category?,tag?}), getPost(slug), listCategories(), listTags() → existing /public/* routes. GATEWAY_SECRET must be one of blog-service's comma-split accepted keys (index.ts:114) — add a dedicated blog-host key to that list for independent rotation.

5.3 Rendering (render/) — MVP = HTML template strings (no RSC):

  • layout.ts → full page shell with inlined <style> and one <script type="module" src="/blog/_hydrate.js">.
  • styles.ts must style the classes the tiptap renderer emits (.callout-*, .task-list, .task-item, figure/figcaption, .video-embed, pre code.language-*) — see tiptap-renderer.ts. This coupling is documented and load-bearing.
  • surfaces.ts: renderPost injects post.content (already-sanitized HTML) in <article>; list/category/tag render cards.
  • Free-tier "Powered by Vlozi" footer when plan === "free" (one conditional; plan already loaded).

5.4 SEO (render/seo.ts) — per surface: <title>=seoTitle||title; description=seoDescription||excerpt; OG (og:image=featuredImageUrl, og:type=article, og:url); Twitter card; <link rel="canonical">=siteUrl+path where siteUrl=https://${host}; RSS alternate. Canonical builder takes verifiedCustomDomain ?? requestHost (one conditional, wired when custom domains ship → SEO equity accrues to the customer's domain).

5.5 Client JS — page fully indexable with zero JS; _hydrate.js lazy-enhances mermaid/carousel + hover-prefetch only; ≤30 KB gz budget; separate esbuild entry, immutable cache.


6. Publish → live invalidation pipeline

6.1 Emit (blog-service) — new helper apps/blog-service/src/services/cache-invalidate.ts:

export function invalidateTenantCache(c, { tenantId, slug }) {
  if (!c.env.BLOG_HOST || !c.env.INTERNAL_KEY) return  // optional → degrade silently
  c.executionCtx.waitUntil(
    c.env.BLOG_HOST.fetch("http://internal/internal/invalidate", {
      method: "POST",
      headers: { "content-type": "application/json", "x-internal-key": c.env.INTERNAL_KEY },
      body: JSON.stringify({ tenantId, slug }),
    }).then(logIfNotOk).catch(logErr)
  )
}

Call at all 5 status-change sites in apps/blog-service/src/routes/admin/posts.ts, each guarded by the existing success check (*.length !== 0):

Route Note
publish (~:646) inside publishRes.length !== 0; skip the idempotent return at :599-605
unpublish inside success check
schedule always emit on successful flip (scheduling hides a live post; avoids a prior-status SELECT)
unschedule always emit on successful flip
delete easy to miss — must include (a deleted post must drop from cache)

Add slug to the .returning()/SELECT on routes that lack it (one column). Fallback tag if slug unavailable: per-id tenant:{id}:post-id:{postId} + tenant:{id}:posts.

6.2 Binding — add optional [[services]] BLOG_HOST to apps/blog-service/wrangler.toml and BLOG_HOST?: Fetcher to the Bindings types. Optional ⇒ blog-service boots without blog-host ⇒ emit code can land inert first; blog-host deploy flips it on (no flag-day).

6.3 Receive (blog-host /internal/invalidate) — constant-time x-internal-key check; body {tenantId, slug?}; call Cloudflare purge_by_tag for tenant:{id}:posts (+ tenant:{id}:post:{slug}); one retry on non-2xx; idempotent; mounted only over the binding, never the public host; validate tenantId shape (a leaked key can only force cache misses, scoped token can only purge).

6.4 Edge cache (blog-host middleware/cache.ts) — Cache API keyed by full URL (host-scoped, no cross-tenant collision); tag every response tenant:{id}:posts (+ per-post tag on post/OG). Cache-Control:

Surface Cache-Tag Cache-Control
Post :posts, :post:{slug} public, max-age=60, s-maxage=604800, swr=86400
List/category/tag :posts public, max-age=30, s-maxage=3600, swr=86400
RSS / sitemap :posts public, max-age=300, s-maxage=3600, swr=86400
OG :posts, :post:{slug} public, max-age=300, s-maxage=2592000, swr=86400

Low browser max-age so a publishing user sees fresh content even before purge propagates; 1 h edge cap on lists/RSS/sitemap so a failed purge self-heals within an hour; purge is the primary freshness lever.

6.5 Secrets — blog-host: scoped CF token (Zone → Cache Purge only) CF_PURGE_TOKEN + CF_ZONE_ID. INTERNAL_KEY = existing fleet secret. blog-service never sees the CF token.


7. vlozi.app strategy

7.1 Interim stopgap (vlozi tenant only) — create a Cloudflare Pages deploy hook for vlozi-website; from the same 5 emit sites, if tenantId === VLOZI_TENANT_ID also waitUntil(fetch(PAGES_DEPLOY_HOOK_URL)). Must fix the stale Next data cache: set the Pages build command to rm -rf .next/cache && next build, and/or make apps/website/lib/blog.ts fetches cache:"no-store". Trade-off: ~1–3 min build vs seconds; fine at vlozi's volume; explicitly temporary.

7.2 Long-term — migrate vlozi.app's blog onto blog-host (best dogfooding; removes build cost, latency, and the .next gotcha; deletes the tenant-specific branch). apps/website keeps marketing pages, drops app/blog/** + the build-time fetch. Gated on custom-domain support (apex vlozi.app isn't a *.vlozi.app subdomain → needs the same host→tenant mapping).


8. Phasing & estimates

Phase Scope Est. Independent?
1a — host scaffold worker, *.vlozi.app/blog* routes, BLOG_SERVICE binding, tenant resolution, /blog stub ~1 day
1b — data + render BlogClient, default layout, list/post/category/tag, SEO ~2 days
1c — feeds + hydrate /feed.xml, /sitemap.xml, _hydrate.js ~1 day
1d — edge cache Cache API + Cache-Tag + Cache-Control ~1 day ships value w/o purge (30s SWR)
1e — purge pipeline /internal/invalidate + blog-service helper + 5 emit sites + BLOG_HOST binding ~1 day emit lands inert first
2 — vlozi interim Pages deploy hook + .next/cache fix ~0.5 day yes (no blog-host dep)
3 — validate/tune purge latency, TTLs, observability ~1 day
4 — layouts + custom domains, then migrate vlozi catalog, Cloudflare for SaaS, cert flow, delete interim ~2.5 wk future

Phase 1 total ≈ 6 working days for a real hosted blog at <slug>.vlozi.app/blog, publish→live in seconds, for every tenant.


9. Pre-build verification gates (do FIRST — read-only)

  1. Cloudflare plan for tag purge. purge_by_tag is classically Enterprise-only. Verify vlozi.app's zone plan. If not Enterprise: keep the /internal/invalidate contract but purge by explicit URL list (post page + /blog + feed + sitemap + affected category/tag) or purge-everything — only the CF call inside the endpoint changes.
  2. Wildcard cert + DNS for *.vlozi.app. Worker routes don't fire on a host without a matching edge cert. Confirm the universal/ACM cert covers *.vlozi.app and a wildcard DNS record exists. (Quick external check: TLS handshake to a random xyz.vlozi.app.)
  3. Account pinning. All wrangler/CF calls must force CLOUDFLARE_ACCOUNT_ID=72fe41e9f39ad60cfb6937bb26ab50c3 (wrangler defaults to a stale wrong account here).

10. Critical files

  • apps/blog-service/src/routes/admin/posts.ts — 5 emit sites; mirror :611-646, skip :599-605.
  • apps/blog-service/src/index.ts — Bindings type, gateway-key gate (:114), status-route list (:178).
  • apps/blog-service/src/routes/public/posts.ts — routes blog-host consumes (:189 body-as-HTML).
  • apps/blog-service/src/utils/tiptap-renderer.ts — CSS class contract blog-host styles.ts must match.
  • apps/blog-service/wrangler.toml — add optional BLOG_HOST binding + secrets.
  • packages/core-database/src/schema.ts:64-73tenants.slug resolution source (@repo/core-database).
  • apps/gateway/src/utils/proxy.utils.ts:83-92 + middleware/api-key.middleware.ts — service-binding header + per-isolate cache precedents.
  • packages/blog-sdk/src/feeds.ts — reuse generateRSS/generateSitemap.
  • apps/website/lib/blog.ts, next.config.ts, package.json — vlozi interim + eventual blog removal.
  • New: apps/blog-host/**, apps/blog-service/src/services/cache-invalidate.ts.

11. Risks & mitigations

  • Tag-purge plan gate (§9.1) — biggest unknown; clean URL-purge fallback.
  • Wildcard cert (§9.2) — can block 1a; verify first.
  • Cache stampede on hot-tenant purge — stale-while-revalidate + per-post tagging; consider CF Tiered Cache.
  • Multi-region DB freshness — emit in waitUntil (post-commit) and/or read primary for blog-host; low risk at current scale.
  • Next build-cache gotcha (proven) — clear .next/cache; disappears after Phase 4.
  • /internal/invalidate security — constant-time key, binding-only mount, tenantId validation; worst case is forced cache misses, not data loss.
  • CSS/renderer couplingstyles.ts must track tiptap-renderer node classes; documented.
  • Slug rename — old subdomain 404s, old cache self-expires; acceptable for MVP.

12. Verification (prove it end-to-end)

  1. Local: wrangler dev blog-host with BLOG_SERVICE bound to local blog-service; seed a tenant + slug; request with Host: <slug>.vlozi.app → list + post render with correct SEO + styled content.
  2. Pipeline: publish via MCP (blog_create_draft + blog_publish_post); assert /internal/invalidate fired (log) and the post appears within the browser max-age window with no deploy; repeat unpublish/schedule/delete.
  3. Edge cache: confirm Cache-Tag + Cache-Control per surface; force a purge → next request re-renders.
  4. SEO: view-source shows server-rendered <article>, canonical, OG; /feed.xml + /sitemap.xml valid.
  5. Regression: blog-service tests pass; add a test asserting the emit helper fires on each of the 5 status changes and is skipped on idempotent re-publish.
  6. vlozi interim: publish on vlozi's tenant → deploy hook fires → rebuild with .next/cache cleared → post appears on vlozi.app/blog and in the index (not just the detail page).

13. Open questions (for pricing/product, not blocking Phase 1)

  • Free-tier request cap per tenant (abuse/DDoS-by-popularity); "Powered by Vlozi" footer removal as paid feature.
  • Custom-domain tier mapping (free=0, starter=1, pro=5, …) — pricing-team decision before Phase 4.
  • Which tenant_blog_config (layout/tokens) table owns layout choice when the catalog ships (recommend it lives in blog-service, exposed via a new GET /public/config).
Blog Engine