Hosted Blog — Architecture
This document describes the current, actual architecture of the hosted-blog feature (blog_site / blog_post, served at {slug}.ai-mee.blog and on customer-owned custom domains). It exists because, before this was written, the only sources of truth were migration header comments, the docker-compose.prod.yml preamble, and JSDoc scattered across api/src/blog/, api/src/services/blog-*.ts, and api/src/integrations/ — see #1220.
For the customer-facing description of what the hosted blog does, see docs/features/hosted-blog.md. This document is the internal "how and why" for engineers working on it.
Two Fastify apps, not one
api/src/index.ts's buildApp() is the authenticated API. api/src/blog/app.ts's buildBlogApp() is a second, independent Fastify instance, listening on its own port (BLOG_PORT), never merged into buildApp().
Why: buildApp() registers ~20 route plugins at absolute, unconstrained paths (matching every Host header), including a static / and /posts. A tenant blog needs GET / and GET /:slug at its own root; find-my-way (Fastify's router) resolves static segments before parametric ones, so acme.ai-mee.blog/posts would hit the API's own /posts route — and its global auth preHandler — instead of the blog. The collision is on the unconstrained side, so Fastify's constraints: { host } route option cannot fix it; only a fully separate instance can. api/tests/routes/blog-isolation.test.ts is the regression guard: it asserts the blog-only route surface (/sitemap.xml, /robots.txt, etc.) is unreachable on the main app regardless of Host header.
Consequences of the split:
- The blog app deliberately does not register
@fastify/cors— every route is same-origin HTML or a crawler fetch, and CORS would extendapi.ai-mee.uk's trust boundary to every tenant blog host. - It deliberately does not enable
trustProxy—request.hostname/request.headers.hostmust read the rawHostheader Cloudflare/Traefik forward. EnablingtrustProxywould make Fastify preferX-Forwarded-Host, which any client reaching the port directly (bypassing the proxy) could set to spoof a different tenant. - It runs its own
onRequesthook (host resolution + redirect), its ownsetNotFoundHandler/setErrorHandler(branded HTML, not JSON), and its own CSP ('unsafe-inline'onscript-src, required for the inline JSON-LD<script>block — safe here becauseblog-sanitize.service.tsstrips<script>/event handlers from post bodies before they ever reach the template).
Host → tenant resolution
blog-host.service.ts's resolveBlogHost(host) is the only place a tenant is chosen. It:
- Checks whether
hostmatches{slug}.{BLOG_ROOT_DOMAIN}— if so, resolves directly againstblog_site.slug. - Otherwise checks
blog_domainfor anactivecustom-domain row. - Caches the result in-process, invalidated by
invalidateBlogHost(hostname)wheneverblog-domain.service.tschanges a domain's status.
Known limitation: the cache is single-process. A second API replica would each hold its own cache and its own analytics buffer (see below) — fine at the current one-container deployment, worth revisiting before scaling out.
The app's onRequest hook calls this once per request and 404s immediately (branded HTML, cache-control: no-store) if it resolves to nothing — no route handler ever runs against an unresolved host. If the host resolves but isn't the tenant's canonical host (e.g. the subdomain, when an active custom domain exists), the hook 301-redirects to the canonical origin, also no-store — a cached permanent redirect keyed on the wrong host is extremely sticky in browsers and would survive the domain becoming primary later. blog-host-paths.ts's isRedirectExemptPath() carves out verification files (/google{token}.html, the IndexNow /{key}.txt) that external services fetch against the specific hostname they asked for — a 301 away from it would make verification permanently fail.
Traefik + Cloudflare for SaaS
Full runbook: see docker-compose.prod.yml's header comment (kept there, not duplicated here, since Traefik labels and comments must stay next to each other or they drift). Summary of the decisions:
- Tenant subdomains live on a separate zone,
ai-mee.blog, notai-mee.uk. Two reasons: it keeps this feature's wildcard DNS off the platform apex, and — decisively — Cloudflare's free Universal SSL only covers a zone's apex and its first-level subdomains. Nesting tenants under the platform zone ({slug}.blog.api.ai-mee.uk) would make them third-level, with no matching edge certificate short of the paid Advanced Certificate Manager add-on. - Custom domains (
blog.acme.com) go through Cloudflare for SaaS (api/src/integrations/cloudflare-saas.ts): a Custom Hostname per domain, HTTP-01 validated (the customer's CNAME toBLOG_CNAME_TARGETis the prerequisite anyway, so Cloudflare can serve the validation token itself).blog-domain.service.tspolls Cloudflare's status and collapses its much larger status vocabulary into four states (pending_dns/pending_ssl/active/failed), ageing a domain out tofailedafter 72h stuck inpending_dns. - An Origin CA certificate covering
ai-mee.blog, *.ai-mee.blogmust be installed as Traefik's default certificate before the blog's Traefik labels are deployed, or every request gets Traefik's self-signed default and Cloudflare returns 526. Zone SSL/TLS mode must be Full (strict). docker-compose.prod.ymlalso flags a version trap: itsHostRegexpsyntax is written for Traefik v3 (raw Go regexp); v2 needs a named-capture form and silently won't match. The Traefik config itself lives on the VPS (shared via the externalsidekickDocker network), not in this repo — verify the running major version before touching that syntax.
Caching model
Shipped (#1220):
| Response | Header | Why |
|---|---|---|
?contact=sent / ?contact=error | private, no-store | Cloudflare's default cache key includes the query string — without this, one visitor's lead-confirmation banner would be served to a different visitor. The highest-risk item in the whole rollout. |
| Non-canonical host 301 | no-store | A cached permanent redirect on the wrong host is extremely sticky and would outlive the domain becoming primary. |
| 404 (no tenant, no such slug) / 410 (retracted post) | no-store | A cached 404 would shadow a slug published moments later. |
sitemap.xml / robots.txt / rss.xml / llms.txt / llms-full.txt | public, max-age=3600 | Static-ish, low-risk, unchanged by this work. |
| Normal index/post pages | public, max-age=0, s-maxage=300, stale-while-revalidate=86400, stale-if-error=86400 | max-age=0 keeps the browser revalidating (an edit shows on reload) while Cloudflare's edge holds it for 5 minutes and serves stale for a day during revalidation or an origin outage. |
s-maxage on normal pages was blocked on #1219 until it shipped: view counting used to happen server-side at render time (blog-analytics.service.ts's now-removed recordBlogView()), and a CDN cache in front of that render path would have silently zeroed out view counts for every cache hit. #1219 moved counting to the client-side POST /_e beacon instead (see Analytics pipeline below), which is unaffected by caching — so s-maxage shipped as soon as that landed.
Caching still does nothing in production until the Cloudflare Cache Rule is configured on the ai-mee.blog zone (Cloudflare does not cache text/html by default) — that's a manual dashboard step, not something this repo can apply, and is documented in docker-compose.prod.yml's header comment rather than duplicated here. Confirm cf-cache-status: HIT on a repeat request once it's in place.
Purge-on-publish
Ships alongside the Cache Rule dependency above — same code path either way, since a CLOUDFLARE_PURGE_TOKEN that isn't yet provisioned just no-ops safely:
cloudflare-saas.ts'spurgeCache(files)— chunked at 30 URLs per call, using a separateCLOUDFLARE_PURGE_TOKEN(Zone → Cache Purge → Purge) fromCLOUDFLARE_API_TOKEN(Zone → SSL and Certificates → Edit, used for custom-hostname management). The two need different Cloudflare permission scopes; reusing one token for both would just 403 on purge.blog-cache.service.ts'spurgeBlogPost(blogSiteId, slug)— resolves every hostname that can serve the site (the{slug}.{BLOG_ROOT_DOMAIN}subdomain, plus everyblog_domainrow withstatus = 'active', since each hostname is a separate cache key under Cloudflare for SaaS) and purges the post, the homepage, and the feed URLs on each. Best-effort: every error is caught and logged, never thrown — a purge failure must not fail a publish, and the practical cost of a missed purge is a stale page for thes-maxagewindow, not a broken one.- Called from
HostedBlogAdapter.publish()(api/src/integrations/hosted-blog-adapter.ts) immediately after theblog_postupsert commits — purging before the write would just re-fill the edge with the stale HTML it was meant to clear. - Until
CLOUDFLARE_PURGE_TOKENis provisioned in Doppler, this plumbing runs on every publish and no-ops safely (a missing token fails closed with a logged warning) — and until the Cache Rule exists, there's nothing at the edge to purge yet either way.
Analytics pipeline
Page views are counted by a client-side beacon, not at render time — this is what unblocked the caching model above. blog-render.service.ts injects a ~300-byte inline script (navigator.sendBeacon, falling back to fetch(..., {keepalive: true})) into the index and post pages only (never 404/410/500), which fires POST /_e (api/src/routes/blog/beacon.ts) on each view.
- Tenant scoping: the beacon takes its tenant from
request.blogHost(the Host header) exactly likecontact.ts— never from the request body — so cross-tenant writes are structurally impossible. - Path validation: a small in-process cache (60s TTL, same pattern as
blog-host.service.ts) holds each site's published slugs; a path outside that set writes nothing, so arbitrary-path spam is impossible too. - Unique visitors without a cookie:
blog-analytics.service.ts'scurrentVisitorHash()issha256(dailySalt || siteId || ip || userAgent), truncated to 16 bytes.dailySaltis 32 random bytes held in module memory only, regenerated when the day flips — never persisted or logged, so yesterday's hashes are irreversible even to us, and nothing is read from or written to the visitor's device (no cookie, nolocalStorage), so UK PECR reg. 6 doesn't require a consent banner. See #1219 for the full reasoning on why a persisted or deterministic salt would defeat this. - Country/referrer:
CF-IPCountry(trustworthy only because Cloudflare overwrites it at the edge — never used for authorisation) and the referrer's hostname only, never the full URL (which can carry tokens). - Bots:
isLikelyBot()(a deliberately loose user-agent check) moved from the old render-time path onto this route — Googlebot renders JS and would otherwise fire the beacon like any other browser. - Buffered in-process and flushed every 30s via the
blog_record_eventsRPC, same shape as the old render-time counter (recordBlogEvent/flushBlogEvents,startBlogAnalyticsFlush/stopBlogAnalyticsFlush), so the wiring inapi/src/blog/app.tsdidn't need to change.
SEO surface
- Feeds:
sitemap.xml,robots.txt,rss.xml,llms.txt,llms-full.txt(api/src/routes/blog/feeds.ts+blog-feed.service.ts), capped at 500 posts per feed. - Structured data (
blog-jsonld.service.ts): every page emits an@graphofOrganization+WebSite+BreadcrumbList, plusBlogon the index andBlogPostingon posts. A post whose body contains ≥2<h2>…?</h2>question/answer pairs also gets an auto-extractedFAQPageentity — aimed as much at LLM crawlers (AEO) as at Google's rich-result eligibility, which shifts independently of this code. - IndexNow:
blog-indexnow.service.ts'spingIndexNow()fires best-effort on every publish, so participating engines (Bing, Yandex, and others on the shared protocol) don't wait for their own crawl schedule. - Pagination (
blog-render.service.ts, added in #1220): the index route fetches one row pastPOSTS_PER_PAGEto detect a next page without a secondcount(*)round-trip. Each page gets a self-referential<link rel="canonical">(page 2 no longer points at page 1),rel="prev"/rel="next"head hints, and — the part that actually fixes crawlability — a real, visible<nav class="pagination">with<a>links, since posts past the first page previously had no in-page link pointing at them at all (only the sitemap listed them). - Google Search Console verification (
GET /google:token.htmlinfeeds.ts, added in #1220): serves Google's HTML-file verification body when the requested token matchesblog_site.gsc_verification_token. Purely passive — this repo does not call the Search Console API to verify or submit a sitemap on the customer's behalf;gsc_verified_atandgsc_sitemap_submitted_atare set from the dashboard (BlogSiteSetup.vue) once the customer has completed those steps in Google's own UI.