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
hostis the hardcodedPLATFORM_BLOG_HOST(blog.ai-mee.uk) — if so, resolves directly againstblog_site.slug = PLATFORM_BLOG_SITE_SLUG(ai-mee), the platform's own company blog. This is a one-off exception:PLATFORM_BLOG_HOSTsits insideai-mee.uk, whichblog_domain_not_platform_zoneforbids any customer domain from ever claiming (seeassertConnectableHostnameinblog-domain.service.ts), so it can't be ablog_domainrow — it never goes through Cloudflare for SaaS, since a custom hostname pointed at our own zone would loop onto the wildcard. It's first-level underai-mee.uk, so Cloudflare's free Universal SSL already covers it without a dedicated cert.resolveCanonical()treats this host as canonical for that one site, so its regular{slug}.{BLOG_ROOT_DOMAIN}subdomain now 301-redirects to it instead. - Otherwise 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.
Don't scope that rule by hostname (see #1443) — a rule matched on http.host eq "ai-mee.blog" or http.host wildcard "*.ai-mee.blog" covers the tenant subdomain but silently excludes every Cloudflare for SaaS custom hostname (those are provisioned dynamically by createCustomHostname() in cloudflare-saas.ts, so a static hostname condition can never enumerate them). This was confirmed live: a real custom domain returned cf-cache-status: DYNAMIC on every request while the *.ai-mee.blog subdomain correctly went MISS → HIT → HIT — every customer who connects a custom domain (the exact customers this caching work exists for) was getting zero edge caching. Since custom hostnames route through this same zone, the rule needs no hostname condition at all, only the path exclusions — that alone covers every hostname, present and future. After changing it, re-test against a live custom domain, not just the subdomain.
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.PLATFORM_BLOG_HOST(blog.ai-mee.uk) is deliberately excluded from this purge batch:CLOUDFLARE_PURGE_TOKEN/CLOUDFLARE_ZONE_IDare scoped to theai-mee.blogzone only, and Cloudflare's purge-by-URL API rejects a request whose files don't all belong to the target zone — so mixing in aai-mee.uk-zone URL would fail the whole batch. It falls back to thes-maxagestaleness window until a second purge token/zone-id forai-mee.ukexists.- 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.