Vibecode Jotform
track this build4 phases, 12 steps, beginner friendly0%Basic forms are easy, but Jotform's broad widget library, payments, approvals, PDF generation, tables, apps, and compliance options create a large surface.
You are building a lean indie version of Jotform. 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 ===== # Jotform · indie build A two-surface form builder where the hard problems are versioning definitions so old responses still mean what they meant, taking file uploads without opening a hole, and never losing a submission to a failed notification email. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express and better-sqlite3, one process | a form tool that needs a cluster is a form tool with a problem | | Surfaces | An admin interface bound to localhost, public form pages at a link-only path | the builder is yours, the form is the world's | | Definitions | Immutable published versions stored as JSON | a response only makes sense beside the version it answered | | Uploads | Files outside the web root with generated names, served as attachments | the client's filename and content type are both claims, not facts | | Notifications | An outbox written in the same transaction as the response | a response must survive a mail server having a bad afternoon | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs both surfaces in one process. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **An SMTP account you can point at a dead host** · free tiers exist, otherwise a few pounds a month - Why: The notification path is the one most likely to lose a submission, and it is only testable if you can break it on purpose. - Get it: Any mail provider with SMTP credentials, plus a local capture tool for development. - Verify: A test notification arrives, and pointing at an unreachable host still stores the response - [ ] **A reverse proxy for the public path** · free, or the cost of the host - Why: The public form has to be reachable while the admin interface stays on localhost, and that separation is the whole security model here. - Get it: Any proxy that can expose one path prefix and nothing else, with TLS. - Verify: The public path answers from outside and the admin path refuses - [ ] **Your retention period for responses and uploads** · free - Why: A form collects other people's information and holding it forever by accident is the default outcome unless somebody picks a number now. - Get it: Decide how long a response is useful, write it into the configuration and the README. - Verify: The figure appears in both and a scheduled job honours it ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Payments of any kind. - PDF generation and approvals. - A widget catalogue beyond the seven field types. - Accounts, teams and permissions. - widget catalog - PDF/approval workflows - payments - app builder - integrations - compliance options - templates If one of those is essential to you, that is the reason to keep paying for Jotform, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Jotform 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 whose responses land in a database you own, with uploads and an export that cannot detonate to replace Jotform. 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 response is evidence of what somebody actually submitted, so it is never edited and never migrated. Publishing a change to a form creates a new immutable version, and every response stores the version identifier it was submitted against. Rendering an old response renders it against the version it was filled in under, labels and options included. The tempting alternative is one mutable form definition that responses point at. It works until the day somebody renames a field or removes an option, at which point every previous answer is silently reinterpreted under labels the person never saw, and a table of six months of submissions quietly becomes fiction. Version from the first save, not from the first time it hurts. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express and better-sqlite3, one process · a form tool that needs a cluster is a form tool with a problem - Surfaces: An admin interface bound to localhost, public form pages at a link-only path · the builder is yours, the form is the world's - Definitions: Immutable published versions stored as JSON · a response only makes sense beside the version it answered - Uploads: Files outside the web root with generated names, served as attachments · the client's filename and content type are both claims, not facts - Notifications: An outbox written in the same transaction as the response · a response must survive a mail server having a bad afternoon ### Data model (create this before Phase 1) - forms(id, slug, title, created_at, archived_at) · the identity of a form across all its versions - form_versions(id, form_id, ordinal, definition_json, published_at) · immutable once published, never edited in place - responses(id, form_version_id, submitted_at, remote_hash, answers_json, status) · remote_hash rather than a raw address, because you do not need the address - uploads(id, response_id, field_key, stored_name, original_name, mime_detected, bytes, sha256) · stored_name is generated, original_name is decoration - outbox(id, response_id, kind, attempts, next_attempt_at, last_error, sent_at) · the notification queue, written with the response - rate_limits(bucket, window_start, count) · per form and per source bucket, because one open form attracts one bored script - Everything a response needs to be read a year later is reachable from its own row: the version, the answers and the files. Nothing is resolved through the current definition. ### Phase 1 · Definitions that are versioned from the first save Build: Build a form as an ordered list of fields with a stable key each: text, email, textarea, select, checkbox, date and file. Publishing writes a new version row. Editing a draft never touches a published version. Responses record the version identifier they answered. Trap: Identifying fields by their label means renaming 'Phone' to 'Mobile' orphans every previous answer, and reordering the fields shuffles the columns of an export that somebody has already filed. Give every field a stable key generated at creation, keep the label as display text, and never reuse a key that has been retired. Done when: Publishing a changed form creates a new version and leaves the previous one byte identical, asserted in the suite. A response submitted under version one renders with version one's labels and options after version two is published. Renaming a field changes no stored answer. A retired field key is never handed out again, tested by retiring and adding fields repeatedly. Do not build yet: No public rendering, uploads, notifications or export. ### Phase 2 · A public form that validates on the server Build: Render each version as a plain accessible form with labels tied to inputs and errors listed at the top and beside each field. Validate every rule on the server whatever the browser did. Add a honeypot field and a per-bucket rate limit, and no third-party captcha. Trap: Validating only in the browser means the first person who posts directly to the endpoint fills your table with whatever they like, and since the form is public that person arrives within days of the link being shared. Treat the browser as a convenience and the server as the only validator, then test by posting a malformed body straight to the endpoint. Done when: A form submits and validates correctly with JavaScript disabled entirely. A direct post with missing required fields and a malformed email is rejected by the server with per-field errors. Errors are announced accessibly and each one links to its field, checked with a keyboard alone. Exceeding the rate limit returns a clear refusal rather than a silent drop, and the limit is stated in the README. Do not build yet: No uploads, notifications or export yet. ### Phase 3 · Uploads that cannot become a problem Build: Stream uploads to a folder outside the web root under a generated name, enforcing the size cap during the stream rather than after it. Detect the type from the content, keep the original filename as a display string only, and serve downloads as attachments with a conservative content type. Trap: Buffering the upload and then checking its size means a single request can exhaust memory before the check ever runs, and storing it under the client's filename means a respondent chooses where in your filesystem the file lands and what extension it gets. Enforce the cap while streaming, generate the name yourself, and treat the submitted filename as text to display and nothing else. Done when: A file larger than the cap is rejected during the upload with the connection ended, not after the whole body is in memory, tested with a file well over the limit. A filename containing path separators and traversal sequences is stored under a generated name and displayed escaped. Downloads are served with an attachment disposition and a content type derived from detection, not from the client. The uploads folder is outside anything the web server will serve directly. Do not build yet: No notifications, export or retention yet. ### Phase 4 · A submission that outlives the notification Build: Write the response and an outbox row in one transaction, then return the thank-you page. A separate worker sends notifications with backoff and records failures. Show unsent notifications in the admin interface rather than only in a log. Trap: Sending the notification before committing, or failing the submission when the mail server refuses, loses the response and shows the respondent an error for something that was entirely your problem. Commit first, queue the mail, and let the thank-you page be honest that the form was received. Done when: A submission succeeds and is stored with the mail server unreachable, tested by pointing at a dead host. The outbox retries with backoff and gives up after a stated number of attempts with the error retained. The admin interface lists unsent notifications with their last error. No notification is sent twice for one response, asserted by a test that restarts the worker mid-send. Do not build yet: No export, retention or deletion yet. ### Phase 5 · Getting responses out without hurting anyone Build: Export per form version to CSV with one column per field key, plus a combined export that marks which version each row came from. Neutralise formula-initiating cells. Support closing a form, a stated retention for responses and uploads, and a deletion that removes files too. Trap: A CSV cell is executed as a formula by spreadsheet software when it begins with certain characters, and a form is an invitation for a stranger to choose those characters. OWASP names equals, plus, minus, at, tab, carriage return and line feed, plus full-width variants, and warns that Excel can strip quoting when a file is saved and reopened so that a previously escaped formula becomes live again; its current advice is to prefix an offending cell with a tab character inside the quoted field. Do that, and test the export by opening it. Done when: A response containing a cell that begins with a formula character exports as inert text and stays inert after the file is saved and reopened in a spreadsheet, verified by hand once and asserted in the suite. Deleting a response deletes its uploaded files and the deletion is logged. Closing a form stops submissions with a stated message while old responses stay readable. Retention removes responses and files together on schedule, and the README states the period. Do not build yet: Nothing after this. ### Out of scope (and why) - Taking payments, which turns a weekend build into a regulated one and makes every bug a financial one. - PDF generation and approval workflows, which are Jotform's actual business and each a project rather than a feature. - A widget catalogue, since the seven field types above are the whole palette and every extra one is a validator, a renderer and an exporter. - Accounts and teams, which multiply every screen by a permission model for a tool one person runs. ### README must contain - That form versions are immutable and every response renders against the version it answered - Where uploads are stored, why the folder sits outside the served tree, and what the size cap is - That the admin interface binds to localhost and only the public form path is exposed - That exports neutralise spreadsheet formula characters, and why that matters for a public form - The retention period for responses and uploads, and what deletion actually removes ===== AGENTS.md ===== # Agent instructions · Jotform indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Express and better-sqlite3, one process, An admin interface bound to localhost, public form pages at a link-only path, Immutable published versions stored as JSON, Files outside the web root with generated names, served as attachments, An outbox written in the same transaction as the response. 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 - Identifying fields by their label means renaming 'Phone' to 'Mobile' orphans every previous answer, and reordering the fields shuffles the columns of an export that somebody has already filed. Give every field a stable key generated at creation, keep the label as display text, and never reuse a key that has been retired. - Validating only in the browser means the first person who posts directly to the endpoint fills your table with whatever they like, and since the form is public that person arrives within days of the link being shared. Treat the browser as a convenience and the server as the only validator, then test by posting a malformed body straight to the endpoint. - Buffering the upload and then checking its size means a single request can exhaust memory before the check ever runs, and storing it under the client's filename means a respondent chooses where in your filesystem the file lands and what extension it gets. Enforce the cap while streaming, generate the name yourself, and treat the submitted filename as text to display and nothing else. - Sending the notification before committing, or failing the submission when the mail server refuses, loses the response and shows the respondent an error for something that was entirely your problem. Commit first, queue the mail, and let the thank-you page be honest that the form was received. ===== BUILD_PLAN.md ===== # Build plan · Jotform A two-surface form builder where the hard problems are versioning definitions so old responses still mean what they meant, taking file uploads without opening a hole, and never losing a submission to a failed notification email. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Definitions that are versioned from the first save Publishing a form creates an immutable version and responses point at one. ### Steps 1. Give every field a stable generated key Labels are for humans and change on a whim; keys are for data and never do. Files: `src/forms/schema.js`, `test/keys.test.js` 2. Publish by inserting, never by updating An immutable version row is what lets an old response still mean something. Files: `src/forms/publish.js` 3. Render responses against their own version Reading last March's answers through today's labels is how a record becomes fiction. Files: `src/admin/response.js` ### Done when - [ ] Publishing leaves the previous version unchanged - [ ] Old responses render under their own version's labels - [ ] Renaming a field changes no stored answer - [ ] Retired field keys are never reissued ### Watch out - Identifying fields by their label means renaming 'Phone' to 'Mobile' orphans every previous answer, and reordering the fields shuffles the columns of an export that somebody has already filed. Give every field a stable key generated at creation, keep the label as display text, and never reuse a key that has been retired. ## Phase 2 · A public form that validates on the server The form works without JavaScript, rejects rubbish server side, and resists a bored script. ### Steps 1. Validate server side, always Post a malformed body straight at the endpoint as a test, because someone will. Files: `src/public/validate.js`, `test/direct-post.test.js` 2. Make the form work without scripts It is the cheapest accessibility win and it doubles as the fallback. Files: `src/public/render.js` 3. Honeypot plus rate limit, no third party A captcha sends your respondents to somebody else's service to prove they exist. Files: `src/public/antispam.js` ### Done when - [ ] The form submits with JavaScript disabled - [ ] A malformed direct post is rejected per field - [ ] Errors are keyboard reachable and announced - [ ] Rate limiting refuses clearly rather than silently ### Watch out - Validating only in the browser means the first person who posts directly to the endpoint fills your table with whatever they like, and since the form is public that person arrives within days of the link being shared. Treat the browser as a convenience and the server as the only validator, then test by posting a malformed body straight to the endpoint. ## Phase 3 · Uploads that cannot become a problem A file arrives, is bounded and stored inertly, and is never served back as something executable. ### Steps 1. Cap during the stream Checking the size after buffering is checking after the damage. Files: `src/uploads/stream.js`, `test/oversize.test.js` 2. Generate the stored name, keep the original as text The submitted filename is a claim about your filesystem made by a stranger. Files: `src/uploads/store.js` 3. Serve as an attachment with a detected type Never hand back a file with a type the uploader chose. Files: `src/admin/download.js` ### Done when - [ ] Oversized uploads are cut off mid-stream - [ ] Hostile filenames are stored under generated names - [ ] Downloads are attachments with detected types - [ ] Uploads live outside the served tree ### Watch out - Buffering the upload and then checking its size means a single request can exhaust memory before the check ever runs, and storing it under the client's filename means a respondent chooses where in your filesystem the file lands and what extension it gets. Enforce the cap while streaming, generate the name yourself, and treat the submitted filename as text to display and nothing else. ## Phase 4 · A submission that outlives the notification The response is committed before anything is emailed, and a mail failure is visible. ### Steps 1. Commit the response, then queue the mail The respondent's obligation ends when the row is committed. Files: `src/public/submit.js` 2. Send from an outbox with backoff A dead mail server should cost you a delay, not a submission. Files: `src/outbox/worker.js`, `test/outbox.test.js` 3. Surface unsent notifications in the interface A queue nobody can see is a queue nobody empties. Files: `src/admin/outbox.js` ### Done when - [ ] Submissions succeed with mail unreachable - [ ] The outbox retries with backoff and records errors - [ ] Unsent notifications are visible in the admin view - [ ] A restart mid-send does not double a notification ### Watch out - Sending the notification before committing, or failing the submission when the mail server refuses, loses the response and shows the respondent an error for something that was entirely your problem. Commit first, queue the mail, and let the thank-you page be honest that the form was received. ## Not in this build - Payments of any kind. - PDF generation and approvals. - A widget catalogue beyond the seven field types. - Accounts, teams and permissions. ## After v1, if you want it - Conditional fields, which are a definition feature and change nothing about storage - A per-form webhook, once the outbox has proven itself on mail - Partial save and resume, which needs a token model and deserves its own thinking ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, versions, responses and the outbox. DATABASE_PATH=./data/forms.db # Required. Outside the served tree. Generated names only. UPLOAD_DIR=./data/uploads # Required. Enforced while streaming, not after buffering. UPLOAD_MAX_MB=10 # Required. Used in links and notifications. Only the form path is exposed here. PUBLIC_BASE_URL=https://forms.example.com # Required. Localhost. The admin surface has no login because it has no route in. ADMIN_BIND=127.0.0.1:4000 # Required · secret. Notifications only. A dead host must not cost you a submission. SMTP_URL=smtps://user:redacted@smtp.example.com:465 # Required. Responses and their uploads are removed together on this schedule. RESPONSE_RETENTION_DAYS=365 # Required. Per source bucket per form. Stated in the README so refusals are explainable. RATE_LIMIT_PER_HOUR=20
You are building a lean indie version of Jotform. 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 ===== # Jotform · indie build A two-surface form builder where the hard problems are versioning definitions so old responses still mean what they meant, taking file uploads without opening a hole, and never losing a submission to a failed notification email. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express and better-sqlite3, one process | a form tool that needs a cluster is a form tool with a problem | | Surfaces | An admin interface bound to localhost, public form pages at a link-only path | the builder is yours, the form is the world's | | Definitions | Immutable published versions stored as JSON | a response only makes sense beside the version it answered | | Uploads | Files outside the web root with generated names, served as attachments | the client's filename and content type are both claims, not facts | | Notifications | An outbox written in the same transaction as the response | a response must survive a mail server having a bad afternoon | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs both surfaces in one process. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **An SMTP account you can point at a dead host** · free tiers exist, otherwise a few pounds a month - Why: The notification path is the one most likely to lose a submission, and it is only testable if you can break it on purpose. - Get it: Any mail provider with SMTP credentials, plus a local capture tool for development. - Verify: A test notification arrives, and pointing at an unreachable host still stores the response - [ ] **A reverse proxy for the public path** · free, or the cost of the host - Why: The public form has to be reachable while the admin interface stays on localhost, and that separation is the whole security model here. - Get it: Any proxy that can expose one path prefix and nothing else, with TLS. - Verify: The public path answers from outside and the admin path refuses - [ ] **Your retention period for responses and uploads** · free - Why: A form collects other people's information and holding it forever by accident is the default outcome unless somebody picks a number now. - Get it: Decide how long a response is useful, write it into the configuration and the README. - Verify: The figure appears in both and a scheduled job honours it ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Payments of any kind. - PDF generation and approvals. - A widget catalogue beyond the seven field types. - Accounts, teams and permissions. - widget catalog - PDF/approval workflows - payments - app builder - integrations - compliance options - templates If one of those is essential to you, that is the reason to keep paying for Jotform, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Jotform 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 whose responses land in a database you own, with uploads and an export that cannot detonate to replace Jotform. 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 response is evidence of what somebody actually submitted, so it is never edited and never migrated. Publishing a change to a form creates a new immutable version, and every response stores the version identifier it was submitted against. Rendering an old response renders it against the version it was filled in under, labels and options included. The tempting alternative is one mutable form definition that responses point at. It works until the day somebody renames a field or removes an option, at which point every previous answer is silently reinterpreted under labels the person never saw, and a table of six months of submissions quietly becomes fiction. Version from the first save, not from the first time it hurts. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express and better-sqlite3, one process · a form tool that needs a cluster is a form tool with a problem - Surfaces: An admin interface bound to localhost, public form pages at a link-only path · the builder is yours, the form is the world's - Definitions: Immutable published versions stored as JSON · a response only makes sense beside the version it answered - Uploads: Files outside the web root with generated names, served as attachments · the client's filename and content type are both claims, not facts - Notifications: An outbox written in the same transaction as the response · a response must survive a mail server having a bad afternoon ### Data model (create this before Phase 1) - forms(id, slug, title, created_at, archived_at) · the identity of a form across all its versions - form_versions(id, form_id, ordinal, definition_json, published_at) · immutable once published, never edited in place - responses(id, form_version_id, submitted_at, remote_hash, answers_json, status) · remote_hash rather than a raw address, because you do not need the address - uploads(id, response_id, field_key, stored_name, original_name, mime_detected, bytes, sha256) · stored_name is generated, original_name is decoration - outbox(id, response_id, kind, attempts, next_attempt_at, last_error, sent_at) · the notification queue, written with the response - rate_limits(bucket, window_start, count) · per form and per source bucket, because one open form attracts one bored script - Everything a response needs to be read a year later is reachable from its own row: the version, the answers and the files. Nothing is resolved through the current definition. ### Phase 1 · Definitions that are versioned from the first save Build: Build a form as an ordered list of fields with a stable key each: text, email, textarea, select, checkbox, date and file. Publishing writes a new version row. Editing a draft never touches a published version. Responses record the version identifier they answered. Trap: Identifying fields by their label means renaming 'Phone' to 'Mobile' orphans every previous answer, and reordering the fields shuffles the columns of an export that somebody has already filed. Give every field a stable key generated at creation, keep the label as display text, and never reuse a key that has been retired. Done when: Publishing a changed form creates a new version and leaves the previous one byte identical, asserted in the suite. A response submitted under version one renders with version one's labels and options after version two is published. Renaming a field changes no stored answer. A retired field key is never handed out again, tested by retiring and adding fields repeatedly. Do not build yet: No public rendering, uploads, notifications or export. ### Phase 2 · A public form that validates on the server Build: Render each version as a plain accessible form with labels tied to inputs and errors listed at the top and beside each field. Validate every rule on the server whatever the browser did. Add a honeypot field and a per-bucket rate limit, and no third-party captcha. Trap: Validating only in the browser means the first person who posts directly to the endpoint fills your table with whatever they like, and since the form is public that person arrives within days of the link being shared. Treat the browser as a convenience and the server as the only validator, then test by posting a malformed body straight to the endpoint. Done when: A form submits and validates correctly with JavaScript disabled entirely. A direct post with missing required fields and a malformed email is rejected by the server with per-field errors. Errors are announced accessibly and each one links to its field, checked with a keyboard alone. Exceeding the rate limit returns a clear refusal rather than a silent drop, and the limit is stated in the README. Do not build yet: No uploads, notifications or export yet. ### Phase 3 · Uploads that cannot become a problem Build: Stream uploads to a folder outside the web root under a generated name, enforcing the size cap during the stream rather than after it. Detect the type from the content, keep the original filename as a display string only, and serve downloads as attachments with a conservative content type. Trap: Buffering the upload and then checking its size means a single request can exhaust memory before the check ever runs, and storing it under the client's filename means a respondent chooses where in your filesystem the file lands and what extension it gets. Enforce the cap while streaming, generate the name yourself, and treat the submitted filename as text to display and nothing else. Done when: A file larger than the cap is rejected during the upload with the connection ended, not after the whole body is in memory, tested with a file well over the limit. A filename containing path separators and traversal sequences is stored under a generated name and displayed escaped. Downloads are served with an attachment disposition and a content type derived from detection, not from the client. The uploads folder is outside anything the web server will serve directly. Do not build yet: No notifications, export or retention yet. ### Phase 4 · A submission that outlives the notification Build: Write the response and an outbox row in one transaction, then return the thank-you page. A separate worker sends notifications with backoff and records failures. Show unsent notifications in the admin interface rather than only in a log. Trap: Sending the notification before committing, or failing the submission when the mail server refuses, loses the response and shows the respondent an error for something that was entirely your problem. Commit first, queue the mail, and let the thank-you page be honest that the form was received. Done when: A submission succeeds and is stored with the mail server unreachable, tested by pointing at a dead host. The outbox retries with backoff and gives up after a stated number of attempts with the error retained. The admin interface lists unsent notifications with their last error. No notification is sent twice for one response, asserted by a test that restarts the worker mid-send. Do not build yet: No export, retention or deletion yet. ### Phase 5 · Getting responses out without hurting anyone Build: Export per form version to CSV with one column per field key, plus a combined export that marks which version each row came from. Neutralise formula-initiating cells. Support closing a form, a stated retention for responses and uploads, and a deletion that removes files too. Trap: A CSV cell is executed as a formula by spreadsheet software when it begins with certain characters, and a form is an invitation for a stranger to choose those characters. OWASP names equals, plus, minus, at, tab, carriage return and line feed, plus full-width variants, and warns that Excel can strip quoting when a file is saved and reopened so that a previously escaped formula becomes live again; its current advice is to prefix an offending cell with a tab character inside the quoted field. Do that, and test the export by opening it. Done when: A response containing a cell that begins with a formula character exports as inert text and stays inert after the file is saved and reopened in a spreadsheet, verified by hand once and asserted in the suite. Deleting a response deletes its uploaded files and the deletion is logged. Closing a form stops submissions with a stated message while old responses stay readable. Retention removes responses and files together on schedule, and the README states the period. Do not build yet: Nothing after this. ### Out of scope (and why) - Taking payments, which turns a weekend build into a regulated one and makes every bug a financial one. - PDF generation and approval workflows, which are Jotform's actual business and each a project rather than a feature. - A widget catalogue, since the seven field types above are the whole palette and every extra one is a validator, a renderer and an exporter. - Accounts and teams, which multiply every screen by a permission model for a tool one person runs. ### README must contain - That form versions are immutable and every response renders against the version it answered - Where uploads are stored, why the folder sits outside the served tree, and what the size cap is - That the admin interface binds to localhost and only the public form path is exposed - That exports neutralise spreadsheet formula characters, and why that matters for a public form - The retention period for responses and uploads, and what deletion actually removes ===== AGENTS.md ===== # Agent instructions · Jotform indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Express and better-sqlite3, one process, An admin interface bound to localhost, public form pages at a link-only path, Immutable published versions stored as JSON, Files outside the web root with generated names, served as attachments, An outbox written in the same transaction as the response. 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 - Identifying fields by their label means renaming 'Phone' to 'Mobile' orphans every previous answer, and reordering the fields shuffles the columns of an export that somebody has already filed. Give every field a stable key generated at creation, keep the label as display text, and never reuse a key that has been retired. - Validating only in the browser means the first person who posts directly to the endpoint fills your table with whatever they like, and since the form is public that person arrives within days of the link being shared. Treat the browser as a convenience and the server as the only validator, then test by posting a malformed body straight to the endpoint. - Buffering the upload and then checking its size means a single request can exhaust memory before the check ever runs, and storing it under the client's filename means a respondent chooses where in your filesystem the file lands and what extension it gets. Enforce the cap while streaming, generate the name yourself, and treat the submitted filename as text to display and nothing else. - Sending the notification before committing, or failing the submission when the mail server refuses, loses the response and shows the respondent an error for something that was entirely your problem. Commit first, queue the mail, and let the thank-you page be honest that the form was received. ===== BUILD_PLAN.md ===== # Build plan · Jotform A two-surface form builder where the hard problems are versioning definitions so old responses still mean what they meant, taking file uploads without opening a hole, and never losing a submission to a failed notification email. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Definitions that are versioned from the first save Publishing a form creates an immutable version and responses point at one. ### Steps 1. Give every field a stable generated key Labels are for humans and change on a whim; keys are for data and never do. Files: `src/forms/schema.js`, `test/keys.test.js` 2. Publish by inserting, never by updating An immutable version row is what lets an old response still mean something. Files: `src/forms/publish.js` 3. Render responses against their own version Reading last March's answers through today's labels is how a record becomes fiction. Files: `src/admin/response.js` ### Done when - [ ] Publishing leaves the previous version unchanged - [ ] Old responses render under their own version's labels - [ ] Renaming a field changes no stored answer - [ ] Retired field keys are never reissued ### Watch out - Identifying fields by their label means renaming 'Phone' to 'Mobile' orphans every previous answer, and reordering the fields shuffles the columns of an export that somebody has already filed. Give every field a stable key generated at creation, keep the label as display text, and never reuse a key that has been retired. ## Phase 2 · A public form that validates on the server The form works without JavaScript, rejects rubbish server side, and resists a bored script. ### Steps 1. Validate server side, always Post a malformed body straight at the endpoint as a test, because someone will. Files: `src/public/validate.js`, `test/direct-post.test.js` 2. Make the form work without scripts It is the cheapest accessibility win and it doubles as the fallback. Files: `src/public/render.js` 3. Honeypot plus rate limit, no third party A captcha sends your respondents to somebody else's service to prove they exist. Files: `src/public/antispam.js` ### Done when - [ ] The form submits with JavaScript disabled - [ ] A malformed direct post is rejected per field - [ ] Errors are keyboard reachable and announced - [ ] Rate limiting refuses clearly rather than silently ### Watch out - Validating only in the browser means the first person who posts directly to the endpoint fills your table with whatever they like, and since the form is public that person arrives within days of the link being shared. Treat the browser as a convenience and the server as the only validator, then test by posting a malformed body straight to the endpoint. ## Phase 3 · Uploads that cannot become a problem A file arrives, is bounded and stored inertly, and is never served back as something executable. ### Steps 1. Cap during the stream Checking the size after buffering is checking after the damage. Files: `src/uploads/stream.js`, `test/oversize.test.js` 2. Generate the stored name, keep the original as text The submitted filename is a claim about your filesystem made by a stranger. Files: `src/uploads/store.js` 3. Serve as an attachment with a detected type Never hand back a file with a type the uploader chose. Files: `src/admin/download.js` ### Done when - [ ] Oversized uploads are cut off mid-stream - [ ] Hostile filenames are stored under generated names - [ ] Downloads are attachments with detected types - [ ] Uploads live outside the served tree ### Watch out - Buffering the upload and then checking its size means a single request can exhaust memory before the check ever runs, and storing it under the client's filename means a respondent chooses where in your filesystem the file lands and what extension it gets. Enforce the cap while streaming, generate the name yourself, and treat the submitted filename as text to display and nothing else. ## Phase 4 · A submission that outlives the notification The response is committed before anything is emailed, and a mail failure is visible. ### Steps 1. Commit the response, then queue the mail The respondent's obligation ends when the row is committed. Files: `src/public/submit.js` 2. Send from an outbox with backoff A dead mail server should cost you a delay, not a submission. Files: `src/outbox/worker.js`, `test/outbox.test.js` 3. Surface unsent notifications in the interface A queue nobody can see is a queue nobody empties. Files: `src/admin/outbox.js` ### Done when - [ ] Submissions succeed with mail unreachable - [ ] The outbox retries with backoff and records errors - [ ] Unsent notifications are visible in the admin view - [ ] A restart mid-send does not double a notification ### Watch out - Sending the notification before committing, or failing the submission when the mail server refuses, loses the response and shows the respondent an error for something that was entirely your problem. Commit first, queue the mail, and let the thank-you page be honest that the form was received. ## Not in this build - Payments of any kind. - PDF generation and approvals. - A widget catalogue beyond the seven field types. - Accounts, teams and permissions. ## After v1, if you want it - Conditional fields, which are a definition feature and change nothing about storage - A per-form webhook, once the outbox has proven itself on mail - Partial save and resume, which needs a token model and deserves its own thinking ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, versions, responses and the outbox. DATABASE_PATH=./data/forms.db # Required. Outside the served tree. Generated names only. UPLOAD_DIR=./data/uploads # Required. Enforced while streaming, not after buffering. UPLOAD_MAX_MB=10 # Required. Used in links and notifications. Only the form path is exposed here. PUBLIC_BASE_URL=https://forms.example.com # Required. Localhost. The admin surface has no login because it has no route in. ADMIN_BIND=127.0.0.1:4000 # Required · secret. Notifications only. A dead host must not cost you a submission. SMTP_URL=smtps://user:redacted@smtp.example.com:465 # Required. Responses and their uploads are removed together on this schedule. RESPONSE_RETENTION_DAYS=365 # Required. Per source bucket per form. Stated in the README so refusals are explainable. RATE_LIMIT_PER_HOUR=20
You are building a production product version of Jotform. 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 ===== # Jotform · product brief ## Problem Basic forms are easy, but Jotform's broad widget library, payments, approvals, PDF generation, tables, apps, and compliance options create a large surface. ## Product outcome A form tool where a response submitted in March still reads correctly in December, an upload cannot become a filesystem problem, a dead mail server costs a delay rather than a submission, and an export opens in a spreadsheet without executing anything. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - database - form renderer/editor - storage - email - payment API - PDF generation if needed ## Explicit non-goals for v1 - Payments of any kind. - PDF generation and approvals. - A widget catalogue beyond the seven field types. - Accounts, teams and permissions. - widget catalog - PDF/approval workflows - payments - app builder - integrations - compliance options - templates ## Success criteria - Old responses are confirmed to render under their own version's labels - A malformed direct post is confirmed rejected server side - An oversized upload is confirmed cut off mid-stream - A submission is confirmed to succeed with mail unreachable - A formula cell is confirmed inert after a spreadsheet round trip ===== BRIEF.md ===== # Build brief · Jotform 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 whose responses land in a database you own, with uploads and an export that cannot detonate to replace Jotform. 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 response is evidence of what somebody actually submitted, so it is never edited and never migrated. Publishing a change to a form creates a new immutable version, and every response stores the version identifier it was submitted against. Rendering an old response renders it against the version it was filled in under, labels and options included. The tempting alternative is one mutable form definition that responses point at. It works until the day somebody renames a field or removes an option, at which point every previous answer is silently reinterpreted under labels the person never saw, and a table of six months of submissions quietly becomes fiction. Version from the first save, not from the first time it hurts. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express and better-sqlite3, one process · a form tool that needs a cluster is a form tool with a problem - Surfaces: An admin interface bound to localhost, public form pages at a link-only path · the builder is yours, the form is the world's - Definitions: Immutable published versions stored as JSON · a response only makes sense beside the version it answered - Uploads: Files outside the web root with generated names, served as attachments · the client's filename and content type are both claims, not facts - Notifications: An outbox written in the same transaction as the response · a response must survive a mail server having a bad afternoon ### Data model (create this before Phase 1) - forms(id, slug, title, created_at, archived_at) · the identity of a form across all its versions - form_versions(id, form_id, ordinal, definition_json, published_at) · immutable once published, never edited in place - responses(id, form_version_id, submitted_at, remote_hash, answers_json, status) · remote_hash rather than a raw address, because you do not need the address - uploads(id, response_id, field_key, stored_name, original_name, mime_detected, bytes, sha256) · stored_name is generated, original_name is decoration - outbox(id, response_id, kind, attempts, next_attempt_at, last_error, sent_at) · the notification queue, written with the response - rate_limits(bucket, window_start, count) · per form and per source bucket, because one open form attracts one bored script - Everything a response needs to be read a year later is reachable from its own row: the version, the answers and the files. Nothing is resolved through the current definition. ### Phase 1 · Definitions that are versioned from the first save Build: Build a form as an ordered list of fields with a stable key each: text, email, textarea, select, checkbox, date and file. Publishing writes a new version row. Editing a draft never touches a published version. Responses record the version identifier they answered. Trap: Identifying fields by their label means renaming 'Phone' to 'Mobile' orphans every previous answer, and reordering the fields shuffles the columns of an export that somebody has already filed. Give every field a stable key generated at creation, keep the label as display text, and never reuse a key that has been retired. Done when: Publishing a changed form creates a new version and leaves the previous one byte identical, asserted in the suite. A response submitted under version one renders with version one's labels and options after version two is published. Renaming a field changes no stored answer. A retired field key is never handed out again, tested by retiring and adding fields repeatedly. Do not build yet: No public rendering, uploads, notifications or export. ### Phase 2 · A public form that validates on the server Build: Render each version as a plain accessible form with labels tied to inputs and errors listed at the top and beside each field. Validate every rule on the server whatever the browser did. Add a honeypot field and a per-bucket rate limit, and no third-party captcha. Trap: Validating only in the browser means the first person who posts directly to the endpoint fills your table with whatever they like, and since the form is public that person arrives within days of the link being shared. Treat the browser as a convenience and the server as the only validator, then test by posting a malformed body straight to the endpoint. Done when: A form submits and validates correctly with JavaScript disabled entirely. A direct post with missing required fields and a malformed email is rejected by the server with per-field errors. Errors are announced accessibly and each one links to its field, checked with a keyboard alone. Exceeding the rate limit returns a clear refusal rather than a silent drop, and the limit is stated in the README. Do not build yet: No uploads, notifications or export yet. ### Phase 3 · Uploads that cannot become a problem Build: Stream uploads to a folder outside the web root under a generated name, enforcing the size cap during the stream rather than after it. Detect the type from the content, keep the original filename as a display string only, and serve downloads as attachments with a conservative content type. Trap: Buffering the upload and then checking its size means a single request can exhaust memory before the check ever runs, and storing it under the client's filename means a respondent chooses where in your filesystem the file lands and what extension it gets. Enforce the cap while streaming, generate the name yourself, and treat the submitted filename as text to display and nothing else. Done when: A file larger than the cap is rejected during the upload with the connection ended, not after the whole body is in memory, tested with a file well over the limit. A filename containing path separators and traversal sequences is stored under a generated name and displayed escaped. Downloads are served with an attachment disposition and a content type derived from detection, not from the client. The uploads folder is outside anything the web server will serve directly. Do not build yet: No notifications, export or retention yet. ### Phase 4 · A submission that outlives the notification Build: Write the response and an outbox row in one transaction, then return the thank-you page. A separate worker sends notifications with backoff and records failures. Show unsent notifications in the admin interface rather than only in a log. Trap: Sending the notification before committing, or failing the submission when the mail server refuses, loses the response and shows the respondent an error for something that was entirely your problem. Commit first, queue the mail, and let the thank-you page be honest that the form was received. Done when: A submission succeeds and is stored with the mail server unreachable, tested by pointing at a dead host. The outbox retries with backoff and gives up after a stated number of attempts with the error retained. The admin interface lists unsent notifications with their last error. No notification is sent twice for one response, asserted by a test that restarts the worker mid-send. Do not build yet: No export, retention or deletion yet. ### Phase 5 · Getting responses out without hurting anyone Build: Export per form version to CSV with one column per field key, plus a combined export that marks which version each row came from. Neutralise formula-initiating cells. Support closing a form, a stated retention for responses and uploads, and a deletion that removes files too. Trap: A CSV cell is executed as a formula by spreadsheet software when it begins with certain characters, and a form is an invitation for a stranger to choose those characters. OWASP names equals, plus, minus, at, tab, carriage return and line feed, plus full-width variants, and warns that Excel can strip quoting when a file is saved and reopened so that a previously escaped formula becomes live again; its current advice is to prefix an offending cell with a tab character inside the quoted field. Do that, and test the export by opening it. Done when: A response containing a cell that begins with a formula character exports as inert text and stays inert after the file is saved and reopened in a spreadsheet, verified by hand once and asserted in the suite. Deleting a response deletes its uploaded files and the deletion is logged. Closing a form stops submissions with a stated message while old responses stay readable. Retention removes responses and files together on schedule, and the README states the period. Do not build yet: Nothing after this. ### Out of scope (and why) - Taking payments, which turns a weekend build into a regulated one and makes every bug a financial one. - PDF generation and approval workflows, which are Jotform's actual business and each a project rather than a feature. - A widget catalogue, since the seven field types above are the whole palette and every extra one is a validator, a renderer and an exporter. - Accounts and teams, which multiply every screen by a permission model for a tool one person runs. ### README must contain - That form versions are immutable and every response renders against the version it answered - Where uploads are stored, why the folder sits outside the served tree, and what the size cap is - That the admin interface binds to localhost and only the public form path is exposed - That exports neutralise spreadsheet formula characters, and why that matters for a public form - The retention period for responses and uploads, and what deletion actually removes ===== ARCHITECTURE.md ===== # Architecture · Jotform ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express and better-sqlite3, one process | a form tool that needs a cluster is a form tool with a problem | | Surfaces | An admin interface bound to localhost, public form pages at a link-only path | the builder is yours, the form is the world's | | Definitions | Immutable published versions stored as JSON | a response only makes sense beside the version it answered | | Uploads | Files outside the web root with generated names, served as attachments | the client's filename and content type are both claims, not facts | | Notifications | An outbox written in the same transaction as the response | a response must survive a mail server having a bad afternoon | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Definitions | Stable field keys and immutable published versions. | Any editor, provided publishing inserts rather than updates. | | Public renderer | Accessible markup and server-side validation. | Any templating, provided the form still works without scripts. | | Uploads | Streaming caps, generated names, detected types. | Any storage, provided the client never names the file. | | Outbox | Notification delivery with retry and visible failure. | Any transport, provided the response commits first. | | Export | Per-version CSV with formula cells neutralised. | Any format, provided the spreadsheet round trip is tested. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Forms, versions, responses and the outbox. - `UPLOAD_DIR` · required · Outside the served tree. Generated names only. - `UPLOAD_MAX_MB` · required · Enforced while streaming, not after buffering. - `PUBLIC_BASE_URL` · required · Used in links and notifications. Only the form path is exposed here. - `ADMIN_BIND` · required · Localhost. The admin surface has no login because it has no route in. - `SMTP_URL` · required, secret · Notifications only. A dead host must not cost you a submission. - `RESPONSE_RETENTION_DAYS` · required · Responses and their uploads are removed together on this schedule. - `RATE_LIMIT_PER_HOUR` · required · Per source bucket per form. Stated in the README so refusals are explainable. ## 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 · Jotform product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Express and better-sqlite3, one process, An admin interface bound to localhost, public form pages at a link-only path, Immutable published versions stored as JSON, Files outside the web root with generated names, served as attachments, An outbox written in the same transaction as the response. - 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 - Identifying fields by their label means renaming 'Phone' to 'Mobile' orphans every previous answer, and reordering the fields shuffles the columns of an export that somebody has already filed. Give every field a stable key generated at creation, keep the label as display text, and never reuse a key that has been retired. - Validating only in the browser means the first person who posts directly to the endpoint fills your table with whatever they like, and since the form is public that person arrives within days of the link being shared. Treat the browser as a convenience and the server as the only validator, then test by posting a malformed body straight to the endpoint. - Buffering the upload and then checking its size means a single request can exhaust memory before the check ever runs, and storing it under the client's filename means a respondent chooses where in your filesystem the file lands and what extension it gets. Enforce the cap while streaming, generate the name yourself, and treat the submitted filename as text to display and nothing else. - Sending the notification before committing, or failing the submission when the mail server refuses, loses the response and shows the respondent an error for something that was entirely your problem. Commit first, queue the mail, and let the thank-you page be honest that the form was received. - A CSV cell is executed as a formula by spreadsheet software when it begins with certain characters, and a form is an invitation for a stranger to choose those characters. OWASP names equals, plus, minus, at, tab, carriage return and line feed, plus full-width variants, and warns that Excel can strip quoting when a file is saved and reopened so that a previously escaped formula becomes live again; its current advice is to prefix an offending cell with a tab character inside the quoted field. Do that, and test the export by opening it. ===== MILESTONES.md ===== # Delivery milestones · Jotform Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Definitions that are versioned from the first save Publishing a form creates an immutable version and responses point at one. ### Steps 1. Give every field a stable generated key Labels are for humans and change on a whim; keys are for data and never do. Files: `src/forms/schema.js`, `test/keys.test.js` 2. Publish by inserting, never by updating An immutable version row is what lets an old response still mean something. Files: `src/forms/publish.js` 3. Render responses against their own version Reading last March's answers through today's labels is how a record becomes fiction. Files: `src/admin/response.js` ### Done when - [ ] Publishing leaves the previous version unchanged - [ ] Old responses render under their own version's labels - [ ] Renaming a field changes no stored answer - [ ] Retired field keys are never reissued ### Watch out - Identifying fields by their label means renaming 'Phone' to 'Mobile' orphans every previous answer, and reordering the fields shuffles the columns of an export that somebody has already filed. Give every field a stable key generated at creation, keep the label as display text, and never reuse a key that has been retired. ## M2 · A public form that validates on the server The form works without JavaScript, rejects rubbish server side, and resists a bored script. ### Steps 1. Validate server side, always Post a malformed body straight at the endpoint as a test, because someone will. Files: `src/public/validate.js`, `test/direct-post.test.js` 2. Make the form work without scripts It is the cheapest accessibility win and it doubles as the fallback. Files: `src/public/render.js` 3. Honeypot plus rate limit, no third party A captcha sends your respondents to somebody else's service to prove they exist. Files: `src/public/antispam.js` ### Done when - [ ] The form submits with JavaScript disabled - [ ] A malformed direct post is rejected per field - [ ] Errors are keyboard reachable and announced - [ ] Rate limiting refuses clearly rather than silently ### Watch out - Validating only in the browser means the first person who posts directly to the endpoint fills your table with whatever they like, and since the form is public that person arrives within days of the link being shared. Treat the browser as a convenience and the server as the only validator, then test by posting a malformed body straight to the endpoint. ## M3 · Uploads that cannot become a problem A file arrives, is bounded and stored inertly, and is never served back as something executable. ### Steps 1. Cap during the stream Checking the size after buffering is checking after the damage. Files: `src/uploads/stream.js`, `test/oversize.test.js` 2. Generate the stored name, keep the original as text The submitted filename is a claim about your filesystem made by a stranger. Files: `src/uploads/store.js` 3. Serve as an attachment with a detected type Never hand back a file with a type the uploader chose. Files: `src/admin/download.js` ### Done when - [ ] Oversized uploads are cut off mid-stream - [ ] Hostile filenames are stored under generated names - [ ] Downloads are attachments with detected types - [ ] Uploads live outside the served tree ### Watch out - Buffering the upload and then checking its size means a single request can exhaust memory before the check ever runs, and storing it under the client's filename means a respondent chooses where in your filesystem the file lands and what extension it gets. Enforce the cap while streaming, generate the name yourself, and treat the submitted filename as text to display and nothing else. ## M4 · A submission that outlives the notification The response is committed before anything is emailed, and a mail failure is visible. ### Steps 1. Commit the response, then queue the mail The respondent's obligation ends when the row is committed. Files: `src/public/submit.js` 2. Send from an outbox with backoff A dead mail server should cost you a delay, not a submission. Files: `src/outbox/worker.js`, `test/outbox.test.js` 3. Surface unsent notifications in the interface A queue nobody can see is a queue nobody empties. Files: `src/admin/outbox.js` ### Done when - [ ] Submissions succeed with mail unreachable - [ ] The outbox retries with backoff and records errors - [ ] Unsent notifications are visible in the admin view - [ ] A restart mid-send does not double a notification ### Watch out - Sending the notification before committing, or failing the submission when the mail server refuses, loses the response and shows the respondent an error for something that was entirely your problem. Commit first, queue the mail, and let the thank-you page be honest that the form was received. ## M5 · Getting responses out without hurting anyone (production only) Export is safe to open, deletion is real, and the form can be closed. ### Steps 1. Neutralise formula cells and verify in a spreadsheet The characters and the save-and-reopen caveat are both published; follow them. Files: `src/export/csv.js`, `test/csv-injection.test.js` 2. Delete files with the response An orphaned upload is a data breach waiting for a directory listing. Files: `src/admin/delete.js` 3. Close a form without deleting it A closed form still holds evidence somebody may need. Files: `src/forms/close.js` ### Done when - [ ] Formula cells stay inert after a spreadsheet round trip - [ ] Deleting a response deletes its files - [ ] A closed form refuses submissions and keeps history - [ ] Retention removes responses and uploads together ### Watch out - A CSV cell is executed as a formula by spreadsheet software when it begins with certain characters, and a form is an invitation for a stranger to choose those characters. OWASP names equals, plus, minus, at, tab, carriage return and line feed, plus full-width variants, and warns that Excel can strip quoting when a file is saved and reopened so that a previously escaped formula becomes live again; its current advice is to prefix an offending cell with a tab character inside the quoted field. Do that, and test the export by opening it. ===== OPERATIONS.md ===== # Operations · Jotform ## Backup The database and the uploads folder together, since a restore of one alone leaves responses pointing at files that are gone or files nobody can attribute. Test the pair, not each half. ## Restore Restore both, then check that a response with an upload still resolves its file and that the outbox has not queued a fresh copy of every historical notification. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Unsent outbox rows and their age, submissions per form per day, and uploads folder size against the disk. A form that suddenly receives a thousand submissions has been found by a script. ## Incident checklist A spam flood is handled by closing the form, which keeps existing responses readable; a lost notification is visible in the outbox and never means a lost response, which is the distinction the design exists to make. 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 - [ ] Old responses are confirmed to render under their own version's labels - [ ] A malformed direct post is confirmed rejected server side - [ ] An oversized upload is confirmed cut off mid-stream - [ ] A submission is confirmed to succeed with mail unreachable - [ ] A formula cell is confirmed inert after a spreadsheet round trip ## Launch constraint Do not market omitted Jotform capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, versions, responses and the outbox. DATABASE_PATH=./data/forms.db # Required. Outside the served tree. Generated names only. UPLOAD_DIR=./data/uploads # Required. Enforced while streaming, not after buffering. UPLOAD_MAX_MB=10 # Required. Used in links and notifications. Only the form path is exposed here. PUBLIC_BASE_URL=https://forms.example.com # Required. Localhost. The admin surface has no login because it has no route in. ADMIN_BIND=127.0.0.1:4000 # Required · secret. Notifications only. A dead host must not cost you a submission. SMTP_URL=smtps://user:redacted@smtp.example.com:465 # Required. Responses and their uploads are removed together on this schedule. RESPONSE_RETENTION_DAYS=365 # Required. Per source bucket per form. Stated in the README so refusals are explainable. RATE_LIMIT_PER_HOUR=20
# Jotform · indie build A two-surface form builder where the hard problems are versioning definitions so old responses still mean what they meant, taking file uploads without opening a hole, and never losing a submission to a failed notification email. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express and better-sqlite3, one process | a form tool that needs a cluster is a form tool with a problem | | Surfaces | An admin interface bound to localhost, public form pages at a link-only path | the builder is yours, the form is the world's | | Definitions | Immutable published versions stored as JSON | a response only makes sense beside the version it answered | | Uploads | Files outside the web root with generated names, served as attachments | the client's filename and content type are both claims, not facts | | Notifications | An outbox written in the same transaction as the response | a response must survive a mail server having a bad afternoon | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs both surfaces in one process. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **An SMTP account you can point at a dead host** · free tiers exist, otherwise a few pounds a month - Why: The notification path is the one most likely to lose a submission, and it is only testable if you can break it on purpose. - Get it: Any mail provider with SMTP credentials, plus a local capture tool for development. - Verify: A test notification arrives, and pointing at an unreachable host still stores the response - [ ] **A reverse proxy for the public path** · free, or the cost of the host - Why: The public form has to be reachable while the admin interface stays on localhost, and that separation is the whole security model here. - Get it: Any proxy that can expose one path prefix and nothing else, with TLS. - Verify: The public path answers from outside and the admin path refuses - [ ] **Your retention period for responses and uploads** · free - Why: A form collects other people's information and holding it forever by accident is the default outcome unless somebody picks a number now. - Get it: Decide how long a response is useful, write it into the configuration and the README. - Verify: The figure appears in both and a scheduled job honours it ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Payments of any kind. - PDF generation and approvals. - A widget catalogue beyond the seven field types. - Accounts, teams and permissions. - widget catalog - PDF/approval workflows - payments - app builder - integrations - compliance options - templates If one of those is essential to you, that is the reason to keep paying for Jotform, and the README should say so rather than pretend.
# Build brief · Jotform 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 whose responses land in a database you own, with uploads and an export that cannot detonate to replace Jotform. 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 response is evidence of what somebody actually submitted, so it is never edited and never migrated. Publishing a change to a form creates a new immutable version, and every response stores the version identifier it was submitted against. Rendering an old response renders it against the version it was filled in under, labels and options included. The tempting alternative is one mutable form definition that responses point at. It works until the day somebody renames a field or removes an option, at which point every previous answer is silently reinterpreted under labels the person never saw, and a table of six months of submissions quietly becomes fiction. Version from the first save, not from the first time it hurts. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express and better-sqlite3, one process · a form tool that needs a cluster is a form tool with a problem - Surfaces: An admin interface bound to localhost, public form pages at a link-only path · the builder is yours, the form is the world's - Definitions: Immutable published versions stored as JSON · a response only makes sense beside the version it answered - Uploads: Files outside the web root with generated names, served as attachments · the client's filename and content type are both claims, not facts - Notifications: An outbox written in the same transaction as the response · a response must survive a mail server having a bad afternoon ### Data model (create this before Phase 1) - forms(id, slug, title, created_at, archived_at) · the identity of a form across all its versions - form_versions(id, form_id, ordinal, definition_json, published_at) · immutable once published, never edited in place - responses(id, form_version_id, submitted_at, remote_hash, answers_json, status) · remote_hash rather than a raw address, because you do not need the address - uploads(id, response_id, field_key, stored_name, original_name, mime_detected, bytes, sha256) · stored_name is generated, original_name is decoration - outbox(id, response_id, kind, attempts, next_attempt_at, last_error, sent_at) · the notification queue, written with the response - rate_limits(bucket, window_start, count) · per form and per source bucket, because one open form attracts one bored script - Everything a response needs to be read a year later is reachable from its own row: the version, the answers and the files. Nothing is resolved through the current definition. ### Phase 1 · Definitions that are versioned from the first save Build: Build a form as an ordered list of fields with a stable key each: text, email, textarea, select, checkbox, date and file. Publishing writes a new version row. Editing a draft never touches a published version. Responses record the version identifier they answered. Trap: Identifying fields by their label means renaming 'Phone' to 'Mobile' orphans every previous answer, and reordering the fields shuffles the columns of an export that somebody has already filed. Give every field a stable key generated at creation, keep the label as display text, and never reuse a key that has been retired. Done when: Publishing a changed form creates a new version and leaves the previous one byte identical, asserted in the suite. A response submitted under version one renders with version one's labels and options after version two is published. Renaming a field changes no stored answer. A retired field key is never handed out again, tested by retiring and adding fields repeatedly. Do not build yet: No public rendering, uploads, notifications or export. ### Phase 2 · A public form that validates on the server Build: Render each version as a plain accessible form with labels tied to inputs and errors listed at the top and beside each field. Validate every rule on the server whatever the browser did. Add a honeypot field and a per-bucket rate limit, and no third-party captcha. Trap: Validating only in the browser means the first person who posts directly to the endpoint fills your table with whatever they like, and since the form is public that person arrives within days of the link being shared. Treat the browser as a convenience and the server as the only validator, then test by posting a malformed body straight to the endpoint. Done when: A form submits and validates correctly with JavaScript disabled entirely. A direct post with missing required fields and a malformed email is rejected by the server with per-field errors. Errors are announced accessibly and each one links to its field, checked with a keyboard alone. Exceeding the rate limit returns a clear refusal rather than a silent drop, and the limit is stated in the README. Do not build yet: No uploads, notifications or export yet. ### Phase 3 · Uploads that cannot become a problem Build: Stream uploads to a folder outside the web root under a generated name, enforcing the size cap during the stream rather than after it. Detect the type from the content, keep the original filename as a display string only, and serve downloads as attachments with a conservative content type. Trap: Buffering the upload and then checking its size means a single request can exhaust memory before the check ever runs, and storing it under the client's filename means a respondent chooses where in your filesystem the file lands and what extension it gets. Enforce the cap while streaming, generate the name yourself, and treat the submitted filename as text to display and nothing else. Done when: A file larger than the cap is rejected during the upload with the connection ended, not after the whole body is in memory, tested with a file well over the limit. A filename containing path separators and traversal sequences is stored under a generated name and displayed escaped. Downloads are served with an attachment disposition and a content type derived from detection, not from the client. The uploads folder is outside anything the web server will serve directly. Do not build yet: No notifications, export or retention yet. ### Phase 4 · A submission that outlives the notification Build: Write the response and an outbox row in one transaction, then return the thank-you page. A separate worker sends notifications with backoff and records failures. Show unsent notifications in the admin interface rather than only in a log. Trap: Sending the notification before committing, or failing the submission when the mail server refuses, loses the response and shows the respondent an error for something that was entirely your problem. Commit first, queue the mail, and let the thank-you page be honest that the form was received. Done when: A submission succeeds and is stored with the mail server unreachable, tested by pointing at a dead host. The outbox retries with backoff and gives up after a stated number of attempts with the error retained. The admin interface lists unsent notifications with their last error. No notification is sent twice for one response, asserted by a test that restarts the worker mid-send. Do not build yet: No export, retention or deletion yet. ### Phase 5 · Getting responses out without hurting anyone Build: Export per form version to CSV with one column per field key, plus a combined export that marks which version each row came from. Neutralise formula-initiating cells. Support closing a form, a stated retention for responses and uploads, and a deletion that removes files too. Trap: A CSV cell is executed as a formula by spreadsheet software when it begins with certain characters, and a form is an invitation for a stranger to choose those characters. OWASP names equals, plus, minus, at, tab, carriage return and line feed, plus full-width variants, and warns that Excel can strip quoting when a file is saved and reopened so that a previously escaped formula becomes live again; its current advice is to prefix an offending cell with a tab character inside the quoted field. Do that, and test the export by opening it. Done when: A response containing a cell that begins with a formula character exports as inert text and stays inert after the file is saved and reopened in a spreadsheet, verified by hand once and asserted in the suite. Deleting a response deletes its uploaded files and the deletion is logged. Closing a form stops submissions with a stated message while old responses stay readable. Retention removes responses and files together on schedule, and the README states the period. Do not build yet: Nothing after this. ### Out of scope (and why) - Taking payments, which turns a weekend build into a regulated one and makes every bug a financial one. - PDF generation and approval workflows, which are Jotform's actual business and each a project rather than a feature. - A widget catalogue, since the seven field types above are the whole palette and every extra one is a validator, a renderer and an exporter. - Accounts and teams, which multiply every screen by a permission model for a tool one person runs. ### README must contain - That form versions are immutable and every response renders against the version it answered - Where uploads are stored, why the folder sits outside the served tree, and what the size cap is - That the admin interface binds to localhost and only the public form path is exposed - That exports neutralise spreadsheet formula characters, and why that matters for a public form - The retention period for responses and uploads, and what deletion actually removes
# Agent instructions · Jotform indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Express and better-sqlite3, one process, An admin interface bound to localhost, public form pages at a link-only path, Immutable published versions stored as JSON, Files outside the web root with generated names, served as attachments, An outbox written in the same transaction as the response. 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 - Identifying fields by their label means renaming 'Phone' to 'Mobile' orphans every previous answer, and reordering the fields shuffles the columns of an export that somebody has already filed. Give every field a stable key generated at creation, keep the label as display text, and never reuse a key that has been retired. - Validating only in the browser means the first person who posts directly to the endpoint fills your table with whatever they like, and since the form is public that person arrives within days of the link being shared. Treat the browser as a convenience and the server as the only validator, then test by posting a malformed body straight to the endpoint. - Buffering the upload and then checking its size means a single request can exhaust memory before the check ever runs, and storing it under the client's filename means a respondent chooses where in your filesystem the file lands and what extension it gets. Enforce the cap while streaming, generate the name yourself, and treat the submitted filename as text to display and nothing else. - Sending the notification before committing, or failing the submission when the mail server refuses, loses the response and shows the respondent an error for something that was entirely your problem. Commit first, queue the mail, and let the thank-you page be honest that the form was received.
# Build plan · Jotform A two-surface form builder where the hard problems are versioning definitions so old responses still mean what they meant, taking file uploads without opening a hole, and never losing a submission to a failed notification email. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Definitions that are versioned from the first save Publishing a form creates an immutable version and responses point at one. ### Steps 1. Give every field a stable generated key Labels are for humans and change on a whim; keys are for data and never do. Files: `src/forms/schema.js`, `test/keys.test.js` 2. Publish by inserting, never by updating An immutable version row is what lets an old response still mean something. Files: `src/forms/publish.js` 3. Render responses against their own version Reading last March's answers through today's labels is how a record becomes fiction. Files: `src/admin/response.js` ### Done when - [ ] Publishing leaves the previous version unchanged - [ ] Old responses render under their own version's labels - [ ] Renaming a field changes no stored answer - [ ] Retired field keys are never reissued ### Watch out - Identifying fields by their label means renaming 'Phone' to 'Mobile' orphans every previous answer, and reordering the fields shuffles the columns of an export that somebody has already filed. Give every field a stable key generated at creation, keep the label as display text, and never reuse a key that has been retired. ## Phase 2 · A public form that validates on the server The form works without JavaScript, rejects rubbish server side, and resists a bored script. ### Steps 1. Validate server side, always Post a malformed body straight at the endpoint as a test, because someone will. Files: `src/public/validate.js`, `test/direct-post.test.js` 2. Make the form work without scripts It is the cheapest accessibility win and it doubles as the fallback. Files: `src/public/render.js` 3. Honeypot plus rate limit, no third party A captcha sends your respondents to somebody else's service to prove they exist. Files: `src/public/antispam.js` ### Done when - [ ] The form submits with JavaScript disabled - [ ] A malformed direct post is rejected per field - [ ] Errors are keyboard reachable and announced - [ ] Rate limiting refuses clearly rather than silently ### Watch out - Validating only in the browser means the first person who posts directly to the endpoint fills your table with whatever they like, and since the form is public that person arrives within days of the link being shared. Treat the browser as a convenience and the server as the only validator, then test by posting a malformed body straight to the endpoint. ## Phase 3 · Uploads that cannot become a problem A file arrives, is bounded and stored inertly, and is never served back as something executable. ### Steps 1. Cap during the stream Checking the size after buffering is checking after the damage. Files: `src/uploads/stream.js`, `test/oversize.test.js` 2. Generate the stored name, keep the original as text The submitted filename is a claim about your filesystem made by a stranger. Files: `src/uploads/store.js` 3. Serve as an attachment with a detected type Never hand back a file with a type the uploader chose. Files: `src/admin/download.js` ### Done when - [ ] Oversized uploads are cut off mid-stream - [ ] Hostile filenames are stored under generated names - [ ] Downloads are attachments with detected types - [ ] Uploads live outside the served tree ### Watch out - Buffering the upload and then checking its size means a single request can exhaust memory before the check ever runs, and storing it under the client's filename means a respondent chooses where in your filesystem the file lands and what extension it gets. Enforce the cap while streaming, generate the name yourself, and treat the submitted filename as text to display and nothing else. ## Phase 4 · A submission that outlives the notification The response is committed before anything is emailed, and a mail failure is visible. ### Steps 1. Commit the response, then queue the mail The respondent's obligation ends when the row is committed. Files: `src/public/submit.js` 2. Send from an outbox with backoff A dead mail server should cost you a delay, not a submission. Files: `src/outbox/worker.js`, `test/outbox.test.js` 3. Surface unsent notifications in the interface A queue nobody can see is a queue nobody empties. Files: `src/admin/outbox.js` ### Done when - [ ] Submissions succeed with mail unreachable - [ ] The outbox retries with backoff and records errors - [ ] Unsent notifications are visible in the admin view - [ ] A restart mid-send does not double a notification ### Watch out - Sending the notification before committing, or failing the submission when the mail server refuses, loses the response and shows the respondent an error for something that was entirely your problem. Commit first, queue the mail, and let the thank-you page be honest that the form was received. ## Not in this build - Payments of any kind. - PDF generation and approvals. - A widget catalogue beyond the seven field types. - Accounts, teams and permissions. ## After v1, if you want it - Conditional fields, which are a definition feature and change nothing about storage - A per-form webhook, once the outbox has proven itself on mail - Partial save and resume, which needs a token model and deserves its own thinking
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, versions, responses and the outbox. DATABASE_PATH=./data/forms.db # Required. Outside the served tree. Generated names only. UPLOAD_DIR=./data/uploads # Required. Enforced while streaming, not after buffering. UPLOAD_MAX_MB=10 # Required. Used in links and notifications. Only the form path is exposed here. PUBLIC_BASE_URL=https://forms.example.com # Required. Localhost. The admin surface has no login because it has no route in. ADMIN_BIND=127.0.0.1:4000 # Required · secret. Notifications only. A dead host must not cost you a submission. SMTP_URL=smtps://user:redacted@smtp.example.com:465 # Required. Responses and their uploads are removed together on this schedule. RESPONSE_RETENTION_DAYS=365 # Required. Per source bucket per form. Stated in the README so refusals are explainable. RATE_LIMIT_PER_HOUR=20
# Jotform · product brief ## Problem Basic forms are easy, but Jotform's broad widget library, payments, approvals, PDF generation, tables, apps, and compliance options create a large surface. ## Product outcome A form tool where a response submitted in March still reads correctly in December, an upload cannot become a filesystem problem, a dead mail server costs a delay rather than a submission, and an export opens in a spreadsheet without executing anything. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - database - form renderer/editor - storage - email - payment API - PDF generation if needed ## Explicit non-goals for v1 - Payments of any kind. - PDF generation and approvals. - A widget catalogue beyond the seven field types. - Accounts, teams and permissions. - widget catalog - PDF/approval workflows - payments - app builder - integrations - compliance options - templates ## Success criteria - Old responses are confirmed to render under their own version's labels - A malformed direct post is confirmed rejected server side - An oversized upload is confirmed cut off mid-stream - A submission is confirmed to succeed with mail unreachable - A formula cell is confirmed inert after a spreadsheet round trip
# Build brief · Jotform 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 whose responses land in a database you own, with uploads and an export that cannot detonate to replace Jotform. 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 response is evidence of what somebody actually submitted, so it is never edited and never migrated. Publishing a change to a form creates a new immutable version, and every response stores the version identifier it was submitted against. Rendering an old response renders it against the version it was filled in under, labels and options included. The tempting alternative is one mutable form definition that responses point at. It works until the day somebody renames a field or removes an option, at which point every previous answer is silently reinterpreted under labels the person never saw, and a table of six months of submissions quietly becomes fiction. Version from the first save, not from the first time it hurts. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express and better-sqlite3, one process · a form tool that needs a cluster is a form tool with a problem - Surfaces: An admin interface bound to localhost, public form pages at a link-only path · the builder is yours, the form is the world's - Definitions: Immutable published versions stored as JSON · a response only makes sense beside the version it answered - Uploads: Files outside the web root with generated names, served as attachments · the client's filename and content type are both claims, not facts - Notifications: An outbox written in the same transaction as the response · a response must survive a mail server having a bad afternoon ### Data model (create this before Phase 1) - forms(id, slug, title, created_at, archived_at) · the identity of a form across all its versions - form_versions(id, form_id, ordinal, definition_json, published_at) · immutable once published, never edited in place - responses(id, form_version_id, submitted_at, remote_hash, answers_json, status) · remote_hash rather than a raw address, because you do not need the address - uploads(id, response_id, field_key, stored_name, original_name, mime_detected, bytes, sha256) · stored_name is generated, original_name is decoration - outbox(id, response_id, kind, attempts, next_attempt_at, last_error, sent_at) · the notification queue, written with the response - rate_limits(bucket, window_start, count) · per form and per source bucket, because one open form attracts one bored script - Everything a response needs to be read a year later is reachable from its own row: the version, the answers and the files. Nothing is resolved through the current definition. ### Phase 1 · Definitions that are versioned from the first save Build: Build a form as an ordered list of fields with a stable key each: text, email, textarea, select, checkbox, date and file. Publishing writes a new version row. Editing a draft never touches a published version. Responses record the version identifier they answered. Trap: Identifying fields by their label means renaming 'Phone' to 'Mobile' orphans every previous answer, and reordering the fields shuffles the columns of an export that somebody has already filed. Give every field a stable key generated at creation, keep the label as display text, and never reuse a key that has been retired. Done when: Publishing a changed form creates a new version and leaves the previous one byte identical, asserted in the suite. A response submitted under version one renders with version one's labels and options after version two is published. Renaming a field changes no stored answer. A retired field key is never handed out again, tested by retiring and adding fields repeatedly. Do not build yet: No public rendering, uploads, notifications or export. ### Phase 2 · A public form that validates on the server Build: Render each version as a plain accessible form with labels tied to inputs and errors listed at the top and beside each field. Validate every rule on the server whatever the browser did. Add a honeypot field and a per-bucket rate limit, and no third-party captcha. Trap: Validating only in the browser means the first person who posts directly to the endpoint fills your table with whatever they like, and since the form is public that person arrives within days of the link being shared. Treat the browser as a convenience and the server as the only validator, then test by posting a malformed body straight to the endpoint. Done when: A form submits and validates correctly with JavaScript disabled entirely. A direct post with missing required fields and a malformed email is rejected by the server with per-field errors. Errors are announced accessibly and each one links to its field, checked with a keyboard alone. Exceeding the rate limit returns a clear refusal rather than a silent drop, and the limit is stated in the README. Do not build yet: No uploads, notifications or export yet. ### Phase 3 · Uploads that cannot become a problem Build: Stream uploads to a folder outside the web root under a generated name, enforcing the size cap during the stream rather than after it. Detect the type from the content, keep the original filename as a display string only, and serve downloads as attachments with a conservative content type. Trap: Buffering the upload and then checking its size means a single request can exhaust memory before the check ever runs, and storing it under the client's filename means a respondent chooses where in your filesystem the file lands and what extension it gets. Enforce the cap while streaming, generate the name yourself, and treat the submitted filename as text to display and nothing else. Done when: A file larger than the cap is rejected during the upload with the connection ended, not after the whole body is in memory, tested with a file well over the limit. A filename containing path separators and traversal sequences is stored under a generated name and displayed escaped. Downloads are served with an attachment disposition and a content type derived from detection, not from the client. The uploads folder is outside anything the web server will serve directly. Do not build yet: No notifications, export or retention yet. ### Phase 4 · A submission that outlives the notification Build: Write the response and an outbox row in one transaction, then return the thank-you page. A separate worker sends notifications with backoff and records failures. Show unsent notifications in the admin interface rather than only in a log. Trap: Sending the notification before committing, or failing the submission when the mail server refuses, loses the response and shows the respondent an error for something that was entirely your problem. Commit first, queue the mail, and let the thank-you page be honest that the form was received. Done when: A submission succeeds and is stored with the mail server unreachable, tested by pointing at a dead host. The outbox retries with backoff and gives up after a stated number of attempts with the error retained. The admin interface lists unsent notifications with their last error. No notification is sent twice for one response, asserted by a test that restarts the worker mid-send. Do not build yet: No export, retention or deletion yet. ### Phase 5 · Getting responses out without hurting anyone Build: Export per form version to CSV with one column per field key, plus a combined export that marks which version each row came from. Neutralise formula-initiating cells. Support closing a form, a stated retention for responses and uploads, and a deletion that removes files too. Trap: A CSV cell is executed as a formula by spreadsheet software when it begins with certain characters, and a form is an invitation for a stranger to choose those characters. OWASP names equals, plus, minus, at, tab, carriage return and line feed, plus full-width variants, and warns that Excel can strip quoting when a file is saved and reopened so that a previously escaped formula becomes live again; its current advice is to prefix an offending cell with a tab character inside the quoted field. Do that, and test the export by opening it. Done when: A response containing a cell that begins with a formula character exports as inert text and stays inert after the file is saved and reopened in a spreadsheet, verified by hand once and asserted in the suite. Deleting a response deletes its uploaded files and the deletion is logged. Closing a form stops submissions with a stated message while old responses stay readable. Retention removes responses and files together on schedule, and the README states the period. Do not build yet: Nothing after this. ### Out of scope (and why) - Taking payments, which turns a weekend build into a regulated one and makes every bug a financial one. - PDF generation and approval workflows, which are Jotform's actual business and each a project rather than a feature. - A widget catalogue, since the seven field types above are the whole palette and every extra one is a validator, a renderer and an exporter. - Accounts and teams, which multiply every screen by a permission model for a tool one person runs. ### README must contain - That form versions are immutable and every response renders against the version it answered - Where uploads are stored, why the folder sits outside the served tree, and what the size cap is - That the admin interface binds to localhost and only the public form path is exposed - That exports neutralise spreadsheet formula characters, and why that matters for a public form - The retention period for responses and uploads, and what deletion actually removes
# Architecture · Jotform ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express and better-sqlite3, one process | a form tool that needs a cluster is a form tool with a problem | | Surfaces | An admin interface bound to localhost, public form pages at a link-only path | the builder is yours, the form is the world's | | Definitions | Immutable published versions stored as JSON | a response only makes sense beside the version it answered | | Uploads | Files outside the web root with generated names, served as attachments | the client's filename and content type are both claims, not facts | | Notifications | An outbox written in the same transaction as the response | a response must survive a mail server having a bad afternoon | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Definitions | Stable field keys and immutable published versions. | Any editor, provided publishing inserts rather than updates. | | Public renderer | Accessible markup and server-side validation. | Any templating, provided the form still works without scripts. | | Uploads | Streaming caps, generated names, detected types. | Any storage, provided the client never names the file. | | Outbox | Notification delivery with retry and visible failure. | Any transport, provided the response commits first. | | Export | Per-version CSV with formula cells neutralised. | Any format, provided the spreadsheet round trip is tested. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Forms, versions, responses and the outbox. - `UPLOAD_DIR` · required · Outside the served tree. Generated names only. - `UPLOAD_MAX_MB` · required · Enforced while streaming, not after buffering. - `PUBLIC_BASE_URL` · required · Used in links and notifications. Only the form path is exposed here. - `ADMIN_BIND` · required · Localhost. The admin surface has no login because it has no route in. - `SMTP_URL` · required, secret · Notifications only. A dead host must not cost you a submission. - `RESPONSE_RETENTION_DAYS` · required · Responses and their uploads are removed together on this schedule. - `RATE_LIMIT_PER_HOUR` · required · Per source bucket per form. Stated in the README so refusals are explainable. ## 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 · Jotform product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Express and better-sqlite3, one process, An admin interface bound to localhost, public form pages at a link-only path, Immutable published versions stored as JSON, Files outside the web root with generated names, served as attachments, An outbox written in the same transaction as the response. - 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 - Identifying fields by their label means renaming 'Phone' to 'Mobile' orphans every previous answer, and reordering the fields shuffles the columns of an export that somebody has already filed. Give every field a stable key generated at creation, keep the label as display text, and never reuse a key that has been retired. - Validating only in the browser means the first person who posts directly to the endpoint fills your table with whatever they like, and since the form is public that person arrives within days of the link being shared. Treat the browser as a convenience and the server as the only validator, then test by posting a malformed body straight to the endpoint. - Buffering the upload and then checking its size means a single request can exhaust memory before the check ever runs, and storing it under the client's filename means a respondent chooses where in your filesystem the file lands and what extension it gets. Enforce the cap while streaming, generate the name yourself, and treat the submitted filename as text to display and nothing else. - Sending the notification before committing, or failing the submission when the mail server refuses, loses the response and shows the respondent an error for something that was entirely your problem. Commit first, queue the mail, and let the thank-you page be honest that the form was received. - A CSV cell is executed as a formula by spreadsheet software when it begins with certain characters, and a form is an invitation for a stranger to choose those characters. OWASP names equals, plus, minus, at, tab, carriage return and line feed, plus full-width variants, and warns that Excel can strip quoting when a file is saved and reopened so that a previously escaped formula becomes live again; its current advice is to prefix an offending cell with a tab character inside the quoted field. Do that, and test the export by opening it.
# Delivery milestones · Jotform Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Definitions that are versioned from the first save Publishing a form creates an immutable version and responses point at one. ### Steps 1. Give every field a stable generated key Labels are for humans and change on a whim; keys are for data and never do. Files: `src/forms/schema.js`, `test/keys.test.js` 2. Publish by inserting, never by updating An immutable version row is what lets an old response still mean something. Files: `src/forms/publish.js` 3. Render responses against their own version Reading last March's answers through today's labels is how a record becomes fiction. Files: `src/admin/response.js` ### Done when - [ ] Publishing leaves the previous version unchanged - [ ] Old responses render under their own version's labels - [ ] Renaming a field changes no stored answer - [ ] Retired field keys are never reissued ### Watch out - Identifying fields by their label means renaming 'Phone' to 'Mobile' orphans every previous answer, and reordering the fields shuffles the columns of an export that somebody has already filed. Give every field a stable key generated at creation, keep the label as display text, and never reuse a key that has been retired. ## M2 · A public form that validates on the server The form works without JavaScript, rejects rubbish server side, and resists a bored script. ### Steps 1. Validate server side, always Post a malformed body straight at the endpoint as a test, because someone will. Files: `src/public/validate.js`, `test/direct-post.test.js` 2. Make the form work without scripts It is the cheapest accessibility win and it doubles as the fallback. Files: `src/public/render.js` 3. Honeypot plus rate limit, no third party A captcha sends your respondents to somebody else's service to prove they exist. Files: `src/public/antispam.js` ### Done when - [ ] The form submits with JavaScript disabled - [ ] A malformed direct post is rejected per field - [ ] Errors are keyboard reachable and announced - [ ] Rate limiting refuses clearly rather than silently ### Watch out - Validating only in the browser means the first person who posts directly to the endpoint fills your table with whatever they like, and since the form is public that person arrives within days of the link being shared. Treat the browser as a convenience and the server as the only validator, then test by posting a malformed body straight to the endpoint. ## M3 · Uploads that cannot become a problem A file arrives, is bounded and stored inertly, and is never served back as something executable. ### Steps 1. Cap during the stream Checking the size after buffering is checking after the damage. Files: `src/uploads/stream.js`, `test/oversize.test.js` 2. Generate the stored name, keep the original as text The submitted filename is a claim about your filesystem made by a stranger. Files: `src/uploads/store.js` 3. Serve as an attachment with a detected type Never hand back a file with a type the uploader chose. Files: `src/admin/download.js` ### Done when - [ ] Oversized uploads are cut off mid-stream - [ ] Hostile filenames are stored under generated names - [ ] Downloads are attachments with detected types - [ ] Uploads live outside the served tree ### Watch out - Buffering the upload and then checking its size means a single request can exhaust memory before the check ever runs, and storing it under the client's filename means a respondent chooses where in your filesystem the file lands and what extension it gets. Enforce the cap while streaming, generate the name yourself, and treat the submitted filename as text to display and nothing else. ## M4 · A submission that outlives the notification The response is committed before anything is emailed, and a mail failure is visible. ### Steps 1. Commit the response, then queue the mail The respondent's obligation ends when the row is committed. Files: `src/public/submit.js` 2. Send from an outbox with backoff A dead mail server should cost you a delay, not a submission. Files: `src/outbox/worker.js`, `test/outbox.test.js` 3. Surface unsent notifications in the interface A queue nobody can see is a queue nobody empties. Files: `src/admin/outbox.js` ### Done when - [ ] Submissions succeed with mail unreachable - [ ] The outbox retries with backoff and records errors - [ ] Unsent notifications are visible in the admin view - [ ] A restart mid-send does not double a notification ### Watch out - Sending the notification before committing, or failing the submission when the mail server refuses, loses the response and shows the respondent an error for something that was entirely your problem. Commit first, queue the mail, and let the thank-you page be honest that the form was received. ## M5 · Getting responses out without hurting anyone (production only) Export is safe to open, deletion is real, and the form can be closed. ### Steps 1. Neutralise formula cells and verify in a spreadsheet The characters and the save-and-reopen caveat are both published; follow them. Files: `src/export/csv.js`, `test/csv-injection.test.js` 2. Delete files with the response An orphaned upload is a data breach waiting for a directory listing. Files: `src/admin/delete.js` 3. Close a form without deleting it A closed form still holds evidence somebody may need. Files: `src/forms/close.js` ### Done when - [ ] Formula cells stay inert after a spreadsheet round trip - [ ] Deleting a response deletes its files - [ ] A closed form refuses submissions and keeps history - [ ] Retention removes responses and uploads together ### Watch out - A CSV cell is executed as a formula by spreadsheet software when it begins with certain characters, and a form is an invitation for a stranger to choose those characters. OWASP names equals, plus, minus, at, tab, carriage return and line feed, plus full-width variants, and warns that Excel can strip quoting when a file is saved and reopened so that a previously escaped formula becomes live again; its current advice is to prefix an offending cell with a tab character inside the quoted field. Do that, and test the export by opening it.
# Operations · Jotform ## Backup The database and the uploads folder together, since a restore of one alone leaves responses pointing at files that are gone or files nobody can attribute. Test the pair, not each half. ## Restore Restore both, then check that a response with an upload still resolves its file and that the outbox has not queued a fresh copy of every historical notification. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Unsent outbox rows and their age, submissions per form per day, and uploads folder size against the disk. A form that suddenly receives a thousand submissions has been found by a script. ## Incident checklist A spam flood is handled by closing the form, which keeps existing responses readable; a lost notification is visible in the outbox and never means a lost response, which is the distinction the design exists to make. 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 - [ ] Old responses are confirmed to render under their own version's labels - [ ] A malformed direct post is confirmed rejected server side - [ ] An oversized upload is confirmed cut off mid-stream - [ ] A submission is confirmed to succeed with mail unreachable - [ ] A formula cell is confirmed inert after a spreadsheet round trip ## Launch constraint Do not market omitted Jotform capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, versions, responses and the outbox. DATABASE_PATH=./data/forms.db # Required. Outside the served tree. Generated names only. UPLOAD_DIR=./data/uploads # Required. Enforced while streaming, not after buffering. UPLOAD_MAX_MB=10 # Required. Used in links and notifications. Only the form path is exposed here. PUBLIC_BASE_URL=https://forms.example.com # Required. Localhost. The admin surface has no login because it has no route in. ADMIN_BIND=127.0.0.1:4000 # Required · secret. Notifications only. A dead host must not cost you a submission. SMTP_URL=smtps://user:redacted@smtp.example.com:465 # Required. Responses and their uploads are removed together on this schedule. RESPONSE_RETENTION_DAYS=365 # Required. Per source bucket per form. Stated in the README so refusals are explainable. RATE_LIMIT_PER_HOUR=20
$ choose a build depth, inspect the files, then open the complete pack in your agent
They pay because forms become mini business processes without custom coding.
xwidget catalog
xPDF/approval workflows
xpayments
xapp builder
xintegrations
xcompliance options
xtemplates
Jotform pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| starter | $0/workspace | $0/workspace | 5 active forms, 100 submissions/month, 100MB upload space, 1,000 form views/month, 500 stored submissions, 10 payment submissions/month, 1 user, and Jotform branding |
| bronze | $39/workspace | $34/workspace | 25 active forms, 1,000 submissions/month, 1GB upload space, and 100 payment submissions/month |
| silver | $49/workspace | $39/workspace | 50 active forms, 2,500 submissions/month, 10GB upload space, and 250 payment submissions/month |
| gold | $129/workspace | $99/workspace | 100 active forms, 10,000 submissions/month, 100GB upload space, 1,000 payment submissions/month, and optional HIPAA features |
| enterprise | custom | — | Multiuser plan with custom or unlimited usage, SSO, custom domains, enhanced security and enterprise administration |
free tier5 active forms; 100 submissions/month; 100MB upload space; 1,000 form views/month; 500 stored submissions; 10 payment submissions/month; 1 user; Jotform branding
billingmonthly + annual, both auto-renewing; no paid-plan trial; 30-day money-back guarantee
hidden costsPromotional annual discounts renew at the full price in the next payment period. Taxes are added unless exempt. A dedicated AI-agent phone number is a separate recurring add-on with an unpublished price. If total submission storage is exceeded, Jotform may delete the oldest response to make room for new ones.
verified 2026-08-12 · source ↗
Vibecode Jotform
Kinda. The core of Jotform is buildable in a weekend with the prompt on this page, but there are real gaps: widget catalog, PDF/approval workflows. Read the honest list above before committing.
How much does Jotform cost?
Jotform costs about $39/month (Bronze, checked 2026-07-30), which is $468 per year.
What do I lose by replacing Jotform?
Honestly: widget catalog; PDF/approval workflows; payments; app builder; integrations; compliance options; templates. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Jotform?
Yes: OhMyForm (Open-source form builder prior art; useful for a simpler Jotform alternative.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.