Status: Draft for approval · Created: 2026-09-04 · Scope: everything needed to ship reader engagement (views, reads, reactions, ratings, comments, members) as a new platform service, plus the "adding a new service" guide and skill. Companion docs: vision + architecture at
logicspike/docs/engagement/vision-and-architecture.md(approved). This plan is mirrored tologicspike/docs/engagement/implementation-plan.mdon approval (forms precedent:docs/forms/implementation-plan.md). Decisions locked: (1) new workerapps/engagement-service, own Neon DB, forms-style public door; (2) Milestone 1 = views + reads; (3) hosted blog dropped, site-sync is publish-to-live; (4) Engagement is a standalone dashboard module with its own sidebar entry (Inbox · Overview · Content · Audience · Sites · Settings), Blog deep-links into it; (5) permissions are a first-class section. All paths are relative tologicspike/. Line numbers are as of 2026-09-04.
0. How to read this plan
- §1 Context, §2 Bugs/gaps/improvements found, §3 Conventions (the rules every file must follow), §4 Phases 0–14 (each: goal, files, exact edits, tests, exit criteria), §5 Permissions, §6 Billing, §7 Security checklist, §8 Observability, §9 Deploy runbook, §10 Verification matrix, §11 Open decisions, Appendix A the new-service registration chain (source for the guide), Appendix B guide + skill outlines, Appendix C reuse map.
- Phases are ordered by dependency, not by calendar. Milestone 1 = Phases 0–4. Nothing in Phases 5+ is required to ship Milestone 1.
- Effort is for one engineer who knows the codebase. Days are working days.
1. Context
Tenants want view counts, ratings and comments on blog posts. Blog is a read-only publishing engine with no analytics (the blog_get_analytics MCP tool returns hardcoded zeros, apps/blog-service/src/mcp/routes.ts:1262-1294) and the platform has no reader identity. The approved design puts all reader engagement in a new worker with a generic target_key model (post:<id>, entry:<id>, page:<hash>) so Collections product reviews and later store reviews reuse it. This plan turns that design into file-level work, and captures the "how to add a service" knowledge as a permanent guide and skill so the pattern is never re-discovered.
2. Bugs, gaps and improvements found during the audit
Severity: S1 security/correctness, S2 functional gap, S3 hygiene. "In plan" = fixed by a task in this plan (phase noted); "Follow-up" = separate ticket.
| # | Sev | Where | Finding | Fix | Status |
|---|---|---|---|---|---|
| B1 | S2 | apps/gateway/src/utils/proxy.utils.ts:94-101 |
x-user-role is not in SPOOFABLE_IDENTITY_HEADERS and is never set by the gateway; 12 services copy it into context. No privilege decision depends on it today (the only consumer labels flow-run actors agent/user, apps/flows-service/src/mcp/routes.ts:65), so this is audit-integrity, not privilege escalation. |
Add "x-user-role" to the list; gateway sets it from ctx.auth.role. |
In plan (P0) |
| B2 | S1 | apps/forms-service/src/routes/public/submit.ts:56-61 |
originAllowed passes when Origin is missing, so a configured allowlist is "decorative against curl" (newsletter's own words, widget.route.ts:99-105). |
Adopt newsletter's strict variant for writes across forms; lift both into @repo/core-abuse. |
Follow-up (engagement uses strict from day one, P1) |
| B3 | S2 | apps/blog-service/src/routes/admin/posts.ts (PUT at L411) |
blog.post.updated is emitted only on the MCP path (src/mcp/routes.ts:727-746); dashboard edits never fire Flows built on it. DELETE emits nothing. No event carries the previous slug. |
Emit emitPostUpdated from the REST PUT with changedFields; emit blog.post.deleted; include previousSlug when slug changes. |
In plan (P2, while touching the file) |
| B4 | S2 | apps/flows-service/src/types.ts:66-79 |
serviceBindings() lacks comms, collections, flow → any node in those namespaces fails "Service binding missing". |
Add the three bindings + wrangler entries; add a parity test that every ALL_TOOLS namespace with nodeMeta tools has a binding. |
In plan (P7 adds engagement + the parity test; the three missing ones are a 10-line follow-up in the same PR) |
| B5 | S2 | apps/brain-service/src/routes/chat.route.ts:186-217 |
Copilot serviceCall wires only blog and content; analyticsTools/chatTools always return "not available". |
Wire all bound services; add engagement. | In plan (P7) |
| B6 | S2 | apps/gateway/src/index.ts:182-202 |
/health omits FLOWS_SERVICE, COLLECTIONS_SERVICE, SITE_SYNC_SERVICE. |
Add them + engagement. | In plan (P0) |
| B7 | S2 | packages/core-database/src/seed.ts vs apps/manager/src/seed.ts |
Seeds disagree on service names, service_limits rows (manager has no forms rows), plan_service_limits for the basic plan (manager seeds the plan but 0 limit rows for it vs 7 in core), and addon rows (3 vs 5). Core seed deletes plan_service_limits (not prod-safe). |
Make manager seed import from core-database; never run core seed on prod; use backfill-*-limits.ts scripts. |
Follow-up; engagement adds rows to both (P0 catalog row, P4 limits) |
| B8 | S2 | apps/manager/src/services/billing.ts:271-341 |
rebuildEntitlements only upserts services present in plan_service_limits; services without limits (collections) are never provisioned on plan change. No backfill ever ran for collections/flows. |
Give every provisioned service at least one limit row, or make rebuild iterate the services catalog. Run backfills for collections/flows. |
Follow-up; engagement ships with limit rows + backfill (P0) |
| B9 | S2 | packages/core-tenant/src/tenant.factory.ts:36 vs apps/manager/src/routes/onboarding.ts:73 |
Two default provisioning lists disagree (media, contacts). |
Single exported DEFAULT_PROVISIONED_SERVICES in core-tenant used by both. |
Follow-up; engagement added to both (P4) |
| B10 | S2 | apps/gateway/src/middleware/rate-limit.middleware.ts:34 |
Global 100/60s per-IP limiter covers every path. Readers behind one NAT firing beacons will be throttled, and their other API calls with them. | Path-scoped limiter: BEACON_RATE_LIMITER for /engagement/e/* with its own budget. |
In plan (P0) |
| B11 | S2 | apps/internal-api/src/services/spend.ts:25-37 |
svcCase lumps collections.%, comms.%, mailbox.% into 'other'. |
Add arms for each + engagement.%. |
In plan (P6 adds engagement; others same PR) |
| B12 | S2 | apps/gateway/src/utils/cache.utils.ts:167-189 |
cachedProxy hardcodes buildDownstreamHeaders(c, "/blog") and tenant-keyed cache keys; unusable by any other service. |
Generalise to cachedProxy(c, service, { prefix, keyFor }). |
In plan (P1) |
| B13 | S2 | packages/mcp-dispatch/src/index.ts:13-25 |
NAMESPACE_SERVICE_CODE is Partial; a forgotten namespace silently bypasses the service toggle. |
Make it Record<Namespace, string | null> (explicit null for billing/workspace) so typecheck catches omissions. |
In plan (P7) |
| B14 | S2 | apps/billing-crons/src/index.ts:148-168 |
Gateway secret compared with exact match (no comma split) while services split; a rotation breaks billing-crons until redeployed. | Split on comma like services. | Follow-up (1 line) |
| B15 | S2 | packages/core-billing, apps/gateway/src/lib/entitlements.ts, packages/mcp-core/src/entitlements.ts, 7 manager routes |
The enabled && !userDisabled rule exists in 3 helpers + 5 inline copies. |
One effectiveServiceAccess(row) in @repo/core-access. |
Follow-up |
| B16 | S2 | apps/chat-engine/src/routes/channels.route.ts:148, src/mcp/routes.ts:339 |
Widget snippet still emits legacy widget.logicspike.com; dashboard emits widget.vlozi.app. |
Single constant in core-types. | Follow-up |
| B17 | S2 | docs/blog/backlog.md B5 |
blog_get_analytics fabricates zeros. |
Delegate to engagement; explicit available:false when unbound. |
In plan (P2) |
| B18 | S2 | public-docs/01-blog/03-api-reference/02-raw-api.md |
Says server-to-server requests are blocked when domains are configured; code allows no-Origin requests (api-key.middleware.ts:175-177). |
Correct the doc; add id field. |
In plan (P13) |
| B19 | S3 | wrangler [dev] port |
Collisions: 8796 forms+chat-engine, 8789 mcp-gateway+media, 8797 internal-api+mailbox, 8799 flows+site-sync. | Re-assign; document a port table in the new guide. | In plan (guide, P0); re-assignment follow-up |
| B20 | S3 | apps/forms-service/src/lib/turnstile.ts, apps/newsletter-service/src/lib/turnstile.ts |
Functionally identical implementations (differ only in logger import path, log-call shape and semicolons). | Lift to @repo/core-abuse with injected logger; engagement imports it. |
In plan (P6 creates the package; forms/newsletter migrate as follow-up) |
| B21 | S3 | turbo.json, .github/workflows/ |
No test task; CI runs per-app tests only where a workflow exists (blog-sdk-test.yml, collections-service-test.yml). New services can ship untested. |
Per-app workflow for engagement now (clone collections-service-test.yml); turbo test task for local use; repo-wide gate as follow-up (manager/blog-sdk need setup). |
In plan (P0) |
| B22 | S3 | apps/blog-test/app/client-demo/page.tsx.tmp.28308.1776155119188 |
Stray editor temp file committed. | Delete. | In plan (P3, while adding the demo page) |
| B23 | S3 | docs/blog/hosted-blog-plan.md:3 |
Still says "Design complete"; the plan is dropped (vision/hosted-blog-vision.md:3 already says "Future / not in current roadmap"). |
Add > **Status:** Superseded (2026-09-04) — site-sync is the publish-to-live path header. |
In plan (P13) |
| B24 | S3 | apps/gateway/wrangler.toml |
No [observability] block on the busiest worker. |
Add [observability] enabled = true. |
In plan (P0) |
| B25 | S3 | apps/mcp-gateway/wrangler.toml:13-15 comment |
"Keep in sync with gateway wrangler" is manual. | Parity test: every ALL_TOOLS namespace has a binding in mcp-gateway bindingMap and flows serviceBindings. |
In plan (P7) |
| B26 | S3 | apps/forms-service/.dev.vars |
Missing CORE_DATABASE_URL → local dev runs unbilled silently. |
Add to .dev.vars.example with a comment. |
Follow-up |
| B27 | S3 | apps/seller-dashboard/src/components/dashboard-header.tsx:60-61,82-90 |
Hardcoded content-engine → content breadcrumb special case. |
Derive from basePath in the registry. |
Follow-up (engagement uses /dashboard/engagement so no new case) |
| B28 | S3 | packages/blog-sdk/README.md |
Hardcoded per-entry sizes and "527 vitest tests" drift with every change. | Generate from size-limit --json in CI or drop the numbers. |
Follow-up |
| B29 | S3 | competitive-audit.md (repo root) |
Empty checklist. | Fill or delete. | Follow-up |
| B30 | S2 | Analytics Engine | Nothing in the repo reads AE back; METRICS unbound locally; two datasets write ops metrics nobody queries. |
Engagement adds the first read path (P11) behind an account token; document AE as write-only until then. | In plan (P11) |
| B31 | S3 | apps/gateway/src/middleware/api-key.middleware.ts:61-68 |
?api_key= in query strings lands in logs/caches; accepted for preflight avoidance. |
Redact api_key in gateway logs; keep behaviour. |
Follow-up |
| B32 | S3 | apps/seller-dashboard/src/components/app-sidebar.tsx |
SidebarMenuBadge exists in ui/sidebar.tsx:580-587 but nothing renders it; ServiceNavItem has no count field. |
Add optional badge?: () => number | null to ServiceNavItem; render it. Needed for Inbox pending count. |
In plan (P6) |
| B33 | S2 | apps/manager/src/routes/tenants.ts:72 |
A third provisioning list ["blog","content","brain","ecommerce"] — ecommerce is not a ServiceCode and has no services row, so the tenant_services insert violates the FK on that path. |
Use one exported DEFAULT_PROVISIONED_SERVICES (B9). |
In plan (P4) |
| B34 | S2 | apps/gateway/src/index.ts:30 vs L61-109 |
The rate limiter runs before every cors() block, so a 429 on any public door (/forms/f/*, /newsletter/f/*, /collections/c/*) has no Access-Control-Allow-Origin; browsers surface it as an opaque TypeError and widgets cannot back off correctly. |
Register public-door CORS blocks before the limiter (engagement does this in P0); move the others in the same PR. | In plan (P0) |
| B35 | S3 | apps/gateway/src/utils/cache.utils.ts:123-136 |
withEdgeCache overwrites downstream Cache-Control (drops stale-while-revalidate) and never evaluates If-None-Match. |
cachedPublicProxy preserves the downstream header; document ETag as browser-only. |
In plan (P1) |
| B36 | S2 | apps/seller-dashboard/src/lib/api-key-roles.ts:120-127 |
EDITOR_WRITE_SERVICES omits forms (and would omit engagement), so the API-key "Editor" preset cannot write forms. |
Add forms and engagement. |
In plan (P6) |
3. Conventions every task must follow
Worker skeleton = apps/forms-service (not blog). Order in src/index.ts: onError → /health + /ready (before guard) → logger() → dbMiddleware → /internal/* mount (token-guarded, before the gateway guard, blog/collections precedent) → gateway-key guard + context hydration (comma-split GATEWAY_SECRET, parse x-user-permissions JSON, validate x-request-id against /^[a-zA-Z0-9._\-:]{1,128}$/, echo it) → public door mount (no requireTenant) → app.use("/admin/*", requireTenant) → app.on(METHOD, [paths], requirePermission(...)) blocks → admin mounts → /mcp/* guards + mount → /internal/flush-accumulator (gateway-key gated) → export { app }; export default app; export { EngagementCounter }.
Headers trusted from the gateway: x-gateway-key, x-tenant-id, x-user-id, x-user-role (after B1), x-user-permissions (JSON array), x-user-services, x-request-id. Public door never reads x-tenant-id; tenant comes from the eng_sites row.
Ids: lib/ids.ts newId(prefix) → ${prefix}_${crypto.randomUUID()}; prefixes site_, rxn_, cmt_, mem_, ban_, flg_. Target keys are natural (post:<post_id>).
Schema: text("id").primaryKey(), text("tenant_id").notNull() everywhere, jsonb(...).$type<T>().notNull().default(sql\'{}'::jsonb`), timestamp("created_at").defaultNow().notNull(), soft delete deleted_at, index names
| File | Responsibility |
|---|---|
package.json |
name engagement-service; scripts and deps identical to forms (@hono/zod-validator, @neondatabase/serverless, @repo/core-billing, @repo/core-access, @repo/core-database, @repo/core-types, @repo/email-kit, @repo/events, @repo/service-catalog, drizzle-orm, hono, wrangler, zod; dev @cloudflare/workers-types, @electric-sql/pglite, @repo/mcp-core, @repo/typescript-config, drizzle-kit, vitest) |
tsconfig.json, drizzle.config.ts, .dev.vars.example |
forms copies; env ENGAGEMENT_DATABASE_URL |
wrangler.toml |
name="vlozi-engagement-service", main, compatibility_date="2024-04-01", compatibility_flags=["nodejs_compat"], [dev] port=8800, [observability] enabled=true, [[services]] BLOG_SERVICE→logicspike-blog-service, [[services]] COMMS_SERVICE→logicspike-communication, [[queues.producers]] EVENTS_QUEUE→vlozi-events, four [[ratelimits]]: ENG_BEACON_LIMITER 5001 120/60, ENG_WRITE_LIMITER 5002 10/60, ENG_TARGET_LIMITER 5003 30/60, ENG_VERIFY_LIMITER 5004 3/60; [[analytics_engine_datasets]] binding="METRICS" dataset="engagement_service_metrics"; [[durable_objects.bindings]] name="ENGAGEMENT_COUNTER" class_name="EngagementCounter"; [[migrations]] tag="v1" new_sqlite_classes=["EngagementCounter"]; secrets listed in comments: ENGAGEMENT_DATABASE_URL, CORE_DATABASE_URL, GATEWAY_SECRET, INTERNAL_KEY, ENGAGEMENT_INTERNAL_TOKEN, VISITOR_HASH_SECRET, ENGAGEMENT_TOKEN_SECRET (P9), TURNSTILE_SECRET_KEY (P6) |
src/types.ts |
RateLimitBinding, Bindings (all of the above, optional where forms marks optional) |
src/context.ts |
ContextVariableMap { context; db; coreDb? } |
src/index.ts |
chain per §3; mounts /e (public), /admin/sites, /admin/settings, /admin/targets, /admin/analytics, /internal/stats, /internal/targets, /internal/flush-accumulator (P6), /mcp (P7); /debug |
src/db/client.ts, src/db/schema.ts |
forms client; tables eng_sites, eng_settings, eng_targets, eng_target_stats, eng_target_daily exactly as the vision doc §9 (Milestone 1 only) |
src/lib/ids.ts, src/utils/logger.ts, src/lib/metrics.ts |
forms/blog copies |
src/lib/origin.ts |
originAllowedRead (lenient, collections shape) and originAllowedWrite (strict, newsletter shape) |
src/lib/visitor.ts |
visitorHash(secret, day, tenantId, ip, ua) = HMAC-SHA256(HMAC-SHA256(secret, day), `${tenant} |
src/lib/bots.ts |
isBot(ua, headers, cf): UA regex `/bot |
src/lib/target-key.ts |
parseTargetKey("post:post_…") → {type, externalId}; regex `^(post |
src/services/sites.service.ts |
loadSite(db, siteId) with 30s per-isolate cache incl. negatives; ensureDefaultSite(db, tenantId) (lazy "Production" site on first admin visit); CRUD; rotateSiteId |
src/services/settings.service.ts |
getSettings(db, tenantId) with defaults; updateSettings |
src/services/targets.service.ts |
verifyTarget(env, tenantId, key): per-isolate LRU (5 min) → BLOG_SERVICE.fetch("https://internal/internal/targets/<id>?tenantId=…", { headers: { "x-internal-token": ENGAGEMENT_INTERNAL_TOKEN } }) → upsert eng_targets (status verified/missing/unpublished, title, slug, url); listTargets, patchTargetMode |
src/services/stats.service.ts |
summaryFor(db, tenantId, keys, settings) (applies views_public, hideViewsBelow, computes commentsOpen); overview(db, tenantId, days), series(db, tenantId, key, days), top(db, tenantId, metric, days) (zero-filled series like forms computeFormAnalytics) |
src/services/health.ts, src/services/result.ts |
forms copies |
src/routes/public/engage.ts |
GET /embed.js (P3), GET /:site/config, POST /:site/beacon, GET /:site/summary, GET /:site/popular |
src/routes/admin/{sites,settings,targets,analytics}.ts |
admin CRUD + analytics; every mutation behind requirePermission |
src/routes/internal/{stats,targets-refresh}.ts |
x-internal-token = ENGAGEMENT_INTERNAL_TOKEN guard; GET /internal/stats?tenantId=&keys=; POST /internal/targets/refresh {tenantId, keys[]} |
src/do/engagement-counter.ts |
EngagementCounter extends DurableObject<Bindings>; fetch routes POST /bump, POST /flush, GET /status; alarm() |
src/lib/counter-sql.ts |
pure: COUNTER_SCHEMA (tables deltas, seen, window, meta), applyBump(rows, bump), flushPlan(deltas), milestones(before, after), pruneStatements(today) — unit-tested under node |
test/{setup,app,fixtures}.ts, vitest.config.ts (alias cloudflare:workers → ../../test-stubs/cloudflare-workers.ts copied from apps/blog-service/vitest.config.ts — forms has no alias because it has no DO), tests listed below |
Beacon route contract (POST /e/:site/beacon): the body arrives as text/plain from navigator.sendBeacon, so never use zValidator("json") (Hono's validator rejects non-JSON content types, hono/dist/validator/validator.js:5,14); instead enforce ≤ 512 B on content-length, await c.req.text(), check .length, JSON.parse in try/catch, then beaconSchema.safeParse with zod { t: targetKey, e: "view"|"read"|"share", p?: ≤512, r?: ≤512, d?: 0..1, s?: "sdk"|"embed" }; site lookup (404 unknown, 403 paused); originAllowedWrite; ENG_BEACON_LIMITER.limit({ key: \${siteId}:${ip}` })andENG_TARGET_LIMITER.limit({ key: t }) **before** body parse; bot filter (c.req.raw.cf as { country?: string; verifiedBotCategory?: string } | undefined, the forms cast idiom) → 204 silently; respond **204 first**, then in waitUntil: verifyTarget(drop whenmissing), recordMetric(env, e, { tenantId, blobs: [t, refHost(r), cf.country, deviceClass(ua), s], doubles: [1, d ?? 0] }), DO bump stub.fetch("http://internal/bump", { method: "POST", body: JSON.stringify({ bumpId, targetKey, event, visitorHash, ts }) })wherestub = env.ENGAGEMENT_COUNTER.get(idFromName(`engage-${tenantId}`)). Fallback to a direct eng_target_dailyupsert (formsviews.tsidiom) **only whenstub.fetchrejects before the request was sent** (binding missing / network error), never on a timeout — a timeout after the DO applied the bump would double count;bumpIdis recorded in the DOmeta table so a retried bump is idempotent. Events emitted from P1 (engagement.target.milestone) are dropped silently by flows until their descriptor lands in P7 (apps/flows-service/src/engine/dispatcher.ts:23`); that is acceptable.
Counter DO: SQLite via this.ctx.storage.sql.exec; ensure() lazy schema; /bump inserts into seen (INSERT OR IGNORE (target_key, day, visitor_hash, minute)), counts a view only when the (target_key, day, visitor_hash) row was new or the last hit on that target from that hash is > 30 min old (align vision doc §12.3 when mirroring); increments deltas; arms setAlarm(now + 60_000) only if getAlarm() === null; alarm() re-arms first (billing-crons pattern), snapshots deltas, runs one batched Neon upsert per table (INSERT … ON CONFLICT (tenant_id, target_key, day) DO UPDATE SET views = views + excluded.views, … and the same for eng_target_stats), on success subtracts the snapshot from deltas (never truncates; bumps that arrived during the external fetch survive), emits engagement.target.milestone, prunes seen rows older than today and window rows older than 10 min. Never call deleteAll() while seen has rows for today — it clears every table (the daily dedup set included), so a quiet minute would make every returning reader a new view; when deltas is empty simply do not re-arm, and deleteAll() only when every table is empty (with compatibility_date = 2024-04-01 it does not clear alarms either way). Serialise alarm() and POST /flush through a flushChain promise (chat-engine session-store.ts:59-61) so they cannot interleave across the Neon await. On throw re-arm +5 min and swallow (content-engine pattern). Neon inside the DO via createDb(this.env.ENGAGEMENT_DATABASE_URL).
Summary route: GET /e/:site/summary?t=a,b (≤ 50 keys) → originAllowedRead → summaryFor → headers cache-control: public, max-age=30, stale-while-revalidate=120 + etag from max updated_at (collections cache-headers.ts shape).
Gateway edge cache (B12): refactor cachedProxy(c, service) to cachedProxy(c, service, { prefix: "/blog" }) (test-safe: test/cache.utils.test.ts never calls it) and add cachedPublicProxy(c, service, { prefix: "/engagement", keyFor: (c) => url path + sorted query, ttlFor }) used by engagement.proxy.ts for GETs under /e/* except /beacon; TTLs: /summary 30, /config 300, /popular 300, /comments 30 (P6), /embed.js 300. Two semantics to decide explicitly: withEdgeCache (cache.utils.ts:123-136) overwrites Cache-Control with public, max-age=${ttl} (drops the worker's stale-while-revalidate) — cachedPublicProxy preserves the downstream header; and the key ignores Origin, so a cached 200 is served to any origin with ACAO: * — acceptable because /e/* reads are origin-agnostic public counts (document it; originAllowedRead still gates the miss path). If-None-Match is not evaluated at the edge; the ETag helps browser revalidation only.
Tests (apps/engagement-service/test/): beacon.test.ts (204 on valid posted with content-type: text/plain, 404 unknown site, 403 origin, 429 via denyLimiter, bot UA counted as 204 but no bump, oversize 413, duplicate bumpId idempotent), summary.test.ts (thresholds, views_public=false, ETag), counter-sql.test.ts (dedup within day, 30-min re-hit, flush plan subtraction, milestones), sites.test.ts, settings.test.ts, targets.test.ts (verify → upsert; missing → no stats), analytics.test.ts (zero-filled series, deltas), tenant-isolation.test.ts, route-coverage.test.ts (allow: POST /e/:site/beacon, POST /internal/targets/refresh), internal.test.ts (token guard).
Exit criteria: worker deployed to staging; gateway redeployed with the [[services]] binding; a curl beacon shows up in /e/:site/summary within 2 minutes; admin analytics return series for the staging/dogfood tenant (insert its tenant_services row for engagement by hand — the admin door 403s via assertServiceAccess until it exists, and general provisioning waits for P4); all tests green.
Phase 2 — Blog integration (2 d)
Goal: engagement can verify posts, blog exposes id, blog_get_analytics returns real numbers, slug/unpublish/delete changes reach engagement, and B3 is fixed.
Edits (apps/blog-service/)
src/routes/public/posts.ts: addidto both public shapes — the list mappertoPublicPost(L143-157 selectsidat L118 but drops it) and the detail payload (L302-315). Without the listid,BlogCardfooters can never buildpost:<id>keys. Flip the SDK doc comment atpackages/blog-sdk/src/types.ts:62-68in P3. Public docs note in P13.src/routes/internal.ts: addGET /targets/:postIdguarded byx-internal-token === c.env.ENGAGEMENT_INTERNAL_TOKEN(extractrequireInternalToken(envKey)helper used by both routes); returns{ exists, status, slug, title, publishedAt }(404 when missing/deleted).src/index.ts:23-78Bindings:ENGAGEMENT_SERVICE?: Fetcher,ENGAGEMENT_INTERNAL_TOKEN?: string.wrangler.toml:[[services]] binding="ENGAGEMENT_SERVICE" service="vlozi-engagement-service".src/mcp/routes.ts:1262-1294: replace the stub: ifENGAGEMENT_SERVICE+ token bound →fetch("https://internal/internal/stats?tenantId=…&keys=post:<id>")and return{ post, analytics: { views, reads, readRate, uniques, rating?, reactions?, comments? }, collected: true, range }; else{ post, available: false, reason: "analytics_not_configured" }. Update the descriptor description inpackages/service-catalog/src/blog.catalog.ts:554-572and addoutputSchema.src/lib/engagement.ts:notifyEngagementTargets(env, tenantId, keys)→ENGAGEMENT_SERVICE.fetch("https://internal/internal/targets/refresh", …)with 5s timeout, never throws — and anengagementNotifyMiddlewareshaped exactly likesiteSyncMiddleware(src/lib/site-sync.ts:78-90): runs after every 2xx non-GET on/admin/*and/mcp/*(mounted next to it atsrc/index.ts:269-270), readsc.get("siteSyncEntity").entityIdand postspost:<entityId>inwaitUntil. One interception point covers PUT, unpublish, delete, restore and the bulk routes; only thePostSchedulerDO auto-publish path needs an explicit call (as site-sync does).- B3 fix — in the REST
PUT /:idhandler (L411): extend the pre-updatelivelookup (L423-427) to{ id, slug }sopreviousSlugis known; extend.returning(L498-500, L510-512) withtitle,categoryId(required byPostLifecycleFields,src/lib/events.ts:66-73); computechangedFieldsfromObject.keys(updateData)minusupdatedAt/contentHtml/contentHtmlVersionplus"tags"(mirrorssrc/mcp/routes.ts:727-746); emitemitPostUpdatedguarded byc.env?.EVENTS_QUEUEandc.executionCtx?.waitUntil(the harness injects both,test/app.ts:62-70). AddemitPostDeletedinsrc/lib/events.ts+ ablog.post.deleteddescriptor and an optionalpreviousSlugonblog.post.updatedinpackages/service-catalog/src/triggers.ts. packages/service-catalog/src/blog.catalog.tsget_analyticsdescription +outputSchema.
Tests: apps/blog-service/test/internal-targets.test.ts; rewrite the existing analytics assertions at test/mcp-routes.test.ts:1040-1057 (they assert collected === false, views === 0 and a /not yet enabled/ note) to the new shapes — unbound → { available: false, reason: "analytics_not_configured" }, bound mock via test/mcp-app.ts env injection → numbers; events.test.ts (REST PUT emits updated with previousSlug; delete emits deleted); public-posts-extra.test.ts asserts id present on list and detail.
Exit criteria: mcp__vlozi__blog_get_analytics returns real views for a post that received beacons.
Phase 3 — SDK @vlozi/blog/engage, embed.js, website + blog-test islands (4 d)
packages/blog-sdk/
package.json: add./engagetoexports(same import/require/types shape as./live), asize-limitentry{ name: "engage island (ESM)", path: "dist/engage/index.js", limit: "8 KB", brotli: true, ignore: ["react","react-dom"] }. Do not hand-bump the version or editCHANGELOG.md: releases go throughchangesets/action(.github/workflows/release.yml:1-13) — add.changeset/engage-entry.mdwith"@vlozi/blog": minorand prose; the bot's "Version Packages" PR bumps to 2.2.0 and writes the changelog.tsup.config.ts: entry"engage/index": "src/engage/index.ts"; addengage/indexto theuseClientFileslist (L94-100) so"use client";is prepended todist/engage/index.{js,cjs}. Bundle-size trap:splitting: falsemeans anything the engage entry imports fromsrc/react/*(resolveSlot,cn, hooks) inlines the whole React entry and blows the 8 KB budget — the engage entry imports only fromsrc/engage/*and keeps its own two-line helpers.src/types.ts:57-92:Post.id?: stringwith doc "present on API ≥ 2026-09; preferidfor stable keys, fall back to slug"; flip the "does not expose an id" comment.src/engage/(client island, no dependency onVloziClient):index.ts(exports),config.ts(EngageConfig { siteId; baseUrl? }, defaulthttps://api.vlozi.app),fetch.ts(sendBeacon(config, payload)usingnavigator.sendBeaconwith atext/plainBlob, fallbackfetch({ keepalive: true });fetchSummary(config, keys)batching within a tick, 4s timeout, module cache 30s),store.ts(single-flight + memory cache,__resetseam),EngagementProvider.tsx(context),useEngagementSummary.ts((keys: string[]) → { data, loading, error }),EngagementBeacon.tsx(firesviewonce per mount after visible + not prerendering; tracks dwell/scroll and firesreadonce viapagehide; propstarget,path?,source?),ViewCount.tsx(target,render?,hideBelow?, optimistic +1 when a beacon fired this session),targetKey.ts(postTarget(post)→post:${id}orslug:fallback),stylesadditions.vlz-engage-*insrc/react/styles.cssusing the five--vlz-*knobs.src/react/components/BlogPost.tsx:renderFooter?: Slot<[post: Post]>inserted at L164 (betweenBlogContentand</article>), resolved withresolveSlot(renderFooter, post).BlogCard.tsx:13-44:footerstays; addrenderFooter?: Slot<[post: Post]>(keepsfooterfor compat).- Tests (
test/):engage.test.tsx(beacon fires once, read fires on thresholds, sendBeacon body is text/plain),engage-summary.test.ts(batching, cache, backoff on 429 and on network errors — a gateway 429 without CORS headers surfaces as a TypeError in browsers);build-artifacts.test.tsentry list (L120-131) and its "use client" describe gainengage/index.{js,cjs,d.ts,d.cts};api-surface.test.tsdoes not enumerate entries — add an engage block mirroring the live-island assertions (L66-104: noclass VloziClientin the bundle, hooks exported); msw handlers for/engagement/e/:site/*intest/msw-server.tsand.storybook/handlers.ts; a storyEngagementFooter.stories.tsx.
apps/engagement-service/src/lib/embed.ts + GET /e/embed.js (P1 route stub filled here): template-literal vanilla script (forms shape, no backticks/${} inside): reads data-site from document.currentScript at eval time, finds [data-vlozi-target], fires beacon, fills [data-vlozi-views], exposes window.VloziEngage = { mount(), version } for SPA re-mount (newsletter pattern); cache-control: public, max-age=300.
Consumers: apps/blog-test — add app/engage-demo/page.tsx and wire EngagementProvider in app/providers.tsx; delete the stray .tmp file (B22). apps/website — components/post-engagement.tsx client island (newsletter-signup pattern: useEffect + next/script for embed or the React entry after the SDK publish); insert after the closing )} of the {post.content && (<article>…)} block at app/blog/[slug]/page.tsx:312, not inside it, so contentless posts still beacon; site id from NEXT_PUBLIC_ENGAGE_SITE_ID and base URL from the existing NEXT_PUBLIC_API_URL — both live in the local .env.production/.env.local because the site deploys via a local next build + wrangler pages deploy (package.json:11), not a Pages dashboard variable. Website bump to @vlozi/blog ^2.2.0 happens after the Version Packages PR publishes (runbook step 9).
Exit criteria: size budget passes; blog-test demo shows a live counter; vlozi.app dogfoods after publish.
Phase 4 — Dashboard module + provisioning go-live (6 d)
Goal: Engagement appears as its own service with Overview, Content, Sites, Settings; existing tenants are provisioned; Milestone 1 ships.
apps/seller-dashboard/
- Registry —
src/lib/service-registry.ts:84: entryengagement{ label: "Engagement", description: "See who's reading, and let readers react, rate and comment.", icon: Heart, gradient: "from-[oklch(0.80_0.13_20/0.15)] to-[oklch(0.78_0.10_290/0.08)]", basePath: "/dashboard/engagement", status: "beta", navItems: [ Overview "/dashboard/engagement" BarChart3, Content "/dashboard/engagement/content" FileText, Sites "/dashboard/engagement/sites" Globe, Settings "/dashboard/engagement/settings" Settings ] }(Inbox first + Audience added in P6/P9; keep the "more specific route first" rule when Inbox lands). - Module
src/modules/engagement/:index.ts,register.ts(reducerManager"engagement"),store/engagement.slice.ts+engagement.types.ts(wire types mirroring the worker:EngagementOverview,TargetRow,TargetSeries,Site,Settings),hooks/useEngagementStore.ts,api/proxy.ts(copy forms proxy;GATEWAY_URL/engagement/…),api/engagement.admin.api.ts(bare fetch +extractError),pages/EngagementOverview.tsx(stat strip Views/Reads/Read rate/Unique,TimeSeriesChart, top content table, empty-state setup card with live "waiting for first beacon" poll),pages/ContentTable.tsx(filters by type, sortable, row →ContentDrawer.tsxwith series + per-target comments mode toggle placeholder),pages/SitesPage.tsx(site cards, origins via forms'OriginsInput, Turnstile key, last beacon, rotate,EmbedSnippet-style tabs React/Script/Raw API),pages/SettingsPage.tsx(Display section only in P4; sections appear per phase),components/(setup-card.tsx,content-drawer.tsx),lib/(target-label.ts). - Shared analytics kit — move
modules/forms/components/analytics-bits.tsxandmodules/forms/lib/trend.tstosrc/components/analytics/{analytics-bits.tsx,trend.ts}with re-exports left in forms (no behaviour change). - Routes —
src/app/api/engagement/[...slug]/route.ts(3-line re-export);src/app/dashboard/engagement/{page.tsx, OverviewWrapper.tsx, content/page.tsx, sites/page.tsx, settings/page.tsx}eachServiceGuard service="engagement"+ dynamic-import wrapper registering the module. - Blog touches —
modules/blog/pages/BlogOverview.tsx: newSection number="06" title="Readers"(top 3 by views 7d + "Open Engagement" link; hidden when the service is not enabled);modules/blog/pages/BlogList.tsx:507-520card footer: add<Eye/> {views}when the engagement summary map is loaded (one batchedGET /admin/targets?type=postcall, merged client-side);modules/blog/components/PostSettingsSheet.tsx: new "Readers" block (30-day sparkline via the engagement series API) after SEO. - Permissions in UI —
usePermission("engagement:sites.write")around site mutations,"engagement:settings.admin"around settings save; nothing else needed (role builder + API-key dialog read the catalogue at runtime). - Provisioning go-live (same commit as the registry entry, same deploy as the dashboard) — there are three provisioning lists, not two:
packages/core-tenant/src/tenant.factory.ts:36,apps/manager/src/routes/onboarding.ts:73, andapps/manager/src/routes/tenants.ts:72(which still lists the non-existent codeecommerce— B33);workspaces.ts:129-134falls back to the factory default. Do B9 here: exportDEFAULT_PROVISIONED_SERVICESfrom@repo/core-tenantand use it in all three, adding"engagement". Seed theservice_limits+plan_service_limitsrows in both seed files (all five tiers) in this same commit; runpnpm tsx packages/core-database/src/backfill-engagement.tsthenbackfill-engagement-limits.tsagainst the prod core DB; announce re-login.
Tests: modules/engagement/**/*.test.ts(x) (overview renders from a fixture, setup card shown when no beacon, content table sort, sites snippet contains the site id). No registry test exists today; add src/lib/service-registry.test.ts (every entry's navItems urls start with its basePath; more specific routes precede the base path).
Exit criteria (Milestone 1 done): a tenant installs the snippet from Sites, sees "verified", sees views on Overview/Content and on the Blog posts list; blog_get_analytics is real; Settings › Services shows an Engagement toggle.
Phase 5 — Reactions and ratings (4 d)
- Schema
eng_reactions+ migration;services/reactions.service.ts(upsertReactionwith delta → DO/bump kind=reaction|rating,value:0removes);POST /e/:site/reactions(strict origin,ENG_WRITE_LIMITER, body ≤ 512 B,vidrequired,client_ididempotency); summary gainsrating {avg,count,histogram}andreactions {kind:n}; settingsreactions_enabled,ratings_enabled,hideRatingBelow. - SDK:
<Reactions>,<StarRating>,<Helpful>,useReaction(target, kind);vidcreated inlocalStorageonly on first interaction; JSON-LD helperengagementJsonLd(post, summary)emittingcommentCount/interactionStatisticonly (noAggregateRatingon articles). - embed.js: reaction buttons under
[data-vlozi-reactions]. - Dashboard: Settings › Reactions & ratings section; Content table rating column; Overview stat.
- Events:
engagement.rating.createdemit helper (descriptor lands in P7). - Tests: one-per-visitor, delta math, removal, thresholds.
Phase 6 — Comments, moderation, spam pipeline, metering, inbox (12 d)
Worker
- Schema
eng_comments,eng_bans,eng_flags,tenant_engagement_usage+ migration. packages/core-abuse(new, B20):verifyTurnstile(token, secret, remoteip, logger?),originAllowedStrict/Lenient,honeypotTripped(body, field),linkCount(text); engagement imports it; forms/newsletter migrate later.services/comments.service.ts: the write pipeline in the vision doc §11.1 order;hold_reasonassignment; "previously approved" lookup on(tenant_id, author_email_hash, status='published');moderate(ids, status, note),reply(commentId, body, userId)(author_kindowner, display name fromeng_settings.reply_as_namefalling back to site name),listPublic(target, cursor, sort),listAdmin(filters),exportCsv.services/moderation-ai.service.ts(intent.ts pattern;@repo/braincollectResponse, labelsok|spam|toxic|off_topic|needs_review, temp 0, maxTokens 8, 1,500-char truncation, 2s timeout, any failure →needs_review).lib/credits.ts(forms copy:engagementUsageStore,canCommentProceed,meterCommentPublishwithkeyPrefix:"engagement-settle", reasonengagement.comment.publish,flushUnbilledEngagementUsage,listTenantsWithUnbilledEngagementUsage),services/limits.ts(forms copy; keyscomments_monthly,events_monthly),routes/internal/flush-accumulator.ts.- Routes: public
GET/POST /e/:site/comments,POST /e/:site/comments/:id/flag; admin/admin/commentslist/get/patch/bulk/reply/delete/export,/admin/bans;eng_targets.comments_modepatch +close_after_daysenforcement;eng_settingscomments/moderation/notify sections. - Billing registry:
packages/core-types/src/credit-costs.tsENGAGEMENT_COMMENT_THOUSANDTHS: 100(with the pricing comment; passes the positive-number test inpackages/core-billing/test/credit-costs.test.ts:23);packages/core-billing/src/reasons.ts"engagement.comment.publish"; metering on publish (auto or moderator), never on spam/rejected; owner replies are free (decision) soreplynever meters;apps/billing-crons:Env.ENGAGEMENT_SERVICE,JobSpec.servicewidened to"manager"|"forms"|"engagement"and the binary binding/host ternaries at L114-115 become a 3-way map, JOBS entryflush_engagement_accumulator→/internal/flush-accumulatordaily, wrangler[[services]], and fix B14 (comma-split the gateway secret at L157-158) in the same PR;apps/internal-api/src/services/spend.ts:29-36addengagement.%(+collections.%,comms.%,mailbox.%, B11). - Permissions: add to the catalogue
engagement:comments.read(readOnly — note it exposes author emails, same posture asnewsletter:subscribers.read),engagement:comments.moderate,engagement:comments.reply,engagement:comments.delete,engagement:bans.manage;packages/core-access/src/rbac.manager.ts:34add"engagement"toMEMBER_WRITE_SERVICES(members moderate; settings stay.admin); dashboard API-key presets: add"engagement"toEDITOR_WRITE_SERVICES(apps/seller-dashboard/src/lib/api-key-roles.ts:120-127, which also lacksforms— B36) so the "Editor" preset carries moderation;PUBLIC_READ_SERVICES(:153-155) stays blog-only, so publishable keys get engagement reads only via "Custom". - Notifications:
services/notify.ts(forms copy) for owner digest via comms/internal/sendwithfrom: PLATFORM_NOTIFY_SENDER,tenantId, idempotencyengagement-digest:${tenantId}:${bucket}; digest scheduling inside the DO (pending_notificationstable; alarm sends hourly/daily pereng_settings.notify.mode); instant on a target's first comment; reply email to the commenter (sendReplyEmailshape) with suppression treated as failure. - Events:
engagement.comment.created|published|flaggedemit helpers.
Dashboard
- Registry: Inbox nav item first (
/dashboard/engagement/inbox), badge via newServiceNavItem.badge?+SidebarMenuBadge(B32) fed by a light/admin/comments/countspoll. modules/engagement/inbox/cloned from forms inbox:InboxView(scope global | target),CommentsToolbar(chips Pending/Published/Spam/Rejected/Flagged/All + AI-label filter + target filter),CommentsTable,CommentDrawer(comment, target context, author history, approve/reject/spam/delete/ban, reply composer, internal note, prev/next),BulkActionBar,useCommentsQuery;pages/InboxPage.tsx; Settings sections Comments/Moderation/Notifications; Content drawer shows latest comments + mode toggle; BlogPostSettingsSheetgains "Comments: inherit/open/closed + close after N days".
SDK/embed: <Comments target> (list, form with name/email/body/honeypot website, Turnstile mount when config.turnstileSiteKey, pending state copy, reply threading one level, flag, client-side linkify with rel="ugc nofollow noopener"), useComments; embed.js [data-vlozi-comments].
Tests: pipeline order (rate-limit before parse via denyLimiter), honeypot silent 200 + no row notified/charged, Turnstile fail → 400, first-time hold then auto-publish, AI failure → pending, metering 10 publishes → 1 credit and spam never charged, plan cap 402 shape, bulk moderation, reply email suppression handling, export CSV, route coverage, tenant isolation, dashboard inbox tests.
Phase 7 — MCP tools, Flows triggers/nodes, brain, parity tests (4 d)
packages/service-catalog/src/engagement.catalog.tswith the tool table from the vision doc §10.4 usingengagement:settings.admineverywhere (the vision doc sayssettings.write; align it when mirroring — an id missing from the catalogue is dropped bysanitizeKeyPermissions,apps/manager/src/lib/mint-api-key.ts:38-43, makingupdate_settingsunreachable). EverynodeMetatool hasoutputSchema(apps/mcp-gateway/test/registry-parity.test.ts:44-48).moderate_commentcharges (approving publishes → meters), so it carriesbilling: { kind: "metered", reason: "engagement.comment.publish", unitsPerCredit: 10, unitLabel: "comment" }(BillingMeta.meteredrequires both fields,packages/service-catalog/src/types.ts:32) and joinsEXPECTED_BILLED_TOOLS(test/billing-parity.test.ts:35-52);reply_to_commentdoes not (owner replies are free);summarize_commentsjoins in P11.src/index.ts: import, export,Namespaceunion,ALL_TOOLSkey (exhaustive — typecheck breaks without it);src/triggers.ts: appendengagement.comment.created|published|flagged,engagement.rating.created,engagement.member.verified(P9),engagement.target.milestonewithfilterKeys.apps/engagement-service/src/mcp/{index.ts,routes.ts}handlers ({data, error}envelope) + guards insrc/index.ts.packages/mcp-dispatch/src/index.ts:13-25: addengagement: "engagement"; convert toRecord<Namespace, string | null>(B13) keeping theif (serviceCode)truthiness check and updating the comment intest/service-toggle-gating.test.ts:89.apps/mcp-gateway:src/types.ts— both theServiceBindingunion (L3-15) andMcpGatewayBindings(L17-30) gainENGAGEMENT_SERVICE;src/server.tsbindingMapengagement: env.ENGAGEMENT_SERVICE;wrangler.toml[[services]].apps/flows-service:src/types.tsENGAGEMENT_SERVICE?+serviceBindings()(engagement, pluscomms,collections,flow— B4),wrangler.toml[[services]]×4; newtest/binding-parity.test.ts: every namespace inALL_TOOLSwith anodeMetatool has a binding key inserviceBindings(B25); same test inapps/mcp-gateway/test.packages/brain/src/tools/engagement.ts(engagement_get_stats,engagement_list_top,engagement_list_comments,engagement_moderate_commentwithrequiresConfirmation: true), register intools/index.ts+registry.tsallTools;TOOL_ACCESS(exhaustive overAgentType): add"engagement_*"toanalyticsandblogagents;apps/brain-service/src/routes/chat.route.ts:186-217bindings addengagement: c.env.ENGAGEMENT_SERVICE(and wire the existing unbound ones, B5); brain-servicewrangler.toml+ env type.- Dashboard flows builder needs nothing (fetches
/flows/nodes).
Tests: mcp routes (permission denied, service_disabled via NAMESPACE_SERVICE_CODE), registry-parity, binding-parity, a flow "comment.published → contacts.create_contact" integration test in flows-service using the trigger descriptor.
Phase 8 — Webhooks and integrations polish (2 d)
lib/webhook.ts(forms copy: HMACsha256=<hmac(timestamp.body)>, 3 attempts, host denylistapi.vlozi.app), tableeng_webhook_deliveries, settingswebhook_url/webhook_secret, admin list/retry; eventscomment.published,rating.created,target.milestone.- Comment → newsletter nudge in the widget (posts to the tenant's
/newsletter/f/:idwheneng_settings.newsletter_form_idis set). - Weekly engagement email (top posts) via the DO weekly alarm + comms.
Phase 9 — Members and identity (6 d)
- Schema
eng_members;lib/hmac.ts(newsletter copy with prefixesverify:/session:andVERIFY_MAX_AGE_SEC = 15*60,SESSION_MAX_AGE_SEC = 30*24*3600);services/members.service.ts(requestVerifywithENG_VERIFY_LIMITER+ per-address cooldown 5 min onlast_verify_sent_at, comms email attributed to the tenant,verifycompare-and-clearverify_nonce, stateless sessionHMAC(member_id:tenant:session_version:exp),me,logout, ban bumpssession_version); routesPOST /e/:site/members/verify-request(generic ok),GET /e/:site/members/verify(redirect toreturn_toonly when its origin ∈allowed_origins, session in#vlz_session=),POST /e/:site/members/me|logout;comments_modeverified/members_only enforcement in the comment pipeline; admin/admin/memberslist/patch/delete (anonymise → "Deleted reader"); permissionsengagement:members.read(readOnly),engagement:members.manage; triggerengagement.member.verified; MCPlist_members,ban_author,delete_member_data. - SDK:
useMember,<SignInWithEmail>, session inlocalStorage, sent in POST bodies; edit-own-comment (5 min) viaedit_token. - Dashboard: Audience page + drawer; registry nav item.
- Tests: cooldown, token expiry/replay, session revocation, redirect origin check, GDPR anonymise.
Phase 10 — Collections targets and reviews (5 d)
apps/collections-service/src/routes/internal.ts:GET /targets/:entryId?tenantId=underx-internal-token === ENGAGEMENT_INTERNAL_TOKEN; engagementverifyTargetroutesentry:keys toCOLLECTIONS_SERVICE(new binding);AggregateRatingJSON-LD helper for Product-shaped collections; collectionsembed.tsappends a reviews/helpful mount beforereturn card(L279) keyed ondata-vlozi-entry; dashboard Content table type filter shows entries; site-sync unaffected.
Phase 11 — Analytics depth: AE read path, trending, reading now, AI summary (5 d)
- First AE read path in the repo:
lib/ae-client.ts(Cloudflare SQL APIhttps://api.cloudflare.com/client/v4/accounts/{id}/analytics_engine/sql, secretsCF_ACCOUNT_ID,CF_AE_TOKEN), adminGET /admin/analytics/breakdown?by=referrer|country|device|hour&range=, dashboard Overview "Sources" section; document AE retention/sampling caveats. GET /e/:site/popularfromeng_target_dailysums;GET /e/:site/livefrom the DOwindow; SDKuseTrendingPosts,<ReadingNow>.- MCP
summarize_commentsviachargedCall(ENGAGEMENT_AI_SUMMARY: 5, reasonengagement.ai.summary, add toEXPECTED_BILLED_TOOLS).
Phase 12 — SSR comments via site-sync, platform blocklist, hardening (4 d)
packages/site-sync-core/src/index.ts:SyncSource+SYNC_SOURCESadd"engagement";apps/site-sync/src/lib/versions.ts:30BINDING_FOR(exhaustive) add the engagement binding +GET /internal/content-versionon engagement (hash of latest published comment per tenant); engagement notifies site-sync oncomment.published(debounced bySiteRevalidator); SDKServerCommentsfor ISR pages.- Platform blocklist:
eng_platform_blocklist(email_hash/ip_hash/domain) managed fromapps/internal-api+apps/internal-dashboard(first cross-tenant moderation surface); engagement checks it after tenant bans. - Load test 10k beacons/min on staging; DO sharding switch (
engage-${tenantId}-${hour % N}) implemented behind a setting but off.
Phase 13 — Documentation and public docs (2 d)
docs/engagement/:README.md(index by role),architecture.md,api-spec.md,database.md,permissions.md,billing.md,embedding.md,backlog.md,implementation-plan.md(this plan, with a completion table); alignvision-and-architecture.md§10.4 (settings.admin) and §12.3 (30-minute re-hit rule) with this plan;docs/blog/sdk-security-model.mdgains the engagement privacy posture;hosted-blog-plan.mdsuperseded header (B23).public-docs/13-engagement/meta.md(name: Engagement,tagline,status: beta,version,lastUpdated), chapters01-getting-started(introduction, quickstart),02-embedding(react-sdk, script-embed, plain-html),03-api-reference(public-door, admin-api),04-comments(moderation, spam-protection),05-privacy;public-docs/01-blog/03-api-reference/02-raw-api.mdaddid, fix domain-locking sentence (B18);public-docs/_changelog/blog/2.2.0.md;public-docs/01-blog/meta.mdversion bump.
Phase 14 — Follow-up bug fixes batch (2 d, optional, separate PRs)
B2 (forms strict origin), B7 (seed unification), B8 (rebuildEntitlements iterate catalog + collections/flows backfills), B15 (single entitlement helper), B16 (widget host constant), B19 (port re-assignment), B26, B27, B28, B29, B31. (B9/B33 land in P4, B14 in P6, B34 in P0, B36 in P6.)
5. Permissions (first-class)
| Permission | readOnly | Owner/Admin | Member | Viewer | Publishable API key | Phase |
|---|---|---|---|---|---|---|
engagement:stats.read |
yes | ✓ | ✓ | ✓ | ✓ | P0 |
engagement:sites.read |
yes | ✓ | ✓ | ✓ | ✓ | P0 |
engagement:sites.write |
✓ | ✓ (via MEMBER_WRITE_SERVICES, P6) |
P0 | |||
engagement:settings.admin |
✓ | ✗ (.admin excluded) |
P0 | |||
engagement:comments.read |
yes | ✓ | ✓ | ✓ | ✓ | P6 |
engagement:comments.moderate |
✓ | ✓ | P6 | |||
engagement:comments.reply |
✓ | ✓ | P6 | |||
engagement:comments.delete |
✓ | ✓ | P6 | |||
engagement:bans.manage |
✓ | ✓ | P6 | |||
engagement:members.read |
yes | ✓ | ✓ | ✓ | ✓ | P9 |
engagement:members.manage |
✓ | ✓ | P9 |
Mechanics: every requirePermission("engagement:…") string must exist in the catalogue (sanitizeKeyPermissions drops unknown ids for API keys; route-coverage.test.ts fails on any ungated mutation); the settings permission is engagement:settings.admin everywhere (catalogue, worker guards, MCP catalog, dashboard) — precedent media:files.admin (permission-catalogue.ts:89), excluded from members by rbac.manager.ts:38; the permission prefix equals the service code so SERVICE_CODE_ALIASES (packages/core-access/src/service-permission.ts:35-37) and the dashboard PERM_PREFIX need no entry; the gateway PBAC floor derives from the catalogue automatically; role builder and API-key dialog fetch /api/permissions/catalog at runtime. The "Publishable API key" column above is reachable only through the Custom preset: PUBLIC_READ_SERVICES in api-key-roles.ts:153-155 is blog-only, and the "Editor" preset carries engagement writes only once EDITOR_WRITE_SERVICES includes it (P6). Public door routes are intentionally ungated and listed in the route-coverage allow list. MCP keys need the exact requiredPermission per tool; publishable keys are refused on MCP (mcp-core/src/auth.ts:134). engagement:comments.read exposes commenter emails; treat like newsletter:subscribers.read.
6. Billing
| Action | Mechanism | Cost | Reason | Phase |
|---|---|---|---|---|
| Views, reads, reactions, ratings | free; soft cap engagement.events_monthly (dashboard hides breakdowns over cap; counting never stops) |
— | — | P1/P5 |
Comment published (auto-approve, moderator approve in the dashboard, or moderate_comment via MCP — the MCP tool carries billing: { kind: "metered", unitsPerCredit: 10, unitLabel: "comment" } and sits in EXPECTED_BILLED_TOOLS) |
accumulator (accumulateAndMaybeSettle, keyPrefix "engagement-settle"), pre-flight canUsageProceed → 402 credit-kit body; plan cap engagement.comments_monthly → 402 PLAN_LIMIT_REACHED; owner replies are free |
ENGAGEMENT_COMMENT_THOUSANDTHS: 100 |
engagement.comment.publish |
P6/P7 |
| AI comment summary | chargedCall flat |
ENGAGEMENT_AI_SUMMARY: 5 |
engagement.ai.summary |
P11 |
| Daily flush | billing-crons flush_engagement_accumulator → POST /internal/flush-accumulator |
— | — | P6 |
| Internal-ops spend row | svcCase arm engagement.% |
— | — | P6 |
Checks: credit-costs.test.ts positive-number test; billing-parity.test.ts hand-list; no-direct-writes.test.ts auto-scans the new app; pricing page rows are editorial (apps/website/app/pricing/PricingClient.tsx:61-63, apps/seller-dashboard/src/app/dashboard/settings/billing/page.tsx:212-220) — add a "comments" row when P6 ships.
7. Security checklist (gate for each public-door PR)
- Tenant derived from
eng_sitesrow only;x-tenant-idignored on/e/*. - CORS for
/engagement/e/*allowsContent-Typeonly; test asserts preflight. - Rate limit before body parse; separate beacon/write/target/verify budgets; gateway path-scoped budget (B10).
- Body caps: beacon/reaction 512 B, comment 8 KB, both
content-lengthand post-parse. - Strict origin on writes, lenient on reads;
return_to/redirects restricted toallowed_origins;safeRedirectscheme check. - No raw IP/UA persisted;
visitor_hashdaily-rotating;ip_hashpurged after 30 days; no cookies. - Honeypot silent 200; Turnstile fails closed; AI moderation fails to pending.
- Comment body plain text, ≤ 5,000 chars, rendered as text (never through
BlogContent); client linkify addsrel="ugc nofollow noopener". - Stranger-safe error bodies on the public door;
onErrornever leakserr.message. - Internal routes use
ENGAGEMENT_INTERNAL_TOKEN(one secret per pair); flush route gateway-key gated. - Magic-link mail attributed to the tenant, never
"system"; per-address cooldown + limiter + Turnstile on verify-request. - Secrets set with
wrangler secret bulkon Windows (BOM issue). -
x-user-rolestripped at the gateway (B1).
8. Observability
- Structured logs:
beacon.accepted|dropped(reason),counter.flush(rows, ms),counter.flush_failed,target.verify(result),comment.created(hold_reason),moderation.ai(label, ms),digest.sent,flush_accumulator(tenants, credits). - Metrics (AE
METRICS):http_requestper route (blog convention) plus product eventsview|read|share|reaction|commentwith blobs[event, target_key, ref_host, country, device, source]. - Health:
/health,/ready(pings engagement DB + core DB); DOGET /status(armed, alarmAt, pending delta count). - Dashboards: Cloudflare observability enabled; alert on
counter.flush_failed> 3 in 15 min and on 5xx rate.
9. Deploy runbook (Milestone 1)
- Create Neon DB
vlozi-engagement(same project as forms); noteENGAGEMENT_DATABASE_URL. pnpm --filter engagement-service generate && migrateagainst it.- Secrets file (
.envstyleKEY=VALUEor JSON{"KEY":"value"}) withENGAGEMENT_DATABASE_URL,CORE_DATABASE_URL,GATEWAY_SECRET= the gateway's current value (comma list allowed),INTERNAL_KEY(comms),ENGAGEMENT_INTERNAL_TOKEN(new random 32+),VISITOR_HASH_SECRET. Preferwrangler deploy --secrets-file <file>on the first deploy so secrets land with the code and/readynever fails in between; otherwisewrangler secret bulk <file>(never pipe tosecret puton Windows). Add the sameENGAGEMENT_INTERNAL_TOKENto blog-service secrets. pnpm --filter engagement-service deploy→ worker live (DO migration v1 applied).- Add the
[[services]] ENGAGEMENT_SERVICEblock toapps/gateway/wrangler.tomlnow (a binding to a non-existent worker fails the deploy) andpnpm --filter gateway deploy(binding, CORS before the limiter, beacon limiter,x-user-rolestrip). pnpm --filter blog-service deploy(id in payloads, internal targets route, engagement binding + notify middleware, get_analytics).- Insert a
tenant_servicesrow forengagementfor the staging/dogfood tenant by hand (the admin door 403s until P4 provisions everyone). - Dashboard: push to
main→ Vercel auto-deploy (no new Vercel variable; the proxy reusesNEXT_PUBLIC_GATEWAY_URL). - Run
backfill-engagement.tsthenbackfill-engagement-limits.tsagainst the prod core DB; users re-login. - SDK: merge the changeset → merge the bot's "Version Packages" PR →
release.ymlpublishes@vlozi/blog@2.2.0(CI already runstest:coverage,build,size,npm pack --dry-run); bumpapps/websitedependency; setNEXT_PUBLIC_ENGAGE_SITE_IDin the local.env.production;pnpm --filter website deploy(Pages). - Smoke: curl a beacon against the prod site id → summary shows +1 in ≤ 2 min → dashboard Overview →
mcp__vlozi__blog_get_analytics. - Later phases add: billing-crons redeploy (P6;
POST /startis idempotent viaensureArmed,apps/billing-crons/src/index.ts:139-144, and DO alarms survive redeploys — call it as a safety step), mcp-gateway + flows-service + brain-service redeploys (P7), collections redeploy (P10), site-sync redeploy (P12). Public docs:13-engagement/meta.mdneedsname(apps/public-docs/lib/docs.ts:72) and changelog entries needdate(lib/changelog.ts:92-93).
Rollback: gateway can be redeployed without the route mount (public door returns 404); the worker is additive; blog changes are additive (id) or guarded by binding presence.
10. Verification matrix
| Layer | How |
|---|---|
| Unit | pnpm --filter engagement-service test (PGlite), pnpm --filter @vlozi/blog test, dashboard vitest, catalog/billing parity tests, gateway tests (proxy.utils.test.ts header list updated), blog mcp-routes.test.ts analytics block rewritten, new packages/core-access/test/catalogue.test.ts |
| Typecheck | pnpm check-types (catches ALL_TOOLS, BINDING_FOR, TOOL_ACCESS, NAMESPACE_SERVICE_CODE after B13) |
| Contract | route-coverage on the worker; registry-parity + binding-parity in mcp-gateway/flows |
| Local e2e | pnpm dev:engagement + apps/blog-test engage-demo against localhost:8788 |
| Staging e2e | beacon → summary → dashboard → MCP tool; comment flow (P6): guest → pending → approve → public list → owner reply → digest email in comms logs |
| Security | preflight header test; spoofed x-user-role ignored; honeypot/Turnstile/limiter tests; secrets not in logs |
| Load | 10k beacons/min one tenant; one batched Neon write per minute; p95 on /blog/public/* unchanged |
| Docs | apps/docs lists Engagement; apps/public-docs renders 13-engagement; changelog parses (has date) |
11. Open decisions (recommendation in bold)
- Pricing: 0.1 cr per published comment + plan caps; alternatives free-under-caps or forms parity 0.25.
- Default comments mode: guest + first-time hold + previously-approved auto-publish.
- AI moderation default: on, fail-to-pending.
- Reaction vocabulary: single "like" by default.
- Public visibility defaults: hide views < 10, rating < 3 votes, always show comment count.
- Who moderates: members (editors) too, via
MEMBER_WRITE_SERVICES. - Sidebar label: "Engagement" (alternatives Audience, Community).
ip_hashretention 30 days; export CSV format = forms'.- Whether to run the P14 follow-up batch inside this programme or as separate tickets.
Appendix A — New-service registration chain (source of truth for the guide)
| # | Touchpoint | File | Enforced by typecheck? | Notes |
|---|---|---|---|---|
| 1 | ServiceCode | packages/core-types/src/service.ts |
no | union only; no arrays |
| 2 | Permission group | packages/core-access/src/permission-catalogue.ts |
no (runtime: sanitizeKeyPermissions drops unknown ids) |
readOnly, .admin semantics; rbac.manager.ts:34 for member writes; alias map only if prefix ≠ code |
| 3 | Services row + limits | packages/core-database/src/seed.ts and apps/manager/src/seed.ts |
no | FK from tenant_services; core seed not prod-safe |
| 4 | Backfill scripts | packages/core-database/src/backfill-<svc>.ts, backfill-<svc>-limits.ts |
no | rebuildEntitlements only inserts services with plan limits |
| 5 | Provisioning lists | packages/core-tenant/src/tenant.factory.ts:36, apps/manager/src/routes/onboarding.ts:73 |
no | both |
| 6 | Gateway | apps/gateway/wrangler.toml, src/types.ts, src/routes/<svc>.proxy.ts, src/index.ts (mount, CORS block for a public door, health) |
partially (GatewayBindings) |
public door = allowHeaders: ["Content-Type"] |
| 7 | Worker | apps/<svc>/ from forms |
— | port table; ratelimit namespace block; GATEWAY_SECRET comma list |
| 8 | MCP catalog | packages/service-catalog/src/<svc>.catalog.ts, src/index.ts (Namespace, ALL_TOOLS) |
yes (ALL_TOOLS) |
nodeMeta ⇒ outputSchema |
| 9 | MCP dispatch/gateway | packages/mcp-dispatch/src/index.ts NAMESPACE_SERVICE_CODE; apps/mcp-gateway/src/{types,server}.ts, wrangler.toml |
no (until B13) | |
| 10 | Flows | apps/flows-service/src/types.ts (Bindings, serviceBindings), wrangler.toml; packages/service-catalog/src/triggers.ts |
no (until binding-parity test) | |
| 11 | Brain | packages/brain/src/tools/<svc>.ts, tools/index.ts, registry.ts (TOOL_ACCESS exhaustive over AgentType), apps/brain-service/src/routes/chat.route.ts bindings + wrangler |
yes for TOOL_ACCESS only |
|
| 12 | Billing | packages/core-types/src/credit-costs.ts, packages/core-billing/src/reasons.ts, packages/service-catalog/test/billing-parity.test.ts, apps/billing-crons JOBS, apps/internal-api/src/services/spend.ts |
tests | |
| 13 | Dashboard | src/lib/service-registry.ts (same commit as provisioning), src/modules/<svc>/, src/app/api/<svc>/[...slug]/route.ts, src/app/dashboard/<svc>/** |
no | PERM_PREFIX only if prefix ≠ code; header special-case if basePath ≠ code |
| 14 | Docs | docs/<svc>/, apps/docs/src/lib/docs.ts CATEGORY_META (optional), public-docs/NN-<svc>/meta.md, _changelog/<svc>/ |
no | meta.name required or the product is invisible |
| 15 | Site-sync (only if content) | packages/site-sync-core SyncSource, apps/site-sync/src/lib/versions.ts BINDING_FOR |
yes | |
| 16 | Turbo/root | turbo.json test task, root dev:<svc> script |
no |
Traps: sidebar fallback item for provisioned-but-unregistered codes; ALL_TOOLS/BINDING_FOR/TOOL_ACCESS exhaustive maps; port collisions; two seeds; gateway secret rotation asymmetry; mcpak1: vs ak: KV prefixes; x-user-id absent on the MCP path.
Appendix B — Guide and skill outlines
docs/guides/adding-a-new-service.md (Active): 0 When a new service is warranted (trust model, blast radius, reuse) · 1 Naming (app dir, worker name, code, permission prefix, MCP namespace) · 2 The registration chain (Appendix A table, with "same-commit" groupings: {code, catalogue, seeds, registry, provisioning, backfill} for user-visible services; {catalog, dispatch map, mcp-gateway, flows, brain} for MCP) · 3 Worker skeleton checklist (files, chain order, ports table, ratelimit namespaces, secrets, .dev.vars.example) · 4 Gateway steps 1–8 · 5 Public-door pattern (opaque id, CORS, origin variants, rate-limit-before-parse, honeypot, Turnstile, stranger-safe errors, waitUntil) · 6 Permissions & RBAC · 7 Provisioning & seeds (both files, limits, factory, onboarding, backfill, rebuildEntitlements caveat, re-login) · 8 Billing (8-step HOWTO, accumulator recipe, crons flush, internal-api arm) · 9 MCP (catalog, exhaustive map, dispatch, mcp-gateway, parity tests, brain) · 10 Flows (triggers, nodeMeta, bindings) · 11 Events · 12 Dashboard (registry, module anatomy, proxy, wrappers, guards, badge) · 13 Docs (internal + public + changelog formats) · 14 Deploy runbook template · 15 Traps · 16 Copy-paste checklist.
.claude/skills/add-new-service.md: front matter; "Operating principles" (never provision before the registry entry exists; every exhaustive map in one commit; clone forms, not blog; costs in the registry; tests before deploy); "Inputs to collect" (code, label, public door?, billed actions?, MCP tools?, Flows?, dashboard nav, DB engine); "Steps" (read the guide; run the checklist section by section; generate files from the forms template; add parity tests; run pnpm check-types + per-app tests; write the runbook; update docs/<svc>/README.md); "Must not forget" list (Appendix A traps); "Output" (a PR list grouped by same-commit rule + the runbook).
Appendix C — Reuse map
| Short | Path |
|---|---|
| forms-submit / views / credits / limits / turnstile / embed / notify / webhook | apps/forms-service/src/{routes/public/submit.ts, lib/views.ts, lib/credits.ts, services/limits.ts, lib/turnstile.ts, lib/embed.ts, services/notify.ts, lib/webhook.ts} |
| forms-tests | apps/forms-service/test/{setup,app}.ts, test/route-coverage.test.ts |
| gw-forms-proxy / gw-cache / gw-proxy-utils / gw-ratelimit | apps/gateway/src/{routes/forms.proxy.ts, utils/cache.utils.ts, utils/proxy.utils.ts, middleware/rate-limit.middleware.ts} |
| blog-internal / blog-public / blog-analytics-stub / blog-events / blog-admin-posts | apps/blog-service/src/{routes/internal.ts, routes/public/posts.ts, mcp/routes.ts, lib/events.ts, routes/admin/posts.ts} |
| ce-analytics-do / ce-session-store / ce-intent | apps/content-engine/src/schedulers/analytics-scheduler.ts, apps/chat-engine/src/{session-store.ts, lib/intent.ts} |
| nl-hmac / nl-confirm / nl-subscribe / nl-widget | apps/newsletter-service/src/{lib/hmac.ts, lib/confirmation.ts, lib/subscribe.ts, routes/widget.route.ts} |
| billing-crons / comms-send | apps/billing-crons/src/index.ts, apps/communication/src/index.ts:1761-1786 |
| catalog / triggers / dispatch / mcp-gateway / flows-bindings / brain-tools | packages/service-catalog/src/{index,types,forms.catalog,triggers}.ts, packages/mcp-dispatch/src/index.ts, apps/mcp-gateway/src/server.ts, apps/flows-service/src/types.ts, packages/brain/src/tools/* |
| perms / rbac / route-coverage | packages/core-access/src/{permission-catalogue,rbac.manager,route-coverage}.ts |
| seeds / backfills / factory / onboarding / rebuild | packages/core-database/src/{seed,backfill-forms,backfill-forms-limits}.ts, apps/manager/src/seed.ts, packages/core-tenant/src/tenant.factory.ts, apps/manager/src/routes/onboarding.ts, apps/manager/src/services/billing.ts:271 |
| dash-registry / dash-forms-module / dash-inbox / dash-analytics-bits / dash-proxy / dash-wrapper | apps/seller-dashboard/src/{lib/service-registry.ts, modules/forms/**, modules/forms/inbox/**, modules/forms/components/analytics-bits.tsx, modules/forms/api/proxy.ts, app/dashboard/forms/inbox/InboxWrapper.tsx} |
| sdk-live / sdk-post / sdk-card / sdk-types / sdk-tsup / sdk-size | packages/blog-sdk/{src/live/**, src/react/components/BlogPost.tsx, src/react/components/BlogCard.tsx, src/types.ts, tsup.config.ts, package.json} |
| web-post / web-nl / blog-test | apps/website/{app/blog/[slug]/page.tsx, components/newsletter-signup.tsx}, apps/blog-test/app/** |
| docs-loader / public-docs-loader | apps/docs/src/lib/docs.ts, apps/public-docs/lib/docs.ts |