Last Updated: 2026-09-05 · Status: Active · Service:
apps/engagement-service(vlozi-engagement-service)
Reader engagement for everything Vlozi publishes: view and read counts, reactions and star ratings, comments with moderation, and passwordless reader identity. One service, one generic target_key model, so a blog post and a collection entry are counted and commented on by exactly the same machinery.
Start here, by what you're doing
| You want to… | Read |
|---|---|
| Understand why this service exists and how it's shaped | vision-and-architecture.md, then architecture.md |
| Call the API | api-spec.md |
| Put a counter or comment thread on a site | embedding.md |
| Change the schema, or work out where a number comes from | database.md |
| Add a permission, or work out why someone got a 403 | permissions.md |
| Understand what costs a credit | billing.md |
| Deploy it, or set a secret | implementation-plan.md §9 |
| Find what's left to build | backlog.md |
The shape of it in one page
A generic target. Everything engagement counts hangs off a target_key: post:<id> for a blog post, entry:<id> for a collection entry, page:<hash> for a bare URL on the customer's own site. Nothing in the schema knows what a blog post is. That is what let Collections reuse the whole service in one phase (see Phase 10) rather than growing a second copy of it.
A public door with no credential. The widget on a customer's blog carries no API key — a browser cannot keep one secret. The tenant is derived server-side from an opaque site_id in the URL path, and x-tenant-id is ignored on every /e/* route. What protects the door instead: an origin allowlist (strict on writes, lenient on reads), native Cloudflare rate limits per site+IP, a bot filter, a honeypot, and optional Turnstile. See architecture.md.
A counter that batches. Beacons land in a per-tenant Durable Object, which dedups repeat visitors within a 30-minute sliding session and flushes accumulated deltas to Postgres in one batched write per minute. The alternative — a Postgres round trip per view — does not survive a post reaching the front page of anything.
Comments that fail toward a human. The write pipeline has a documented order (honeypot → bans → platform blocklist → domain → links → banned words → policy → first-time → AI). Every uncertain outcome resolves to pending, never to silently published and never to silently dropped. AI moderation failing means a person looks at the comment, which is the correct failure.
Readers, not accounts. A reader verifies an email address with a magic link and gets a stateless signed session in localStorage. No password, no cookie, no account to manage, and no CSRF surface — the token is only ever sent in bodies the SDK constructs. Revocation rides on a session_version counter, which is what makes "log out everywhere" and a ban take effect immediately.
What's deliberately NOT here
- A hosted blog. Dropped 2026-09-04. Publishing to a live site is site-sync's job; engagement notifies it when a comment goes public.
- Reader profiles, follows, or a social graph. A member exists to prove control of an email address for commenting. Nothing more is stored, and nothing more should be.
- Exact analytics. The Postgres rollups are exact; the Analytics Engine breakdowns (referrer, country, device, hour) are sampled estimates and are labelled as such everywhere they appear. Never reconcile billing against them.
- Rating markup on articles.
engagementJsonLdrefuses to emitaggregateRatingfor aBlogPosting— that is self-serving review markup.productRatingJsonLdexists for the legitimate Product case. See embedding.md.