logicspike/docs

forms

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

Last Updated: 2026-06-23 Status: Draft (v1 — Web3Forms parity) Scope: apps/forms-service backend + gateway wiring + seller-dashboard inbox Companion docs: architecture.md · database.md · api-spec.md · domain-model.md · deployment.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/.


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