logicspike/docs

Engagement

Engagement — Architecture

Last Updated: 2026-09-05 · Status: Active

The why behind the shape. For the what, see api-spec.md and database.md. For the original design rationale, vision-and-architecture.md.

Where it sits

browser (tenant's blog)
   │  no credential — opaque site_id in the path
   ▼
gateway  /engagement/e/*  ──── CORS (registered BEFORE the rate limiter)
   │                           BEACON_RATE_LIMITER, path-scoped
   ▼
engagement-service ─── ENGAGEMENT_COUNTER (Durable Object, one per tenant)
   │                        └─ batched flush → Neon (engagement DB)
   ├── BLOG_SERVICE        /internal/targets/:postId    verify a post:  target
   ├── COLLECTIONS_SERVICE /internal/targets/:entryId   verify an entry: target
   ├── COMMS_SERVICE       /internal/send               digests, magic links
   ├── SITE_SYNC           /internal/notify             a comment went public
   ├── EVENTS_QUEUE        vlozi-events                 Flows triggers
   └── METRICS             Analytics Engine             written per beacon, read for breakdowns

Callers reaching in: blog-service and collections-service (target verification), site-sync (content version), internal-api (platform blocklist), billing-crons (accumulator flush). Each authenticates with its own token — see Three internal tokens.

The public door

Everything under /e/* faces the open internet with no credential. That is not a compromise; it is the only workable design, because the only thing that could carry a credential is a browser, and a browser cannot keep one.

What stands in for authentication:

Control Where Note
Tenant from site_id sites.service.ts loadSite x-tenant-id is ignored on /e/*. The site row is the only source of tenant.
Origin allowlist lib/origin.ts Strict on writes (a missing Origin fails), lenient on reads. A read is origin-agnostic public data; a write is not.
Rate limit before body parse every write route Native Cloudflare limiters, keyed site:ip. Parsing a body you're about to reject is work an attacker gets for free.
Body caps route handlers Checked on content-length and after parse — content-length is client-supplied.
Bot filter lib/bots.ts UA regex, empty UA, prefetch headers, cf.verifiedBotCategory. Answers 204 and counts nothing.
Honeypot @repo/core-abuse A filled hidden field means spam, recorded silently with a 200.
Turnstile @repo/core-abuse Optional per site. Fails closed — an unverifiable token is a rejection.
Stranger-safe copy every route A visitor never learns the tenant's billing state, moderation rules, or whether an address is known.

Why some rejections are 200s. A spam comment, a tripped honeypot and a banned author all get {status: "pending"} and a 200. Real status codes are reserved for problems with the request — bad origin, rate limit, oversized payload, malformed JSON, closed comments. The line is: a spammer must not be able to use our responses to tune their payload. Distinguishing spam from pending on the wire would do exactly that.

The counter Durable Object

One instance per tenant, addressed engage-<tenantId>.

Why a DO at all. A blog post reaching the front page of Hacker News is thousands of views a minute against one row. Forms' idiom (one Postgres upsert per event) would melt. The DO absorbs writes into its own SQLite, dedups, and flushes deltas in one batched upsert per target per minute.

Why only views and reads. Reactions, ratings and comments write straight to Postgres. They need read-before-write (has this visitor already voted?), they are orders of magnitude rarer, and they must be visible immediately — a reader who leaves a comment and doesn't see it assumes it broke.

Three footguns, all load-bearing:

  1. Never deleteAll() while today's seen rows exist. It clears every table including the daily dedup set, which would make every returning reader a fresh view for the rest of the day. pruneSeen() deletes by day instead.
  2. Subtract the snapshot after a successful flush; never truncate. Bumps arriving during the Neon round trip must survive it.
  3. Re-arm the alarm first on failure. Otherwise the platform's own near-immediate retry races the flush we're about to schedule.

alarm() and POST /flush are serialised through a promise chain so they cannot interleave across the Neon await.

Sharding (lib/counter-shard.ts) exists and is off. Turning it on splits the dedup set, so uniques inflate; splits the live-presence set, so reads fan out; and makes hour boundaries visible in the data. Only worth it if one DO is measurably the bottleneck.

Two identities, deliberately different

Views / reads Reactions / ratings Comments (signed in)
Identifier visitor_hash vid member session
Where it lives computed server-side, never stored localStorage, created on first interaction localStorage, per site
Lifetime rotates daily until cleared 30 days, revocable
Why anonymous by design must resolve against the visitor's prior vote must prove control of an address

A reader who only reads never has anything written to their browser at all.

The comment pipeline

Documented order, implemented once in screenBody() so createComment and editComment cannot drift:

honeypot        → spam      (silent)
tenant ban      → rejected  (silent)
platform block  → rejected  (silent)
blocked domain  → pending
too many links  → pending
banned word     → pending
mode = all      → pending
first-timer     → pending   (a verified member skips this)
otherwise       → published
then: AI moderation may escalate a tentative "published" to pending

Editing re-runs the whole thing. A five-minute edit window that skipped screening would be a free bypass: post something innocuous, get auto-published, rewrite it into spam. An edit that trips a rule pulls the comment back to pending and decrements the public count.

Metering fires at most once per comment, keyed on published_at already being set. A comment can legitimately go published → pending → published (an edit that trips moderation, then an approval); charging each round would bill a tenant repeatedly for one comment.

Three internal tokens

/internal/* is one wildcard guard that picks the expected secret by path prefix:

Prefix Token Caller
(default) ENGAGEMENT_INTERNAL_TOKEN blog-service, collections-service
/internal/content-version SITE_SYNC_INTERNAL_TOKEN site-sync
/internal/platform-blocklist PLATFORM_ADMIN_TOKEN internal-api

Separate secrets so any one caller can be rotated alone, and so a content service's credential can never reach the cross-tenant blocklist — a power no tenant has. It stays a single wildcard so a newly added internal route is closed by default rather than open.

Failure posture, by subsystem

The rule throughout: fail toward the outcome a human can still fix.

Subsystem On failure Why
Target verification fail open (accept the beacon) A dropped beacon during a blog-service blip is data lost forever.
AI moderation fail to pending A person reviews it. Never silently published, never silently dropped.
Turnstile fail closed An unverifiable challenge is not a passed challenge.
Platform blocklist fail open One bad query must not fail every tenant's comment traffic closed.
Site-sync notify swallow The comment IS published; whether a static site heard is separate.
Webhooks swallow, log a delivery row A tenant's broken endpoint must not fail the publish.
Analytics Engine reads available: false Every other analytics surface keeps working.
Live reader count zeros Decoration must never break a post.
AI summary throw, refund the credit No safe default exists, and a fabricated summary is worse than an error.
Engagement