Last Updated: 2026-06-23 Status: Draft (v1 — Web3Forms parity) Scope:
apps/forms-servicebackend + 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)
- Backend worker
apps/forms-service— scaffold → schema/migration → public submit → spam guard → admin CRUD → submissions inbox → comms notify. - Gateway wiring —
forms.proxy.ts(public/f/*any-origin + admin/forms*JWT) + service binding +accessMiddleware("forms"). - Seller dashboard —
formsmodule (service-registry entry, API client, Redux slice, Forms list, Form editor, Submissions inbox). - 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):
forms—id(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(jsonbstring[]),honeypotField(defaultbotcheck),captchaRequired(bool),status(active|paused|archived),createdBy, timestamps,deletedAt.form_submissions—id(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:
- Parse body for both content types —
application/json→c.req.json();application/x-www-form-urlencoded/multipart/form-data→c.req.parseBody(). This is the key Web3Forms-parity detail (plain HTML form posts and AJAX). - Load form (cached per-isolate Map, short TTL) by
form_id; 404 if missing/archived. DerivetenantIdfrom the row. allowed_originscheck — compare requestOrigin(same clean-compare asapi-key.middleware.tsdomain-lock) againstform.allowedOrigins; empty list = allow any.- Honeypot — if
body[form.honeypotField]is non-empty → recordstatus:spam,spamReason:"honeypot", return200 {success:true}(silent, like Web3Forms). - Rate limit —
c.env.FORM_RATE_LIMITER.limit({ key: \${form_id}:${ip}` }); over limit →429 {success:false,message:"Too many requests..."}`. - Turnstile — if
form.captchaRequired, readcf-turnstile-responsefrom body, POST tohttps://challenges.cloudflare.com/turnstile/v0/siteverifywithTURNSTILE_SECRET_KEY+remoteip; fail →400. - Validate — rebuild a Zod object from
form.schema(helperzodFromSchema()insrc/services/schema.ts) andsafeParsethe reserved-field-stripped body; fail →400 {success:false,message}. - Insert
form_submissions(status:new), strip reserved fields (access_key-equivalents,botcheck,cf-turnstile-response,redirect,subject,from_name,replyto) fromdatabut keep them as structured columns/meta. - Notify (optional) — if
form.notify.email.enabled,c.executionCtx.waitUntil(sendNotification(...))(non-blocking, §1.6). - Respond — Web3Forms-compatible
200 {success:true, message: form.settings.successMessage, data}; ifredirectpresent and request is a plain form post →303to 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 viadeletedAt),POST /:id/duplicate.submissions.ts:GET /forms/:id/submissions(filterstatus, paginate,submittedAt DESC),GET /submissions/:id,PATCH /submissions/:id(status: seen|handled|spam, setshandledAt),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.export1.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
src/routes/forms.proxy.ts(new) — built:- Public:
formsProxy.all("/f/*", forward)— no auth middleware;buildDownstreamHeaders(c, "/forms")strips/formsand injectsx-gateway-key(also passes through inboundorigin/cf-connecting-ip). Mapsapi.vlozi.app/forms/f/:id→ forms-service/f/:id. - Admin:
formsProxy.use("/admin/*", authMiddleware)+subscriptionGuard, thenforward→ forms-service/admin/*. Mapsapi.vlozi.app/forms/admin/*→/admin/*. - Fallback
formsProxy.all("*", 404). accessMiddleware("forms")intentionally deferred (TODO in the file):formsis not yet aServiceCodeand no JWT carries a forms entitlement, so gating now would 403 every tenant. Re-add once forms is provisioned (item 4).
- Public:
src/index.ts— built: any-origin CORS for/forms/f/*(mirrors/blog/public/*),app.route("/forms", formsProxy),FORMS_SERVICEadded to the/healthecho.wrangler.toml— built:[[services]] binding = "FORMS_SERVICE" service = "vlozi-forms-service";FORMS_SERVICE: Fetcheradded toGatewayBindingsinsrc/types.ts. Gatewaycheck-typespasses.Public form URL is
api.vlozi.app/forms/f/:idin v1 (no new hostname needed).forms.vlozi.app/f/:idis a Phase-2 vanity route — add aroutes=custom domain then.accessMiddleware/entitlements (follow-up): addformsto@repo/core-typesServiceCode+ the manager entitlement catalog + tenant_services provisioning, then restoreaccessMiddleware("forms")informs.proxy.tsto gate forms as a billable service.
3. Seller Dashboard — apps/seller-dashboard
Mirror the blog module exactly (src/modules/blog/).
src/lib/service-registry.ts— add aformsentry:label "Forms",basePath /dashboard/forms,status "beta", navItems Overview / Forms / Submissions / Settings. (Sidebar auto-renders it.)src/app/api/forms/[...slug]/route.ts— copy the blog[...slug]proxy (NextAuth JWT → gateway/forms/*with Bearer).src/modules/forms/:api/forms.api.ts—fetchForms,getForm,createForm,updateForm,deleteForm,fetchSubmissions(formId, params),updateSubmissionStatus,exportSubmissions(allfetch("/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).
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)
- Local boot: run
wrangler devforforms-service(8796),communication(8790),gateway(8788).GET /forms/healthvia gateway →200. - Create form (admin): with a dashboard JWT,
POST /forms→ returnsform_…id. Confirm row + slug in Neon. - HTML submit (parity):
curl -X POST api.vlozi.app/forms/f/<id> -d "email=a@b.com&message=hi"→200 {success:true,...}; row inform_submissionswithstatus:new. - JSON submit: same with
-H "Content-Type: application/json" -d '{"email":"a@b.com","message":"hi"}'→ identical result. - Honeypot: include
botcheck=x→200but row storedstatus:spam(not in inbox). - Rate limit: >20 posts/min from one IP →
429. - Turnstile: set
captchaRequired, submit without token →400; with a valid test token →200. - Notify: enable
notify.email, submit → email arrives via Resend;idempotencyKey form:<sub>present in comms logs. - Inbox: dashboard Forms → Submissions shows the new rows; mark handled flips
status/handledAt; export returns CSV. - Origin lock: set
allowedOrigins:["example.com"], submit with a differentOrigin→ 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) |