Ai-Mee Help Centre
Home
Features
How-To Guides
FAQ
Need Help?
Home
Features
How-To Guides
FAQ
Need Help?

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 extend api.ai-mee.uk's trust boundary to every tenant blog host.
  • It deliberately does not enable trustProxy — request.hostname / request.headers.host must read the raw Host header Cloudflare/Traefik forward. Enabling trustProxy would make Fastify prefer X-Forwarded-Host, which any client reaching the port directly (bypassing the proxy) could set to spoof a different tenant.
  • It runs its own onRequest hook (host resolution + redirect), its own setNotFoundHandler/setErrorHandler (branded HTML, not JSON), and its own CSP ('unsafe-inline' on script-src, required for the inline JSON-LD <script> block — safe here because blog-sanitize.service.ts strips <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:

  1. Checks whether host matches {slug}.{BLOG_ROOT_DOMAIN} — if so, resolves directly against blog_site.slug.
  2. Otherwise checks blog_domain for an active custom-domain row.
  3. Caches the result in-process, invalidated by invalidateBlogHost(hostname) whenever blog-domain.service.ts changes 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, not ai-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 to BLOG_CNAME_TARGET is the prerequisite anyway, so Cloudflare can serve the validation token itself). blog-domain.service.ts polls Cloudflare's status and collapses its much larger status vocabulary into four states (pending_dns / pending_ssl / active / failed), ageing a domain out to failed after 72h stuck in pending_dns.
  • An Origin CA certificate covering ai-mee.blog, *.ai-mee.blog must 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.yml also flags a version trap: its HostRegexp syntax 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 external sidekick Docker network), not in this repo — verify the running major version before touching that syntax.

Caching model

Shipped (#1220):

ResponseHeaderWhy
?contact=sent / ?contact=errorprivate, no-storeCloudflare'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 301no-storeA 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-storeA cached 404 would shadow a slug published moments later.
sitemap.xml / robots.txt / rss.xml / llms.txt / llms-full.txtpublic, max-age=3600Static-ish, low-risk, unchanged by this work.
Normal index/post pagespublic, max-age=0, s-maxage=300, stale-while-revalidate=86400, stale-if-error=86400max-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's purgeCache(files) — chunked at 30 URLs per call, using a separate CLOUDFLARE_PURGE_TOKEN (Zone → Cache Purge → Purge) from CLOUDFLARE_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's purgeBlogPost(blogSiteId, slug) — resolves every hostname that can serve the site (the {slug}.{BLOG_ROOT_DOMAIN} subdomain, plus every blog_domain row with status = '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 the s-maxage window, not a broken one.
  • Called from HostedBlogAdapter.publish() (api/src/integrations/hosted-blog-adapter.ts) immediately after the blog_post upsert commits — purging before the write would just re-fill the edge with the stale HTML it was meant to clear.
  • Until CLOUDFLARE_PURGE_TOKEN is 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 like contact.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's currentVisitorHash() is sha256(dailySalt || siteId || ip || userAgent), truncated to 16 bytes. dailySalt is 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, no localStorage), 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_events RPC, same shape as the old render-time counter (recordBlogEvent/ flushBlogEvents, startBlogAnalyticsFlush/stopBlogAnalyticsFlush), so the wiring in api/src/blog/app.ts didn'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 @graph of Organization + WebSite + BreadcrumbList, plus Blog on the index and BlogPosting on posts. A post whose body contains ≥2 <h2>…?</h2> question/answer pairs also gets an auto-extracted FAQPage entity — 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's pingIndexNow() 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 past POSTS_PER_PAGE to detect a next page without a second count(*) 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.html in feeds.ts, added in #1220): serves Google's HTML-file verification body when the requested token matches blog_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_at and gsc_sitemap_submitted_at are set from the dashboard (BlogSiteSetup.vue) once the customer has completed those steps in Google's own UI.