logicspike/docs

Engagement

Engagement Service — Implementation Plan

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 to logicspike/docs/engagement/implementation-plan.md on approval (forms precedent: docs/forms/implementation-plan.md). Decisions locked: (1) new worker apps/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 to logicspike/. 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

__idx/_unique, composite PKs via primaryKey({ columns }). Migrations via drizzle-kit generateintodrizzle/` (journal version 7).

Errors: services/result.ts ServiceResult<T> = {ok:true,data} | {ok:false,status,code,message}; public door returns stranger-safe copy only; onError returns {error, code:"internal_error", request_id}.

Side effects: always c.executionCtx?.waitUntil(...) (optional chain; test harness supplies execCtx()).

Billing: costs only in packages/core-types/src/credit-costs.ts; reasons only in packages/core-billing/src/reasons.ts; ledger via new CreditLedger(asLedgerDb(createCachedHttpDb(CORE_DATABASE_URL))); never write tenant_credits/credit_transactions directly (no-direct-writes.test.ts scans apps/*/src automatically).

Events: lib/events.ts with one emitXxx(queue, payload) per type; payload must match the TriggerDescriptor.payloadSchema in packages/service-catalog/src/triggers.ts.

Tests: vitest + PGlite with real migrations (test/setup.ts), makeTestApp(db, auth, coreDb?) mounting real routes and skipping gateway/permission middleware, makeEnv() with allowLimiter/denyLimiter, execCtx() (all from the forms harness); vitest.config.ts aliases cloudflare:workers → ../../test-stubs/cloudflare-workers.ts (copied from apps/blog-service/vitest.config.ts; forms has none); the stub exposes no ctx.storage, so DO logic lives in a pure module (lib/counter-sql.ts) that is unit-tested, while the DO class is imported only for route coverage; test/route-coverage.test.ts with findUngatedMutations(app, { allow }).

Logging: utils/logger.ts structured JSON with service: "engagement-service", event field, Errors normalised.

Dev port: 8800. Ratelimit namespace_ids: 5001–5004. Worker name: vlozi-engagement-service.


4. Phases

Phase 0 — Registration, gateway door, guide + skill (4 d)

Goal: the platform knows engagement exists, the gateway code can route to it, the public door has its own CORS and rate budget, and the "add a service" knowledge is written down. No tenant is provisioned yet — and that means no plan_service_limits rows in P0 either: rebuildEntitlements (apps/manager/src/services/billing.ts:282-340) upserts tenant_services for every service that has plan-limit rows, on onboarding and on every plan change, which would put engagement in JWTs and show the sidebar fallback item (app-sidebar.tsx:73-85 filters on enabled + permissions only; a coming_soon registry status does not hide it). Provisioning, limits and the registry entry land together in Phase 4.

Tasks

  1. ServiceCode — packages/core-types/src/service.ts:8-22: add | "engagement" // Engagement: reader views, reactions, ratings, comments, members. Verified: zero exhaustive Record<ServiceCode,…> in the repo; nothing else breaks.
  2. Permission group (minimum) — packages/core-access/src/permission-catalogue.ts after the flows group (L263-272):
    { service: "engagement", label: "Engagement", permissions: [
      { id: "engagement:stats.read",    label: "View engagement analytics",            group: "Analytics", readOnly: true },
      { id: "engagement:sites.read",    label: "View sites & embed snippets",           group: "Sites",     readOnly: true },
      { id: "engagement:sites.write",   label: "Create, edit & rotate sites",           group: "Sites" },
      { id: "engagement:settings.admin",label: "Change engagement settings",            group: "Settings" },
    ] }
    .admin suffix keeps settings off role_member (rbac.manager.ts:36-40). Comments/members permissions arrive in P6/P9. Owner/admin/viewer derive automatically.
  3. Services catalog row only (both seeds) — packages/core-database/src/seed.ts:131-148 and apps/manager/src/seed.ts:93-108: { code: "engagement", name: "Engagement" } (inert FK target). The service_limits rows (engagement/events_monthly default 10000, engagement/comments_monthly default 100) and the plan_service_limits rows (events free 10000 / basic 50000 / starter 250000 / pro 1000000 / business −1; comments free 100 / basic 500 / starter 2500 / pro 10000 / business −1, all five tiers in both seeds — both plan tables have all five, manager just lacks basic limit rows today) are written in P4 task 7, not here.
  4. Backfill scripts (written now, run in P4) — packages/core-database/src/backfill-engagement.ts (copy backfill-forms.ts: insert services row, insert tenant_services for every tenant lacking it, print the re-login warning) and packages/core-database/src/backfill-engagement-limits.ts (copy backfill-forms-limits.ts with the two limit keys and the five-tier plan map).
  5. Gateway (code only; the wrangler binding ships with P1) — Cloudflare rejects a deploy whose [[services]] targets a worker that does not exist, so apps/gateway/wrangler.toml gets [[services]] binding="ENGAGEMENT_SERVICE" service="vlozi-engagement-service" only in the P1 deploy, after the worker exists. In P0: wrangler.toml adds [[ratelimits]] name="BEACON_RATE_LIMITER" namespace_id="1002" simple={limit=600, period=60} and [observability] enabled=true (B24). src/types.ts:13-29: ENGAGEMENT_SERVICE?: Fetcher (optional until P1), BEACON_RATE_LIMITER?: RateLimit. src/routes/engagement.proxy.ts = copy of forms.proxy.ts with ENGAGEMENT_SERVICE, prefix /engagement, public .all("/e/*", forward), admin chain authMiddleware → accessMiddleware("engagement") → subscriptionGuard, 404 fallback, 500 "binding missing" branch. src/index.ts: the public CORS block is registered before app.use("/*", rateLimitMiddleware) at L30 so a 429 still carries Access-Control-Allow-Origin: * (today every public door's 429 is an opaque TypeError in the browser — B34):
    app.use("/engagement/e/*", cors({ origin: "*", allowMethods: ["GET","POST","OPTIONS"], allowHeaders: ["Content-Type"], maxAge: 86400 }))
    (path-scoped; the strict allowlist at L131 sets ACAO only for allowed origins, so it never overrides the *). Mount app.route("/engagement", engagementProxy) in the block at L147-162; /health adds ENGAGEMENT_SERVICE, FLOWS_SERVICE, COLLECTIONS_SERVICE, SITE_SYNC_SERVICE (B6). src/middleware/rate-limit.middleware.ts: if c.req.path.startsWith("/engagement/e/") use BEACON_RATE_LIMITER (fallback to the IP limiter when unbound) (B10). src/utils/proxy.utils.ts:94-101: add "x-user-role" to SPOOFABLE_IDENTITY_HEADERS and set it in buildDownstreamHeaders from ctx.auth.role (JWTClaims.role, packages/core-types/src/auth.ts:31; the API-key path sets the literal "api_key") (B1); update the hand-listed headers in apps/gateway/test/proxy.utils.test.ts:51-75. Docs: docs/gateway/developer-guide.md §4 gains step 7 "public door CORS block (before the rate limiter)" and step 8 "health flag".
  6. CI + scripts — clone .github/workflows/collections-service-test.yml to engagement-service-test.yml (paths apps/engagement-service/**, packages/core-*/**, packages/service-catalog/**; runs check-types + test). Add a turbo.json "test" task (cache: false) for local use; a repo-wide CI test gate is a follow-up (manager/blog-sdk need dist//PGlite setup). Root package.json: "dev:engagement": "turbo run dev --filter=seller-dashboard --filter=gateway --filter=engagement-service --filter=manager" (B21).
  7. Guide — docs/guides/adding-a-new-service.md (content in Appendix B; header > **Last Updated:** 2026-09-xx / > **Status:** Active). Add docs/engagement to apps/docs/src/lib/docs.ts CATEGORY_META (name "Engagement", icon "Heart") and orderedCategories after forms.
  8. Skill — .claude/skills/add-new-service.md (front matter name: Add New Service, description: Register and scaffold a new Vlozi platform service without missing a touchpoint; content in Appendix B).
  9. Memory/docs hygiene — docs/blog/hosted-blog-plan.md + vision/hosted-blog-vision.md get the Superseded header (B23).

Tests: apps/gateway existing tests + a new test/engagement-proxy.test.ts (public /e/* forwards without auth; /admin/* 401 without JWT; CORS preflight on /engagement/e/x returns Access-Control-Allow-Headers: Content-Type only; a 429 on /engagement/e/* carries ACAO: *; x-user-role stripped and re-set). New packages/core-access/test/catalogue.test.ts (none exists today): unique ids, ^[a-z]+:[a-z]+\.[a-z-]+$, each group's id prefix equals its service or an alias, no duplicate labels — the only place a settings.write vs settings.admin typo would be caught. Typecheck across repo.

Exit criteria: pnpm check-types green; gateway code merged with the optional binding (the wrangler [[services]] block is committed on the P1 branch and deployed after the worker); guide + skill merged.

Phase 1 — Worker core: schema, sites, targets, beacon, counter DO, summary, admin analytics (7 d)

Goal: vlozi-engagement-service counts views/reads for a tenant and serves the public summary and admin analytics.

New files (apps/engagement-service/)

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/)

  1. src/routes/public/posts.ts: add id to both public shapes — the list mapper toPublicPost (L143-157 selects id at L118 but drops it) and the detail payload (L302-315). Without the list id, BlogCard footers can never build post:<id> keys. Flip the SDK doc comment at packages/blog-sdk/src/types.ts:62-68 in P3. Public docs note in P13.
  2. src/routes/internal.ts: add GET /targets/:postId guarded by x-internal-token === c.env.ENGAGEMENT_INTERNAL_TOKEN (extract requireInternalToken(envKey) helper used by both routes); returns { exists, status, slug, title, publishedAt } (404 when missing/deleted).
  3. src/index.ts:23-78 Bindings: ENGAGEMENT_SERVICE?: Fetcher, ENGAGEMENT_INTERNAL_TOKEN?: string. wrangler.toml: [[services]] binding="ENGAGEMENT_SERVICE" service="vlozi-engagement-service".
  4. src/mcp/routes.ts:1262-1294: replace the stub: if ENGAGEMENT_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 in packages/service-catalog/src/blog.catalog.ts:554-572 and add outputSchema.
  5. src/lib/engagement.ts: notifyEngagementTargets(env, tenantId, keys) → ENGAGEMENT_SERVICE.fetch("https://internal/internal/targets/refresh", …) with 5s timeout, never throws — and an engagementNotifyMiddleware shaped exactly like siteSyncMiddleware (src/lib/site-sync.ts:78-90): runs after every 2xx non-GET on /admin/* and /mcp/* (mounted next to it at src/index.ts:269-270), reads c.get("siteSyncEntity").entityId and posts post:<entityId> in waitUntil. One interception point covers PUT, unpublish, delete, restore and the bulk routes; only the PostScheduler DO auto-publish path needs an explicit call (as site-sync does).
  6. B3 fix — in the REST PUT /:id handler (L411): extend the pre-update live lookup (L423-427) to { id, slug } so previousSlug is known; extend .returning (L498-500, L510-512) with title, categoryId (required by PostLifecycleFields, src/lib/events.ts:66-73); compute changedFields from Object.keys(updateData) minus updatedAt/contentHtml/contentHtmlVersion plus "tags" (mirrors src/mcp/routes.ts:727-746); emit emitPostUpdated guarded by c.env?.EVENTS_QUEUE and c.executionCtx?.waitUntil (the harness injects both, test/app.ts:62-70). Add emitPostDeleted in src/lib/events.ts + a blog.post.deleted descriptor and an optional previousSlug on blog.post.updated in packages/service-catalog/src/triggers.ts.
  7. packages/service-catalog/src/blog.catalog.ts get_analytics description + 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/

  1. package.json: add ./engage to exports (same import/require/types shape as ./live), a size-limit entry { 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 edit CHANGELOG.md: releases go through changesets/action (.github/workflows/release.yml:1-13) — add .changeset/engage-entry.md with "@vlozi/blog": minor and prose; the bot's "Version Packages" PR bumps to 2.2.0 and writes the changelog.
  2. tsup.config.ts: entry "engage/index": "src/engage/index.ts"; add engage/index to the useClientFiles list (L94-100) so "use client"; is prepended to dist/engage/index.{js,cjs}. Bundle-size trap: splitting: false means anything the engage entry imports from src/react/* (resolveSlot, cn, hooks) inlines the whole React entry and blows the 8 KB budget — the engage entry imports only from src/engage/* and keeps its own two-line helpers.
  3. src/types.ts:57-92: Post.id?: string with doc "present on API ≥ 2026-09; prefer id for stable keys, fall back to slug"; flip the "does not expose an id" comment.
  4. src/engage/ (client island, no dependency on VloziClient): index.ts (exports), config.ts (EngageConfig { siteId; baseUrl? }, default https://api.vlozi.app), fetch.ts (sendBeacon(config, payload) using navigator.sendBeacon with a text/plain Blob, fallback fetch({ keepalive: true }); fetchSummary(config, keys) batching within a tick, 4s timeout, module cache 30s), store.ts (single-flight + memory cache, __reset seam), EngagementProvider.tsx (context), useEngagementSummary.ts ((keys: string[]) → { data, loading, error }), EngagementBeacon.tsx (fires view once per mount after visible + not prerendering; tracks dwell/scroll and fires read once via pagehide; props target, path?, source?), ViewCount.tsx (target, render?, hideBelow?, optimistic +1 when a beacon fired this session), targetKey.ts (postTarget(post) → post:${id} or slug: fallback), styles additions .vlz-engage-* in src/react/styles.css using the five --vlz-* knobs.
  5. src/react/components/BlogPost.tsx: renderFooter?: Slot<[post: Post]> inserted at L164 (between BlogContent and </article>), resolved with resolveSlot(renderFooter, post). BlogCard.tsx:13-44: footer stays; add renderFooter?: Slot<[post: Post]> (keeps footer for compat).
  6. 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.ts entry list (L120-131) and its "use client" describe gain engage/index.{js,cjs,d.ts,d.cts}; api-surface.test.ts does not enumerate entries — add an engage block mirroring the live-island assertions (L66-104: no class VloziClient in the bundle, hooks exported); msw handlers for /engagement/e/:site/* in test/msw-server.ts and .storybook/handlers.ts; a story EngagementFooter.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/

  1. Registry — src/lib/service-registry.ts:84: entry engagement { 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).
  2. 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.tsx with 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).
  3. Shared analytics kit — move modules/forms/components/analytics-bits.tsx and modules/forms/lib/trend.ts to src/components/analytics/{analytics-bits.tsx,trend.ts} with re-exports left in forms (no behaviour change).
  4. 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} each ServiceGuard service="engagement" + dynamic-import wrapper registering the module.
  5. Blog touches — modules/blog/pages/BlogOverview.tsx: new Section 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-520 card footer: add <Eye/> {views} when the engagement summary map is loaded (one batched GET /admin/targets?type=post call, merged client-side); modules/blog/components/PostSettingsSheet.tsx: new "Readers" block (30-day sparkline via the engagement series API) after SEO.
  6. 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).
  7. 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, and apps/manager/src/routes/tenants.ts:72 (which still lists the non-existent code ecommerce — B33); workspaces.ts:129-134 falls back to the factory default. Do B9 here: export DEFAULT_PROVISIONED_SERVICES from @repo/core-tenant and use it in all three, adding "engagement". Seed the service_limits + plan_service_limits rows in both seed files (all five tiers) in this same commit; run pnpm tsx packages/core-database/src/backfill-engagement.ts then backfill-engagement-limits.ts against 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 (upsertReaction with delta → DO /bump kind=reaction|rating, value:0 removes); POST /e/:site/reactions (strict origin, ENG_WRITE_LIMITER, body ≤ 512 B, vid required, client_id idempotency); summary gains rating {avg,count,histogram} and reactions {kind:n}; settings reactions_enabled, ratings_enabled, hideRatingBelow.
  • SDK: <Reactions>, <StarRating>, <Helpful>, useReaction(target, kind); vid created in localStorage only on first interaction; JSON-LD helper engagementJsonLd(post, summary) emitting commentCount/interactionStatistic only (no AggregateRating on articles).
  • embed.js: reaction buttons under [data-vlozi-reactions].
  • Dashboard: Settings › Reactions & ratings section; Content table rating column; Overview stat.
  • Events: engagement.rating.created emit 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_reason assignment; "previously approved" lookup on (tenant_id, author_email_hash, status='published'); moderate(ids, status, note), reply(commentId, body, userId) (author_kind owner, display name from eng_settings.reply_as_name falling back to site name), listPublic(target, cursor, sort), listAdmin(filters), exportCsv.
  • services/moderation-ai.service.ts (intent.ts pattern; @repo/brain collectResponse, labels ok|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, meterCommentPublish with keyPrefix:"engagement-settle", reason engagement.comment.publish, flushUnbilledEngagementUsage, listTenantsWithUnbilledEngagementUsage), services/limits.ts (forms copy; keys comments_monthly, events_monthly), routes/internal/flush-accumulator.ts.
  • Routes: public GET/POST /e/:site/comments, POST /e/:site/comments/:id/flag; admin /admin/comments list/get/patch/bulk/reply/delete/export, /admin/bans; eng_targets.comments_mode patch + close_after_days enforcement; eng_settings comments/moderation/notify sections.
  • Billing registry: packages/core-types/src/credit-costs.ts ENGAGEMENT_COMMENT_THOUSANDTHS: 100 (with the pricing comment; passes the positive-number test in packages/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) so reply never meters; apps/billing-crons: Env.ENGAGEMENT_SERVICE, JobSpec.service widened to "manager"|"forms"|"engagement" and the binary binding/host ternaries at L114-115 become a 3-way map, JOBS entry flush_engagement_accumulator → /internal/flush-accumulator daily, 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-36 add engagement.% (+ collections.%, comms.%, mailbox.%, B11).
  • Permissions: add to the catalogue engagement:comments.read (readOnly — note it exposes author emails, same posture as newsletter:subscribers.read), engagement:comments.moderate, engagement:comments.reply, engagement:comments.delete, engagement:bans.manage; packages/core-access/src/rbac.manager.ts:34 add "engagement" to MEMBER_WRITE_SERVICES (members moderate; settings stay .admin); dashboard API-key presets: add "engagement" to EDITOR_WRITE_SERVICES (apps/seller-dashboard/src/lib/api-key-roles.ts:120-127, which also lacks forms — 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/send with from: PLATFORM_NOTIFY_SENDER, tenantId, idempotency engagement-digest:${tenantId}:${bucket}; digest scheduling inside the DO (pending_notifications table; alarm sends hourly/daily per eng_settings.notify.mode); instant on a target's first comment; reply email to the commenter (sendReplyEmail shape) with suppression treated as failure.
  • Events: engagement.comment.created|published|flagged emit helpers.

Dashboard

  • Registry: Inbox nav item first (/dashboard/engagement/inbox), badge via new ServiceNavItem.badge? + SidebarMenuBadge (B32) fed by a light /admin/comments/counts poll.
  • 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; Blog PostSettingsSheet gains "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.ts with the tool table from the vision doc §10.4 using engagement:settings.admin everywhere (the vision doc says settings.write; align it when mirroring — an id missing from the catalogue is dropped by sanitizeKeyPermissions, apps/manager/src/lib/mint-api-key.ts:38-43, making update_settings unreachable). Every nodeMeta tool has outputSchema (apps/mcp-gateway/test/registry-parity.test.ts:44-48). moderate_comment charges (approving publishes → meters), so it carries billing: { kind: "metered", reason: "engagement.comment.publish", unitsPerCredit: 10, unitLabel: "comment" } (BillingMeta.metered requires both fields, packages/service-catalog/src/types.ts:32) and joins EXPECTED_BILLED_TOOLS (test/billing-parity.test.ts:35-52); reply_to_comment does not (owner replies are free); summarize_comments joins in P11. src/index.ts: import, export, Namespace union, ALL_TOOLS key (exhaustive — typecheck breaks without it); src/triggers.ts: append engagement.comment.created|published|flagged, engagement.rating.created, engagement.member.verified (P9), engagement.target.milestone with filterKeys.
  • apps/engagement-service/src/mcp/{index.ts,routes.ts} handlers ({data, error} envelope) + guards in src/index.ts.
  • packages/mcp-dispatch/src/index.ts:13-25: add engagement: "engagement"; convert to Record<Namespace, string | null> (B13) keeping the if (serviceCode) truthiness check and updating the comment in test/service-toggle-gating.test.ts:89.
  • apps/mcp-gateway: src/types.ts — both the ServiceBinding union (L3-15) and McpGatewayBindings (L17-30) gain ENGAGEMENT_SERVICE; src/server.ts bindingMap engagement: env.ENGAGEMENT_SERVICE; wrangler.toml [[services]].
  • apps/flows-service: src/types.ts ENGAGEMENT_SERVICE? + serviceBindings() (engagement, plus comms, collections, flow — B4), wrangler.toml [[services]] ×4; new test/binding-parity.test.ts: every namespace in ALL_TOOLS with a nodeMeta tool has a binding key in serviceBindings (B25); same test in apps/mcp-gateway/test.
  • packages/brain/src/tools/engagement.ts (engagement_get_stats, engagement_list_top, engagement_list_comments, engagement_moderate_comment with requiresConfirmation: true), register in tools/index.ts + registry.ts allTools; TOOL_ACCESS (exhaustive over AgentType): add "engagement_*" to analytics and blog agents; apps/brain-service/src/routes/chat.route.ts:186-217 bindings add engagement: c.env.ENGAGEMENT_SERVICE (and wire the existing unbound ones, B5); brain-service wrangler.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: HMAC sha256=<hmac(timestamp.body)>, 3 attempts, host denylist api.vlozi.app), table eng_webhook_deliveries, settings webhook_url/webhook_secret, admin list/retry; events comment.published, rating.created, target.milestone.
  • Comment → newsletter nudge in the widget (posts to the tenant's /newsletter/f/:id when eng_settings.newsletter_form_id is 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 prefixes verify: / session: and VERIFY_MAX_AGE_SEC = 15*60, SESSION_MAX_AGE_SEC = 30*24*3600); services/members.service.ts (requestVerify with ENG_VERIFY_LIMITER + per-address cooldown 5 min on last_verify_sent_at, comms email attributed to the tenant, verify compare-and-clear verify_nonce, stateless session HMAC(member_id:tenant:session_version:exp), me, logout, ban bumps session_version); routes POST /e/:site/members/verify-request (generic ok), GET /e/:site/members/verify (redirect to return_to only when its origin ∈ allowed_origins, session in #vlz_session=), POST /e/:site/members/me|logout; comments_mode verified/members_only enforcement in the comment pipeline; admin /admin/members list/patch/delete (anonymise → "Deleted reader"); permissions engagement:members.read (readOnly), engagement:members.manage; trigger engagement.member.verified; MCP list_members, ban_author, delete_member_data.
  • SDK: useMember, <SignInWithEmail>, session in localStorage, sent in POST bodies; edit-own-comment (5 min) via edit_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= under x-internal-token === ENGAGEMENT_INTERNAL_TOKEN; engagement verifyTarget routes entry: keys to COLLECTIONS_SERVICE (new binding); AggregateRating JSON-LD helper for Product-shaped collections; collections embed.ts appends a reviews/helpful mount before return card (L279) keyed on data-vlozi-entry; dashboard Content table type filter shows entries; site-sync unaffected.
  • First AE read path in the repo: lib/ae-client.ts (Cloudflare SQL API https://api.cloudflare.com/client/v4/accounts/{id}/analytics_engine/sql, secrets CF_ACCOUNT_ID, CF_AE_TOKEN), admin GET /admin/analytics/breakdown?by=referrer|country|device|hour&range=, dashboard Overview "Sources" section; document AE retention/sampling caveats.
  • GET /e/:site/popular from eng_target_daily sums; GET /e/:site/live from the DO window; SDK useTrendingPosts, <ReadingNow>.
  • MCP summarize_comments via chargedCall (ENGAGEMENT_AI_SUMMARY: 5, reason engagement.ai.summary, add to EXPECTED_BILLED_TOOLS).

Phase 12 — SSR comments via site-sync, platform blocklist, hardening (4 d)

  • packages/site-sync-core/src/index.ts: SyncSource + SYNC_SOURCES add "engagement"; apps/site-sync/src/lib/versions.ts:30 BINDING_FOR (exhaustive) add the engagement binding + GET /internal/content-version on engagement (hash of latest published comment per tenant); engagement notifies site-sync on comment.published (debounced by SiteRevalidator); SDK ServerComments for ISR pages.
  • Platform blocklist: eng_platform_blocklist (email_hash/ip_hash/domain) managed from apps/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); align vision-and-architecture.md §10.4 (settings.admin) and §12.3 (30-minute re-hit rule) with this plan; docs/blog/sdk-security-model.md gains the engagement privacy posture; hosted-blog-plan.md superseded header (B23).
  • public-docs/13-engagement/meta.md (name: Engagement, tagline, status: beta, version, lastUpdated), chapters 01-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.md add id, fix domain-locking sentence (B18); public-docs/_changelog/blog/2.2.0.md; public-docs/01-blog/meta.md version 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_sites row only; x-tenant-id ignored on /e/*.
  • CORS for /engagement/e/* allows Content-Type only; 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-length and post-parse.
  • Strict origin on writes, lenient on reads; return_to/redirects restricted to allowed_origins; safeRedirect scheme check.
  • No raw IP/UA persisted; visitor_hash daily-rotating; ip_hash purged 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 adds rel="ugc nofollow noopener".
  • Stranger-safe error bodies on the public door; onError never leaks err.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 bulk on Windows (BOM issue).
  • x-user-role stripped 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_request per route (blog convention) plus product events view|read|share|reaction|comment with blobs [event, target_key, ref_host, country, device, source].
  • Health: /health, /ready (pings engagement DB + core DB); DO GET /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)

  1. Create Neon DB vlozi-engagement (same project as forms); note ENGAGEMENT_DATABASE_URL.
  2. pnpm --filter engagement-service generate && migrate against it.
  3. Secrets file (.env style KEY=VALUE or JSON {"KEY":"value"}) with ENGAGEMENT_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. Prefer wrangler deploy --secrets-file <file> on the first deploy so secrets land with the code and /ready never fails in between; otherwise wrangler secret bulk <file> (never pipe to secret put on Windows). Add the same ENGAGEMENT_INTERNAL_TOKEN to blog-service secrets.
  4. pnpm --filter engagement-service deploy → worker live (DO migration v1 applied).
  5. Add the [[services]] ENGAGEMENT_SERVICE block to apps/gateway/wrangler.toml now (a binding to a non-existent worker fails the deploy) and pnpm --filter gateway deploy (binding, CORS before the limiter, beacon limiter, x-user-role strip).
  6. pnpm --filter blog-service deploy (id in payloads, internal targets route, engagement binding + notify middleware, get_analytics).
  7. Insert a tenant_services row for engagement for the staging/dogfood tenant by hand (the admin door 403s until P4 provisions everyone).
  8. Dashboard: push to main → Vercel auto-deploy (no new Vercel variable; the proxy reuses NEXT_PUBLIC_GATEWAY_URL).
  9. Run backfill-engagement.ts then backfill-engagement-limits.ts against the prod core DB; users re-login.
  10. SDK: merge the changeset → merge the bot's "Version Packages" PR → release.yml publishes @vlozi/blog@2.2.0 (CI already runs test:coverage, build, size, npm pack --dry-run); bump apps/website dependency; set NEXT_PUBLIC_ENGAGE_SITE_ID in the local .env.production; pnpm --filter website deploy (Pages).
  11. Smoke: curl a beacon against the prod site id → summary shows +1 in ≤ 2 min → dashboard Overview → mcp__vlozi__blog_get_analytics.
  12. Later phases add: billing-crons redeploy (P6; POST /start is idempotent via ensureArmed, 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.md needs name (apps/public-docs/lib/docs.ts:72) and changelog entries need date (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)

  1. Pricing: 0.1 cr per published comment + plan caps; alternatives free-under-caps or forms parity 0.25.
  2. Default comments mode: guest + first-time hold + previously-approved auto-publish.
  3. AI moderation default: on, fail-to-pending.
  4. Reaction vocabulary: single "like" by default.
  5. Public visibility defaults: hide views < 10, rating < 3 votes, always show comment count.
  6. Who moderates: members (editors) too, via MEMBER_WRITE_SERVICES.
  7. Sidebar label: "Engagement" (alternatives Audience, Community).
  8. ip_hash retention 30 days; export CSV format = forms'.
  9. 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
Engagement