Last Updated: 2026-06-28 Status: Active (Phase 1 Complete) Scope:
apps/forms-servicebackend + 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.newsletterListIdstored, not triggered) - Meta enrichment (referer/UTM captured, Phase 3 enrichment pipeline not wired)
accessMiddleware("forms")entitlement gate in gateway (TODO comment informs.proxy.ts)forms.vlozi.appvanity 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)
- 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) |