logicspike/docs

Forms

Forms Service — Implementation Plan (Phase 1: Web3Forms Parity)

Last Updated: 2026-06-28 Status: Active (Phase 1 Complete) Scope: apps/forms-service backend + gateway wiring + seller-dashboard inbox Companion docs: architecture.md · database.md · api-spec.md · permissions.md · embedding.md · webhooks.md · billing.md

This plan mirrors apps/blog-service conventions verbatim (Hono app, dbMiddleware, gateway-key guard, requireTenant/requirePermission, ServiceResult<T> + jsonError, Drizzle/Neon, structured logger). File paths below are relative to repo root logicspike/.


Phase 1 Completion Status

Item Status
Backend worker scaffold (apps/forms-service) ✅ Done
Database schema + migrations (5 tables) ✅ Done
Public submit endpoint (POST /f/:form_id) ✅ Done
Spam guard (honeypot · Turnstile · rate limit) ✅ Done
Admin forms CRUD + duplicate ✅ Done
Admin submissions inbox + status update + delete + reply ✅ Done
Submissions export (CSV / JSON) ✅ Done
Comms notification (owner email on submit) ✅ Done
Webhook fan-out (3-attempt retry, HMAC signing, delivery log) ✅ Done
File uploads via Media Service ✅ Done
Credit gate + fractional accumulator metering ✅ Done
Analytics (tenant overview + per-form) ✅ Done
View tracking (form_views_daily) ✅ Done
Embed widget (embed.js) + hosted page (/f/:id/page) ✅ Done
@vlozi/forms SDK + React subpackage ✅ Done (unpublished)
MCP tools (15 tools mirroring REST surface) ✅ Done
Gateway wiring (public + admin proxy) ✅ Done
Seller dashboard forms module ✅ Done
Permissions (forms:read, forms:write, forms:submissions.export) ✅ Done

Deferred to Phase 2–5:

  • Newsletter opt-in on submission (notify.newsletterListId stored, not triggered)
  • Meta enrichment (referer/UTM captured, Phase 3 enrichment pipeline not wired)
  • accessMiddleware("forms") entitlement gate in gateway (TODO comment in forms.proxy.ts)
  • forms.vlozi.app vanity hostname (Phase 2 custom domain)

Known Issues & Gaps

Full detail, scores, and improvement priority tiers in backlog.md. Summary:

Bugs

# Severity Location Issue
B1 Medium submit.ts Captcha failure returns 400 (bot learns it was caught); honeypot returns silent 200 — inconsistent response envelope
B2 Low webhook.ts Manual retry creates a new delivery row but the original failed row stays failed — misleading history
B3 Medium credits.ts Fails open if CORE_DATABASE_URL unavailable — submissions accepted without billing check; metering also fails silently
B4 Low embed.js + submit.ts captchaRequired=true with no turnstileSiteKey → widget not rendered, form submits without captcha, server returns 400 — form is completely broken silently
B5 Low credits.ts + overview Plan-based monthly cap surfaced in analytics but not enforced in submit path — displays a limit that has no effect
B6 Low forms.ts Webhook secret cannot be rotated without clearing and re-setting webhookUrl
B7 Low submit.ts Transient Media Service failure stores filename string instead of {__file} object — CSV export renders inconsistently, uploaded file is lost
B8 Low submissions/reply Idempotency key uses Date.now() — millisecond-level collision possible on rapid double-submit
B9 Low forms.ts validation honeypotField can be set to the same name as a real field — silently marks all submissions as spam
B10 Low apps/gateway/forms.proxy.ts accessMiddleware("forms") intentionally deferred — forms not yet a provisioned ServiceCode; any valid tenant JWT grants access

Gaps

# Priority Issue
G1 HIGH notify.newsletterListId stored but never triggered — newsletter opt-in on submission not wired (Phase 4 deferred)
G2 Medium No GDPR right-to-erasure for submissions (/submissions/erase-by-email missing)
G3 Medium No webhook secret rotation endpoint (complement to B6)
G4 Medium No per-form rate limit configuration — all forms share 20/60s
G5 Medium No conditional field logic (show/hide based on other field values)
G6 Medium No GDPR-compliant anonymised export (?anonymise=true)
G7 Low No audit trail for submission status changes
G8 Low No embed.js version pinning — breaking changes go live immediately for all embeds
G9 Low notify.newsletterListId not validated against newsletter service on save
G10 Low No "form closed" message for paused forms
G11 Low No multi-step / multi-page forms
G12 Low Reply email uses platform sender; submitter's reply goes to Vlozi, not tenant
G13 Low embed.js does not detect paused form before render (user fills out form that will 403)
G14 Low No submission deduplication (double-click / network retry stores two rows + bills twice)

