Vibecode Wufoo
track this build4 phases, 12 steps, beginner friendly0%Wufoo's solo core is compact: build a online forms tool with a schema-based form editor, public share link, validation, response table, CSV export, and webhook. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on delivery, integrations, compliance.
You are building a lean indie version of Wufoo. 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 ===== # Wufoo · indie build A self-hosted form builder whose hard problems are never losing a submission on any failure path, letting a form's fields change without breaking the responses already collected, and holding back spam without punishing every honest respondent. Estimated effort: **one sitting**. 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 | One Node and TypeScript web application serving both the editor and the public forms | the public form has to be fast and dependency-free; the editor can be anything | | Storage | SQLite with the raw submission body stored verbatim alongside the parsed answers | the parsed copy is a convenience and the raw body is the evidence | | Schema | Versioned field definitions with stable keys that are never reused | a renamed field must not orphan six months of answers | | Delivery | A queue with retries for email and webhooks, outside the request | a mail server that is down must never cost you a response | | Spam | Honeypot, timing and rate limiting, with quarantine rather than deletion | a challenge that blocks a screen reader user is a worse outcome than a spam row | ## 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 editor and the public form endpoint. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A host with persistent disk and a real domain** · a few pounds a month - Why: Public forms need a stable address people can be sent to, and uploads plus SQLite both need a disk that survives a restart. - Get it: Any small virtual machine with a volume, and a domain with a certificate. - Verify: A form submitted from another network is stored and visible in the editor - [ ] **A transactional email provider for notifications** (optional) · usually free at small volume - Why: Notifications are the part most likely to fail, and the queue exists so that when they do, the response is already safe. - Get it: Any provider with a verified sending domain and an API key. - Verify: A notification arrives and the delivery row records the attempt - [ ] **The retention period and the deletion process** · free - Why: This is other people's personal data, and deciding afterwards means deciding it from whatever the code happens to do. - Get it: Write down how long submissions are kept per form, who can delete one, and what deletion removes. - Verify: The README states all three and the retention sweep enforces the number ## Quick start ```sh npm run reparse ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Payment fields and card handling. - Branching logic beyond show and hide. - A template gallery and theming system. - Sending from your own mail server. - enterprise workflow logic - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery If one of those is essential to you, that is the reason to keep paying for Wufoo, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Wufoo 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 publishes a public form and never loses a submission to replace Wufoo. 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. ### Rule zero A form has one job: not to lose what somebody typed. Everything else on this page is decoration around that job. So the submission endpoint writes the raw request body to durable storage and returns success before it validates anything, renders anything, or sends any email. Validation, notification, webhooks and file processing all happen after the response is safe, and none of them is allowed to discard a submission. A submission that fails validation is stored and flagged, not rejected into nothing. The second half of the job is that this data is not yours. A form collects other people's names, addresses and sometimes a good deal worse, on a page you sent them. Decide the retention period before the first form is published, put a delete control on every single submission, and keep the raw body out of every log line. If you cannot say how long a response is kept and how somebody gets theirs deleted, the form is not ready to publish. ### Stack (fixed, do not substitute) - Runtime: One Node and TypeScript web application serving both the editor and the public forms · the public form has to be fast and dependency-free; the editor can be anything - Storage: SQLite with the raw submission body stored verbatim alongside the parsed answers · the parsed copy is a convenience and the raw body is the evidence - Schema: Versioned field definitions with stable keys that are never reused · a renamed field must not orphan six months of answers - Delivery: A queue with retries for email and webhooks, outside the request · a mail server that is down must never cost you a response - Spam: Honeypot, timing and rate limiting, with quarantine rather than deletion · a challenge that blocks a screen reader user is a worse outcome than a spam row ### Data model (create this before Phase 1) - forms(id, slug, title, schema_version, published_at, closed_at, close_message, retention_days) - fields(id, form_id, key, type, label, required, options_json, position, removed_at) · key is stable and never reused, even after removal - submissions(id, form_id, schema_version, received_at, raw_body, content_type, state, spam_score) · state is stored, flagged, quarantined or deleted - answers(submission_id, field_key, value_text, value_number, value_json) · derived from the raw body and rebuildable from it - uploads(id, submission_id, field_key, filename, bytes, content_type, path, scanned_at) - deliveries(id, submission_id, channel, target, attempts, last_error, delivered_at) · email and webhook attempts, retried outside the request - The answers table can be dropped and rebuilt from raw_body. That is the test of whether the raw body really is being stored first. ### Phase 1 · Store it before you understand it Build: One public endpoint per form. Read the body, write it verbatim with the form id and schema version, flush, then respond. Parse into answers afterwards. Everything else, notification included, is queued work that reads from storage. Never hold a submission in memory while doing anything slow. Trap: Validating first and storing after means a field you got wrong, a mail server timing out or a crash between the two loses the response entirely, and the person who filled the form has no idea and will not do it twice. Write the raw body first, acknowledge, then do everything else against what is already on disk. Done when: Killing the process immediately after the response still leaves the raw body stored, tested by injecting a crash after the write. Dropping the answers table and rebuilding it from raw bodies reproduces every answer, tested. No outbound email or webhook happens inside the request, asserted by timing the endpoint with the mail host unreachable. A submission that fails validation is stored with a flagged state rather than refused. Do not build yet: No editor, spam handling, uploads or exports yet. ### Phase 2 · A form that can change its mind Build: Fields carry a stable key that is generated once and never reused, separate from the label. A structural change bumps the form's schema version, and each submission records the version it arrived under. Removing a field sets removed_at and keeps the definition, so old answers still render with their original label and type. Trap: Keying answers by the field's label or its position means renaming a question or dragging it up two places silently detaches every answer collected so far, and the response table quietly fills with blanks nobody can explain. Generate a key on creation, never reuse one, and store the schema version with every submission. Done when: Renaming a field's label leaves every existing answer attached and rendering under the old label, tested. Reordering fields changes nothing in stored answers, tested. A removed field's answers are still visible and exportable, with the field marked removed. Viewing a submission uses the schema version it arrived under, not the current one, tested by editing the form afterwards. Do not build yet: No validation rules, spam handling or delivery yet. ### Phase 3 · Validation that agrees with the browser Build: Validate on the server against the same definition the input types use in the browser, field by field. For email fields take the pattern the HTML specification publishes for the email input type rather than writing one. Enforce required, type, length, numeric range, and a per-field and per-form upload size. Return errors keyed by field, and keep the submission either way. Trap: Writing your own email regular expression rejects addresses that exist, and the people it rejects skew towards newer and longer domains who then cannot reach you and never find out why. The HTML specification defines exactly one pattern for the email input type and says openly that it departs from the mail standards on purpose, which is the point: copy that pattern, run it server side, and accept anything the browser accepted. Done when: A set of addresses that the browser's built-in email validation accepts is accepted by the server too, tested as a table of cases including long and recently created top level domains. Every refusal names the field and says what was wrong in plain language. An upload over the configured per-field or per-form limit is refused before the file is written to disk, tested. A submission failing validation is still stored, flagged, and visible in the editor. Do not build yet: No spam handling, exports or retention yet. ### Phase 4 · Spam, without punishing everyone else Build: A hidden field no human fills, a minimum time between render and submit, a per-address rate limit using a hashed and salted client address, and a link and keyword score. Anything that trips a rule goes to quarantine with the reason recorded, never to deletion. Quarantine is reviewable and a release puts the submission back into the normal flow including notification. Trap: Reaching for a visual or puzzle challenge as the first line makes the form harder for people using screen readers, older phones and slow connections, and the bots that actually target small forms solve it anyway. Start with the invisible signals, quarantine rather than delete, and read the quarantine for a week before deciding it needs anything stronger. Done when: A submission that fills the honeypot or arrives faster than the configured minimum is quarantined with the reason recorded, tested. Nothing is ever deleted by a spam rule, asserted by review of the code paths. Releasing from quarantine triggers the notifications that were held, tested. Client addresses are stored only as a salted hash and the salt is not in the repository, verified by inspecting the table. Do not build yet: No export, retention sweep or form closing yet. ### Phase 5 · Getting responses out, and getting them deleted Build: Retry deliveries with backoff and a cap, recording every attempt and the last error, with a manual resend. Export a form's submissions as CSV and JSON including removed fields and upload references, and reimport into an empty database. A per-submission delete removes the raw body, the answers and the uploaded files together. A retention sweep applies the form's retention period on a schedule. Trap: Deleting a submission row while the uploaded file stays on disk and the raw body sits in a webhook log means you told somebody their data was gone and it was not, which is the one failure here with consequences outside your own project. Make deletion one function that removes the row, the raw body, the derived answers and the files, and write a test that walks the disk afterwards. Done when: Deleting a submission removes its row, raw body, answers and uploaded files, verified by a test that scans the upload directory afterwards. A failed delivery is retried with backoff up to a cap, with every attempt and error recorded and a manual resend available, tested with an unreachable target. Export and reimport round-trip every submission including removed fields, tested. A closed form shows its close message and refuses new submissions with a clear page rather than an error. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment fields, which make you responsible for somebody else's card details and belong behind a hosted checkout rather than inside a form builder. - Conditional logic beyond showing and hiding a field, since branching rules become a small programming language and the state machine is where form builders go wrong. - A template gallery and theming system, which is most of the paid product's surface area and none of its difficulty. - Sending from your own mail server, because deliverability for transactional mail is a standing commitment rather than a feature you finish. ### README must contain - That the raw request body is stored before validation, notification or anything else, and that the answers table is rebuildable from it - That field keys are stable and never reused, and that every submission records the schema version it arrived under - That email validation copies the pattern published for the browser's email input type rather than a regular expression written here - That spam rules quarantine and never delete, and that client addresses are kept only as salted hashes - The retention period, where uploaded files live, and exactly what a per-submission delete removes ===== AGENTS.md ===== # Agent instructions · Wufoo indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One Node and TypeScript web application serving both the editor and the public forms, SQLite with the raw submission body stored verbatim alongside the parsed answers, Versioned field definitions with stable keys that are never reused, A queue with retries for email and webhooks, outside the request, Honeypot, timing and rate limiting, with quarantine rather than deletion. 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 - Validating first and storing after means a field you got wrong, a mail server timing out or a crash between the two loses the response entirely, and the person who filled the form has no idea and will not do it twice. Write the raw body first, acknowledge, then do everything else against what is already on disk. - Keying answers by the field's label or its position means renaming a question or dragging it up two places silently detaches every answer collected so far, and the response table quietly fills with blanks nobody can explain. Generate a key on creation, never reuse one, and store the schema version with every submission. - Writing your own email regular expression rejects addresses that exist, and the people it rejects skew towards newer and longer domains who then cannot reach you and never find out why. The HTML specification defines exactly one pattern for the email input type and says openly that it departs from the mail standards on purpose, which is the point: copy that pattern, run it server side, and accept anything the browser accepted. - Reaching for a visual or puzzle challenge as the first line makes the form harder for people using screen readers, older phones and slow connections, and the bots that actually target small forms solve it anyway. Start with the invisible signals, quarantine rather than delete, and read the quarantine for a week before deciding it needs anything stronger. ===== BUILD_PLAN.md ===== # Build plan · Wufoo A self-hosted form builder whose hard problems are never losing a submission on any failure path, letting a form's fields change without breaking the responses already collected, and holding back spam without punishing every honest respondent. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Store it before you understand it A submission is durably stored and acknowledged before any validation, email or webhook runs. ### Steps 1. Write the raw body, flush, then respond Everything after this line is recoverable; everything before it is not. Files: `src/routes/submit.ts` 2. Derive answers as a second pass you can rerun If it can be rebuilt from the raw body, you stored the right thing. Files: `src/submissions/parse.ts`, `tests/reparse.test.ts` ```sh npm run reparse ``` 3. Move email and webhooks out of the request A slow mail server should never be visible to the person submitting. Files: `src/delivery/queue.ts` ### Done when - [ ] A crash after the write still leaves the submission - [ ] Answers rebuild from raw bodies exactly - [ ] No outbound call happens inside the request - [ ] Invalid submissions are stored and flagged ### Watch out - Validating first and storing after means a field you got wrong, a mail server timing out or a crash between the two loses the response entirely, and the person who filled the form has no idea and will not do it twice. Write the raw body first, acknowledge, then do everything else against what is already on disk. ## Phase 2 · A form that can change its mind Editing a form's fields never breaks or rewrites the responses already collected. ### Steps 1. Generate a stable key per field and never reuse it The label is for humans and changes; the key is for data and does not. Files: `src/forms/fields.ts` 2. Version the schema and stamp every submission Rendering an old response needs the form as it was that day. Files: `src/forms/versions.ts`, `tests/schema-version.test.ts` 3. Soft delete fields, keep their definitions Deleting a question should not delete the answers. Files: `src/forms/fields.ts` ### Done when - [ ] Renaming a label keeps answers attached - [ ] Reordering changes no stored data - [ ] Removed fields still render and export - [ ] Submissions render under their own schema version ### Watch out - Keying answers by the field's label or its position means renaming a question or dragging it up two places silently detaches every answer collected so far, and the response table quietly fills with blanks nobody can explain. Generate a key on creation, never reuse one, and store the schema version with every submission. ## Phase 3 · Validation that agrees with the browser Server-side validation accepts everything the browser accepted and explains everything it refuses. ### Steps 1. Copy the published email pattern, do not invent one Being stricter than the browser only loses you real people. Files: `src/validate/email.ts`, `tests/email-cases.test.ts` 2. Return errors keyed by field, in plain language A single red banner teaches nobody what to fix. Files: `src/validate/form.ts` 3. Enforce upload limits before writing to disk Otherwise the limit is enforced by your disk filling up. Files: `src/uploads/receive.ts` ### Done when - [ ] Browser-valid email addresses are accepted server side - [ ] Errors name the field and the problem - [ ] Oversized uploads are refused before hitting disk - [ ] Invalid submissions are still stored and flagged ### Watch out - Writing your own email regular expression rejects addresses that exist, and the people it rejects skew towards newer and longer domains who then cannot reach you and never find out why. The HTML specification defines exactly one pattern for the email input type and says openly that it departs from the mail standards on purpose, which is the point: copy that pattern, run it server side, and accept anything the browser accepted. ## Phase 4 · Spam, without punishing everyone else Automated submissions are caught and quarantined without putting a puzzle in front of honest people. ### Steps 1. Start with honeypot, timing and rate limits Invisible signals cost honest people nothing. Files: `src/spam/rules.ts`, `tests/spam.test.ts` 2. Quarantine with a reason, never delete Your rules will be wrong sometimes and you need the evidence. Files: `src/spam/quarantine.ts` 3. Hash the client address with a salt outside the repository You need rate limiting, not a log of who visited. Files: `src/spam/rate-limit.ts` ### Done when - [ ] Honeypot and timing trips are quarantined with reasons - [ ] No spam rule deletes anything - [ ] Release from quarantine sends the held notifications - [ ] Addresses are stored only as salted hashes ### Watch out - Reaching for a visual or puzzle challenge as the first line makes the form harder for people using screen readers, older phones and slow connections, and the bots that actually target small forms solve it anyway. Start with the invisible signals, quarantine rather than delete, and read the quarantine for a week before deciding it needs anything stronger. ## Not in this build - Payment fields and card handling. - Branching logic beyond show and hide. - A template gallery and theming system. - Sending from your own mail server. ## After v1, if you want it - A per-form webhook signature so receivers can verify the payload came from you - Partial save for long forms, stored under the same raw-body-first rule - A summary view per field that reports counts and ranges without exporting the personal data ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, fields, submissions and deliveries. Back this up before anything else. DATABASE_PATH=./data/forms.db # Required. Uploaded files. Deletion removes these along with the row. UPLOAD_DIR=./data/uploads # Required. Refused before the file is written, not after. MAX_UPLOAD_BYTES_PER_FIELD=10485760 # Required. The whole form's ceiling, checked as parts arrive. MAX_UPLOAD_BYTES_PER_SUBMISSION=26214400 # Required · secret. Salts the hashed client address used for rate limiting. Never commit it. IP_HASH_SALT=a long random string, redacted # Required. Anything faster than this from render to submit is quarantined. MIN_SUBMIT_SECONDS=3 # Optional · secret. Notification delivery. Without it the app works and deliveries queue as skipped. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Required. Applied by the retention sweep unless a form overrides it. DEFAULT_RETENTION_DAYS=365
You are building a lean indie version of Wufoo. 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 ===== # Wufoo · indie build A self-hosted form builder whose hard problems are never losing a submission on any failure path, letting a form's fields change without breaking the responses already collected, and holding back spam without punishing every honest respondent. Estimated effort: **one sitting**. 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 | One Node and TypeScript web application serving both the editor and the public forms | the public form has to be fast and dependency-free; the editor can be anything | | Storage | SQLite with the raw submission body stored verbatim alongside the parsed answers | the parsed copy is a convenience and the raw body is the evidence | | Schema | Versioned field definitions with stable keys that are never reused | a renamed field must not orphan six months of answers | | Delivery | A queue with retries for email and webhooks, outside the request | a mail server that is down must never cost you a response | | Spam | Honeypot, timing and rate limiting, with quarantine rather than deletion | a challenge that blocks a screen reader user is a worse outcome than a spam row | ## 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 editor and the public form endpoint. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A host with persistent disk and a real domain** · a few pounds a month - Why: Public forms need a stable address people can be sent to, and uploads plus SQLite both need a disk that survives a restart. - Get it: Any small virtual machine with a volume, and a domain with a certificate. - Verify: A form submitted from another network is stored and visible in the editor - [ ] **A transactional email provider for notifications** (optional) · usually free at small volume - Why: Notifications are the part most likely to fail, and the queue exists so that when they do, the response is already safe. - Get it: Any provider with a verified sending domain and an API key. - Verify: A notification arrives and the delivery row records the attempt - [ ] **The retention period and the deletion process** · free - Why: This is other people's personal data, and deciding afterwards means deciding it from whatever the code happens to do. - Get it: Write down how long submissions are kept per form, who can delete one, and what deletion removes. - Verify: The README states all three and the retention sweep enforces the number ## Quick start ```sh npm run reparse ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Payment fields and card handling. - Branching logic beyond show and hide. - A template gallery and theming system. - Sending from your own mail server. - enterprise workflow logic - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery If one of those is essential to you, that is the reason to keep paying for Wufoo, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Wufoo 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 publishes a public form and never loses a submission to replace Wufoo. 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. ### Rule zero A form has one job: not to lose what somebody typed. Everything else on this page is decoration around that job. So the submission endpoint writes the raw request body to durable storage and returns success before it validates anything, renders anything, or sends any email. Validation, notification, webhooks and file processing all happen after the response is safe, and none of them is allowed to discard a submission. A submission that fails validation is stored and flagged, not rejected into nothing. The second half of the job is that this data is not yours. A form collects other people's names, addresses and sometimes a good deal worse, on a page you sent them. Decide the retention period before the first form is published, put a delete control on every single submission, and keep the raw body out of every log line. If you cannot say how long a response is kept and how somebody gets theirs deleted, the form is not ready to publish. ### Stack (fixed, do not substitute) - Runtime: One Node and TypeScript web application serving both the editor and the public forms · the public form has to be fast and dependency-free; the editor can be anything - Storage: SQLite with the raw submission body stored verbatim alongside the parsed answers · the parsed copy is a convenience and the raw body is the evidence - Schema: Versioned field definitions with stable keys that are never reused · a renamed field must not orphan six months of answers - Delivery: A queue with retries for email and webhooks, outside the request · a mail server that is down must never cost you a response - Spam: Honeypot, timing and rate limiting, with quarantine rather than deletion · a challenge that blocks a screen reader user is a worse outcome than a spam row ### Data model (create this before Phase 1) - forms(id, slug, title, schema_version, published_at, closed_at, close_message, retention_days) - fields(id, form_id, key, type, label, required, options_json, position, removed_at) · key is stable and never reused, even after removal - submissions(id, form_id, schema_version, received_at, raw_body, content_type, state, spam_score) · state is stored, flagged, quarantined or deleted - answers(submission_id, field_key, value_text, value_number, value_json) · derived from the raw body and rebuildable from it - uploads(id, submission_id, field_key, filename, bytes, content_type, path, scanned_at) - deliveries(id, submission_id, channel, target, attempts, last_error, delivered_at) · email and webhook attempts, retried outside the request - The answers table can be dropped and rebuilt from raw_body. That is the test of whether the raw body really is being stored first. ### Phase 1 · Store it before you understand it Build: One public endpoint per form. Read the body, write it verbatim with the form id and schema version, flush, then respond. Parse into answers afterwards. Everything else, notification included, is queued work that reads from storage. Never hold a submission in memory while doing anything slow. Trap: Validating first and storing after means a field you got wrong, a mail server timing out or a crash between the two loses the response entirely, and the person who filled the form has no idea and will not do it twice. Write the raw body first, acknowledge, then do everything else against what is already on disk. Done when: Killing the process immediately after the response still leaves the raw body stored, tested by injecting a crash after the write. Dropping the answers table and rebuilding it from raw bodies reproduces every answer, tested. No outbound email or webhook happens inside the request, asserted by timing the endpoint with the mail host unreachable. A submission that fails validation is stored with a flagged state rather than refused. Do not build yet: No editor, spam handling, uploads or exports yet. ### Phase 2 · A form that can change its mind Build: Fields carry a stable key that is generated once and never reused, separate from the label. A structural change bumps the form's schema version, and each submission records the version it arrived under. Removing a field sets removed_at and keeps the definition, so old answers still render with their original label and type. Trap: Keying answers by the field's label or its position means renaming a question or dragging it up two places silently detaches every answer collected so far, and the response table quietly fills with blanks nobody can explain. Generate a key on creation, never reuse one, and store the schema version with every submission. Done when: Renaming a field's label leaves every existing answer attached and rendering under the old label, tested. Reordering fields changes nothing in stored answers, tested. A removed field's answers are still visible and exportable, with the field marked removed. Viewing a submission uses the schema version it arrived under, not the current one, tested by editing the form afterwards. Do not build yet: No validation rules, spam handling or delivery yet. ### Phase 3 · Validation that agrees with the browser Build: Validate on the server against the same definition the input types use in the browser, field by field. For email fields take the pattern the HTML specification publishes for the email input type rather than writing one. Enforce required, type, length, numeric range, and a per-field and per-form upload size. Return errors keyed by field, and keep the submission either way. Trap: Writing your own email regular expression rejects addresses that exist, and the people it rejects skew towards newer and longer domains who then cannot reach you and never find out why. The HTML specification defines exactly one pattern for the email input type and says openly that it departs from the mail standards on purpose, which is the point: copy that pattern, run it server side, and accept anything the browser accepted. Done when: A set of addresses that the browser's built-in email validation accepts is accepted by the server too, tested as a table of cases including long and recently created top level domains. Every refusal names the field and says what was wrong in plain language. An upload over the configured per-field or per-form limit is refused before the file is written to disk, tested. A submission failing validation is still stored, flagged, and visible in the editor. Do not build yet: No spam handling, exports or retention yet. ### Phase 4 · Spam, without punishing everyone else Build: A hidden field no human fills, a minimum time between render and submit, a per-address rate limit using a hashed and salted client address, and a link and keyword score. Anything that trips a rule goes to quarantine with the reason recorded, never to deletion. Quarantine is reviewable and a release puts the submission back into the normal flow including notification. Trap: Reaching for a visual or puzzle challenge as the first line makes the form harder for people using screen readers, older phones and slow connections, and the bots that actually target small forms solve it anyway. Start with the invisible signals, quarantine rather than delete, and read the quarantine for a week before deciding it needs anything stronger. Done when: A submission that fills the honeypot or arrives faster than the configured minimum is quarantined with the reason recorded, tested. Nothing is ever deleted by a spam rule, asserted by review of the code paths. Releasing from quarantine triggers the notifications that were held, tested. Client addresses are stored only as a salted hash and the salt is not in the repository, verified by inspecting the table. Do not build yet: No export, retention sweep or form closing yet. ### Phase 5 · Getting responses out, and getting them deleted Build: Retry deliveries with backoff and a cap, recording every attempt and the last error, with a manual resend. Export a form's submissions as CSV and JSON including removed fields and upload references, and reimport into an empty database. A per-submission delete removes the raw body, the answers and the uploaded files together. A retention sweep applies the form's retention period on a schedule. Trap: Deleting a submission row while the uploaded file stays on disk and the raw body sits in a webhook log means you told somebody their data was gone and it was not, which is the one failure here with consequences outside your own project. Make deletion one function that removes the row, the raw body, the derived answers and the files, and write a test that walks the disk afterwards. Done when: Deleting a submission removes its row, raw body, answers and uploaded files, verified by a test that scans the upload directory afterwards. A failed delivery is retried with backoff up to a cap, with every attempt and error recorded and a manual resend available, tested with an unreachable target. Export and reimport round-trip every submission including removed fields, tested. A closed form shows its close message and refuses new submissions with a clear page rather than an error. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment fields, which make you responsible for somebody else's card details and belong behind a hosted checkout rather than inside a form builder. - Conditional logic beyond showing and hiding a field, since branching rules become a small programming language and the state machine is where form builders go wrong. - A template gallery and theming system, which is most of the paid product's surface area and none of its difficulty. - Sending from your own mail server, because deliverability for transactional mail is a standing commitment rather than a feature you finish. ### README must contain - That the raw request body is stored before validation, notification or anything else, and that the answers table is rebuildable from it - That field keys are stable and never reused, and that every submission records the schema version it arrived under - That email validation copies the pattern published for the browser's email input type rather than a regular expression written here - That spam rules quarantine and never delete, and that client addresses are kept only as salted hashes - The retention period, where uploaded files live, and exactly what a per-submission delete removes ===== AGENTS.md ===== # Agent instructions · Wufoo indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One Node and TypeScript web application serving both the editor and the public forms, SQLite with the raw submission body stored verbatim alongside the parsed answers, Versioned field definitions with stable keys that are never reused, A queue with retries for email and webhooks, outside the request, Honeypot, timing and rate limiting, with quarantine rather than deletion. 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 - Validating first and storing after means a field you got wrong, a mail server timing out or a crash between the two loses the response entirely, and the person who filled the form has no idea and will not do it twice. Write the raw body first, acknowledge, then do everything else against what is already on disk. - Keying answers by the field's label or its position means renaming a question or dragging it up two places silently detaches every answer collected so far, and the response table quietly fills with blanks nobody can explain. Generate a key on creation, never reuse one, and store the schema version with every submission. - Writing your own email regular expression rejects addresses that exist, and the people it rejects skew towards newer and longer domains who then cannot reach you and never find out why. The HTML specification defines exactly one pattern for the email input type and says openly that it departs from the mail standards on purpose, which is the point: copy that pattern, run it server side, and accept anything the browser accepted. - Reaching for a visual or puzzle challenge as the first line makes the form harder for people using screen readers, older phones and slow connections, and the bots that actually target small forms solve it anyway. Start with the invisible signals, quarantine rather than delete, and read the quarantine for a week before deciding it needs anything stronger. ===== BUILD_PLAN.md ===== # Build plan · Wufoo A self-hosted form builder whose hard problems are never losing a submission on any failure path, letting a form's fields change without breaking the responses already collected, and holding back spam without punishing every honest respondent. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Store it before you understand it A submission is durably stored and acknowledged before any validation, email or webhook runs. ### Steps 1. Write the raw body, flush, then respond Everything after this line is recoverable; everything before it is not. Files: `src/routes/submit.ts` 2. Derive answers as a second pass you can rerun If it can be rebuilt from the raw body, you stored the right thing. Files: `src/submissions/parse.ts`, `tests/reparse.test.ts` ```sh npm run reparse ``` 3. Move email and webhooks out of the request A slow mail server should never be visible to the person submitting. Files: `src/delivery/queue.ts` ### Done when - [ ] A crash after the write still leaves the submission - [ ] Answers rebuild from raw bodies exactly - [ ] No outbound call happens inside the request - [ ] Invalid submissions are stored and flagged ### Watch out - Validating first and storing after means a field you got wrong, a mail server timing out or a crash between the two loses the response entirely, and the person who filled the form has no idea and will not do it twice. Write the raw body first, acknowledge, then do everything else against what is already on disk. ## Phase 2 · A form that can change its mind Editing a form's fields never breaks or rewrites the responses already collected. ### Steps 1. Generate a stable key per field and never reuse it The label is for humans and changes; the key is for data and does not. Files: `src/forms/fields.ts` 2. Version the schema and stamp every submission Rendering an old response needs the form as it was that day. Files: `src/forms/versions.ts`, `tests/schema-version.test.ts` 3. Soft delete fields, keep their definitions Deleting a question should not delete the answers. Files: `src/forms/fields.ts` ### Done when - [ ] Renaming a label keeps answers attached - [ ] Reordering changes no stored data - [ ] Removed fields still render and export - [ ] Submissions render under their own schema version ### Watch out - Keying answers by the field's label or its position means renaming a question or dragging it up two places silently detaches every answer collected so far, and the response table quietly fills with blanks nobody can explain. Generate a key on creation, never reuse one, and store the schema version with every submission. ## Phase 3 · Validation that agrees with the browser Server-side validation accepts everything the browser accepted and explains everything it refuses. ### Steps 1. Copy the published email pattern, do not invent one Being stricter than the browser only loses you real people. Files: `src/validate/email.ts`, `tests/email-cases.test.ts` 2. Return errors keyed by field, in plain language A single red banner teaches nobody what to fix. Files: `src/validate/form.ts` 3. Enforce upload limits before writing to disk Otherwise the limit is enforced by your disk filling up. Files: `src/uploads/receive.ts` ### Done when - [ ] Browser-valid email addresses are accepted server side - [ ] Errors name the field and the problem - [ ] Oversized uploads are refused before hitting disk - [ ] Invalid submissions are still stored and flagged ### Watch out - Writing your own email regular expression rejects addresses that exist, and the people it rejects skew towards newer and longer domains who then cannot reach you and never find out why. The HTML specification defines exactly one pattern for the email input type and says openly that it departs from the mail standards on purpose, which is the point: copy that pattern, run it server side, and accept anything the browser accepted. ## Phase 4 · Spam, without punishing everyone else Automated submissions are caught and quarantined without putting a puzzle in front of honest people. ### Steps 1. Start with honeypot, timing and rate limits Invisible signals cost honest people nothing. Files: `src/spam/rules.ts`, `tests/spam.test.ts` 2. Quarantine with a reason, never delete Your rules will be wrong sometimes and you need the evidence. Files: `src/spam/quarantine.ts` 3. Hash the client address with a salt outside the repository You need rate limiting, not a log of who visited. Files: `src/spam/rate-limit.ts` ### Done when - [ ] Honeypot and timing trips are quarantined with reasons - [ ] No spam rule deletes anything - [ ] Release from quarantine sends the held notifications - [ ] Addresses are stored only as salted hashes ### Watch out - Reaching for a visual or puzzle challenge as the first line makes the form harder for people using screen readers, older phones and slow connections, and the bots that actually target small forms solve it anyway. Start with the invisible signals, quarantine rather than delete, and read the quarantine for a week before deciding it needs anything stronger. ## Not in this build - Payment fields and card handling. - Branching logic beyond show and hide. - A template gallery and theming system. - Sending from your own mail server. ## After v1, if you want it - A per-form webhook signature so receivers can verify the payload came from you - Partial save for long forms, stored under the same raw-body-first rule - A summary view per field that reports counts and ranges without exporting the personal data ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, fields, submissions and deliveries. Back this up before anything else. DATABASE_PATH=./data/forms.db # Required. Uploaded files. Deletion removes these along with the row. UPLOAD_DIR=./data/uploads # Required. Refused before the file is written, not after. MAX_UPLOAD_BYTES_PER_FIELD=10485760 # Required. The whole form's ceiling, checked as parts arrive. MAX_UPLOAD_BYTES_PER_SUBMISSION=26214400 # Required · secret. Salts the hashed client address used for rate limiting. Never commit it. IP_HASH_SALT=a long random string, redacted # Required. Anything faster than this from render to submit is quarantined. MIN_SUBMIT_SECONDS=3 # Optional · secret. Notification delivery. Without it the app works and deliveries queue as skipped. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Required. Applied by the retention sweep unless a form overrides it. DEFAULT_RETENTION_DAYS=365
You are building a production product version of Wufoo. 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 ===== # Wufoo · product brief ## Problem Wufoo's solo core is compact: build a online forms tool with a schema-based form editor, public share link, validation, response table, CSV export, and webhook. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on delivery, integrations, compliance. ## Product outcome A form that keeps what people typed through crashes, validation mistakes and a dead mail server, lets you rewrite the questions without losing the answers, and can genuinely delete a single response when somebody asks. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - deploy target - SMTP or transactional email key in .env ## Explicit non-goals for v1 - Payment fields and card handling. - Branching logic beyond show and hide. - A template gallery and theming system. - Sending from your own mail server. - enterprise workflow logic - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery ## Success criteria - A crash immediately after the write is confirmed to leave the submission stored - Answers are confirmed rebuildable from raw bodies alone - Renaming and reordering fields is confirmed to keep existing answers attached - No spam rule is confirmed to delete anything - A per-submission delete is confirmed to remove the row, the body, the answers and the files ===== BRIEF.md ===== # Build brief · Wufoo 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 publishes a public form and never loses a submission to replace Wufoo. 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. ### Rule zero A form has one job: not to lose what somebody typed. Everything else on this page is decoration around that job. So the submission endpoint writes the raw request body to durable storage and returns success before it validates anything, renders anything, or sends any email. Validation, notification, webhooks and file processing all happen after the response is safe, and none of them is allowed to discard a submission. A submission that fails validation is stored and flagged, not rejected into nothing. The second half of the job is that this data is not yours. A form collects other people's names, addresses and sometimes a good deal worse, on a page you sent them. Decide the retention period before the first form is published, put a delete control on every single submission, and keep the raw body out of every log line. If you cannot say how long a response is kept and how somebody gets theirs deleted, the form is not ready to publish. ### Stack (fixed, do not substitute) - Runtime: One Node and TypeScript web application serving both the editor and the public forms · the public form has to be fast and dependency-free; the editor can be anything - Storage: SQLite with the raw submission body stored verbatim alongside the parsed answers · the parsed copy is a convenience and the raw body is the evidence - Schema: Versioned field definitions with stable keys that are never reused · a renamed field must not orphan six months of answers - Delivery: A queue with retries for email and webhooks, outside the request · a mail server that is down must never cost you a response - Spam: Honeypot, timing and rate limiting, with quarantine rather than deletion · a challenge that blocks a screen reader user is a worse outcome than a spam row ### Data model (create this before Phase 1) - forms(id, slug, title, schema_version, published_at, closed_at, close_message, retention_days) - fields(id, form_id, key, type, label, required, options_json, position, removed_at) · key is stable and never reused, even after removal - submissions(id, form_id, schema_version, received_at, raw_body, content_type, state, spam_score) · state is stored, flagged, quarantined or deleted - answers(submission_id, field_key, value_text, value_number, value_json) · derived from the raw body and rebuildable from it - uploads(id, submission_id, field_key, filename, bytes, content_type, path, scanned_at) - deliveries(id, submission_id, channel, target, attempts, last_error, delivered_at) · email and webhook attempts, retried outside the request - The answers table can be dropped and rebuilt from raw_body. That is the test of whether the raw body really is being stored first. ### Phase 1 · Store it before you understand it Build: One public endpoint per form. Read the body, write it verbatim with the form id and schema version, flush, then respond. Parse into answers afterwards. Everything else, notification included, is queued work that reads from storage. Never hold a submission in memory while doing anything slow. Trap: Validating first and storing after means a field you got wrong, a mail server timing out or a crash between the two loses the response entirely, and the person who filled the form has no idea and will not do it twice. Write the raw body first, acknowledge, then do everything else against what is already on disk. Done when: Killing the process immediately after the response still leaves the raw body stored, tested by injecting a crash after the write. Dropping the answers table and rebuilding it from raw bodies reproduces every answer, tested. No outbound email or webhook happens inside the request, asserted by timing the endpoint with the mail host unreachable. A submission that fails validation is stored with a flagged state rather than refused. Do not build yet: No editor, spam handling, uploads or exports yet. ### Phase 2 · A form that can change its mind Build: Fields carry a stable key that is generated once and never reused, separate from the label. A structural change bumps the form's schema version, and each submission records the version it arrived under. Removing a field sets removed_at and keeps the definition, so old answers still render with their original label and type. Trap: Keying answers by the field's label or its position means renaming a question or dragging it up two places silently detaches every answer collected so far, and the response table quietly fills with blanks nobody can explain. Generate a key on creation, never reuse one, and store the schema version with every submission. Done when: Renaming a field's label leaves every existing answer attached and rendering under the old label, tested. Reordering fields changes nothing in stored answers, tested. A removed field's answers are still visible and exportable, with the field marked removed. Viewing a submission uses the schema version it arrived under, not the current one, tested by editing the form afterwards. Do not build yet: No validation rules, spam handling or delivery yet. ### Phase 3 · Validation that agrees with the browser Build: Validate on the server against the same definition the input types use in the browser, field by field. For email fields take the pattern the HTML specification publishes for the email input type rather than writing one. Enforce required, type, length, numeric range, and a per-field and per-form upload size. Return errors keyed by field, and keep the submission either way. Trap: Writing your own email regular expression rejects addresses that exist, and the people it rejects skew towards newer and longer domains who then cannot reach you and never find out why. The HTML specification defines exactly one pattern for the email input type and says openly that it departs from the mail standards on purpose, which is the point: copy that pattern, run it server side, and accept anything the browser accepted. Done when: A set of addresses that the browser's built-in email validation accepts is accepted by the server too, tested as a table of cases including long and recently created top level domains. Every refusal names the field and says what was wrong in plain language. An upload over the configured per-field or per-form limit is refused before the file is written to disk, tested. A submission failing validation is still stored, flagged, and visible in the editor. Do not build yet: No spam handling, exports or retention yet. ### Phase 4 · Spam, without punishing everyone else Build: A hidden field no human fills, a minimum time between render and submit, a per-address rate limit using a hashed and salted client address, and a link and keyword score. Anything that trips a rule goes to quarantine with the reason recorded, never to deletion. Quarantine is reviewable and a release puts the submission back into the normal flow including notification. Trap: Reaching for a visual or puzzle challenge as the first line makes the form harder for people using screen readers, older phones and slow connections, and the bots that actually target small forms solve it anyway. Start with the invisible signals, quarantine rather than delete, and read the quarantine for a week before deciding it needs anything stronger. Done when: A submission that fills the honeypot or arrives faster than the configured minimum is quarantined with the reason recorded, tested. Nothing is ever deleted by a spam rule, asserted by review of the code paths. Releasing from quarantine triggers the notifications that were held, tested. Client addresses are stored only as a salted hash and the salt is not in the repository, verified by inspecting the table. Do not build yet: No export, retention sweep or form closing yet. ### Phase 5 · Getting responses out, and getting them deleted Build: Retry deliveries with backoff and a cap, recording every attempt and the last error, with a manual resend. Export a form's submissions as CSV and JSON including removed fields and upload references, and reimport into an empty database. A per-submission delete removes the raw body, the answers and the uploaded files together. A retention sweep applies the form's retention period on a schedule. Trap: Deleting a submission row while the uploaded file stays on disk and the raw body sits in a webhook log means you told somebody their data was gone and it was not, which is the one failure here with consequences outside your own project. Make deletion one function that removes the row, the raw body, the derived answers and the files, and write a test that walks the disk afterwards. Done when: Deleting a submission removes its row, raw body, answers and uploaded files, verified by a test that scans the upload directory afterwards. A failed delivery is retried with backoff up to a cap, with every attempt and error recorded and a manual resend available, tested with an unreachable target. Export and reimport round-trip every submission including removed fields, tested. A closed form shows its close message and refuses new submissions with a clear page rather than an error. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment fields, which make you responsible for somebody else's card details and belong behind a hosted checkout rather than inside a form builder. - Conditional logic beyond showing and hiding a field, since branching rules become a small programming language and the state machine is where form builders go wrong. - A template gallery and theming system, which is most of the paid product's surface area and none of its difficulty. - Sending from your own mail server, because deliverability for transactional mail is a standing commitment rather than a feature you finish. ### README must contain - That the raw request body is stored before validation, notification or anything else, and that the answers table is rebuildable from it - That field keys are stable and never reused, and that every submission records the schema version it arrived under - That email validation copies the pattern published for the browser's email input type rather than a regular expression written here - That spam rules quarantine and never delete, and that client addresses are kept only as salted hashes - The retention period, where uploaded files live, and exactly what a per-submission delete removes ===== ARCHITECTURE.md ===== # Architecture · Wufoo ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One Node and TypeScript web application serving both the editor and the public forms | the public form has to be fast and dependency-free; the editor can be anything | | Storage | SQLite with the raw submission body stored verbatim alongside the parsed answers | the parsed copy is a convenience and the raw body is the evidence | | Schema | Versioned field definitions with stable keys that are never reused | a renamed field must not orphan six months of answers | | Delivery | A queue with retries for email and webhooks, outside the request | a mail server that is down must never cost you a response | | Spam | Honeypot, timing and rate limiting, with quarantine rather than deletion | a challenge that blocks a screen reader user is a worse outcome than a spam row | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Intake | Raw body write and acknowledgement. | Any runtime, provided the write happens before the response. | | Schema | Stable field keys and versions. | Any editor, provided keys are never reused. | | Validation | Server-side rules matching the browser. | Any implementation, provided it is not stricter than the input types. | | Spam | Invisible signals and quarantine. | Any signal set, provided nothing is deleted automatically. | | Delivery | Queued email and webhooks with retries. | Any transport, provided it stays outside the request. | ## 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, fields, submissions and deliveries. Back this up before anything else. - `UPLOAD_DIR` · required · Uploaded files. Deletion removes these along with the row. - `MAX_UPLOAD_BYTES_PER_FIELD` · required · Refused before the file is written, not after. - `MAX_UPLOAD_BYTES_PER_SUBMISSION` · required · The whole form's ceiling, checked as parts arrive. - `IP_HASH_SALT` · required, secret · Salts the hashed client address used for rate limiting. Never commit it. - `MIN_SUBMIT_SECONDS` · required · Anything faster than this from render to submit is quarantined. - `SMTP_URL` · optional, secret · Notification delivery. Without it the app works and deliveries queue as skipped. - `DEFAULT_RETENTION_DAYS` · required · Applied by the retention sweep unless a form overrides it. ## 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 · Wufoo product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: One Node and TypeScript web application serving both the editor and the public forms, SQLite with the raw submission body stored verbatim alongside the parsed answers, Versioned field definitions with stable keys that are never reused, A queue with retries for email and webhooks, outside the request, Honeypot, timing and rate limiting, with quarantine rather than deletion. - 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 - Validating first and storing after means a field you got wrong, a mail server timing out or a crash between the two loses the response entirely, and the person who filled the form has no idea and will not do it twice. Write the raw body first, acknowledge, then do everything else against what is already on disk. - Keying answers by the field's label or its position means renaming a question or dragging it up two places silently detaches every answer collected so far, and the response table quietly fills with blanks nobody can explain. Generate a key on creation, never reuse one, and store the schema version with every submission. - Writing your own email regular expression rejects addresses that exist, and the people it rejects skew towards newer and longer domains who then cannot reach you and never find out why. The HTML specification defines exactly one pattern for the email input type and says openly that it departs from the mail standards on purpose, which is the point: copy that pattern, run it server side, and accept anything the browser accepted. - Reaching for a visual or puzzle challenge as the first line makes the form harder for people using screen readers, older phones and slow connections, and the bots that actually target small forms solve it anyway. Start with the invisible signals, quarantine rather than delete, and read the quarantine for a week before deciding it needs anything stronger. - Deleting a submission row while the uploaded file stays on disk and the raw body sits in a webhook log means you told somebody their data was gone and it was not, which is the one failure here with consequences outside your own project. Make deletion one function that removes the row, the raw body, the derived answers and the files, and write a test that walks the disk afterwards. ===== MILESTONES.md ===== # Delivery milestones · Wufoo Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Store it before you understand it A submission is durably stored and acknowledged before any validation, email or webhook runs. ### Steps 1. Write the raw body, flush, then respond Everything after this line is recoverable; everything before it is not. Files: `src/routes/submit.ts` 2. Derive answers as a second pass you can rerun If it can be rebuilt from the raw body, you stored the right thing. Files: `src/submissions/parse.ts`, `tests/reparse.test.ts` ```sh npm run reparse ``` 3. Move email and webhooks out of the request A slow mail server should never be visible to the person submitting. Files: `src/delivery/queue.ts` ### Done when - [ ] A crash after the write still leaves the submission - [ ] Answers rebuild from raw bodies exactly - [ ] No outbound call happens inside the request - [ ] Invalid submissions are stored and flagged ### Watch out - Validating first and storing after means a field you got wrong, a mail server timing out or a crash between the two loses the response entirely, and the person who filled the form has no idea and will not do it twice. Write the raw body first, acknowledge, then do everything else against what is already on disk. ## M2 · A form that can change its mind Editing a form's fields never breaks or rewrites the responses already collected. ### Steps 1. Generate a stable key per field and never reuse it The label is for humans and changes; the key is for data and does not. Files: `src/forms/fields.ts` 2. Version the schema and stamp every submission Rendering an old response needs the form as it was that day. Files: `src/forms/versions.ts`, `tests/schema-version.test.ts` 3. Soft delete fields, keep their definitions Deleting a question should not delete the answers. Files: `src/forms/fields.ts` ### Done when - [ ] Renaming a label keeps answers attached - [ ] Reordering changes no stored data - [ ] Removed fields still render and export - [ ] Submissions render under their own schema version ### Watch out - Keying answers by the field's label or its position means renaming a question or dragging it up two places silently detaches every answer collected so far, and the response table quietly fills with blanks nobody can explain. Generate a key on creation, never reuse one, and store the schema version with every submission. ## M3 · Validation that agrees with the browser Server-side validation accepts everything the browser accepted and explains everything it refuses. ### Steps 1. Copy the published email pattern, do not invent one Being stricter than the browser only loses you real people. Files: `src/validate/email.ts`, `tests/email-cases.test.ts` 2. Return errors keyed by field, in plain language A single red banner teaches nobody what to fix. Files: `src/validate/form.ts` 3. Enforce upload limits before writing to disk Otherwise the limit is enforced by your disk filling up. Files: `src/uploads/receive.ts` ### Done when - [ ] Browser-valid email addresses are accepted server side - [ ] Errors name the field and the problem - [ ] Oversized uploads are refused before hitting disk - [ ] Invalid submissions are still stored and flagged ### Watch out - Writing your own email regular expression rejects addresses that exist, and the people it rejects skew towards newer and longer domains who then cannot reach you and never find out why. The HTML specification defines exactly one pattern for the email input type and says openly that it departs from the mail standards on purpose, which is the point: copy that pattern, run it server side, and accept anything the browser accepted. ## M4 · Spam, without punishing everyone else Automated submissions are caught and quarantined without putting a puzzle in front of honest people. ### Steps 1. Start with honeypot, timing and rate limits Invisible signals cost honest people nothing. Files: `src/spam/rules.ts`, `tests/spam.test.ts` 2. Quarantine with a reason, never delete Your rules will be wrong sometimes and you need the evidence. Files: `src/spam/quarantine.ts` 3. Hash the client address with a salt outside the repository You need rate limiting, not a log of who visited. Files: `src/spam/rate-limit.ts` ### Done when - [ ] Honeypot and timing trips are quarantined with reasons - [ ] No spam rule deletes anything - [ ] Release from quarantine sends the held notifications - [ ] Addresses are stored only as salted hashes ### Watch out - Reaching for a visual or puzzle challenge as the first line makes the form harder for people using screen readers, older phones and slow connections, and the bots that actually target small forms solve it anyway. Start with the invisible signals, quarantine rather than delete, and read the quarantine for a week before deciding it needs anything stronger. ## M5 · Getting responses out, and getting them deleted (production only) Notifications retry safely, everything exports, and a deletion request is one action that really removes the data. ### Steps 1. Make deletion one function that takes everything Row, raw body, answers and files, or it is not deletion. Files: `src/submissions/delete.ts`, `tests/delete-really-deletes.test.ts` 2. Retry deliveries with backoff and keep the errors The error text is how you find out the webhook moved. Files: `src/delivery/retry.ts` 3. Round-trip the export into an empty database An export nobody has reimported is a guess. Files: `src/export/csv.ts`, `tests/roundtrip.test.ts` ```sh npm run export -- --form=contact npm run import -- --file=contact.json ``` ### Done when - [ ] Deletion removes row, body, answers and files - [ ] Deliveries retry with backoff and record errors - [ ] Export and reimport round-trip completely - [ ] A closed form explains itself instead of erroring ### Watch out - Deleting a submission row while the uploaded file stays on disk and the raw body sits in a webhook log means you told somebody their data was gone and it was not, which is the one failure here with consequences outside your own project. Make deletion one function that removes the row, the raw body, the derived answers and the files, and write a test that walks the disk afterwards. ===== OPERATIONS.md ===== # Operations · Wufoo ## Backup The database and the upload directory, together and at the same moment, since a database referencing files that were not backed up is half a backup. Test a restore by reparsing answers from raw bodies afterwards. ## Restore Restore both, run the reparse command, and confirm the submission count and the most recent received_at match. If answers rebuild cleanly, the restore is good. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Delivery failure rate, the size and age of the quarantine, and the gap between submissions stored and answers parsed. A growing quarantine usually means a rule is too tight rather than that spam has arrived. ## Incident checklist The incident to fear is silent: a response that never got stored. Check the intake path first, compare submission counts against any external notification record, and never fix a parsing bug by editing raw bodies. 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 - [ ] A crash immediately after the write is confirmed to leave the submission stored - [ ] Answers are confirmed rebuildable from raw bodies alone - [ ] Renaming and reordering fields is confirmed to keep existing answers attached - [ ] No spam rule is confirmed to delete anything - [ ] A per-submission delete is confirmed to remove the row, the body, the answers and the files ## Launch constraint Do not market omitted Wufoo 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, fields, submissions and deliveries. Back this up before anything else. DATABASE_PATH=./data/forms.db # Required. Uploaded files. Deletion removes these along with the row. UPLOAD_DIR=./data/uploads # Required. Refused before the file is written, not after. MAX_UPLOAD_BYTES_PER_FIELD=10485760 # Required. The whole form's ceiling, checked as parts arrive. MAX_UPLOAD_BYTES_PER_SUBMISSION=26214400 # Required · secret. Salts the hashed client address used for rate limiting. Never commit it. IP_HASH_SALT=a long random string, redacted # Required. Anything faster than this from render to submit is quarantined. MIN_SUBMIT_SECONDS=3 # Optional · secret. Notification delivery. Without it the app works and deliveries queue as skipped. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Required. Applied by the retention sweep unless a form overrides it. DEFAULT_RETENTION_DAYS=365
# Wufoo · indie build A self-hosted form builder whose hard problems are never losing a submission on any failure path, letting a form's fields change without breaking the responses already collected, and holding back spam without punishing every honest respondent. Estimated effort: **one sitting**. 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 | One Node and TypeScript web application serving both the editor and the public forms | the public form has to be fast and dependency-free; the editor can be anything | | Storage | SQLite with the raw submission body stored verbatim alongside the parsed answers | the parsed copy is a convenience and the raw body is the evidence | | Schema | Versioned field definitions with stable keys that are never reused | a renamed field must not orphan six months of answers | | Delivery | A queue with retries for email and webhooks, outside the request | a mail server that is down must never cost you a response | | Spam | Honeypot, timing and rate limiting, with quarantine rather than deletion | a challenge that blocks a screen reader user is a worse outcome than a spam row | ## 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 editor and the public form endpoint. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A host with persistent disk and a real domain** · a few pounds a month - Why: Public forms need a stable address people can be sent to, and uploads plus SQLite both need a disk that survives a restart. - Get it: Any small virtual machine with a volume, and a domain with a certificate. - Verify: A form submitted from another network is stored and visible in the editor - [ ] **A transactional email provider for notifications** (optional) · usually free at small volume - Why: Notifications are the part most likely to fail, and the queue exists so that when they do, the response is already safe. - Get it: Any provider with a verified sending domain and an API key. - Verify: A notification arrives and the delivery row records the attempt - [ ] **The retention period and the deletion process** · free - Why: This is other people's personal data, and deciding afterwards means deciding it from whatever the code happens to do. - Get it: Write down how long submissions are kept per form, who can delete one, and what deletion removes. - Verify: The README states all three and the retention sweep enforces the number ## Quick start ```sh npm run reparse ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Payment fields and card handling. - Branching logic beyond show and hide. - A template gallery and theming system. - Sending from your own mail server. - enterprise workflow logic - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery If one of those is essential to you, that is the reason to keep paying for Wufoo, and the README should say so rather than pretend.
# Build brief · Wufoo 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 publishes a public form and never loses a submission to replace Wufoo. 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. ### Rule zero A form has one job: not to lose what somebody typed. Everything else on this page is decoration around that job. So the submission endpoint writes the raw request body to durable storage and returns success before it validates anything, renders anything, or sends any email. Validation, notification, webhooks and file processing all happen after the response is safe, and none of them is allowed to discard a submission. A submission that fails validation is stored and flagged, not rejected into nothing. The second half of the job is that this data is not yours. A form collects other people's names, addresses and sometimes a good deal worse, on a page you sent them. Decide the retention period before the first form is published, put a delete control on every single submission, and keep the raw body out of every log line. If you cannot say how long a response is kept and how somebody gets theirs deleted, the form is not ready to publish. ### Stack (fixed, do not substitute) - Runtime: One Node and TypeScript web application serving both the editor and the public forms · the public form has to be fast and dependency-free; the editor can be anything - Storage: SQLite with the raw submission body stored verbatim alongside the parsed answers · the parsed copy is a convenience and the raw body is the evidence - Schema: Versioned field definitions with stable keys that are never reused · a renamed field must not orphan six months of answers - Delivery: A queue with retries for email and webhooks, outside the request · a mail server that is down must never cost you a response - Spam: Honeypot, timing and rate limiting, with quarantine rather than deletion · a challenge that blocks a screen reader user is a worse outcome than a spam row ### Data model (create this before Phase 1) - forms(id, slug, title, schema_version, published_at, closed_at, close_message, retention_days) - fields(id, form_id, key, type, label, required, options_json, position, removed_at) · key is stable and never reused, even after removal - submissions(id, form_id, schema_version, received_at, raw_body, content_type, state, spam_score) · state is stored, flagged, quarantined or deleted - answers(submission_id, field_key, value_text, value_number, value_json) · derived from the raw body and rebuildable from it - uploads(id, submission_id, field_key, filename, bytes, content_type, path, scanned_at) - deliveries(id, submission_id, channel, target, attempts, last_error, delivered_at) · email and webhook attempts, retried outside the request - The answers table can be dropped and rebuilt from raw_body. That is the test of whether the raw body really is being stored first. ### Phase 1 · Store it before you understand it Build: One public endpoint per form. Read the body, write it verbatim with the form id and schema version, flush, then respond. Parse into answers afterwards. Everything else, notification included, is queued work that reads from storage. Never hold a submission in memory while doing anything slow. Trap: Validating first and storing after means a field you got wrong, a mail server timing out or a crash between the two loses the response entirely, and the person who filled the form has no idea and will not do it twice. Write the raw body first, acknowledge, then do everything else against what is already on disk. Done when: Killing the process immediately after the response still leaves the raw body stored, tested by injecting a crash after the write. Dropping the answers table and rebuilding it from raw bodies reproduces every answer, tested. No outbound email or webhook happens inside the request, asserted by timing the endpoint with the mail host unreachable. A submission that fails validation is stored with a flagged state rather than refused. Do not build yet: No editor, spam handling, uploads or exports yet. ### Phase 2 · A form that can change its mind Build: Fields carry a stable key that is generated once and never reused, separate from the label. A structural change bumps the form's schema version, and each submission records the version it arrived under. Removing a field sets removed_at and keeps the definition, so old answers still render with their original label and type. Trap: Keying answers by the field's label or its position means renaming a question or dragging it up two places silently detaches every answer collected so far, and the response table quietly fills with blanks nobody can explain. Generate a key on creation, never reuse one, and store the schema version with every submission. Done when: Renaming a field's label leaves every existing answer attached and rendering under the old label, tested. Reordering fields changes nothing in stored answers, tested. A removed field's answers are still visible and exportable, with the field marked removed. Viewing a submission uses the schema version it arrived under, not the current one, tested by editing the form afterwards. Do not build yet: No validation rules, spam handling or delivery yet. ### Phase 3 · Validation that agrees with the browser Build: Validate on the server against the same definition the input types use in the browser, field by field. For email fields take the pattern the HTML specification publishes for the email input type rather than writing one. Enforce required, type, length, numeric range, and a per-field and per-form upload size. Return errors keyed by field, and keep the submission either way. Trap: Writing your own email regular expression rejects addresses that exist, and the people it rejects skew towards newer and longer domains who then cannot reach you and never find out why. The HTML specification defines exactly one pattern for the email input type and says openly that it departs from the mail standards on purpose, which is the point: copy that pattern, run it server side, and accept anything the browser accepted. Done when: A set of addresses that the browser's built-in email validation accepts is accepted by the server too, tested as a table of cases including long and recently created top level domains. Every refusal names the field and says what was wrong in plain language. An upload over the configured per-field or per-form limit is refused before the file is written to disk, tested. A submission failing validation is still stored, flagged, and visible in the editor. Do not build yet: No spam handling, exports or retention yet. ### Phase 4 · Spam, without punishing everyone else Build: A hidden field no human fills, a minimum time between render and submit, a per-address rate limit using a hashed and salted client address, and a link and keyword score. Anything that trips a rule goes to quarantine with the reason recorded, never to deletion. Quarantine is reviewable and a release puts the submission back into the normal flow including notification. Trap: Reaching for a visual or puzzle challenge as the first line makes the form harder for people using screen readers, older phones and slow connections, and the bots that actually target small forms solve it anyway. Start with the invisible signals, quarantine rather than delete, and read the quarantine for a week before deciding it needs anything stronger. Done when: A submission that fills the honeypot or arrives faster than the configured minimum is quarantined with the reason recorded, tested. Nothing is ever deleted by a spam rule, asserted by review of the code paths. Releasing from quarantine triggers the notifications that were held, tested. Client addresses are stored only as a salted hash and the salt is not in the repository, verified by inspecting the table. Do not build yet: No export, retention sweep or form closing yet. ### Phase 5 · Getting responses out, and getting them deleted Build: Retry deliveries with backoff and a cap, recording every attempt and the last error, with a manual resend. Export a form's submissions as CSV and JSON including removed fields and upload references, and reimport into an empty database. A per-submission delete removes the raw body, the answers and the uploaded files together. A retention sweep applies the form's retention period on a schedule. Trap: Deleting a submission row while the uploaded file stays on disk and the raw body sits in a webhook log means you told somebody their data was gone and it was not, which is the one failure here with consequences outside your own project. Make deletion one function that removes the row, the raw body, the derived answers and the files, and write a test that walks the disk afterwards. Done when: Deleting a submission removes its row, raw body, answers and uploaded files, verified by a test that scans the upload directory afterwards. A failed delivery is retried with backoff up to a cap, with every attempt and error recorded and a manual resend available, tested with an unreachable target. Export and reimport round-trip every submission including removed fields, tested. A closed form shows its close message and refuses new submissions with a clear page rather than an error. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment fields, which make you responsible for somebody else's card details and belong behind a hosted checkout rather than inside a form builder. - Conditional logic beyond showing and hiding a field, since branching rules become a small programming language and the state machine is where form builders go wrong. - A template gallery and theming system, which is most of the paid product's surface area and none of its difficulty. - Sending from your own mail server, because deliverability for transactional mail is a standing commitment rather than a feature you finish. ### README must contain - That the raw request body is stored before validation, notification or anything else, and that the answers table is rebuildable from it - That field keys are stable and never reused, and that every submission records the schema version it arrived under - That email validation copies the pattern published for the browser's email input type rather than a regular expression written here - That spam rules quarantine and never delete, and that client addresses are kept only as salted hashes - The retention period, where uploaded files live, and exactly what a per-submission delete removes
# Agent instructions · Wufoo indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One Node and TypeScript web application serving both the editor and the public forms, SQLite with the raw submission body stored verbatim alongside the parsed answers, Versioned field definitions with stable keys that are never reused, A queue with retries for email and webhooks, outside the request, Honeypot, timing and rate limiting, with quarantine rather than deletion. 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 - Validating first and storing after means a field you got wrong, a mail server timing out or a crash between the two loses the response entirely, and the person who filled the form has no idea and will not do it twice. Write the raw body first, acknowledge, then do everything else against what is already on disk. - Keying answers by the field's label or its position means renaming a question or dragging it up two places silently detaches every answer collected so far, and the response table quietly fills with blanks nobody can explain. Generate a key on creation, never reuse one, and store the schema version with every submission. - Writing your own email regular expression rejects addresses that exist, and the people it rejects skew towards newer and longer domains who then cannot reach you and never find out why. The HTML specification defines exactly one pattern for the email input type and says openly that it departs from the mail standards on purpose, which is the point: copy that pattern, run it server side, and accept anything the browser accepted. - Reaching for a visual or puzzle challenge as the first line makes the form harder for people using screen readers, older phones and slow connections, and the bots that actually target small forms solve it anyway. Start with the invisible signals, quarantine rather than delete, and read the quarantine for a week before deciding it needs anything stronger.
# Build plan · Wufoo A self-hosted form builder whose hard problems are never losing a submission on any failure path, letting a form's fields change without breaking the responses already collected, and holding back spam without punishing every honest respondent. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Store it before you understand it A submission is durably stored and acknowledged before any validation, email or webhook runs. ### Steps 1. Write the raw body, flush, then respond Everything after this line is recoverable; everything before it is not. Files: `src/routes/submit.ts` 2. Derive answers as a second pass you can rerun If it can be rebuilt from the raw body, you stored the right thing. Files: `src/submissions/parse.ts`, `tests/reparse.test.ts` ```sh npm run reparse ``` 3. Move email and webhooks out of the request A slow mail server should never be visible to the person submitting. Files: `src/delivery/queue.ts` ### Done when - [ ] A crash after the write still leaves the submission - [ ] Answers rebuild from raw bodies exactly - [ ] No outbound call happens inside the request - [ ] Invalid submissions are stored and flagged ### Watch out - Validating first and storing after means a field you got wrong, a mail server timing out or a crash between the two loses the response entirely, and the person who filled the form has no idea and will not do it twice. Write the raw body first, acknowledge, then do everything else against what is already on disk. ## Phase 2 · A form that can change its mind Editing a form's fields never breaks or rewrites the responses already collected. ### Steps 1. Generate a stable key per field and never reuse it The label is for humans and changes; the key is for data and does not. Files: `src/forms/fields.ts` 2. Version the schema and stamp every submission Rendering an old response needs the form as it was that day. Files: `src/forms/versions.ts`, `tests/schema-version.test.ts` 3. Soft delete fields, keep their definitions Deleting a question should not delete the answers. Files: `src/forms/fields.ts` ### Done when - [ ] Renaming a label keeps answers attached - [ ] Reordering changes no stored data - [ ] Removed fields still render and export - [ ] Submissions render under their own schema version ### Watch out - Keying answers by the field's label or its position means renaming a question or dragging it up two places silently detaches every answer collected so far, and the response table quietly fills with blanks nobody can explain. Generate a key on creation, never reuse one, and store the schema version with every submission. ## Phase 3 · Validation that agrees with the browser Server-side validation accepts everything the browser accepted and explains everything it refuses. ### Steps 1. Copy the published email pattern, do not invent one Being stricter than the browser only loses you real people. Files: `src/validate/email.ts`, `tests/email-cases.test.ts` 2. Return errors keyed by field, in plain language A single red banner teaches nobody what to fix. Files: `src/validate/form.ts` 3. Enforce upload limits before writing to disk Otherwise the limit is enforced by your disk filling up. Files: `src/uploads/receive.ts` ### Done when - [ ] Browser-valid email addresses are accepted server side - [ ] Errors name the field and the problem - [ ] Oversized uploads are refused before hitting disk - [ ] Invalid submissions are still stored and flagged ### Watch out - Writing your own email regular expression rejects addresses that exist, and the people it rejects skew towards newer and longer domains who then cannot reach you and never find out why. The HTML specification defines exactly one pattern for the email input type and says openly that it departs from the mail standards on purpose, which is the point: copy that pattern, run it server side, and accept anything the browser accepted. ## Phase 4 · Spam, without punishing everyone else Automated submissions are caught and quarantined without putting a puzzle in front of honest people. ### Steps 1. Start with honeypot, timing and rate limits Invisible signals cost honest people nothing. Files: `src/spam/rules.ts`, `tests/spam.test.ts` 2. Quarantine with a reason, never delete Your rules will be wrong sometimes and you need the evidence. Files: `src/spam/quarantine.ts` 3. Hash the client address with a salt outside the repository You need rate limiting, not a log of who visited. Files: `src/spam/rate-limit.ts` ### Done when - [ ] Honeypot and timing trips are quarantined with reasons - [ ] No spam rule deletes anything - [ ] Release from quarantine sends the held notifications - [ ] Addresses are stored only as salted hashes ### Watch out - Reaching for a visual or puzzle challenge as the first line makes the form harder for people using screen readers, older phones and slow connections, and the bots that actually target small forms solve it anyway. Start with the invisible signals, quarantine rather than delete, and read the quarantine for a week before deciding it needs anything stronger. ## Not in this build - Payment fields and card handling. - Branching logic beyond show and hide. - A template gallery and theming system. - Sending from your own mail server. ## After v1, if you want it - A per-form webhook signature so receivers can verify the payload came from you - Partial save for long forms, stored under the same raw-body-first rule - A summary view per field that reports counts and ranges without exporting the personal data
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, fields, submissions and deliveries. Back this up before anything else. DATABASE_PATH=./data/forms.db # Required. Uploaded files. Deletion removes these along with the row. UPLOAD_DIR=./data/uploads # Required. Refused before the file is written, not after. MAX_UPLOAD_BYTES_PER_FIELD=10485760 # Required. The whole form's ceiling, checked as parts arrive. MAX_UPLOAD_BYTES_PER_SUBMISSION=26214400 # Required · secret. Salts the hashed client address used for rate limiting. Never commit it. IP_HASH_SALT=a long random string, redacted # Required. Anything faster than this from render to submit is quarantined. MIN_SUBMIT_SECONDS=3 # Optional · secret. Notification delivery. Without it the app works and deliveries queue as skipped. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Required. Applied by the retention sweep unless a form overrides it. DEFAULT_RETENTION_DAYS=365
# Wufoo · product brief ## Problem Wufoo's solo core is compact: build a online forms tool with a schema-based form editor, public share link, validation, response table, CSV export, and webhook. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on delivery, integrations, compliance. ## Product outcome A form that keeps what people typed through crashes, validation mistakes and a dead mail server, lets you rewrite the questions without losing the answers, and can genuinely delete a single response when somebody asks. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - deploy target - SMTP or transactional email key in .env ## Explicit non-goals for v1 - Payment fields and card handling. - Branching logic beyond show and hide. - A template gallery and theming system. - Sending from your own mail server. - enterprise workflow logic - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery ## Success criteria - A crash immediately after the write is confirmed to leave the submission stored - Answers are confirmed rebuildable from raw bodies alone - Renaming and reordering fields is confirmed to keep existing answers attached - No spam rule is confirmed to delete anything - A per-submission delete is confirmed to remove the row, the body, the answers and the files
# Build brief · Wufoo 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 publishes a public form and never loses a submission to replace Wufoo. 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. ### Rule zero A form has one job: not to lose what somebody typed. Everything else on this page is decoration around that job. So the submission endpoint writes the raw request body to durable storage and returns success before it validates anything, renders anything, or sends any email. Validation, notification, webhooks and file processing all happen after the response is safe, and none of them is allowed to discard a submission. A submission that fails validation is stored and flagged, not rejected into nothing. The second half of the job is that this data is not yours. A form collects other people's names, addresses and sometimes a good deal worse, on a page you sent them. Decide the retention period before the first form is published, put a delete control on every single submission, and keep the raw body out of every log line. If you cannot say how long a response is kept and how somebody gets theirs deleted, the form is not ready to publish. ### Stack (fixed, do not substitute) - Runtime: One Node and TypeScript web application serving both the editor and the public forms · the public form has to be fast and dependency-free; the editor can be anything - Storage: SQLite with the raw submission body stored verbatim alongside the parsed answers · the parsed copy is a convenience and the raw body is the evidence - Schema: Versioned field definitions with stable keys that are never reused · a renamed field must not orphan six months of answers - Delivery: A queue with retries for email and webhooks, outside the request · a mail server that is down must never cost you a response - Spam: Honeypot, timing and rate limiting, with quarantine rather than deletion · a challenge that blocks a screen reader user is a worse outcome than a spam row ### Data model (create this before Phase 1) - forms(id, slug, title, schema_version, published_at, closed_at, close_message, retention_days) - fields(id, form_id, key, type, label, required, options_json, position, removed_at) · key is stable and never reused, even after removal - submissions(id, form_id, schema_version, received_at, raw_body, content_type, state, spam_score) · state is stored, flagged, quarantined or deleted - answers(submission_id, field_key, value_text, value_number, value_json) · derived from the raw body and rebuildable from it - uploads(id, submission_id, field_key, filename, bytes, content_type, path, scanned_at) - deliveries(id, submission_id, channel, target, attempts, last_error, delivered_at) · email and webhook attempts, retried outside the request - The answers table can be dropped and rebuilt from raw_body. That is the test of whether the raw body really is being stored first. ### Phase 1 · Store it before you understand it Build: One public endpoint per form. Read the body, write it verbatim with the form id and schema version, flush, then respond. Parse into answers afterwards. Everything else, notification included, is queued work that reads from storage. Never hold a submission in memory while doing anything slow. Trap: Validating first and storing after means a field you got wrong, a mail server timing out or a crash between the two loses the response entirely, and the person who filled the form has no idea and will not do it twice. Write the raw body first, acknowledge, then do everything else against what is already on disk. Done when: Killing the process immediately after the response still leaves the raw body stored, tested by injecting a crash after the write. Dropping the answers table and rebuilding it from raw bodies reproduces every answer, tested. No outbound email or webhook happens inside the request, asserted by timing the endpoint with the mail host unreachable. A submission that fails validation is stored with a flagged state rather than refused. Do not build yet: No editor, spam handling, uploads or exports yet. ### Phase 2 · A form that can change its mind Build: Fields carry a stable key that is generated once and never reused, separate from the label. A structural change bumps the form's schema version, and each submission records the version it arrived under. Removing a field sets removed_at and keeps the definition, so old answers still render with their original label and type. Trap: Keying answers by the field's label or its position means renaming a question or dragging it up two places silently detaches every answer collected so far, and the response table quietly fills with blanks nobody can explain. Generate a key on creation, never reuse one, and store the schema version with every submission. Done when: Renaming a field's label leaves every existing answer attached and rendering under the old label, tested. Reordering fields changes nothing in stored answers, tested. A removed field's answers are still visible and exportable, with the field marked removed. Viewing a submission uses the schema version it arrived under, not the current one, tested by editing the form afterwards. Do not build yet: No validation rules, spam handling or delivery yet. ### Phase 3 · Validation that agrees with the browser Build: Validate on the server against the same definition the input types use in the browser, field by field. For email fields take the pattern the HTML specification publishes for the email input type rather than writing one. Enforce required, type, length, numeric range, and a per-field and per-form upload size. Return errors keyed by field, and keep the submission either way. Trap: Writing your own email regular expression rejects addresses that exist, and the people it rejects skew towards newer and longer domains who then cannot reach you and never find out why. The HTML specification defines exactly one pattern for the email input type and says openly that it departs from the mail standards on purpose, which is the point: copy that pattern, run it server side, and accept anything the browser accepted. Done when: A set of addresses that the browser's built-in email validation accepts is accepted by the server too, tested as a table of cases including long and recently created top level domains. Every refusal names the field and says what was wrong in plain language. An upload over the configured per-field or per-form limit is refused before the file is written to disk, tested. A submission failing validation is still stored, flagged, and visible in the editor. Do not build yet: No spam handling, exports or retention yet. ### Phase 4 · Spam, without punishing everyone else Build: A hidden field no human fills, a minimum time between render and submit, a per-address rate limit using a hashed and salted client address, and a link and keyword score. Anything that trips a rule goes to quarantine with the reason recorded, never to deletion. Quarantine is reviewable and a release puts the submission back into the normal flow including notification. Trap: Reaching for a visual or puzzle challenge as the first line makes the form harder for people using screen readers, older phones and slow connections, and the bots that actually target small forms solve it anyway. Start with the invisible signals, quarantine rather than delete, and read the quarantine for a week before deciding it needs anything stronger. Done when: A submission that fills the honeypot or arrives faster than the configured minimum is quarantined with the reason recorded, tested. Nothing is ever deleted by a spam rule, asserted by review of the code paths. Releasing from quarantine triggers the notifications that were held, tested. Client addresses are stored only as a salted hash and the salt is not in the repository, verified by inspecting the table. Do not build yet: No export, retention sweep or form closing yet. ### Phase 5 · Getting responses out, and getting them deleted Build: Retry deliveries with backoff and a cap, recording every attempt and the last error, with a manual resend. Export a form's submissions as CSV and JSON including removed fields and upload references, and reimport into an empty database. A per-submission delete removes the raw body, the answers and the uploaded files together. A retention sweep applies the form's retention period on a schedule. Trap: Deleting a submission row while the uploaded file stays on disk and the raw body sits in a webhook log means you told somebody their data was gone and it was not, which is the one failure here with consequences outside your own project. Make deletion one function that removes the row, the raw body, the derived answers and the files, and write a test that walks the disk afterwards. Done when: Deleting a submission removes its row, raw body, answers and uploaded files, verified by a test that scans the upload directory afterwards. A failed delivery is retried with backoff up to a cap, with every attempt and error recorded and a manual resend available, tested with an unreachable target. Export and reimport round-trip every submission including removed fields, tested. A closed form shows its close message and refuses new submissions with a clear page rather than an error. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment fields, which make you responsible for somebody else's card details and belong behind a hosted checkout rather than inside a form builder. - Conditional logic beyond showing and hiding a field, since branching rules become a small programming language and the state machine is where form builders go wrong. - A template gallery and theming system, which is most of the paid product's surface area and none of its difficulty. - Sending from your own mail server, because deliverability for transactional mail is a standing commitment rather than a feature you finish. ### README must contain - That the raw request body is stored before validation, notification or anything else, and that the answers table is rebuildable from it - That field keys are stable and never reused, and that every submission records the schema version it arrived under - That email validation copies the pattern published for the browser's email input type rather than a regular expression written here - That spam rules quarantine and never delete, and that client addresses are kept only as salted hashes - The retention period, where uploaded files live, and exactly what a per-submission delete removes
# Architecture · Wufoo ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One Node and TypeScript web application serving both the editor and the public forms | the public form has to be fast and dependency-free; the editor can be anything | | Storage | SQLite with the raw submission body stored verbatim alongside the parsed answers | the parsed copy is a convenience and the raw body is the evidence | | Schema | Versioned field definitions with stable keys that are never reused | a renamed field must not orphan six months of answers | | Delivery | A queue with retries for email and webhooks, outside the request | a mail server that is down must never cost you a response | | Spam | Honeypot, timing and rate limiting, with quarantine rather than deletion | a challenge that blocks a screen reader user is a worse outcome than a spam row | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Intake | Raw body write and acknowledgement. | Any runtime, provided the write happens before the response. | | Schema | Stable field keys and versions. | Any editor, provided keys are never reused. | | Validation | Server-side rules matching the browser. | Any implementation, provided it is not stricter than the input types. | | Spam | Invisible signals and quarantine. | Any signal set, provided nothing is deleted automatically. | | Delivery | Queued email and webhooks with retries. | Any transport, provided it stays outside the request. | ## 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, fields, submissions and deliveries. Back this up before anything else. - `UPLOAD_DIR` · required · Uploaded files. Deletion removes these along with the row. - `MAX_UPLOAD_BYTES_PER_FIELD` · required · Refused before the file is written, not after. - `MAX_UPLOAD_BYTES_PER_SUBMISSION` · required · The whole form's ceiling, checked as parts arrive. - `IP_HASH_SALT` · required, secret · Salts the hashed client address used for rate limiting. Never commit it. - `MIN_SUBMIT_SECONDS` · required · Anything faster than this from render to submit is quarantined. - `SMTP_URL` · optional, secret · Notification delivery. Without it the app works and deliveries queue as skipped. - `DEFAULT_RETENTION_DAYS` · required · Applied by the retention sweep unless a form overrides it. ## 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 · Wufoo product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: One Node and TypeScript web application serving both the editor and the public forms, SQLite with the raw submission body stored verbatim alongside the parsed answers, Versioned field definitions with stable keys that are never reused, A queue with retries for email and webhooks, outside the request, Honeypot, timing and rate limiting, with quarantine rather than deletion. - 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 - Validating first and storing after means a field you got wrong, a mail server timing out or a crash between the two loses the response entirely, and the person who filled the form has no idea and will not do it twice. Write the raw body first, acknowledge, then do everything else against what is already on disk. - Keying answers by the field's label or its position means renaming a question or dragging it up two places silently detaches every answer collected so far, and the response table quietly fills with blanks nobody can explain. Generate a key on creation, never reuse one, and store the schema version with every submission. - Writing your own email regular expression rejects addresses that exist, and the people it rejects skew towards newer and longer domains who then cannot reach you and never find out why. The HTML specification defines exactly one pattern for the email input type and says openly that it departs from the mail standards on purpose, which is the point: copy that pattern, run it server side, and accept anything the browser accepted. - Reaching for a visual or puzzle challenge as the first line makes the form harder for people using screen readers, older phones and slow connections, and the bots that actually target small forms solve it anyway. Start with the invisible signals, quarantine rather than delete, and read the quarantine for a week before deciding it needs anything stronger. - Deleting a submission row while the uploaded file stays on disk and the raw body sits in a webhook log means you told somebody their data was gone and it was not, which is the one failure here with consequences outside your own project. Make deletion one function that removes the row, the raw body, the derived answers and the files, and write a test that walks the disk afterwards.
# Delivery milestones · Wufoo Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Store it before you understand it A submission is durably stored and acknowledged before any validation, email or webhook runs. ### Steps 1. Write the raw body, flush, then respond Everything after this line is recoverable; everything before it is not. Files: `src/routes/submit.ts` 2. Derive answers as a second pass you can rerun If it can be rebuilt from the raw body, you stored the right thing. Files: `src/submissions/parse.ts`, `tests/reparse.test.ts` ```sh npm run reparse ``` 3. Move email and webhooks out of the request A slow mail server should never be visible to the person submitting. Files: `src/delivery/queue.ts` ### Done when - [ ] A crash after the write still leaves the submission - [ ] Answers rebuild from raw bodies exactly - [ ] No outbound call happens inside the request - [ ] Invalid submissions are stored and flagged ### Watch out - Validating first and storing after means a field you got wrong, a mail server timing out or a crash between the two loses the response entirely, and the person who filled the form has no idea and will not do it twice. Write the raw body first, acknowledge, then do everything else against what is already on disk. ## M2 · A form that can change its mind Editing a form's fields never breaks or rewrites the responses already collected. ### Steps 1. Generate a stable key per field and never reuse it The label is for humans and changes; the key is for data and does not. Files: `src/forms/fields.ts` 2. Version the schema and stamp every submission Rendering an old response needs the form as it was that day. Files: `src/forms/versions.ts`, `tests/schema-version.test.ts` 3. Soft delete fields, keep their definitions Deleting a question should not delete the answers. Files: `src/forms/fields.ts` ### Done when - [ ] Renaming a label keeps answers attached - [ ] Reordering changes no stored data - [ ] Removed fields still render and export - [ ] Submissions render under their own schema version ### Watch out - Keying answers by the field's label or its position means renaming a question or dragging it up two places silently detaches every answer collected so far, and the response table quietly fills with blanks nobody can explain. Generate a key on creation, never reuse one, and store the schema version with every submission. ## M3 · Validation that agrees with the browser Server-side validation accepts everything the browser accepted and explains everything it refuses. ### Steps 1. Copy the published email pattern, do not invent one Being stricter than the browser only loses you real people. Files: `src/validate/email.ts`, `tests/email-cases.test.ts` 2. Return errors keyed by field, in plain language A single red banner teaches nobody what to fix. Files: `src/validate/form.ts` 3. Enforce upload limits before writing to disk Otherwise the limit is enforced by your disk filling up. Files: `src/uploads/receive.ts` ### Done when - [ ] Browser-valid email addresses are accepted server side - [ ] Errors name the field and the problem - [ ] Oversized uploads are refused before hitting disk - [ ] Invalid submissions are still stored and flagged ### Watch out - Writing your own email regular expression rejects addresses that exist, and the people it rejects skew towards newer and longer domains who then cannot reach you and never find out why. The HTML specification defines exactly one pattern for the email input type and says openly that it departs from the mail standards on purpose, which is the point: copy that pattern, run it server side, and accept anything the browser accepted. ## M4 · Spam, without punishing everyone else Automated submissions are caught and quarantined without putting a puzzle in front of honest people. ### Steps 1. Start with honeypot, timing and rate limits Invisible signals cost honest people nothing. Files: `src/spam/rules.ts`, `tests/spam.test.ts` 2. Quarantine with a reason, never delete Your rules will be wrong sometimes and you need the evidence. Files: `src/spam/quarantine.ts` 3. Hash the client address with a salt outside the repository You need rate limiting, not a log of who visited. Files: `src/spam/rate-limit.ts` ### Done when - [ ] Honeypot and timing trips are quarantined with reasons - [ ] No spam rule deletes anything - [ ] Release from quarantine sends the held notifications - [ ] Addresses are stored only as salted hashes ### Watch out - Reaching for a visual or puzzle challenge as the first line makes the form harder for people using screen readers, older phones and slow connections, and the bots that actually target small forms solve it anyway. Start with the invisible signals, quarantine rather than delete, and read the quarantine for a week before deciding it needs anything stronger. ## M5 · Getting responses out, and getting them deleted (production only) Notifications retry safely, everything exports, and a deletion request is one action that really removes the data. ### Steps 1. Make deletion one function that takes everything Row, raw body, answers and files, or it is not deletion. Files: `src/submissions/delete.ts`, `tests/delete-really-deletes.test.ts` 2. Retry deliveries with backoff and keep the errors The error text is how you find out the webhook moved. Files: `src/delivery/retry.ts` 3. Round-trip the export into an empty database An export nobody has reimported is a guess. Files: `src/export/csv.ts`, `tests/roundtrip.test.ts` ```sh npm run export -- --form=contact npm run import -- --file=contact.json ``` ### Done when - [ ] Deletion removes row, body, answers and files - [ ] Deliveries retry with backoff and record errors - [ ] Export and reimport round-trip completely - [ ] A closed form explains itself instead of erroring ### Watch out - Deleting a submission row while the uploaded file stays on disk and the raw body sits in a webhook log means you told somebody their data was gone and it was not, which is the one failure here with consequences outside your own project. Make deletion one function that removes the row, the raw body, the derived answers and the files, and write a test that walks the disk afterwards.
# Operations · Wufoo ## Backup The database and the upload directory, together and at the same moment, since a database referencing files that were not backed up is half a backup. Test a restore by reparsing answers from raw bodies afterwards. ## Restore Restore both, run the reparse command, and confirm the submission count and the most recent received_at match. If answers rebuild cleanly, the restore is good. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Delivery failure rate, the size and age of the quarantine, and the gap between submissions stored and answers parsed. A growing quarantine usually means a rule is too tight rather than that spam has arrived. ## Incident checklist The incident to fear is silent: a response that never got stored. Check the intake path first, compare submission counts against any external notification record, and never fix a parsing bug by editing raw bodies. 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 - [ ] A crash immediately after the write is confirmed to leave the submission stored - [ ] Answers are confirmed rebuildable from raw bodies alone - [ ] Renaming and reordering fields is confirmed to keep existing answers attached - [ ] No spam rule is confirmed to delete anything - [ ] A per-submission delete is confirmed to remove the row, the body, the answers and the files ## Launch constraint Do not market omitted Wufoo 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, fields, submissions and deliveries. Back this up before anything else. DATABASE_PATH=./data/forms.db # Required. Uploaded files. Deletion removes these along with the row. UPLOAD_DIR=./data/uploads # Required. Refused before the file is written, not after. MAX_UPLOAD_BYTES_PER_FIELD=10485760 # Required. The whole form's ceiling, checked as parts arrive. MAX_UPLOAD_BYTES_PER_SUBMISSION=26214400 # Required · secret. Salts the hashed client address used for rate limiting. Never commit it. IP_HASH_SALT=a long random string, redacted # Required. Anything faster than this from render to submit is quarantined. MIN_SUBMIT_SECONDS=3 # Optional · secret. Notification delivery. Without it the app works and deliveries queue as skipped. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Required. Applied by the retention sweep unless a form overrides it. DEFAULT_RETENTION_DAYS=365
$ choose a build depth, inspect the files, then open the complete pack in your agent
Wufoo: Customers pay for dependable collection, spam control, notifications, integrations, and confidence that responses will not disappear.
xenterprise workflow logic
xspam and abuse defense at scale
xlarge integration and template catalog
xcompliance controls and guaranteed delivery
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Wufoo pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | 5 forms; 100 entries/month total; 10 fields/form; 1 user; 100 API requests/day; 0 GB file storage. |
| starter | — | $16.25/workspace | 10 forms; 1,000 entries/month; 1 user; 10,000 API requests/day; 1 GB storage. |
| professional | — | $33.25/workspace | Unlimited forms; 5,000 entries/month; 5 users; 25,000 API requests/day; 5 GB storage. |
| advanced | — | $83.25/workspace | Unlimited forms; 25,000 entries/month; 20 users; 50,000 API requests/day; 10 GB storage. |
| ultimate | — | $210.25/workspace | Unlimited forms; 200,000 entries/month; 60 users; 100,000 API requests/day; 20 GB storage. |
free tier5 forms; 100 entries/month total; 10 fields/form; 1 user; 100 API requests/day; 0 GB file storage.
billingMonthly + annual; the public cards checked defaulted to annual billing and exposed the annual-equivalent rates, while exact month-to-month rates were not reliably exposed.
hidden costsOver-limit entries cost $0.05 each; free forms can be deactivated at the entry cap; file uploads are limited to 25 MB/field and 100 MB/form.
verified 2026-08-12 · source ↗
Vibecode Wufoo
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Wufoo replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Wufoo cost?
Wufoo costs about $16.25/month (Starter, checked 2026-08-12), which is $195 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Wufoo?
Honestly: enterprise workflow logic; spam and abuse defense at scale; large integration and template catalog; compliance controls and guaranteed delivery. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Wufoo?
Yes: Forminator (Forms, polls, quizzes, calculations, and payments, free as long as you already live inside WordPress) The prompt is for when you want it exactly your way.