Last Updated: 2026-06-23 Status: Draft (v1 — Web3Forms parity) Service:
apps/forms-service(planned) Companion docs: forms-vision.md · domain-model.md · api-spec.md · database.md · deployment.md
1. What This Service Is
Forms Service is vlozi's multi-tenant form backend — the Web3Forms-grade capture layer described in forms-vision.md. A tenant creates a form, gets a public access key, and embeds a plain <form> (or AJAX/JSON call) on any website. The service authenticates the key, filters spam, stores the submission, and notifies the owner.
It is net-new code only at the Forms Service layer. Identity (Contact Intelligence), email delivery (Communication), file storage (Media/R2), and auth/rate-limiting (Gateway) are existing platform services consumed over internal APIs.
NOTE
v1 targets Web3Forms parity (Pillar 1 — Capture + basic Action). The Intelligence pillar (enrichment, lead scoring, intent/mood) and advanced Action (chatbot handoff, auto-reply) are later phases — see forms-vision.md §9.
2. Where It Sits in the Platform
3. Tech Stack
| Layer | Technology |
|---|---|
| Runtime | Cloudflare Workers |
| Framework | Hono v4 |
| Database | Neon PostgreSQL (serverless) |
| ORM | Drizzle ORM (drizzle-orm/neon-serverless) |
| Validation | Zod + @hono/zod-validator |
| Migrations | drizzle-kit |
| Spam | Honeypot field · Cloudflare Turnstile · native Workers rate-limit binding |
| Email notify | communication service /internal/send (Resend) — via service binding |
| Auth (public) | form_id in the URL is the public identifier (non-secret, safe in client code); tenant derived from the form row |
| Auth (admin) | Gateway-issued x-gateway-key + JWT tenant context headers (x-tenant-id, x-user-id, x-user-permissions) |
4. Two Request Surfaces
The service exposes two distinct surfaces, mirroring the blog service's public vs admin split.
| Surface | Caller | Auth | Routes |
|---|---|---|---|
| Public submit | Any website / visitor | form_id in URL (public identifier) + per-form allowed_origins / honeypot / Turnstile / rate-limit |
POST /f/:form_id, GET /f/:form_id/schema |
| Admin | Seller dashboard (logged-in tenant) | JWT → gateway → x-gateway-key + tenant context |
/forms*, /submissions* CRUD + inbox |
Both reach forms-service only through the gateway — direct worker access is rejected unless the x-gateway-key matches GATEWAY_SECRET.
IMPORTANT
The public submit path does not use the gateway apiKeyMiddleware: that middleware marks publishable keys read-only and rejects POST. Instead the form_id is itself the public credential (like Web3Forms' /submit/{form_id}) — safe to embed in client HTML. forms-service resolves form_id → tenant_id from its own forms table, so no tenant context is needed from the gateway on this path. Abuse is contained by allowed_origins (CORS), honeypot, Turnstile, and rate limiting — not by a secret.
5. Middleware Pipeline
Requests pass through the following layers in order (same pattern as apps/blog-service):
1. Logger → hono/logger
2. DB Middleware → create/cache Neon connection from FORMS_DATABASE_URL
3. Gateway Guard → validate x-gateway-key against GATEWAY_SECRET
hydrate RequestContext from trusted headers
(admin: x-tenant-id, x-user-id, x-user-permissions)
4a. Public submit → load form by :form_id → derive tenant_id from the row
→ allowed_origins (CORS) check
→ Spam Guard (honeypot → rate limit → Turnstile if enabled)
4b. Admin → requireTenant → requirePermission("forms:read|write|submissions.export")
5. Route handlerOn the public submit path there is no api-key resolution: forms-service loads the form by
:form_idand readstenant_idstraight off the row (form definitions are cached per-isolate with a short TTL, mirroring blog-service's DB-cache pattern). The admin path is gateway-authenticated exactly like blog-service's/admin/*. See api-spec.md §Auth.
6. Submission Flow (Public POST /f/:form_id)
Failure outcomes: honeypot tripped or Turnstile fail → recorded as status=spam (not surfaced) or 400; validation error → 400 { success:false, message }; rate limit → 429. Response shape is Web3Forms-compatible ({ success, message, data }) so existing Web3Forms front-ends work unchanged. Full matrix in api-spec.md.
7. Service Boundaries
| Concern | Owner |
|---|---|
| Form CRUD, submission ingestion, spam filtering, settings, webhook fan-out | Forms Service (apps/forms-service) — net new |
CORS (any-origin for /f/*), IP rate limiting, gateway-key injection |
Gateway (existing) |
Form-level abuse control: allowed_origins, honeypot, Turnstile, per-form rate limit |
Forms Service (net new) |
| Email / Slack notifications | Communication Service (existing, /internal/send) |
| Newsletter opt-in | Newsletter Service (existing) |
| File uploads (Phase 2, signed R2 URLs) | Media Service (existing) |
| Contact merge, lead scoring, intent (Phase 3) | Contact Intelligence / AI Brain (existing) |
8. Gateway Integration
The gateway mounts forms-service and binds the worker:
forms.vlozi.app/f/* → cors(origin:"*", POST/GET/OPTIONS) → forms.proxy (no api-key mw) → forms-service /f/*
api.vlozi.app/forms* → authMiddleware (JWT) → accessMiddleware("forms") → forms.proxy → forms-service (admin)The public /f/* block follows the same any-origin cors() pattern the gateway already uses for /blog/public/* and /newsletter/public/* in apps/gateway/src/index.ts (just adding POST). The admin path mirrors blog.proxy.ts (authMiddleware → accessMiddleware → subscriptionGuard → buildDownstreamHeaders → proxyFetch).
# apps/gateway/wrangler.toml (added)
[[services]]
binding = "FORMS_SERVICE"
service = "vlozi-forms-service"Headers buildDownstreamHeaders() forwards: x-gateway-key, x-request-id; (admin) x-tenant-id, x-user-id, x-user-permissions. The public path additionally relies on the inbound origin, cf-connecting-ip, user-agent, and referer (passed through by the proxy).
9. Environment & Bindings
| Variable / Binding | Required | Purpose |
|---|---|---|
FORMS_DATABASE_URL |
✅ | Neon connection string (forms tables) |
GATEWAY_SECRET |
✅ | Validate x-gateway-key from gateway |
TURNSTILE_SECRET_KEY |
✅ | Server-side Turnstile siteverify |
INTERNAL_KEY |
✅ | x-internal-key shared secret for the comms /internal/send call (same value as newsletter↔comms) |
COMMS_SERVICE (binding) |
✅ | Service binding to logicspike-communication (http://internal/internal/send) |
FORM_RATE_LIMITER (binding) |
✅ | Native Workers [[ratelimits]] binding for per-form/per-IP submit limits |
DEBUG |
❌ | Verbose context logging |
Full setup, ports, and deploy steps in deployment.md.
10. Design Constraints (v1)
- No new auth primitive — the public
form_idis the credential (no secret, no api-key middleware change); the admin path reuses the existing JWT/gateway flow. - Web3Forms response compatibility — public submit returns
{ success, message, data }and honorsredirect. - Stateless worker — all state in Neon; form definitions cached per-isolate with short TTL.
- Dashboard-first delivery — every submission is stored and visible in the inbox; email is an opt-in per-form setting, never the only record.
- Spam without friction — honeypot + rate limit are always on; Turnstile is per-form opt-in for high-spam endpoints.