Build Formasty

KINDAreplaces $29/mosaves $348/yrback to the verdict

0%0 of 17 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

A self-hosted form builder whose hard problems are editing a live form without rewriting the meaning of past answers, submitting from an embed on someone else's site where your cookies do not exist, and accepting files and spam without ruining the disk or the inbox.

estimated effort weekendthe files for this build are in the project pack

RuntimeA small Node server behind your own domain, server rendered, no client build stepStorageSQLite for forms and submissions, uploads on disk beside itSchemaImmutable published versions, each submission stamped with the version it usedEmbeddingAn iframe with a per-form origin allowlist and a postMessage resize channelNotificationA queued job with retries, never part of the submit request

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    Why Runs the server and the test suite.

    Get it nodejs.org, or your version manager. open ↗

    Verify node --version prints v22 or higher

  2. a few pounds a month for the machine, plus the domain

    Why A form that is down is an enquiry nobody knows they did not receive, and the embed needs a secure origin on a domain you control.

    Get it Any small virtual machine plus a domain, with automatic certificate renewal configured.

    Verify The public form loads over https from another network

  3. API keyfree at low volume with most providers

    Why Notifications leave through it, and its failures are the ones you will spend the most time diagnosing.

    Get it Any transactional mail provider, or a mailbox you already own with application credentials.

    Verify A test submission produces a delivery row with status sent

  4. have readyfree

    Why Every cookie assumption in this build only breaks when the form is framed by a site that is not yours, so this is not optional.

    Get it Any static page on another domain, or a local server on a different hostname with its own certificate.

    Verify The form submits from it with third party cookies blocked

  5. decidefree

    Why Somebody will eventually ask you to delete what they sent, and a retention rule decided afterwards is decided under pressure.

    Get it Write down a retention period per form, what deletion removes, and who can trigger it.

    Verify The rule is in the README and enforced by a scheduled job

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- forms(id, slug, title, status, created_at, embed_origins_json) · origins are the sites allowed to frame this form
- form_versions(id, form_id, version, schema_json, published_at) · published versions are never edited, only superseded
- Fields live inside schema_json with a key generated once at creation, never reused and never derived from the label
- submissions(id, form_id, form_version_id, received_at, ip_hash, user_agent, state, spam_score) · state is accepted, quarantined or rejected
- answers(submission_id, field_key, value_json) · keyed by the stable field key, never by position in the form
- uploads(id, submission_id, field_key, stored_name, original_name, bytes, sha256, declared_type, sniffed_type)
- deliveries(id, submission_id, channel, attempt, status, error, at) · every notification attempt, including the failures
- A submission row is written before anything is sent anywhere, because it is the only copy of something somebody typed once.

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
DATABASE_PATHrequired./data/forms.dbForms, versions, submissions and answers. Back up with the upload directory.
UPLOAD_DIRrequired./data/uploadsSubmitted files. Outside any directory that is served or executed.
TOKEN_SIGNING_KEYsecretrequiredhex-64-chars-redactedSigns the anti-forgery token carried in the form body. Rotating it invalidates open forms.
SMTP_URLsecretrequiredsmtps://user:redacted@mail.example.com:465Notification transport. Failures appear in the deliveries table.
MAX_UPLOAD_BYTESrequired10485760Per file. Stated in the rejection message when exceeded.
MAX_UPLOADS_PER_SUBMISSIONrequired5Count cap, checked before anything is written to disk.
SUBMISSION_RETENTION_DAYSrequired365Deletion removes the submission, its answers and its files.

The build, in order

  1. A schema that can change without rewriting history

    A form can be edited after submissions arrive and every old answer still means what it meant.

    1. The key is the only stable identity an answer has.

      Files src/schema/fields.tstests/field-keys.test.ts

    2. A published version is a record of what somebody was shown.

      Files src/schema/versions.ts

    3. Otherwise last year's answers arrive under this year's questions.

      Files src/views/submission.ejs

    done when · tick each as it passes
    watch out
    • Editing a live form in place and storing answers keyed by field position means renaming a question silently rewrites the meaning of every past answer, and deleting a field shifts every column after it in the export. Generate a key per field once, never reuse a key even after deletion, stamp each submission with its version id, and render history against the version it was captured with.
  2. One write that cannot fail for a stupid reason

    A submission is validated server side, written, and acknowledged before anything is sent anywhere.

    1. The browser's copy of your rules is a suggestion.

      Files src/submit/validate.tstests/validate.test.ts

    2. Answers and submission arrive together or not at all.

      Files src/submit/store.ts

    3. A mail server having a bad day is not a lost lead.

      Files src/delivery/queue.tssrc/views/admin/deliveries.ejs

    done when · tick each as it passes
    watch out
    • Sending the notification inside the request handler means a slow mail server turns a successful submission into a timeout, the person presses submit again, and you end up with either nothing or two of everything. Store first, acknowledge second, deliver third, and make the delivery table show attempts and errors so a silent mail failure is visible rather than assumed.
  3. The embed, where your cookies do not exist

    A form embedded on someone else's site submits correctly with third party cookies switched off.

    1. The embed never receives the cookie you were relying on.

      Files src/submit/token.tstests/token.test.ts

    2. Anyone can iframe you otherwise, including a phishing page.

      Files src/middleware/headers.ts

    3. A wildcard target origin is a broadcast.

      Files public/embed.jssrc/views/embed.ejs

    done when · tick each as it passes
    watch out
    • Putting the anti-forgery token in a cookie passes every test you run on your own domain and then fails in the embed, because a cross-site iframe is not sent a cookie defaulted to SameSite Lax, and Chromium's stopgap only sends such a cookie on a top-level cross-site POST when the cookie is under two minutes old, a behaviour its own documentation calls purely temporary. Sign the token into the form body instead, bind it to the form version and an expiry, and verify it with no cookie in the request at all.
    • Accepting any postMessage and resizing to whatever it says lets any page on the internet drive your iframe, and sending your height to any listener leaks that the form is open. Check event.origin against the allowlist on receive and pass an explicit target origin on send, never a wildcard.
  4. Files and rubbish, the two things that fill the disk

    Uploads are stored safely and obvious spam is held rather than delivered or deleted.

    1. Both the extension and the declared type are attacker supplied.

      Files src/uploads/store.tstests/upload-type.test.ts

    2. A generic rejection makes people retry until they give up.

      Files src/uploads/limits.ts

    3. Your first false positive will be a real customer.

      Files src/spam/quarantine.ts

    done when · tick each as it passes
    watch out
    • The accept attribute on the input and the declared content type both come from whoever is posting, so a file named photo.jpg that is actually a script passes both, and serving the upload directory back under its original name completes the picture. Sniff the bytes, store under a name you generated, serve only through a handler that sets the type you determined, and never place the upload directory inside anything that executes.
    • Deleting anything scored as spam means the first false positive is a real enquiry that nobody ever knew arrived. Quarantine instead, show the count, and let a human release a submission, which also gives you the only feedback your scoring will ever get.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more forms.