0. Build Order (high level)

  1. Backend worker apps/forms-service — scaffold → schema/migration → public submit → spam guard → admin CRUD → submissions inbox → comms notify.
  2. Gateway wiringforms.proxy.ts (public /f/* any-origin + admin /forms* JWT) + service binding + accessMiddleware("forms").
  3. Seller dashboardforms module (service-registry entry, API client, Redux slice, Forms list, Form editor, Submissions inbox).
  4. Permissions — add forms:* PBAC strings to the role bundles.

Phases 2–5 (SDK, intelligence, action/webhooks, advanced builder) are tracked in forms-vision.md §9 and out of scope here.


1. Backend — apps/forms-service

1.1 Scaffold (copy from blog-service)

File Source to copy Change
package.json apps/blog-service/package.json name: "forms-service"; drop blog-only deps (hast-util-to-html, highlight.js, lowlight); keep hono, @hono/zod-validator, @neondatabase/serverless, drizzle-orm, drizzle-kit, zod, @repo/core-database, @repo/core-types, wrangler, vitest
drizzle.config.ts blog-service url: process.env.FORMS_DATABASE_URL!
tsconfig.json blog-service unchanged
src/context.ts blog-service keep context + db; drop coreDb/creditGuardRefund (no billing in v1)
src/db/client.ts blog-service verbatim (createDb, QueryLogger)
src/middleware/db.middleware.ts blog-service single DB (FORMS_DATABASE_URL); drop the core-DB block
src/middleware/auth.middleware.ts blog-service verbatim (requirePermission, requireTenant)
src/utils/errors.ts, src/utils/logger.ts, src/services/result.ts blog-service verbatim (logger service: "forms-service")

1.2 Database schema — src/db/schema.ts

Tables (full columns/indexes in database.md). Drizzle style mirrors blogPosts (tenant-scoped, partial unique on live rows, hot-path indexes):

  • formsid (form_{uuid}), tenantId, name, slug (unique per live tenant), schema (jsonb — Zod-as-JSON field defs), settings (jsonb — successMessage, redirectUrl, theme), notify (jsonb — email.enabled, email.to, newsletterListId), webhookUrl, allowedOrigins (jsonb string[]), honeypotField (default botcheck), captchaRequired (bool), status (active|paused|archived), createdBy, timestamps, deletedAt.
  • form_submissionsid (sub_{uuid}), formId (FK→forms, cascade), tenantId, data (jsonb), meta (jsonb — geo/ua/referrer/utm placeholders), status (new|seen|handled|spam), spamReason (nullable), sourceIp, userAgent, submittedAt, handledAt. Indexes: (tenantId, formId, submittedAt), (tenantId, status).
  • form_webhook_deliveries (scaffold only, wired in Phase 4) — as in the vision ER.

Generate the migration: npm run generate → commit drizzle/0000_*.sql.

1.3 ID + slug helpers — src/services/form.utils.ts

Copy generatePostId/slugify/generateUniqueSlug/appendSlugSuffix/isSlugUniqueViolation from apps/blog-service/src/services/post.utils.ts; rename post_form_, retarget the unique-violation check to forms_tenant_slug_unique.

1.4 Public submit — src/routes/public/submit.ts

Route mounted at /f. POST /f/:form_id:

  1. Parse body for both content typesapplication/jsonc.req.json(); application/x-www-form-urlencoded / multipart/form-datac.req.parseBody(). This is the key Web3Forms-parity detail (plain HTML form posts and AJAX).
  2. Load form (cached per-isolate Map, short TTL) by form_id; 404 if missing/archived. Derive tenantId from the row.
  3. allowed_origins check — compare request Origin (same clean-compare as api-key.middleware.ts domain-lock) against form.allowedOrigins; empty list = allow any.
  4. Honeypot — if body[form.honeypotField] is non-empty → record status:spam, spamReason:"honeypot", return 200 {success:true} (silent, like Web3Forms).
  5. Rate limitc.env.FORM_RATE_LIMITER.limit({ key: \${form_id}:${ip}` }); over limit → 429 {success:false,message:"Too many requests..."}`.
  6. Turnstile — if form.captchaRequired, read cf-turnstile-response from body, POST to https://challenges.cloudflare.com/turnstile/v0/siteverify with TURNSTILE_SECRET_KEY + remoteip; fail → 400.
  7. Validate — rebuild a Zod object from form.schema (helper zodFromSchema() in src/services/schema.ts) and safeParse the reserved-field-stripped body; fail → 400 {success:false,message}.
  8. Insert form_submissions (status:new), strip reserved fields (access_key-equivalents, botcheck, cf-turnstile-response, redirect, subject, from_name, replyto) from data but keep them as structured columns/meta.
  9. Notify (optional) — if form.notify.email.enabled, c.executionCtx.waitUntil(sendNotification(...)) (non-blocking, §1.6).
  10. Respond — Web3Forms-compatible 200 {success:true, message: form.settings.successMessage, data}; if redirect present and request is a plain form post → 303 to that URL.

GET /f/:form_id/schema → returns { id, name, schema, settings.successMessage } for SDK/widget rendering (Phase 2 consumer; cheap to ship now).

1.5 Admin routes — src/routes/admin/forms.ts + src/routes/admin/submissions.ts

Thin Hono handlers over a service layer returning ServiceResult<T> (blog categories.ts pattern):

  • forms.ts: POST / create, GET / list, GET /:id, PUT /:id (schema/settings/notify/origins/captcha), DELETE /:id (soft-delete via deletedAt), POST /:id/duplicate.
  • submissions.ts: GET /forms/:id/submissions (filter status, paginate, submittedAt DESC), GET /submissions/:id, PATCH /submissions/:id (status: seen|handled|spam, sets handledAt), GET /forms/:id/submissions/export (CSV/JSON stream).

1.6 Comms notification — src/services/notify.ts

Copy the newsletter→comms call verbatim (apps/newsletter-service/src/lib/campaign-send.ts):

await env.COMMS_SERVICE.fetch("http://internal/internal/send", {
  method: "POST",
  headers: { "Content-Type": "application/json", "x-internal-key": env.INTERNAL_KEY ?? "" },
  body: JSON.stringify({
    channel: "email",
    to: form.notify.email.to,
    subject: `New submission: ${form.name}`,
    content: { html: renderSubmissionEmail(form, submission), text: renderSubmissionText(submission) },
    tenantId,
    idempotencyKey: `form:${submission.id}`,
    metadata: { formId: form.id, submissionId: submission.id },
  }),
})

Newsletter opt-in (form.notify.newsletterListId) is documented but deferred to Phase 4 unless you want it now.

1.7 App entry — src/index.ts

Copy blog index.ts structure: onError cause-chain handler, /ready + /health, logger, dbMiddleware, gateway-key guard + context hydration. Difference from blog: mount /f before requireTenant (public path has no JWT tenant); apply requireTenant + requirePermission("forms:*") only on /admin/* and /forms/*. Permission guards:

GET    /forms, /forms/:id, /forms/:id/submissions, /submissions/:id  → forms:read
POST   /forms, /forms/:id/duplicate                                  → forms:write
PUT    /forms/:id        PATCH /submissions/:id                      → forms:write
DELETE /forms/:id                                                    → forms:write
GET    /forms/:id/submissions/export                                 → forms:submissions.export

1.8 wrangler.toml

name = "vlozi-forms-service"
main = "src/index.ts"
compatibility_date = "2024-04-01"
compatibility_flags = ["nodejs_compat"]
 
[dev]
port = 8796   # next free port after lead-funnel (8795)
 
[[services]]
binding = "COMMS_SERVICE"
service = "logicspike-communication"
 
[[ratelimits]]
name = "FORM_RATE_LIMITER"
namespace_id = "2001"
simple = { limit = 20, period = 60 }

Secrets via .dev.vars / wrangler secret put: FORMS_DATABASE_URL, GATEWAY_SECRET, INTERNAL_KEY, TURNSTILE_SECRET_KEY.


2. Gateway — apps/gateway ✅ DONE

  1. src/routes/forms.proxy.ts (new) — built:
    • Public: formsProxy.all("/f/*", forward) — no auth middleware; buildDownstreamHeaders(c, "/forms") strips /forms and injects x-gateway-key (also passes through inbound origin/cf-connecting-ip). Maps api.vlozi.app/forms/f/:id → forms-service /f/:id.
    • Admin: formsProxy.use("/admin/*", authMiddleware) + subscriptionGuard, then forward → forms-service /admin/*. Maps api.vlozi.app/forms/admin/*/admin/*.
    • Fallback formsProxy.all("*", 404).
    • accessMiddleware("forms") intentionally deferred (TODO in the file): forms is not yet a ServiceCode and no JWT carries a forms entitlement, so gating now would 403 every tenant. Re-add once forms is provisioned (item 4).
  2. src/index.ts — built: any-origin CORS for /forms/f/* (mirrors /blog/public/*), app.route("/forms", formsProxy), FORMS_SERVICE added to the /health echo.
  3. wrangler.toml — built: [[services]] binding = "FORMS_SERVICE" service = "vlozi-forms-service"; FORMS_SERVICE: Fetcher added to GatewayBindings in src/types.ts. Gateway check-types passes.

    Public form URL is api.vlozi.app/forms/f/:id in v1 (no new hostname needed). forms.vlozi.app/f/:id is a Phase-2 vanity route — add a routes= custom domain then.

  4. accessMiddleware/entitlements (follow-up): add forms to @repo/core-types ServiceCode + the manager entitlement catalog + tenant_services provisioning, then restore accessMiddleware("forms") in forms.proxy.ts to gate forms as a billable service.

3. Seller Dashboard — apps/seller-dashboard

Mirror the blog module exactly (src/modules/blog/).

  1. src/lib/service-registry.ts — add a forms entry: label "Forms", basePath /dashboard/forms, status "beta", navItems Overview / Forms / Submissions / Settings. (Sidebar auto-renders it.)
  2. src/app/api/forms/[...slug]/route.ts — copy the blog [...slug] proxy (NextAuth JWT → gateway /forms/* with Bearer).
  3. src/modules/forms/:
    • api/forms.api.tsfetchForms, getForm, createForm, updateForm, deleteForm, fetchSubmissions(formId, params), updateSubmissionStatus, exportSubmissions (all fetch("/api/forms/...", { credentials: "include" })).
    • store/forms.slice.ts + hooks/useFormsStore.ts + register.ts (dynamic reducer, blog pattern).
    • pages/FormsList.tsx, pages/FormEditor.tsx (name, fields, settings, notify toggle, origins, Turnstile toggle, embed-snippet generator), pages/SubmissionsInbox.tsx (filter by status, detail drawer, mark handled, export).
  4. src/app/dashboard/forms/ route files wrapping the dynamic module pages.

The embed snippet the editor shows (the Web3Forms litmus test):

<form action="https://api.vlozi.app/forms/f/form_abc123" method="POST">
  <input type="email" name="email" required />
  <textarea name="message" required></textarea>
  <input type="checkbox" name="botcheck" style="display:none" tabindex="-1" autocomplete="off" />
  <button type="submit">Send</button>
</form>

4. Permissions

Add to the role/permission catalog (where blog:posts.* bundles live, manager + @repo/core-types): forms:read, forms:write, forms:submissions.export. Bundle forms:read+forms:write into the owner/admin/editor roles; system:owner already bypasses.


5. Verification (end-to-end)

  1. Local boot: run wrangler dev for forms-service (8796), communication (8790), gateway (8788). GET /forms/health via gateway → 200.
  2. Create form (admin): with a dashboard JWT, POST /forms → returns form_… id. Confirm row + slug in Neon.
  3. HTML submit (parity): curl -X POST api.vlozi.app/forms/f/<id> -d "email=a@b.com&message=hi"200 {success:true,...}; row in form_submissions with status:new.
  4. JSON submit: same with -H "Content-Type: application/json" -d '{"email":"a@b.com","message":"hi"}' → identical result.
  5. Honeypot: include botcheck=x200 but row stored status:spam (not in inbox).
  6. Rate limit: >20 posts/min from one IP → 429.
  7. Turnstile: set captchaRequired, submit without token → 400; with a valid test token → 200.
  8. Notify: enable notify.email, submit → email arrives via Resend; idempotencyKey form:<sub> present in comms logs.
  9. Inbox: dashboard Forms → Submissions shows the new rows; mark handled flips status/handledAt; export returns CSV.
  10. Origin lock: set allowedOrigins:["example.com"], submit with a different Origin → rejected.

6. Net-new vs reused (summary)

Reused as-is Net new (forms-service)
dbMiddleware, auth.middleware, errors, logger, result, db/client (copied) forms/form_submissions schema + migration
Gateway authMiddleware, accessMiddleware, subscriptionGuard, buildDownstreamHeaders, proxyFetch, any-origin cors() forms.proxy.ts, public submit + spam guard, admin CRUD, submissions inbox
Comms /internal/send (Resend) notify.ts, zodFromSchema/schema.ts, embed-snippet generator
Dashboard module/registry/proxy patterns modules/forms/*, dashboard routes
api_keys, billing ledger (intentionally not used in v1 public path)
Forms