logicspike/docs

forms

Forms Service — Architecture

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 handler

On the public submit path there is no api-key resolution: forms-service loads the form by :form_id and reads tenant_id straight 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_id is 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 honors redirect.
  • 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.
forms