Vibecode Formasty
track this build4 phases, 12 steps, beginner friendly0%A form builder is one of the most one-shottable categories there is: a schema, a renderer, a responses table, a CSV export. An agent will get you a working multi-step form with conditional fields and an embed snippet in a single session, and for a personal signup form or client intake that is genuinely enough. The gaps are the boring parts nobody thinks about until they bite: email that actually lands in inboxes, spam that gets past a honeypot, file uploads you have to back up, and the dozen integrations that turn a response into a row in someone else's tool. You also inherit uptime, because a form that is down is a lead you never knew existed. Build it if the form is yours and the volume is low; pay if the form is a business process.
You are building a lean indie version of Formasty. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Formasty · indie build 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: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A small Node server behind your own domain, server rendered, no client build step | a form that needs a bundle to display is a form that fails when the bundle does | | Storage | SQLite for forms and submissions, uploads on disk beside it | the two must be backed up together or a restore gives you rows pointing at nothing | | Schema | Immutable published versions, each submission stamped with the version it used | editing a live form is normal and must not change what old answers meant | | Embedding | An iframe with a per-form origin allowlist and a postMessage resize channel | the embed is the feature and it is also where every cookie assumption breaks | | Notification | A queued job with retries, never part of the submit request | an SMTP timeout must not turn into a lost lead | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server and the test suite. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **An always-on host with a domain and a certificate** · 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 - [ ] **Credentials for a mail transport** · free 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 - [ ] **A page on a different origin to test the embed from** · free - 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 - [ ] **How long submissions and uploads are kept** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Payment fields and signatures. - An integration shelf beyond one outbound webhook. - Hosting forms for other people. - A template gallery. - Deliverability: their notification email is warmed and authenticated, your SMTP box is not - The integration shelf: Sheets, Slack, Notion, CRM, Zapier style webhooks with retries and mapping UI - Template library and builder polish, which is most of what you are paying for in a form tool - Payment fields, e-signature, and anything that needs a compliance story like a signed DPA - Uptime and CDN edge delivery, so a form on a busy page fails silently when your box does If one of those is essential to you, that is the reason to keep paying for Formasty, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Formasty The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a form builder that renders a versioned schema and stores every submission it receives to replace Formasty. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: A small Node server behind your own domain, server rendered, no client build step · a form that needs a bundle to display is a form that fails when the bundle does - Storage: SQLite for forms and submissions, uploads on disk beside it · the two must be backed up together or a restore gives you rows pointing at nothing - Schema: Immutable published versions, each submission stamped with the version it used · editing a live form is normal and must not change what old answers meant - Embedding: An iframe with a per-form origin allowlist and a postMessage resize channel · the embed is the feature and it is also where every cookie assumption breaks - Notification: A queued job with retries, never part of the submit request · an SMTP timeout must not turn into a lost lead ### Data model (create this before Phase 1) - 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. ### Phase 1 · A schema that can change without rewriting history Build: An admin that edits a draft schema and publishes it as a new immutable version. Fields carry a key generated at creation. A submission stores the version id it was rendered from, and viewing an old submission renders it against that version's labels and field order. Trap: 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. Done when: Renaming a field label leaves every existing answer attached to the same key, verified in the database. Deleting a field and adding a new one does not reuse the deleted key, asserted. Viewing a submission from a superseded version shows that version's labels and order, tested with two published versions. A draft schema cannot be submitted against; only published versions render publicly. Do not build yet: No embedding, uploads, spam handling or export yet. ### Phase 2 · One write that cannot fail for a stupid reason Build: Validate every submission against the stored published version on the server, ignoring whatever the browser did. Write the submission and its answers in one transaction, return the confirmation, then queue the notification as a job with retries and a visible failure log. Trap: 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. Done when: A submission is persisted before any outbound request is attempted, asserted by failing the mail transport in a test and confirming the row exists. A notification failure is recorded with its error and retried on a stated schedule, and never deletes or alters the submission. Server side validation rejects a payload that a modified page would have allowed, tested by posting directly. Two identical rapid submissions are distinguishable in the admin rather than silently merged. Do not build yet: No embed, uploads or spam handling yet. ### Phase 3 · The embed, where your cookies do not exist Build: Serve the form in an iframe, send frame-ancestors built from the per-form origin allowlist, and resize through postMessage with the origin checked on both sides. Carry the anti-forgery token in the form body: a signed value bound to the form version with an expiry, verified without reading any cookie at all. Trap: 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. Trap: 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. Done when: A form embedded on a different origin submits successfully in a browser with third party cookies blocked entirely, tested manually and recorded. No submission path reads a cookie, verified by review and by a test that strips the cookie header. A token replayed after its expiry, or against a different form version, is rejected with a message that offers to reload. An origin not in the form's allowlist cannot frame the form, checked in a browser and asserted against the response header. Do not build yet: No uploads, spam scoring or export yet. ### Phase 4 · Files and rubbish, the two things that fill the disk Build: Sniff uploaded bytes rather than trusting the declared type, store under a generated name in a directory that is never executed or served directly, and cap size and count per submission. Score submissions with a timing check, a hidden field and a per-address rate limit, and put failures in quarantine where a human can release them. Trap: 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. Trap: 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. Done when: A file whose declared type disagrees with its bytes is rejected or stored with the sniffed type, tested with a crafted file. Uploaded files are stored under generated names and are not reachable by guessing the original name, tested by request. A submission over the size or count cap is refused with a message naming the limit, not a generic error. A quarantined submission is visible, releasable, and never deleted automatically. Do not build yet: No export, retention or backup work yet. ### Phase 5 · Getting the answers out and keeping them Build: Export per form version as CSV, plus a combined export carrying a version column and the union of columns with a documented rule for fields that did not exist yet. Back up the database and the upload directory in one operation and restore both in the test suite. Set retention per form, and make deletion remove the row, the answers and the files. Trap: A single CSV across a form that changed shape either drops the columns added later or blanks them for old rows, and whoever opens it in a spreadsheet will not notice which. Export one file per version by default, and when a combined file is asked for, include a version column and mark a cell that could not exist rather than leaving it empty. Trap: Backing up the database without the upload directory, or the other way round, gives you a restore where half the submissions point at files that are not there. Take both in one operation with the database's own backup command, and restore the pair into an empty directory in the suite so the pairing is proven rather than assumed. Done when: Exporting a form with two published versions produces one file per version by default, checked. A combined export carries a version column and distinguishes an absent field from an empty answer, tested. A backup and restore into an empty directory yields every submission with its files intact, exercised in the suite. Deleting a submission removes its answers and its uploaded files, verified on disk. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment fields and signatures, which drag in a payment processor's obligations and an evidence trail that a weekend build should not be holding. - An integration shelf for spreadsheets, chat tools and customer systems, which is endless plumbing rather than a hard problem; ship one outbound webhook with retries instead. - Running forms for other people, which makes you responsible for their uptime and their data and is a different product. - A template gallery, which is the part of a paid form tool you are actually buying and is content work, not code. ### README must contain - That the anti-forgery token is signed into the form body rather than stored in a cookie, and why the embed makes that necessary - That published schema versions are immutable and each submission records the version it used - That the database and the upload directory must be backed up and restored together, with the command - That notifications are delivered out of band and that a delivery failure never loses a submission - That suspected spam is quarantined rather than deleted, and where to find it ===== AGENTS.md ===== # Agent instructions · Formasty indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A small Node server behind your own domain, server rendered, no client build step, SQLite for forms and submissions, uploads on disk beside it, Immutable published versions, each submission stamped with the version it used, An iframe with a per-form origin allowlist and a postMessage resize channel, A queued job with retries, never part of the submit request. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Formasty 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 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. ### Steps 1. Generate a field key once and never reuse it The key is the only stable identity an answer has. Files: `src/schema/fields.ts`, `tests/field-keys.test.ts` 2. Publish versions rather than editing in place A published version is a record of what somebody was shown. Files: `src/schema/versions.ts` 3. Render history against the captured version Otherwise last year's answers arrive under this year's questions. Files: `src/views/submission.ejs` ### Done when - [ ] Renaming a label leaves answers attached to the same key - [ ] A deleted field's key is never reused - [ ] Old submissions render against their own version - [ ] A draft version cannot receive a submission ### 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. ## Phase 2 · One write that cannot fail for a stupid reason A submission is validated server side, written, and acknowledged before anything is sent anywhere. ### Steps 1. Validate against the stored version on the server The browser's copy of your rules is a suggestion. Files: `src/submit/validate.ts`, `tests/validate.test.ts` 2. Write the submission in one transaction Answers and submission arrive together or not at all. Files: `src/submit/store.ts` 3. Queue delivery with retries and a failure log A mail server having a bad day is not a lost lead. Files: `src/delivery/queue.ts`, `src/views/admin/deliveries.ejs` ### Done when - [ ] The row exists even when the mail transport fails - [ ] Delivery attempts and errors are visible in the admin - [ ] A direct post bypassing the page is rejected - [ ] Duplicate rapid submissions stay distinguishable ### 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. ## Phase 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. ### Steps 1. Sign the anti-forgery token into the form body The embed never receives the cookie you were relying on. Files: `src/submit/token.ts`, `tests/token.test.ts` 2. Set frame-ancestors from the per-form allowlist Anyone can iframe you otherwise, including a phishing page. Files: `src/middleware/headers.ts` 3. Check the origin on every postMessage, both directions A wildcard target origin is a broadcast. Files: `public/embed.js`, `src/views/embed.ejs` ### Done when - [ ] Submission works with third party cookies blocked - [ ] No submission path reads a cookie - [ ] An expired or mismatched token is rejected clearly - [ ] An unlisted origin cannot frame the form ### 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. ## Phase 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. ### Steps 1. Sniff bytes and store under a generated name Both the extension and the declared type are attacker supplied. Files: `src/uploads/store.ts`, `tests/upload-type.test.ts` 2. Cap size and count and say the number A generic rejection makes people retry until they give up. Files: `src/uploads/limits.ts` 3. Quarantine rather than delete Your first false positive will be a real customer. Files: `src/spam/quarantine.ts` ### Done when - [ ] A mislabelled file is rejected or stored by sniffed type - [ ] Stored files are not reachable by their original name - [ ] Limits are refused with the number stated - [ ] Quarantined submissions are visible and releasable ### 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. ## Not in this build - Payment fields and signatures. - An integration shelf beyond one outbound webhook. - Hosting forms for other people. - A template gallery. ## After v1, if you want it - Partial save and resume for long forms, which needs a token that survives a closed tab without becoming a way to read someone else's draft - A second notification channel so a mail outage is not a total outage - Per-field analytics on where people abandon, computed from timing already captured at submit ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, versions, submissions and answers. Back up with the upload directory. DATABASE_PATH=./data/forms.db # Required. Submitted files. Outside any directory that is served or executed. UPLOAD_DIR=./data/uploads # Required · secret. Signs the anti-forgery token carried in the form body. Rotating it invalidates open forms. TOKEN_SIGNING_KEY=hex-64-chars-redacted # Required · secret. Notification transport. Failures appear in the deliveries table. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Required. Per file. Stated in the rejection message when exceeded. MAX_UPLOAD_BYTES=10485760 # Required. Count cap, checked before anything is written to disk. MAX_UPLOADS_PER_SUBMISSION=5 # Required. Deletion removes the submission, its answers and its files. SUBMISSION_RETENTION_DAYS=365
You are building a lean indie version of Formasty. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Formasty · indie build 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: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A small Node server behind your own domain, server rendered, no client build step | a form that needs a bundle to display is a form that fails when the bundle does | | Storage | SQLite for forms and submissions, uploads on disk beside it | the two must be backed up together or a restore gives you rows pointing at nothing | | Schema | Immutable published versions, each submission stamped with the version it used | editing a live form is normal and must not change what old answers meant | | Embedding | An iframe with a per-form origin allowlist and a postMessage resize channel | the embed is the feature and it is also where every cookie assumption breaks | | Notification | A queued job with retries, never part of the submit request | an SMTP timeout must not turn into a lost lead | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server and the test suite. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **An always-on host with a domain and a certificate** · 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 - [ ] **Credentials for a mail transport** · free 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 - [ ] **A page on a different origin to test the embed from** · free - 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 - [ ] **How long submissions and uploads are kept** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Payment fields and signatures. - An integration shelf beyond one outbound webhook. - Hosting forms for other people. - A template gallery. - Deliverability: their notification email is warmed and authenticated, your SMTP box is not - The integration shelf: Sheets, Slack, Notion, CRM, Zapier style webhooks with retries and mapping UI - Template library and builder polish, which is most of what you are paying for in a form tool - Payment fields, e-signature, and anything that needs a compliance story like a signed DPA - Uptime and CDN edge delivery, so a form on a busy page fails silently when your box does If one of those is essential to you, that is the reason to keep paying for Formasty, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Formasty The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a form builder that renders a versioned schema and stores every submission it receives to replace Formasty. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: A small Node server behind your own domain, server rendered, no client build step · a form that needs a bundle to display is a form that fails when the bundle does - Storage: SQLite for forms and submissions, uploads on disk beside it · the two must be backed up together or a restore gives you rows pointing at nothing - Schema: Immutable published versions, each submission stamped with the version it used · editing a live form is normal and must not change what old answers meant - Embedding: An iframe with a per-form origin allowlist and a postMessage resize channel · the embed is the feature and it is also where every cookie assumption breaks - Notification: A queued job with retries, never part of the submit request · an SMTP timeout must not turn into a lost lead ### Data model (create this before Phase 1) - 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. ### Phase 1 · A schema that can change without rewriting history Build: An admin that edits a draft schema and publishes it as a new immutable version. Fields carry a key generated at creation. A submission stores the version id it was rendered from, and viewing an old submission renders it against that version's labels and field order. Trap: 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. Done when: Renaming a field label leaves every existing answer attached to the same key, verified in the database. Deleting a field and adding a new one does not reuse the deleted key, asserted. Viewing a submission from a superseded version shows that version's labels and order, tested with two published versions. A draft schema cannot be submitted against; only published versions render publicly. Do not build yet: No embedding, uploads, spam handling or export yet. ### Phase 2 · One write that cannot fail for a stupid reason Build: Validate every submission against the stored published version on the server, ignoring whatever the browser did. Write the submission and its answers in one transaction, return the confirmation, then queue the notification as a job with retries and a visible failure log. Trap: 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. Done when: A submission is persisted before any outbound request is attempted, asserted by failing the mail transport in a test and confirming the row exists. A notification failure is recorded with its error and retried on a stated schedule, and never deletes or alters the submission. Server side validation rejects a payload that a modified page would have allowed, tested by posting directly. Two identical rapid submissions are distinguishable in the admin rather than silently merged. Do not build yet: No embed, uploads or spam handling yet. ### Phase 3 · The embed, where your cookies do not exist Build: Serve the form in an iframe, send frame-ancestors built from the per-form origin allowlist, and resize through postMessage with the origin checked on both sides. Carry the anti-forgery token in the form body: a signed value bound to the form version with an expiry, verified without reading any cookie at all. Trap: 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. Trap: 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. Done when: A form embedded on a different origin submits successfully in a browser with third party cookies blocked entirely, tested manually and recorded. No submission path reads a cookie, verified by review and by a test that strips the cookie header. A token replayed after its expiry, or against a different form version, is rejected with a message that offers to reload. An origin not in the form's allowlist cannot frame the form, checked in a browser and asserted against the response header. Do not build yet: No uploads, spam scoring or export yet. ### Phase 4 · Files and rubbish, the two things that fill the disk Build: Sniff uploaded bytes rather than trusting the declared type, store under a generated name in a directory that is never executed or served directly, and cap size and count per submission. Score submissions with a timing check, a hidden field and a per-address rate limit, and put failures in quarantine where a human can release them. Trap: 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. Trap: 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. Done when: A file whose declared type disagrees with its bytes is rejected or stored with the sniffed type, tested with a crafted file. Uploaded files are stored under generated names and are not reachable by guessing the original name, tested by request. A submission over the size or count cap is refused with a message naming the limit, not a generic error. A quarantined submission is visible, releasable, and never deleted automatically. Do not build yet: No export, retention or backup work yet. ### Phase 5 · Getting the answers out and keeping them Build: Export per form version as CSV, plus a combined export carrying a version column and the union of columns with a documented rule for fields that did not exist yet. Back up the database and the upload directory in one operation and restore both in the test suite. Set retention per form, and make deletion remove the row, the answers and the files. Trap: A single CSV across a form that changed shape either drops the columns added later or blanks them for old rows, and whoever opens it in a spreadsheet will not notice which. Export one file per version by default, and when a combined file is asked for, include a version column and mark a cell that could not exist rather than leaving it empty. Trap: Backing up the database without the upload directory, or the other way round, gives you a restore where half the submissions point at files that are not there. Take both in one operation with the database's own backup command, and restore the pair into an empty directory in the suite so the pairing is proven rather than assumed. Done when: Exporting a form with two published versions produces one file per version by default, checked. A combined export carries a version column and distinguishes an absent field from an empty answer, tested. A backup and restore into an empty directory yields every submission with its files intact, exercised in the suite. Deleting a submission removes its answers and its uploaded files, verified on disk. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment fields and signatures, which drag in a payment processor's obligations and an evidence trail that a weekend build should not be holding. - An integration shelf for spreadsheets, chat tools and customer systems, which is endless plumbing rather than a hard problem; ship one outbound webhook with retries instead. - Running forms for other people, which makes you responsible for their uptime and their data and is a different product. - A template gallery, which is the part of a paid form tool you are actually buying and is content work, not code. ### README must contain - That the anti-forgery token is signed into the form body rather than stored in a cookie, and why the embed makes that necessary - That published schema versions are immutable and each submission records the version it used - That the database and the upload directory must be backed up and restored together, with the command - That notifications are delivered out of band and that a delivery failure never loses a submission - That suspected spam is quarantined rather than deleted, and where to find it ===== AGENTS.md ===== # Agent instructions · Formasty indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A small Node server behind your own domain, server rendered, no client build step, SQLite for forms and submissions, uploads on disk beside it, Immutable published versions, each submission stamped with the version it used, An iframe with a per-form origin allowlist and a postMessage resize channel, A queued job with retries, never part of the submit request. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Formasty 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 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. ### Steps 1. Generate a field key once and never reuse it The key is the only stable identity an answer has. Files: `src/schema/fields.ts`, `tests/field-keys.test.ts` 2. Publish versions rather than editing in place A published version is a record of what somebody was shown. Files: `src/schema/versions.ts` 3. Render history against the captured version Otherwise last year's answers arrive under this year's questions. Files: `src/views/submission.ejs` ### Done when - [ ] Renaming a label leaves answers attached to the same key - [ ] A deleted field's key is never reused - [ ] Old submissions render against their own version - [ ] A draft version cannot receive a submission ### 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. ## Phase 2 · One write that cannot fail for a stupid reason A submission is validated server side, written, and acknowledged before anything is sent anywhere. ### Steps 1. Validate against the stored version on the server The browser's copy of your rules is a suggestion. Files: `src/submit/validate.ts`, `tests/validate.test.ts` 2. Write the submission in one transaction Answers and submission arrive together or not at all. Files: `src/submit/store.ts` 3. Queue delivery with retries and a failure log A mail server having a bad day is not a lost lead. Files: `src/delivery/queue.ts`, `src/views/admin/deliveries.ejs` ### Done when - [ ] The row exists even when the mail transport fails - [ ] Delivery attempts and errors are visible in the admin - [ ] A direct post bypassing the page is rejected - [ ] Duplicate rapid submissions stay distinguishable ### 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. ## Phase 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. ### Steps 1. Sign the anti-forgery token into the form body The embed never receives the cookie you were relying on. Files: `src/submit/token.ts`, `tests/token.test.ts` 2. Set frame-ancestors from the per-form allowlist Anyone can iframe you otherwise, including a phishing page. Files: `src/middleware/headers.ts` 3. Check the origin on every postMessage, both directions A wildcard target origin is a broadcast. Files: `public/embed.js`, `src/views/embed.ejs` ### Done when - [ ] Submission works with third party cookies blocked - [ ] No submission path reads a cookie - [ ] An expired or mismatched token is rejected clearly - [ ] An unlisted origin cannot frame the form ### 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. ## Phase 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. ### Steps 1. Sniff bytes and store under a generated name Both the extension and the declared type are attacker supplied. Files: `src/uploads/store.ts`, `tests/upload-type.test.ts` 2. Cap size and count and say the number A generic rejection makes people retry until they give up. Files: `src/uploads/limits.ts` 3. Quarantine rather than delete Your first false positive will be a real customer. Files: `src/spam/quarantine.ts` ### Done when - [ ] A mislabelled file is rejected or stored by sniffed type - [ ] Stored files are not reachable by their original name - [ ] Limits are refused with the number stated - [ ] Quarantined submissions are visible and releasable ### 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. ## Not in this build - Payment fields and signatures. - An integration shelf beyond one outbound webhook. - Hosting forms for other people. - A template gallery. ## After v1, if you want it - Partial save and resume for long forms, which needs a token that survives a closed tab without becoming a way to read someone else's draft - A second notification channel so a mail outage is not a total outage - Per-field analytics on where people abandon, computed from timing already captured at submit ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, versions, submissions and answers. Back up with the upload directory. DATABASE_PATH=./data/forms.db # Required. Submitted files. Outside any directory that is served or executed. UPLOAD_DIR=./data/uploads # Required · secret. Signs the anti-forgery token carried in the form body. Rotating it invalidates open forms. TOKEN_SIGNING_KEY=hex-64-chars-redacted # Required · secret. Notification transport. Failures appear in the deliveries table. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Required. Per file. Stated in the rejection message when exceeded. MAX_UPLOAD_BYTES=10485760 # Required. Count cap, checked before anything is written to disk. MAX_UPLOADS_PER_SUBMISSION=5 # Required. Deletion removes the submission, its answers and its files. SUBMISSION_RETENTION_DAYS=365
You are building a production product version of Formasty. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Formasty · product brief ## Problem A form builder is one of the most one-shottable categories there is: a schema, a renderer, a responses table, a CSV export. An agent will get you a working multi-step form with conditional fields and an embed snippet in a single session, and for a personal signup form or client intake that is genuinely enough. The gaps are the boring parts nobody thinks about until they bite: email that actually lands in inboxes, spam that gets past a honeypot, file uploads you have to back up, and the dozen integrations that turn a response into a row in someone else's tool. You also inherit uptime, because a form that is down is a lead you never knew existed. Build it if the form is yours and the volume is low; pay if the form is a business process. ## Product outcome A form service where editing a live form never rewrites past answers, the embed works with third party cookies switched off, uploads cannot be tricked by their own filename, and a restore brings back rows and files together. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - a small VPS or always-on box to host it - SMTP credentials for notification email - disk space and a backup habit for uploads and the SQLite file - your own domain if you want the embed to look credible ## Explicit non-goals for v1 - Payment fields and signatures. - An integration shelf beyond one outbound webhook. - Hosting forms for other people. - A template gallery. - Deliverability: their notification email is warmed and authenticated, your SMTP box is not - The integration shelf: Sheets, Slack, Notion, CRM, Zapier style webhooks with retries and mapping UI - Template library and builder polish, which is most of what you are paying for in a form tool - Payment fields, e-signature, and anything that needs a compliance story like a signed DPA - Uptime and CDN edge delivery, so a form on a busy page fails silently when your box does ## Success criteria - Renaming a field is confirmed not to change the meaning of past answers - The submission row is confirmed written before any outbound request - The embed is confirmed to submit with third party cookies blocked - A mislabelled upload is confirmed rejected or stored by sniffed type - A restore into an empty directory is confirmed to yield rows and files together ===== BRIEF.md ===== # Build brief · Formasty The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a form builder that renders a versioned schema and stores every submission it receives to replace Formasty. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: A small Node server behind your own domain, server rendered, no client build step · a form that needs a bundle to display is a form that fails when the bundle does - Storage: SQLite for forms and submissions, uploads on disk beside it · the two must be backed up together or a restore gives you rows pointing at nothing - Schema: Immutable published versions, each submission stamped with the version it used · editing a live form is normal and must not change what old answers meant - Embedding: An iframe with a per-form origin allowlist and a postMessage resize channel · the embed is the feature and it is also where every cookie assumption breaks - Notification: A queued job with retries, never part of the submit request · an SMTP timeout must not turn into a lost lead ### Data model (create this before Phase 1) - 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. ### Phase 1 · A schema that can change without rewriting history Build: An admin that edits a draft schema and publishes it as a new immutable version. Fields carry a key generated at creation. A submission stores the version id it was rendered from, and viewing an old submission renders it against that version's labels and field order. Trap: 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. Done when: Renaming a field label leaves every existing answer attached to the same key, verified in the database. Deleting a field and adding a new one does not reuse the deleted key, asserted. Viewing a submission from a superseded version shows that version's labels and order, tested with two published versions. A draft schema cannot be submitted against; only published versions render publicly. Do not build yet: No embedding, uploads, spam handling or export yet. ### Phase 2 · One write that cannot fail for a stupid reason Build: Validate every submission against the stored published version on the server, ignoring whatever the browser did. Write the submission and its answers in one transaction, return the confirmation, then queue the notification as a job with retries and a visible failure log. Trap: 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. Done when: A submission is persisted before any outbound request is attempted, asserted by failing the mail transport in a test and confirming the row exists. A notification failure is recorded with its error and retried on a stated schedule, and never deletes or alters the submission. Server side validation rejects a payload that a modified page would have allowed, tested by posting directly. Two identical rapid submissions are distinguishable in the admin rather than silently merged. Do not build yet: No embed, uploads or spam handling yet. ### Phase 3 · The embed, where your cookies do not exist Build: Serve the form in an iframe, send frame-ancestors built from the per-form origin allowlist, and resize through postMessage with the origin checked on both sides. Carry the anti-forgery token in the form body: a signed value bound to the form version with an expiry, verified without reading any cookie at all. Trap: 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. Trap: 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. Done when: A form embedded on a different origin submits successfully in a browser with third party cookies blocked entirely, tested manually and recorded. No submission path reads a cookie, verified by review and by a test that strips the cookie header. A token replayed after its expiry, or against a different form version, is rejected with a message that offers to reload. An origin not in the form's allowlist cannot frame the form, checked in a browser and asserted against the response header. Do not build yet: No uploads, spam scoring or export yet. ### Phase 4 · Files and rubbish, the two things that fill the disk Build: Sniff uploaded bytes rather than trusting the declared type, store under a generated name in a directory that is never executed or served directly, and cap size and count per submission. Score submissions with a timing check, a hidden field and a per-address rate limit, and put failures in quarantine where a human can release them. Trap: 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. Trap: 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. Done when: A file whose declared type disagrees with its bytes is rejected or stored with the sniffed type, tested with a crafted file. Uploaded files are stored under generated names and are not reachable by guessing the original name, tested by request. A submission over the size or count cap is refused with a message naming the limit, not a generic error. A quarantined submission is visible, releasable, and never deleted automatically. Do not build yet: No export, retention or backup work yet. ### Phase 5 · Getting the answers out and keeping them Build: Export per form version as CSV, plus a combined export carrying a version column and the union of columns with a documented rule for fields that did not exist yet. Back up the database and the upload directory in one operation and restore both in the test suite. Set retention per form, and make deletion remove the row, the answers and the files. Trap: A single CSV across a form that changed shape either drops the columns added later or blanks them for old rows, and whoever opens it in a spreadsheet will not notice which. Export one file per version by default, and when a combined file is asked for, include a version column and mark a cell that could not exist rather than leaving it empty. Trap: Backing up the database without the upload directory, or the other way round, gives you a restore where half the submissions point at files that are not there. Take both in one operation with the database's own backup command, and restore the pair into an empty directory in the suite so the pairing is proven rather than assumed. Done when: Exporting a form with two published versions produces one file per version by default, checked. A combined export carries a version column and distinguishes an absent field from an empty answer, tested. A backup and restore into an empty directory yields every submission with its files intact, exercised in the suite. Deleting a submission removes its answers and its uploaded files, verified on disk. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment fields and signatures, which drag in a payment processor's obligations and an evidence trail that a weekend build should not be holding. - An integration shelf for spreadsheets, chat tools and customer systems, which is endless plumbing rather than a hard problem; ship one outbound webhook with retries instead. - Running forms for other people, which makes you responsible for their uptime and their data and is a different product. - A template gallery, which is the part of a paid form tool you are actually buying and is content work, not code. ### README must contain - That the anti-forgery token is signed into the form body rather than stored in a cookie, and why the embed makes that necessary - That published schema versions are immutable and each submission records the version it used - That the database and the upload directory must be backed up and restored together, with the command - That notifications are delivered out of band and that a delivery failure never loses a submission - That suspected spam is quarantined rather than deleted, and where to find it ===== ARCHITECTURE.md ===== # Architecture · Formasty ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A small Node server behind your own domain, server rendered, no client build step | a form that needs a bundle to display is a form that fails when the bundle does | | Storage | SQLite for forms and submissions, uploads on disk beside it | the two must be backed up together or a restore gives you rows pointing at nothing | | Schema | Immutable published versions, each submission stamped with the version it used | editing a live form is normal and must not change what old answers meant | | Embedding | An iframe with a per-form origin allowlist and a postMessage resize channel | the embed is the feature and it is also where every cookie assumption breaks | | Notification | A queued job with retries, never part of the submit request | an SMTP timeout must not turn into a lost lead | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Schema | Draft editing and immutable published versions. | Any editor, provided published versions stay immutable. | | Submit | Server side validation, the transaction and the token. | Any framework, provided the token never depends on a cookie. | | Embed | Frame headers, the resize channel and origin checks. | Any transport, provided both sides check the origin. | | Uploads | Sniffing, naming, limits and serving. | Any storage, provided nothing is served under a client supplied name. | | Delivery | Queued notifications, retries and the failure log. | Any channel, provided it runs after the write and never inside it. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Forms, versions, submissions and answers. Back up with the upload directory. - `UPLOAD_DIR` · required · Submitted files. Outside any directory that is served or executed. - `TOKEN_SIGNING_KEY` · required, secret · Signs the anti-forgery token carried in the form body. Rotating it invalidates open forms. - `SMTP_URL` · required, secret · Notification transport. Failures appear in the deliveries table. - `MAX_UPLOAD_BYTES` · required · Per file. Stated in the rejection message when exceeded. - `MAX_UPLOADS_PER_SUBMISSION` · required · Count cap, checked before anything is written to disk. - `SUBMISSION_RETENTION_DAYS` · required · Deletion removes the submission, its answers and its files. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Formasty product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A small Node server behind your own domain, server rendered, no client build step, SQLite for forms and submissions, uploads on disk beside it, Immutable published versions, each submission stamped with the version it used, An iframe with a per-form origin allowlist and a postMessage resize channel, A queued job with retries, never part of the submit request. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - A single CSV across a form that changed shape either drops the columns added later or blanks them for old rows, and whoever opens it in a spreadsheet will not notice which. Export one file per version by default, and when a combined file is asked for, include a version column and mark a cell that could not exist rather than leaving it empty. - Backing up the database without the upload directory, or the other way round, gives you a restore where half the submissions point at files that are not there. Take both in one operation with the database's own backup command, and restore the pair into an empty directory in the suite so the pairing is proven rather than assumed. ===== MILESTONES.md ===== # Delivery milestones · Formasty Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · 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. ### Steps 1. Generate a field key once and never reuse it The key is the only stable identity an answer has. Files: `src/schema/fields.ts`, `tests/field-keys.test.ts` 2. Publish versions rather than editing in place A published version is a record of what somebody was shown. Files: `src/schema/versions.ts` 3. Render history against the captured version Otherwise last year's answers arrive under this year's questions. Files: `src/views/submission.ejs` ### Done when - [ ] Renaming a label leaves answers attached to the same key - [ ] A deleted field's key is never reused - [ ] Old submissions render against their own version - [ ] A draft version cannot receive a submission ### 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. ## M2 · One write that cannot fail for a stupid reason A submission is validated server side, written, and acknowledged before anything is sent anywhere. ### Steps 1. Validate against the stored version on the server The browser's copy of your rules is a suggestion. Files: `src/submit/validate.ts`, `tests/validate.test.ts` 2. Write the submission in one transaction Answers and submission arrive together or not at all. Files: `src/submit/store.ts` 3. Queue delivery with retries and a failure log A mail server having a bad day is not a lost lead. Files: `src/delivery/queue.ts`, `src/views/admin/deliveries.ejs` ### Done when - [ ] The row exists even when the mail transport fails - [ ] Delivery attempts and errors are visible in the admin - [ ] A direct post bypassing the page is rejected - [ ] Duplicate rapid submissions stay distinguishable ### 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. ## M3 · The embed, where your cookies do not exist A form embedded on someone else's site submits correctly with third party cookies switched off. ### Steps 1. Sign the anti-forgery token into the form body The embed never receives the cookie you were relying on. Files: `src/submit/token.ts`, `tests/token.test.ts` 2. Set frame-ancestors from the per-form allowlist Anyone can iframe you otherwise, including a phishing page. Files: `src/middleware/headers.ts` 3. Check the origin on every postMessage, both directions A wildcard target origin is a broadcast. Files: `public/embed.js`, `src/views/embed.ejs` ### Done when - [ ] Submission works with third party cookies blocked - [ ] No submission path reads a cookie - [ ] An expired or mismatched token is rejected clearly - [ ] An unlisted origin cannot frame the form ### 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. ## M4 · Files and rubbish, the two things that fill the disk Uploads are stored safely and obvious spam is held rather than delivered or deleted. ### Steps 1. Sniff bytes and store under a generated name Both the extension and the declared type are attacker supplied. Files: `src/uploads/store.ts`, `tests/upload-type.test.ts` 2. Cap size and count and say the number A generic rejection makes people retry until they give up. Files: `src/uploads/limits.ts` 3. Quarantine rather than delete Your first false positive will be a real customer. Files: `src/spam/quarantine.ts` ### Done when - [ ] A mislabelled file is rejected or stored by sniffed type - [ ] Stored files are not reachable by their original name - [ ] Limits are refused with the number stated - [ ] Quarantined submissions are visible and releasable ### 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. ## M5 · Getting the answers out and keeping them (production only) Every submission can leave the system, and a restore brings back rows and files together. ### Steps 1. Export per version by default One table cannot honestly hold two shapes of form. Files: `src/export/csv.ts`, `tests/export.test.ts` 2. Back up database and uploads together Either alone restores to a half-working system. Files: `scripts/backup.sh`, `tests/restore.test.ts` 3. Make deletion cover rows and files A deleted submission whose photo is still on disk is not deleted. Files: `src/submissions/delete.ts` ### Done when - [ ] Default export is one file per published version - [ ] Combined export distinguishes absent from empty - [ ] Restore into an empty directory yields rows and files - [ ] Deletion removes answers and uploaded files ### Watch out - A single CSV across a form that changed shape either drops the columns added later or blanks them for old rows, and whoever opens it in a spreadsheet will not notice which. Export one file per version by default, and when a combined file is asked for, include a version column and mark a cell that could not exist rather than leaving it empty. - Backing up the database without the upload directory, or the other way round, gives you a restore where half the submissions point at files that are not there. Take both in one operation with the database's own backup command, and restore the pair into an empty directory in the suite so the pairing is proven rather than assumed. ===== OPERATIONS.md ===== # Operations · Formasty ## Backup Database and upload directory in one operation, on a schedule, with generations. A submission is something a person typed once and will not type again, which puts it a tier above most application data. ## Restore Restore both into an empty directory and confirm the submission count, that every upload row resolves to a file, and that an export of the largest form still produces the same columns. A restore that only covers the database is a half restore. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Delivery failures by day, quarantine depth, upload directory size against the disk, and whether the public form responded in the last five minutes from outside your network. The last one is the check that matters, because a form that is down fails silently by definition. ## Incident checklist The incident is a submission that never arrived or never got looked at. The deliveries table and the quarantine are the two places to look first, and both exist so that the answer is a record rather than a guess. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Renaming a field is confirmed not to change the meaning of past answers - [ ] The submission row is confirmed written before any outbound request - [ ] The embed is confirmed to submit with third party cookies blocked - [ ] A mislabelled upload is confirmed rejected or stored by sniffed type - [ ] A restore into an empty directory is confirmed to yield rows and files together ## Launch constraint Do not market omitted Formasty capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, versions, submissions and answers. Back up with the upload directory. DATABASE_PATH=./data/forms.db # Required. Submitted files. Outside any directory that is served or executed. UPLOAD_DIR=./data/uploads # Required · secret. Signs the anti-forgery token carried in the form body. Rotating it invalidates open forms. TOKEN_SIGNING_KEY=hex-64-chars-redacted # Required · secret. Notification transport. Failures appear in the deliveries table. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Required. Per file. Stated in the rejection message when exceeded. MAX_UPLOAD_BYTES=10485760 # Required. Count cap, checked before anything is written to disk. MAX_UPLOADS_PER_SUBMISSION=5 # Required. Deletion removes the submission, its answers and its files. SUBMISSION_RETENTION_DAYS=365
# Formasty · indie build 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: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A small Node server behind your own domain, server rendered, no client build step | a form that needs a bundle to display is a form that fails when the bundle does | | Storage | SQLite for forms and submissions, uploads on disk beside it | the two must be backed up together or a restore gives you rows pointing at nothing | | Schema | Immutable published versions, each submission stamped with the version it used | editing a live form is normal and must not change what old answers meant | | Embedding | An iframe with a per-form origin allowlist and a postMessage resize channel | the embed is the feature and it is also where every cookie assumption breaks | | Notification | A queued job with retries, never part of the submit request | an SMTP timeout must not turn into a lost lead | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server and the test suite. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **An always-on host with a domain and a certificate** · 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 - [ ] **Credentials for a mail transport** · free 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 - [ ] **A page on a different origin to test the embed from** · free - 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 - [ ] **How long submissions and uploads are kept** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Payment fields and signatures. - An integration shelf beyond one outbound webhook. - Hosting forms for other people. - A template gallery. - Deliverability: their notification email is warmed and authenticated, your SMTP box is not - The integration shelf: Sheets, Slack, Notion, CRM, Zapier style webhooks with retries and mapping UI - Template library and builder polish, which is most of what you are paying for in a form tool - Payment fields, e-signature, and anything that needs a compliance story like a signed DPA - Uptime and CDN edge delivery, so a form on a busy page fails silently when your box does If one of those is essential to you, that is the reason to keep paying for Formasty, and the README should say so rather than pretend.
# Build brief · Formasty The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a form builder that renders a versioned schema and stores every submission it receives to replace Formasty. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: A small Node server behind your own domain, server rendered, no client build step · a form that needs a bundle to display is a form that fails when the bundle does - Storage: SQLite for forms and submissions, uploads on disk beside it · the two must be backed up together or a restore gives you rows pointing at nothing - Schema: Immutable published versions, each submission stamped with the version it used · editing a live form is normal and must not change what old answers meant - Embedding: An iframe with a per-form origin allowlist and a postMessage resize channel · the embed is the feature and it is also where every cookie assumption breaks - Notification: A queued job with retries, never part of the submit request · an SMTP timeout must not turn into a lost lead ### Data model (create this before Phase 1) - 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. ### Phase 1 · A schema that can change without rewriting history Build: An admin that edits a draft schema and publishes it as a new immutable version. Fields carry a key generated at creation. A submission stores the version id it was rendered from, and viewing an old submission renders it against that version's labels and field order. Trap: 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. Done when: Renaming a field label leaves every existing answer attached to the same key, verified in the database. Deleting a field and adding a new one does not reuse the deleted key, asserted. Viewing a submission from a superseded version shows that version's labels and order, tested with two published versions. A draft schema cannot be submitted against; only published versions render publicly. Do not build yet: No embedding, uploads, spam handling or export yet. ### Phase 2 · One write that cannot fail for a stupid reason Build: Validate every submission against the stored published version on the server, ignoring whatever the browser did. Write the submission and its answers in one transaction, return the confirmation, then queue the notification as a job with retries and a visible failure log. Trap: 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. Done when: A submission is persisted before any outbound request is attempted, asserted by failing the mail transport in a test and confirming the row exists. A notification failure is recorded with its error and retried on a stated schedule, and never deletes or alters the submission. Server side validation rejects a payload that a modified page would have allowed, tested by posting directly. Two identical rapid submissions are distinguishable in the admin rather than silently merged. Do not build yet: No embed, uploads or spam handling yet. ### Phase 3 · The embed, where your cookies do not exist Build: Serve the form in an iframe, send frame-ancestors built from the per-form origin allowlist, and resize through postMessage with the origin checked on both sides. Carry the anti-forgery token in the form body: a signed value bound to the form version with an expiry, verified without reading any cookie at all. Trap: 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. Trap: 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. Done when: A form embedded on a different origin submits successfully in a browser with third party cookies blocked entirely, tested manually and recorded. No submission path reads a cookie, verified by review and by a test that strips the cookie header. A token replayed after its expiry, or against a different form version, is rejected with a message that offers to reload. An origin not in the form's allowlist cannot frame the form, checked in a browser and asserted against the response header. Do not build yet: No uploads, spam scoring or export yet. ### Phase 4 · Files and rubbish, the two things that fill the disk Build: Sniff uploaded bytes rather than trusting the declared type, store under a generated name in a directory that is never executed or served directly, and cap size and count per submission. Score submissions with a timing check, a hidden field and a per-address rate limit, and put failures in quarantine where a human can release them. Trap: 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. Trap: 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. Done when: A file whose declared type disagrees with its bytes is rejected or stored with the sniffed type, tested with a crafted file. Uploaded files are stored under generated names and are not reachable by guessing the original name, tested by request. A submission over the size or count cap is refused with a message naming the limit, not a generic error. A quarantined submission is visible, releasable, and never deleted automatically. Do not build yet: No export, retention or backup work yet. ### Phase 5 · Getting the answers out and keeping them Build: Export per form version as CSV, plus a combined export carrying a version column and the union of columns with a documented rule for fields that did not exist yet. Back up the database and the upload directory in one operation and restore both in the test suite. Set retention per form, and make deletion remove the row, the answers and the files. Trap: A single CSV across a form that changed shape either drops the columns added later or blanks them for old rows, and whoever opens it in a spreadsheet will not notice which. Export one file per version by default, and when a combined file is asked for, include a version column and mark a cell that could not exist rather than leaving it empty. Trap: Backing up the database without the upload directory, or the other way round, gives you a restore where half the submissions point at files that are not there. Take both in one operation with the database's own backup command, and restore the pair into an empty directory in the suite so the pairing is proven rather than assumed. Done when: Exporting a form with two published versions produces one file per version by default, checked. A combined export carries a version column and distinguishes an absent field from an empty answer, tested. A backup and restore into an empty directory yields every submission with its files intact, exercised in the suite. Deleting a submission removes its answers and its uploaded files, verified on disk. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment fields and signatures, which drag in a payment processor's obligations and an evidence trail that a weekend build should not be holding. - An integration shelf for spreadsheets, chat tools and customer systems, which is endless plumbing rather than a hard problem; ship one outbound webhook with retries instead. - Running forms for other people, which makes you responsible for their uptime and their data and is a different product. - A template gallery, which is the part of a paid form tool you are actually buying and is content work, not code. ### README must contain - That the anti-forgery token is signed into the form body rather than stored in a cookie, and why the embed makes that necessary - That published schema versions are immutable and each submission records the version it used - That the database and the upload directory must be backed up and restored together, with the command - That notifications are delivered out of band and that a delivery failure never loses a submission - That suspected spam is quarantined rather than deleted, and where to find it
# Agent instructions · Formasty indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A small Node server behind your own domain, server rendered, no client build step, SQLite for forms and submissions, uploads on disk beside it, Immutable published versions, each submission stamped with the version it used, An iframe with a per-form origin allowlist and a postMessage resize channel, A queued job with retries, never part of the submit request. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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.
# Build plan · Formasty 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 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. ### Steps 1. Generate a field key once and never reuse it The key is the only stable identity an answer has. Files: `src/schema/fields.ts`, `tests/field-keys.test.ts` 2. Publish versions rather than editing in place A published version is a record of what somebody was shown. Files: `src/schema/versions.ts` 3. Render history against the captured version Otherwise last year's answers arrive under this year's questions. Files: `src/views/submission.ejs` ### Done when - [ ] Renaming a label leaves answers attached to the same key - [ ] A deleted field's key is never reused - [ ] Old submissions render against their own version - [ ] A draft version cannot receive a submission ### 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. ## Phase 2 · One write that cannot fail for a stupid reason A submission is validated server side, written, and acknowledged before anything is sent anywhere. ### Steps 1. Validate against the stored version on the server The browser's copy of your rules is a suggestion. Files: `src/submit/validate.ts`, `tests/validate.test.ts` 2. Write the submission in one transaction Answers and submission arrive together or not at all. Files: `src/submit/store.ts` 3. Queue delivery with retries and a failure log A mail server having a bad day is not a lost lead. Files: `src/delivery/queue.ts`, `src/views/admin/deliveries.ejs` ### Done when - [ ] The row exists even when the mail transport fails - [ ] Delivery attempts and errors are visible in the admin - [ ] A direct post bypassing the page is rejected - [ ] Duplicate rapid submissions stay distinguishable ### 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. ## Phase 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. ### Steps 1. Sign the anti-forgery token into the form body The embed never receives the cookie you were relying on. Files: `src/submit/token.ts`, `tests/token.test.ts` 2. Set frame-ancestors from the per-form allowlist Anyone can iframe you otherwise, including a phishing page. Files: `src/middleware/headers.ts` 3. Check the origin on every postMessage, both directions A wildcard target origin is a broadcast. Files: `public/embed.js`, `src/views/embed.ejs` ### Done when - [ ] Submission works with third party cookies blocked - [ ] No submission path reads a cookie - [ ] An expired or mismatched token is rejected clearly - [ ] An unlisted origin cannot frame the form ### 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. ## Phase 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. ### Steps 1. Sniff bytes and store under a generated name Both the extension and the declared type are attacker supplied. Files: `src/uploads/store.ts`, `tests/upload-type.test.ts` 2. Cap size and count and say the number A generic rejection makes people retry until they give up. Files: `src/uploads/limits.ts` 3. Quarantine rather than delete Your first false positive will be a real customer. Files: `src/spam/quarantine.ts` ### Done when - [ ] A mislabelled file is rejected or stored by sniffed type - [ ] Stored files are not reachable by their original name - [ ] Limits are refused with the number stated - [ ] Quarantined submissions are visible and releasable ### 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. ## Not in this build - Payment fields and signatures. - An integration shelf beyond one outbound webhook. - Hosting forms for other people. - A template gallery. ## After v1, if you want it - Partial save and resume for long forms, which needs a token that survives a closed tab without becoming a way to read someone else's draft - A second notification channel so a mail outage is not a total outage - Per-field analytics on where people abandon, computed from timing already captured at submit
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, versions, submissions and answers. Back up with the upload directory. DATABASE_PATH=./data/forms.db # Required. Submitted files. Outside any directory that is served or executed. UPLOAD_DIR=./data/uploads # Required · secret. Signs the anti-forgery token carried in the form body. Rotating it invalidates open forms. TOKEN_SIGNING_KEY=hex-64-chars-redacted # Required · secret. Notification transport. Failures appear in the deliveries table. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Required. Per file. Stated in the rejection message when exceeded. MAX_UPLOAD_BYTES=10485760 # Required. Count cap, checked before anything is written to disk. MAX_UPLOADS_PER_SUBMISSION=5 # Required. Deletion removes the submission, its answers and its files. SUBMISSION_RETENTION_DAYS=365
# Formasty · product brief ## Problem A form builder is one of the most one-shottable categories there is: a schema, a renderer, a responses table, a CSV export. An agent will get you a working multi-step form with conditional fields and an embed snippet in a single session, and for a personal signup form or client intake that is genuinely enough. The gaps are the boring parts nobody thinks about until they bite: email that actually lands in inboxes, spam that gets past a honeypot, file uploads you have to back up, and the dozen integrations that turn a response into a row in someone else's tool. You also inherit uptime, because a form that is down is a lead you never knew existed. Build it if the form is yours and the volume is low; pay if the form is a business process. ## Product outcome A form service where editing a live form never rewrites past answers, the embed works with third party cookies switched off, uploads cannot be tricked by their own filename, and a restore brings back rows and files together. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - a small VPS or always-on box to host it - SMTP credentials for notification email - disk space and a backup habit for uploads and the SQLite file - your own domain if you want the embed to look credible ## Explicit non-goals for v1 - Payment fields and signatures. - An integration shelf beyond one outbound webhook. - Hosting forms for other people. - A template gallery. - Deliverability: their notification email is warmed and authenticated, your SMTP box is not - The integration shelf: Sheets, Slack, Notion, CRM, Zapier style webhooks with retries and mapping UI - Template library and builder polish, which is most of what you are paying for in a form tool - Payment fields, e-signature, and anything that needs a compliance story like a signed DPA - Uptime and CDN edge delivery, so a form on a busy page fails silently when your box does ## Success criteria - Renaming a field is confirmed not to change the meaning of past answers - The submission row is confirmed written before any outbound request - The embed is confirmed to submit with third party cookies blocked - A mislabelled upload is confirmed rejected or stored by sniffed type - A restore into an empty directory is confirmed to yield rows and files together
# Build brief · Formasty The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a form builder that renders a versioned schema and stores every submission it receives to replace Formasty. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: A small Node server behind your own domain, server rendered, no client build step · a form that needs a bundle to display is a form that fails when the bundle does - Storage: SQLite for forms and submissions, uploads on disk beside it · the two must be backed up together or a restore gives you rows pointing at nothing - Schema: Immutable published versions, each submission stamped with the version it used · editing a live form is normal and must not change what old answers meant - Embedding: An iframe with a per-form origin allowlist and a postMessage resize channel · the embed is the feature and it is also where every cookie assumption breaks - Notification: A queued job with retries, never part of the submit request · an SMTP timeout must not turn into a lost lead ### Data model (create this before Phase 1) - 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. ### Phase 1 · A schema that can change without rewriting history Build: An admin that edits a draft schema and publishes it as a new immutable version. Fields carry a key generated at creation. A submission stores the version id it was rendered from, and viewing an old submission renders it against that version's labels and field order. Trap: 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. Done when: Renaming a field label leaves every existing answer attached to the same key, verified in the database. Deleting a field and adding a new one does not reuse the deleted key, asserted. Viewing a submission from a superseded version shows that version's labels and order, tested with two published versions. A draft schema cannot be submitted against; only published versions render publicly. Do not build yet: No embedding, uploads, spam handling or export yet. ### Phase 2 · One write that cannot fail for a stupid reason Build: Validate every submission against the stored published version on the server, ignoring whatever the browser did. Write the submission and its answers in one transaction, return the confirmation, then queue the notification as a job with retries and a visible failure log. Trap: 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. Done when: A submission is persisted before any outbound request is attempted, asserted by failing the mail transport in a test and confirming the row exists. A notification failure is recorded with its error and retried on a stated schedule, and never deletes or alters the submission. Server side validation rejects a payload that a modified page would have allowed, tested by posting directly. Two identical rapid submissions are distinguishable in the admin rather than silently merged. Do not build yet: No embed, uploads or spam handling yet. ### Phase 3 · The embed, where your cookies do not exist Build: Serve the form in an iframe, send frame-ancestors built from the per-form origin allowlist, and resize through postMessage with the origin checked on both sides. Carry the anti-forgery token in the form body: a signed value bound to the form version with an expiry, verified without reading any cookie at all. Trap: 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. Trap: 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. Done when: A form embedded on a different origin submits successfully in a browser with third party cookies blocked entirely, tested manually and recorded. No submission path reads a cookie, verified by review and by a test that strips the cookie header. A token replayed after its expiry, or against a different form version, is rejected with a message that offers to reload. An origin not in the form's allowlist cannot frame the form, checked in a browser and asserted against the response header. Do not build yet: No uploads, spam scoring or export yet. ### Phase 4 · Files and rubbish, the two things that fill the disk Build: Sniff uploaded bytes rather than trusting the declared type, store under a generated name in a directory that is never executed or served directly, and cap size and count per submission. Score submissions with a timing check, a hidden field and a per-address rate limit, and put failures in quarantine where a human can release them. Trap: 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. Trap: 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. Done when: A file whose declared type disagrees with its bytes is rejected or stored with the sniffed type, tested with a crafted file. Uploaded files are stored under generated names and are not reachable by guessing the original name, tested by request. A submission over the size or count cap is refused with a message naming the limit, not a generic error. A quarantined submission is visible, releasable, and never deleted automatically. Do not build yet: No export, retention or backup work yet. ### Phase 5 · Getting the answers out and keeping them Build: Export per form version as CSV, plus a combined export carrying a version column and the union of columns with a documented rule for fields that did not exist yet. Back up the database and the upload directory in one operation and restore both in the test suite. Set retention per form, and make deletion remove the row, the answers and the files. Trap: A single CSV across a form that changed shape either drops the columns added later or blanks them for old rows, and whoever opens it in a spreadsheet will not notice which. Export one file per version by default, and when a combined file is asked for, include a version column and mark a cell that could not exist rather than leaving it empty. Trap: Backing up the database without the upload directory, or the other way round, gives you a restore where half the submissions point at files that are not there. Take both in one operation with the database's own backup command, and restore the pair into an empty directory in the suite so the pairing is proven rather than assumed. Done when: Exporting a form with two published versions produces one file per version by default, checked. A combined export carries a version column and distinguishes an absent field from an empty answer, tested. A backup and restore into an empty directory yields every submission with its files intact, exercised in the suite. Deleting a submission removes its answers and its uploaded files, verified on disk. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment fields and signatures, which drag in a payment processor's obligations and an evidence trail that a weekend build should not be holding. - An integration shelf for spreadsheets, chat tools and customer systems, which is endless plumbing rather than a hard problem; ship one outbound webhook with retries instead. - Running forms for other people, which makes you responsible for their uptime and their data and is a different product. - A template gallery, which is the part of a paid form tool you are actually buying and is content work, not code. ### README must contain - That the anti-forgery token is signed into the form body rather than stored in a cookie, and why the embed makes that necessary - That published schema versions are immutable and each submission records the version it used - That the database and the upload directory must be backed up and restored together, with the command - That notifications are delivered out of band and that a delivery failure never loses a submission - That suspected spam is quarantined rather than deleted, and where to find it
# Architecture · Formasty ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A small Node server behind your own domain, server rendered, no client build step | a form that needs a bundle to display is a form that fails when the bundle does | | Storage | SQLite for forms and submissions, uploads on disk beside it | the two must be backed up together or a restore gives you rows pointing at nothing | | Schema | Immutable published versions, each submission stamped with the version it used | editing a live form is normal and must not change what old answers meant | | Embedding | An iframe with a per-form origin allowlist and a postMessage resize channel | the embed is the feature and it is also where every cookie assumption breaks | | Notification | A queued job with retries, never part of the submit request | an SMTP timeout must not turn into a lost lead | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Schema | Draft editing and immutable published versions. | Any editor, provided published versions stay immutable. | | Submit | Server side validation, the transaction and the token. | Any framework, provided the token never depends on a cookie. | | Embed | Frame headers, the resize channel and origin checks. | Any transport, provided both sides check the origin. | | Uploads | Sniffing, naming, limits and serving. | Any storage, provided nothing is served under a client supplied name. | | Delivery | Queued notifications, retries and the failure log. | Any channel, provided it runs after the write and never inside it. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Forms, versions, submissions and answers. Back up with the upload directory. - `UPLOAD_DIR` · required · Submitted files. Outside any directory that is served or executed. - `TOKEN_SIGNING_KEY` · required, secret · Signs the anti-forgery token carried in the form body. Rotating it invalidates open forms. - `SMTP_URL` · required, secret · Notification transport. Failures appear in the deliveries table. - `MAX_UPLOAD_BYTES` · required · Per file. Stated in the rejection message when exceeded. - `MAX_UPLOADS_PER_SUBMISSION` · required · Count cap, checked before anything is written to disk. - `SUBMISSION_RETENTION_DAYS` · required · Deletion removes the submission, its answers and its files. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Formasty product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A small Node server behind your own domain, server rendered, no client build step, SQLite for forms and submissions, uploads on disk beside it, Immutable published versions, each submission stamped with the version it used, An iframe with a per-form origin allowlist and a postMessage resize channel, A queued job with retries, never part of the submit request. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - A single CSV across a form that changed shape either drops the columns added later or blanks them for old rows, and whoever opens it in a spreadsheet will not notice which. Export one file per version by default, and when a combined file is asked for, include a version column and mark a cell that could not exist rather than leaving it empty. - Backing up the database without the upload directory, or the other way round, gives you a restore where half the submissions point at files that are not there. Take both in one operation with the database's own backup command, and restore the pair into an empty directory in the suite so the pairing is proven rather than assumed.
# Delivery milestones · Formasty Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · 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. ### Steps 1. Generate a field key once and never reuse it The key is the only stable identity an answer has. Files: `src/schema/fields.ts`, `tests/field-keys.test.ts` 2. Publish versions rather than editing in place A published version is a record of what somebody was shown. Files: `src/schema/versions.ts` 3. Render history against the captured version Otherwise last year's answers arrive under this year's questions. Files: `src/views/submission.ejs` ### Done when - [ ] Renaming a label leaves answers attached to the same key - [ ] A deleted field's key is never reused - [ ] Old submissions render against their own version - [ ] A draft version cannot receive a submission ### 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. ## M2 · One write that cannot fail for a stupid reason A submission is validated server side, written, and acknowledged before anything is sent anywhere. ### Steps 1. Validate against the stored version on the server The browser's copy of your rules is a suggestion. Files: `src/submit/validate.ts`, `tests/validate.test.ts` 2. Write the submission in one transaction Answers and submission arrive together or not at all. Files: `src/submit/store.ts` 3. Queue delivery with retries and a failure log A mail server having a bad day is not a lost lead. Files: `src/delivery/queue.ts`, `src/views/admin/deliveries.ejs` ### Done when - [ ] The row exists even when the mail transport fails - [ ] Delivery attempts and errors are visible in the admin - [ ] A direct post bypassing the page is rejected - [ ] Duplicate rapid submissions stay distinguishable ### 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. ## M3 · The embed, where your cookies do not exist A form embedded on someone else's site submits correctly with third party cookies switched off. ### Steps 1. Sign the anti-forgery token into the form body The embed never receives the cookie you were relying on. Files: `src/submit/token.ts`, `tests/token.test.ts` 2. Set frame-ancestors from the per-form allowlist Anyone can iframe you otherwise, including a phishing page. Files: `src/middleware/headers.ts` 3. Check the origin on every postMessage, both directions A wildcard target origin is a broadcast. Files: `public/embed.js`, `src/views/embed.ejs` ### Done when - [ ] Submission works with third party cookies blocked - [ ] No submission path reads a cookie - [ ] An expired or mismatched token is rejected clearly - [ ] An unlisted origin cannot frame the form ### 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. ## M4 · Files and rubbish, the two things that fill the disk Uploads are stored safely and obvious spam is held rather than delivered or deleted. ### Steps 1. Sniff bytes and store under a generated name Both the extension and the declared type are attacker supplied. Files: `src/uploads/store.ts`, `tests/upload-type.test.ts` 2. Cap size and count and say the number A generic rejection makes people retry until they give up. Files: `src/uploads/limits.ts` 3. Quarantine rather than delete Your first false positive will be a real customer. Files: `src/spam/quarantine.ts` ### Done when - [ ] A mislabelled file is rejected or stored by sniffed type - [ ] Stored files are not reachable by their original name - [ ] Limits are refused with the number stated - [ ] Quarantined submissions are visible and releasable ### 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. ## M5 · Getting the answers out and keeping them (production only) Every submission can leave the system, and a restore brings back rows and files together. ### Steps 1. Export per version by default One table cannot honestly hold two shapes of form. Files: `src/export/csv.ts`, `tests/export.test.ts` 2. Back up database and uploads together Either alone restores to a half-working system. Files: `scripts/backup.sh`, `tests/restore.test.ts` 3. Make deletion cover rows and files A deleted submission whose photo is still on disk is not deleted. Files: `src/submissions/delete.ts` ### Done when - [ ] Default export is one file per published version - [ ] Combined export distinguishes absent from empty - [ ] Restore into an empty directory yields rows and files - [ ] Deletion removes answers and uploaded files ### Watch out - A single CSV across a form that changed shape either drops the columns added later or blanks them for old rows, and whoever opens it in a spreadsheet will not notice which. Export one file per version by default, and when a combined file is asked for, include a version column and mark a cell that could not exist rather than leaving it empty. - Backing up the database without the upload directory, or the other way round, gives you a restore where half the submissions point at files that are not there. Take both in one operation with the database's own backup command, and restore the pair into an empty directory in the suite so the pairing is proven rather than assumed.
# Operations · Formasty ## Backup Database and upload directory in one operation, on a schedule, with generations. A submission is something a person typed once and will not type again, which puts it a tier above most application data. ## Restore Restore both into an empty directory and confirm the submission count, that every upload row resolves to a file, and that an export of the largest form still produces the same columns. A restore that only covers the database is a half restore. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Delivery failures by day, quarantine depth, upload directory size against the disk, and whether the public form responded in the last five minutes from outside your network. The last one is the check that matters, because a form that is down fails silently by definition. ## Incident checklist The incident is a submission that never arrived or never got looked at. The deliveries table and the quarantine are the two places to look first, and both exist so that the answer is a record rather than a guess. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Renaming a field is confirmed not to change the meaning of past answers - [ ] The submission row is confirmed written before any outbound request - [ ] The embed is confirmed to submit with third party cookies blocked - [ ] A mislabelled upload is confirmed rejected or stored by sniffed type - [ ] A restore into an empty directory is confirmed to yield rows and files together ## Launch constraint Do not market omitted Formasty capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, versions, submissions and answers. Back up with the upload directory. DATABASE_PATH=./data/forms.db # Required. Submitted files. Outside any directory that is served or executed. UPLOAD_DIR=./data/uploads # Required · secret. Signs the anti-forgery token carried in the form body. Rotating it invalidates open forms. TOKEN_SIGNING_KEY=hex-64-chars-redacted # Required · secret. Notification transport. Failures appear in the deliveries table. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Required. Per file. Stated in the rejection message when exceeded. MAX_UPLOAD_BYTES=10485760 # Required. Count cap, checked before anything is written to disk. MAX_UPLOADS_PER_SUBMISSION=5 # Required. Deletion removes the submission, its answers and its files. SUBMISSION_RETENTION_DAYS=365
$ choose a build depth, inspect the files, then open the complete pack in your agent
Because forms are load-bearing. The moment a form is collecting real leads, applications, or client files, the cost of it quietly breaking exceeds any subscription, and you stop wanting to own the email deliverability, spam waves, and file storage yourself. Paid form tools also sell speed: a decent one gets a branded, conditional, multi-step form live in ten minutes with a template, which is faster than reviewing an agent's output. The self-hosted version wins when it is one form you control on a site you already run.
xDeliverability: their notification email is warmed and authenticated, your SMTP box is not
xThe integration shelf: Sheets, Slack, Notion, CRM, Zapier style webhooks with retries and mapping UI
xTemplate library and builder polish, which is most of what you are paying for in a form tool
xPayment fields, e-signature, and anything that needs a compliance story like a signed DPA
xUptime and CDN edge delivery, so a form on a busy page fails silently when your box does
Nothing worth pointing at. That's why the prompt exists.
Vibecode Formasty
Kinda. The core of Formasty is buildable in a weekend with the prompt on this page, but there are real gaps: Deliverability: their notification email is warmed and authenticated, your SMTP box is not, The integration shelf: Sheets, Slack, Notion, CRM, Zapier style webhooks with retries and mapping UI. Read the honest list above before committing.
How much does Formasty cost?
Formasty costs about $29/month (Pro, checked 2026-08-18), which is $348 per year.
What do I lose by replacing Formasty?
Honestly: Deliverability: their notification email is warmed and authenticated, your SMTP box is not; The integration shelf: Sheets, Slack, Notion, CRM, Zapier style webhooks with retries and mapping UI; Template library and builder polish, which is most of what you are paying for in a form tool; Payment fields, e-signature, and anything that needs a compliance story like a signed DPA; Uptime and CDN edge delivery, so a form on a busy page fails silently when your box does. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Formasty?
No mature open-source alternative worth pointing at, which is exactly why the one-shot prompt on this page exists.