logicspike/docs

Forms

Forms Service — Backlog: Bugs, Gaps & Improvement Scope

Last Updated: 2026-06-28 Status: Active Purpose: Known bugs, gaps, and improvement opportunities found during codebase analysis. Mark items ✅ with date when fixed. Do not delete fixed items.


Scorecard

# Parameter Score Notes
1 API Design 9.0/10 Three clean surfaces (public/admin/MCP), Web3Forms compat, good error codes, paginated inbox. Minor: webhook retry MCP-only, no reply idempotency.
2 Data Model 9.0/10 5 well-designed tables, soft-delete, cascade, partial unique slug index, good hot-path indexes. Minor: no audit trail for status changes.
3 Security & Auth 8.5/10 HMAC webhook signing, honeypot, Turnstile, rate limiting, origin check. B4 (captcha misconfiguration silent) and B9 (honeypot name collision) ding this.
4 Multi-tenancy 10/10 Perfect isolation. tenant_id on every table, derived from form row on public path. No cross-tenant leaks possible.
5 Spam Protection 8.0/10 Three layers (honeypot → rate limit → Turnstile). Silent 200 on honeypot is correct. B1 (captcha failure leaks which defense fired) slightly weakens this.
6 File Uploads 7.5/10 Multi-file (5 × 25 MB), fail-soft on transient error. B7 (CSV inconsistency on failed uploads) is a visible data quality issue. No per-form file type restrictions.
7 Billing Integration 8.5/10 Accumulator model elegant (thousandths avoid fractional credits), settlement idempotency via seq, fail-open documented. Plan cap read but not enforced.
8 Webhooks 7.5/10 HMAC signing (correct), 3 retries, delivery log. B6 (no secret rotation), B2 (misleading retry history), MCP-only retry UI.
9 Analytics 7.5/10 View tracking, submission counts, conversion rate, daily series, credit snapshot. Missing: time-series views in overview (30d count only), no multi-form comparison.
10 Embedding 9.5/10 Four surfaces (hosted page, script embed, SDK, plain HTML), all work. No version pinning on embed.js is a mild ops risk.
11 Testing 9.0/10 11 test files, PGLite in-memory integration tests, covers all major paths including spam, file upload, billing, MCP.
12 MCP Tools 8.5/10 15 tools mirroring all REST surfaces. Webhook retry is MCP-only (REST gap).
13 Feature Completeness 7.0/10 Core capture loop solid. Newsletter opt-in stored not triggered, no GDPR erasure, no conditional fields, no per-form rate config.
14 Observability 7.0/10 Structured logging, analytics endpoint, delivery log. No CF Analytics Engine wiring, no real-time ops metrics, no submission change history.

Overall: 8.2 / 10

The forms service is the platform's most polished new feature. The core loop (capture → store → notify → webhook → meter) is solid and well-tested. The score is held back by a cluster of low-severity bugs around webhook management, a few silent misconfigurations (B4, B9), and genuinely missing features in the roadmap (newsletter opt-in, GDPR, conditional logic) that were deferred to later phases by design.


Bugs (Broken or Subtly Wrong)

B1 — Captcha failure leaks which defense fired MEDIUM

Where: apps/forms-service/src/routes/public/submit.ts

When a honeypot field is tripped, the service returns 200 { success: true } (silent — bot doesn't learn it was caught). But when Turnstile verification fails, it returns 400 { success: false, message: "Captcha verification failed" }. This inconsistency tells a sophisticated attacker exactly which defense layer blocked them: a 200 means honeypot; a 400 means captcha.

For maximum friction, captcha failures should also return a silent 200 (or at least a generic 400 "Submission failed" without naming Turnstile). The stored spam record can still use spamReason: "captcha" internally.

Fix: Return 200 { success: true } on captcha failure too, store as spam. Or: return 400 on both but with a generic message ("Submission could not be processed") that doesn't reveal which guard fired.


B2 — Manual webhook retry creates new row; failed row stays failed LOW

Where: apps/forms-service/src/services/webhook.ts

When a webhook delivery fails and a tenant retries via POST /admin/webhook-deliveries/:id/retry (or MCP retry-webhook-delivery), the service inserts a new form_webhook_deliveries row for the retry. The original failed row's status stays failed permanently. The delivery log shows both: a failed row with no resolution, and a new row with the retry outcome.

This misleads ops: a delivery log with a "failed" row and then a "success" row looks like two attempts where one failed, not one failed attempt that was later manually retried.

Fix: On manual retry, update the original row's status to retrying, then insert the new attempt row. Or: link the new row to the original via a parent_id FK so the history chain is explicit.


B3 — Credit gate fails open if CORE_DATABASE_URL is unavailable MEDIUM

Where: apps/forms-service/src/services/credits.ts (canSubmissionProceed)

If CORE_DATABASE_URL is not set or the core DB is unreachable, canSubmissionProceed returns true unconditionally. This is a deliberate availability trade-off (capture > billing accuracy during infra wobble). However, it also means a tenant with zero credits continues to receive submissions during a core DB outage, and the metering step also fails silently — those submissions are never billed.

There is no alert, no reconciliation process, and no cap on how long this can go on.

Fix (long-term): Add a BILLING_FAIL_OPEN_WINDOW_MS config. After the first miss, log an alert-level event. For tenants on free plans, enforce a hard submission count cap using the forms DB only (no core DB needed), so capture doesn't run unbounded.


B4 — captchaRequired=true with no turnstileSiteKey silently bypasses client-side captcha LOW

Where: apps/forms-service/src/lib/embed.ts, apps/forms-service/src/routes/public/submit.ts

If a form has captchaRequired=true but no settings.turnstileSiteKey set:

  • embed.js skips rendering the Turnstile widget (no siteKey = no widget)
  • The user submits the form with no cf-turnstile-response field
  • The server checks captchaRequiredtrue, looks for the token → absent → returns 400

Result: the form is completely broken from the user's perspective. The embed widget renders, the user fills it out, submits, and gets a 400 Captcha verification failed with no visible CAPTCHA ever shown. There is no warning in the dashboard or in the embed.

Fix: (a) Dashboard: warn when captchaRequired=true and turnstileSiteKey is null — "Your form requires a CAPTCHA but no site key is configured." (b) embed.js: if captchaRequired=true and no siteKey present in schema, render a visible error in the form widget rather than silently submitting without a token.


B5 — Plan-based monthly cap surfaced but not enforced LOW

Where: apps/forms-service/src/services/credits.ts, api-spec.md §8

GET /admin/forms/overview returns usage.limit which is read from plan_service_limits in the core DB. However, this limit is not checked in the submit path — only the credit balance is. A tenant on a plan with limit=1000/month who has enough credits will happily receive submission #1001 with no enforcement.

This is an intentional v1 simplification (credit balance as the hard cap), but the dashboard displays a usage limit that implies it's enforced.

Fix: Either enforce the limit in the submit path (canSubmissionProceed should also check submissionsThisMonth < monthlyLimit), or stop displaying usage.limit in the overview until enforcement is wired.


B6 — Webhook secret cannot be rotated LOW

Where: apps/forms-service/src/routes/admin/forms.ts, webhooks.md §8

The webhook_secret is generated once when webhook_url is first set. There is no POST /admin/forms/:id/rotate-webhook-secret endpoint. To get a new secret, a tenant must clear webhook_url to null, save, then set it again — which generates a new secret but also invalidates the old webhook endpoint record.

If a secret leaks (copied to a third-party, stored in public git history, etc.), there is no safe rotation path.

Fix: Add POST /admin/forms/:id/rotate-webhook-secret that regenerates webhook_secret without touching webhook_url. Return the new secret in the response (shown only once, like API keys). This mirrors how all standard webhook platforms handle secret rotation.


B7 — Media Service transient failure stores filename string instead of {__file} object LOW

Where: apps/forms-service/src/routes/public/submit.ts (file upload section)

When a file upload to Media Service succeeds, the field is stored as:

{ "__file": true, "mediaId": "...", "name": "resume.pdf", "size": 184320, "type": "application/pdf", "url": "..." }

When the upload fails transiently (5xx from media-service), the submission is kept (fail-soft) but the field is stored as just "resume.pdf" — the raw filename string.

This means:

  • CSV export renders the field as resume.pdf instead of a download URL — confusing
  • Dashboard submission detail shows the filename with no download link and no indication the upload failed
  • No way for the tenant to recover the file; the visitor's file is lost silently

Fix: Store a structured error object instead of a raw string on transient failure:

{ "__file": true, "name": "resume.pdf", "size": 184320, "type": "application/pdf", "url": null, "uploadError": "transient" }

This lets the dashboard show "Upload failed — file not available" instead of a bare filename, and CSV export can render it distinctly.


B8 — Reply endpoint idempotency key uses Date.now() LOW

Where: apps/forms-service/src/routes/admin/submissions.ts (POST /admin/submissions/:id/reply)

The email reply is sent to comms-service with idempotency key form:reply:${submissionId}:${Date.now()}. Millisecond-level timestamp keys can collide if the same endpoint is called twice in the same millisecond (possible via rapid double-click or automated retry).

A millisecond collision would cause comms-service to deduplicate and send only one email — in this case the collision is benign. But if the idempotency key were form:reply:${submissionId} (no timestamp), it would prevent sending a second reply altogether. The current implementation avoids both problems but relies on millisecond uniqueness, which is fragile.

Fix: Use form:reply:${submissionId}:${crypto.randomUUID()} for true uniqueness, or form:reply:${submissionId}:${replyCount} (requires tracking reply count on the submission row) for deterministic idempotency.


B9 — honeypotField can be set to the same name as a real schema field LOW

Where: apps/forms-service/src/routes/admin/forms.ts (form create/update)

honeypotField defaults to botcheck but can be set to any string via PUT /admin/forms/:id. There is no validation that checks whether the chosen name conflicts with a field in schema.fields. If a tenant sets honeypotField: "email", every submission with a filled-in email would be silently stored as spam.

Fix: In the form create/update handler, validate that honeypotField does not appear in schema.fields[*].name. Return a 400 with a clear message if it does.


B10 — accessMiddleware("forms") deferred in gateway LOW

Where: apps/gateway/src/routes/forms.proxy.ts

The gateway proxy has a TODO comment: accessMiddleware("forms") is intentionally skipped because forms is not yet a provisioned ServiceCode in the entitlement catalog. This means any tenant with a valid JWT can access the admin forms routes regardless of whether their plan includes forms access.

Fix (follow-up): Add forms to @repo/core-types ServiceCode, the manager entitlement catalog, and the tenant_services provisioning flow. Then restore accessMiddleware("forms") in forms.proxy.ts.


Gaps (Missing Features)

G1 — No newsletter opt-in on submission HIGH

Where: apps/forms-service/src/db/schema.ts (notify.newsletterListId), src/routes/public/submit.ts

FormNotify has a newsletterListId?: string | null field that is stored in the form's notify JSONB column. But no code reads this field in the submit handler — the newsletter service is never called. Phase 4 deferred.

Impact: Tenants using forms as a lead-capture surface cannot automatically add subscribers to their newsletter list. This is the primary reason forms and newsletter exist in the same platform.

Fix: In the submit handler (after successful submission insert), if form.notify.newsletterListId is set, call newsletter-service /internal/subscribers with the submitter's email. Use waitUntil() so it doesn't block the response. Handle the "already subscribed" 409 silently.


G2 — No GDPR right-to-erasure for submissions MEDIUM

Where: apps/forms-service/src/routes/admin/submissions.ts

DELETE /admin/submissions/:id hard-deletes the row. There is no "erase PII" endpoint that removes the email + field values but keeps a tombstone row for audit purposes. There is no bulk erasure by email address (e.g., "erase all submissions from alice@example.com").

Fix: Add POST /admin/submissions/erase-by-email (or hook it into the tenant's GDPR erasure pipeline) that:

  1. Finds all form_submissions rows where data JSONB contains the given email
  2. Replaces the data field with { "_erased": true, "erasedAt": "..." }
  3. Keeps the row for analytics count accuracy (conversion rate stays valid)

G3 — Webhook secret rotation has no endpoint MEDIUM

Covered in B6 above. Deserves a gap entry because it's a missing feature, not just a bug.

Fix: POST /admin/forms/:id/rotate-webhook-secret — no body required, returns { webhookSecret: "whsec_..." }.


G4 — No per-form rate limit configuration MEDIUM

Where: apps/forms-service/wrangler.toml (FORM_RATE_LIMITER, simple = { limit = 20, period = 60 })

All forms share the same 20 req/60s per form+IP rate limit. A high-traffic event form (conference registration, product launch) might legitimately receive more than 20 submissions per minute from a single office IP. Conversely, a sensitive internal feedback form might want a stricter 5/60s cap.

The native CF rate limiter binding does not support per-request dynamic limits — the limit is configured at the binding level.

Fix (possible approach): Use multiple rate limiter bindings at different tiers (FORM_RATE_LIMITER_LOW = 5/60s, FORM_RATE_LIMITER_HIGH = 100/60s) and select based on form.settings.rateLimitTier. Alternatively, fall back to KV-based counting for forms with custom limits (but this costs KV writes — check free tier usage, see memory: KV free-tier).


G5 — No conditional field logic MEDIUM

Forms only support flat, unconditional field lists. There is no way to show/hide fields based on other field values (e.g., "show 'Company name' only if user selects 'Business' in the dropdown"). This is a standard feature of TypeForm, JotForm, and Tally.

Track here for Phase 3.


G6 — No GDPR-compliant submission export MEDIUM

The CSV/JSON export includes all raw submission data including source_ip and user_agent. For tenants operating under GDPR, exporting this data without anonymisation may violate data minimisation principles.

Fix: Add an ?anonymise=true query param to the export endpoint that omits source_ip, user_agent, and replaces email addresses with hashed tokens.


G7 — No audit trail for submission status changes LOW

When a submission's status changes from newseenhandled, there is no record of who made the change and when. The note field is editable but overwrites the previous note — there's no history.

Fix: Add a form_submission_events table (append-only) logging { submissionId, changedBy, fromStatus, toStatus, note, changedAt }. Or: store a JSON array of events in a statusHistory JSONB column on form_submissions.


G8 — Embed.js has no version pinning LOW

Where: GET /f/embed.js (always returns latest)

The embed.js script served from https://api.vlozi.app/forms/f/embed.js is always the current version. If a breaking change is made to the widget, all embedded forms update immediately. There is no ?v=1.2.3 version pin mechanism.

Fix: Add a versioned path: GET /f/embed/v1.js. The unversioned path (/f/embed.js) always returns latest. Tenants on stable sites can pin to a specific version.


G9 — notify.newsletterListId stored but never validated LOW

Where: apps/forms-service/src/routes/admin/forms.ts

When a form is created/updated with notify.newsletterListId = "seg_abc123", the ID is stored in the JSONB without validation. The newsletter service is never called (G1) so there's no validation today, but when G1 is wired, a stale or incorrect newsletterListId would silently fail.

Fix: At form save time, if newsletterListId is set, call newsletter-service /segments/:id to verify it exists for this tenant. Return a 400 if not found.


G10 — No "form closed" state LOW

status has three values: active, paused, archived. Both paused and archived effectively close the form, but they have different semantics:

  • paused = temporarily not accepting submissions (operator intent: resume later)
  • archived = permanently done (operator intent: don't show in list)

But paused returns 403 (same as origin check failures) while archived returns 404. The dashboard shows these as different states but there's no UI affordance for "close with a message" (e.g., "This event is now over — registration is closed.").

Fix: Add closedMessage?: string to FormSettings. When the form is paused and closedMessage is set, return it in the 403 response body. embed.js renders the message in place of the form.


G11 — No multi-page / multi-step forms LOW

All form fields are rendered on a single page. There is no section/step concept in FormSchema. TypeForm-style multi-step forms are a common request.

Track here for Phase 3.


G12 — No reply-from customisation LOW

Where: apps/forms-service/src/services/notify.ts

When a tenant replies to a submission via POST /admin/submissions/:id/reply, the email goes from the platform's default sender (hello@vlozi.app or comms sender settings). There's no way to set a custom reply-from address per form. The submitter's reply will go back to Vlozi, not to the tenant's own email.

Fix: Use the form's notify.email.to address as the replyTo header in the reply email. This way, if the submitter replies to the reply, it goes to the tenant's actual email address.


G13 — Embed.js does not handle paused forms gracefully LOW

Where: apps/forms-service/src/lib/embed.ts

When a form is paused, GET /f/:form_id/schema returns 200 (schema is still returned for paused forms per api-spec.md §2), but POST /f/:form_id returns 403 "Form is paused". The embed widget submits successfully from the user's perspective, gets a 403, and shows a generic error. There's no "This form is not currently accepting submissions" message.

Fix: Add a paused: boolean field to the schema response. embed.js checks it and renders a notice instead of the form fields.


G14 — No submission deduplication LOW

Two identical submissions from the same IP within seconds (double-click, network retry) are both stored and billed. There is no submission fingerprint check.

Fix: Add a submission_fingerprint column: hash of (formId, sourceIp, submittedAt/60s bucket, dataHash). Check for duplicates within a 60-second window and return the existing submission's success response without re-inserting or re-billing.


Improvement Scope

Priority 1 — Fix now (silent failures that cost tenants)

Item Effort Links
Newsletter opt-in on submission Medium G1
Fix B7: store structured error on media upload failure Small B7
Fix B4: warn when captchaRequired + no siteKey Small B4
Fix B9: validate honeypotField vs schema field names Tiny B9
Webhook secret rotation endpoint Small B6, G3

Priority 2 — Trust and compliance

Item Effort Links
GDPR erasure endpoint (/submissions/erase-by-email) Medium G2
Anonymised export option (?anonymise=true) Small G6
Fix B1: make captcha failure silent (same as honeypot) Small B1
Fix B2: link retry rows to original failed row Small B2
Enforce plan monthly submission cap in submit path Medium B5

Priority 3 — Quality-of-life features

Item Effort Links
Reply-from fix (use form's notify email as replyTo) Tiny G12
Closed message when form is paused (closedMessage setting) Small G10
Paused form indicator in schema response Tiny G13
Submission deduplication (60s window) Small G14
Submission status audit trail Medium G7
Validate newsletterListId on save Small G9
Webhook retry REST endpoint (not just MCP) Small api-spec §6

Priority 4 — Larger scope / phase 2–3

Item Effort Links
Embed.js version pinning Medium G8
Per-form rate limit configuration Large G4
Conditional field logic Large G5
Multi-step / multi-page forms Large G11
Custom reply-from address per form Medium G12
accessMiddleware("forms") entitlement gate Medium B10
Real-time credit balance alert (fail-open mitigation) Medium B3

Fixed Items

Nothing fixed yet — initial backlog from 2026-06-28 analysis.

Forms