Vibecode Cognito Forms
track this build4 phases, 12 steps, beginner friendly0%Cognito Forms's solo core is compact: build a advanced forms tool with a schema-based form editor, public share link, validation, response table, CSV export, and webhook. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on delivery, integrations, compliance.
You are building a lean indie version of Cognito Forms. 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 ===== # Cognito Forms · indie build A form builder whose difficulty is not the fields but the contract around them: calculations evaluated identically on both sides with the server authoritative, submissions that survive retries and spam filters, and a payment that confirms from the provider's webhook rather than the browser. 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 | TypeScript with SQLite in WAL mode | one person's forms and entries fit in one file that is trivial to back up | | Schema | A JSON form definition with fields, conditions and calculations | the definition is data, so it can be versioned and evaluated anywhere | | Evaluation | One expression engine shared by browser and server, money in integer minor units | two implementations drift, and the drift is a wrong price | | Submission | Client-generated idempotency keys, entries appended and revised | retries and double taps must produce one entry | | Payment | Hosted checkout with the amount computed server side, confirmed on webhook | the redirect is a convenience and the webhook is the truth | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server, the form pages and the test suite. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **SQLite with WAL mode** · free - Why: Entries, revisions and payments for one person fit in one file, and WAL lets the public form write while you read. - Get it: Bundled with most runtimes; enable WAL on open. - Verify: PRAGMA journal_mode returns wal - [ ] **A payment provider account in test mode** · free to test - Why: Webhook confirmation, duplicate events and out-of-order delivery only appear against a real provider flow. - Get it: Sign up, use test keys, register a webhook, and install the CLI to forward events locally. - Verify: A test payment with the browser closed still marks an entry paid - [ ] **An email provider for notifications** · free tier or pennies - Why: Received entries and the daily quarantine count go out by email, and a notification that never arrives is how a quarantined real entry stays unnoticed. - Get it: Any provider with SMTP or an API key, and a verified sender. - Verify: A notification arrives for a test entry - [ ] **Three real forms including one with a discount calculation** · free - Why: The golden set and the CSV alignment test need schemas that changed over time and calculations with awkward rounding, which invented forms never have. - Get it: Rebuild three forms you actually use, and change one after collecting a few entries. - Verify: The golden set covers all three and one form has two published versions - [ ] **Your rounding rule and your hidden-field rule** · free - Why: A percentage discount and a hidden calculated field each have several defensible behaviours, and choosing after seeing the totals means choosing the flattering one. - Get it: Write down how percentages round and whether hidden fields contribute to calculations. - Verify: Both rules are documented and asserted in the golden set ## Quick start ```sh stripe listen --forward-to localhost:3000/api/payments/webhook ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Compliance certifications and processing agreements. - Multi-user approval workflows. - A third-party challenge service by default. - Hundreds of integrations. - compliance controls and guaranteed delivery - enterprise workflow logic - spam and abuse defense at scale - large integration and template catalog If one of those is essential to you, that is the reason to keep paying for Cognito Forms, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Cognito Forms 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 with calculations, conditional fields and payment where no submission is ever lost to replace Cognito Forms. 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 Two things are not negotiable in a form tool, and both are invisible until they fail. A submission that reached the server is never lost. Not to a double tap, not to a dropped connection after the write committed, not to a spam filter that guessed wrong, and not to an edit. Entries are appended and revised, never overwritten, and suspected spam is quarantined where it can be seen rather than dropped. And the amount charged is never the client's number. A calculated total lives in the browser so the person can watch it change, but the server recomputes it from the stored schema and the submitted answers, and only that figure reaches the payment provider. A form that trusts a posted total is a form where the price is whatever the buyer types. ### Stack (fixed, do not substitute) - Runtime: TypeScript with SQLite in WAL mode · one person's forms and entries fit in one file that is trivial to back up - Schema: A JSON form definition with fields, conditions and calculations · the definition is data, so it can be versioned and evaluated anywhere - Evaluation: One expression engine shared by browser and server, money in integer minor units · two implementations drift, and the drift is a wrong price - Submission: Client-generated idempotency keys, entries appended and revised · retries and double taps must produce one entry - Payment: Hosted checkout with the amount computed server side, confirmed on webhook · the redirect is a convenience and the webhook is the truth ### Data model (create this before Phase 1) - forms(id, slug, name, published_version, created_at, archived_at) - form_versions(form_id, version, schema_json, created_at) · every published schema kept, since entries reference the version they were filled against - entries(id, form_id, form_version, idempotency_key, state, created_at, archived_at) · state is received, quarantined, pending_payment, paid or complete - entry_revisions(entry_id, ordinal, answers_json, calculated_json, edited_by, at) · answers are never overwritten - uploads(id, entry_id, field_id, filename, bytes, sha256, stored_path, completed_at) - payments(id, entry_id, amount_minor, currency, provider_session_id, processed_event_ids_json, state, confirmed_at) - The idempotency key is unique per form, so a retried submission returns the existing entry rather than creating a second one. ### Phase 1 · One engine for calculations and conditions Build: Define the schema: field types, show-when conditions and calculated fields with a small expression grammar. Implement the evaluator once as a shared module, run it in the browser for live totals and on the server as the authority. Represent money as integer minor units. Test against a golden set of schemas and answers. Trap: Writing the calculation logic twice, once for the live preview and once for the server, produces two evaluators that agree in testing and disagree on the rounding of a percentage discount, and the customer is charged a total that is not the one they were shown. Share one module, and do arithmetic on integers. Done when: A golden set of at least thirty schema and answer pairs evaluates identically in the browser and on the server, asserted in the suite. Money fields are integer minor units and a percentage discount rounds by a stated rule. A condition referencing a hidden field behaves by a stated rule. A circular calculation is rejected when the schema is saved rather than at runtime. Do not build yet: No public form, submissions, uploads, spam handling or payment. ### Phase 2 · Submissions that survive the network Build: A public form page rendered from the published version. The browser generates an idempotency key when the form opens and sends it with every attempt; the server inserts the entry keyed on it and returns the same entry on a retry. Uploads go first with their own ids and the entry references them. Store the form version with the entry. Trap: A phone submits, the connection drops after the server committed, the browser shows an error, the person taps again, and there are two entries and later two charges. The idempotency key turns the second attempt into a read of the first. Done when: Submitting twice with the same key produces one entry, tested with concurrent requests. Killing the connection after commit and retrying returns the committed entry rather than an error. Uploads complete before the entry references them and an orphaned upload is removed after a stated period. An entry records the schema version it was filled against and renders correctly after the form changes. Do not build yet: No spam handling, payment, notifications or export. ### Phase 3 · Spam that cannot eat a real entry Build: A honeypot field, a minimum time between form open and submit, and a per-address rate limit, with no third-party challenge by default. Anything that trips a rule is stored as quarantined and listed with the reason. Notify on received entries and send a daily count of quarantined ones. Trap: Dropping a submission the filter dislikes discards the real customer who used autofill and submitted in four seconds, and nobody ever learns it happened. Quarantine with the reason, show the count, and make releasing one a single action. Done when: A submission tripping a rule is stored as quarantined with the reason and is never deleted. Releasing a quarantined entry makes it a received entry and triggers its notification. The honeypot is invisible to people and to assistive technology, checked by inspection. A burst from one address is rate limited with a readable message rather than silently discarded. Do not build yet: No payment, document generation, outbound webhooks or export. ### Phase 4 · Payment the server priced Build: Compute the amount on the server from the stored schema version and the submitted answers, create a hosted checkout session for that amount, and mark the entry pending. Confirm on the signed webhook, deduplicate by event id, and tolerate events arriving out of order. Handle a payment that arrives after the person closed the tab. Trap: The provider retries a webhook for up to three days and does not promise delivery order, so an integration that treats the first event as the whole story, or processes the same event twice, marks entries paid twice or never. Verify the signature, record processed event ids, and derive state from the entry rather than from which event came first. Done when: The checkout amount equals the server's calculation and never the client's, tested by posting a tampered total. A duplicate webhook is ignored by event id and an out-of-order pair leaves the correct final state. Closing the browser after paying still marks the entry paid, tested by confirming through the webhook alone. A webhook with a bad signature is rejected and logged. Do not build yet: No document generation, outbound webhooks or export. ### Phase 5 · Getting entries out, and telling other systems Build: CSV export keyed by field id with a header mapping, so entries filled against older versions align. Document generation from an HTML template to PDF per entry. Outbound webhooks per entry with a signed body and retries. Archive rather than delete, and back up the database file and uploads with a restore exercised in the suite. Trap: Exporting columns in the current schema's order silently misaligns entries filled before a field was renamed or inserted, and the spreadsheet looks fine while every older row is shifted by one. Key columns by field id and include a mapping row. Done when: A CSV export of entries spanning three schema versions aligns every answer under the right field, asserted in the suite. A generated document reproduces the entry's stored answers and calculated values from a template. Outbound webhooks are signed, retried with backoff and recorded per attempt. A restore from backup reopens every entry and its uploads, exercised in the suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Compliance certifications and signed processing agreements, which are organisational commitments rather than code. - Multi-user workflows with approvals and roles, which change the entry model and are where the paid product's value lives. - A third-party challenge service by default, which adds a dependency and an accessibility cost a personal form rarely needs. - Hundreds of integrations, which are maintenance rather than design; the signed outbound webhook is the integration. ### README must contain - That one evaluator runs in the browser and on the server, and only the server's result reaches the payment provider - That every submission carries an idempotency key, so a retry or a double tap yields one entry - That suspected spam is quarantined with a reason and never dropped - That the payment amount is computed on the server from the stored schema version and confirmed by webhook - That CSV export is keyed by field id, because schema order shifts older rows silently ===== AGENTS.md ===== # Agent instructions · Cognito Forms indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with SQLite in WAL mode, A JSON form definition with fields, conditions and calculations, One expression engine shared by browser and server, money in integer minor units, Client-generated idempotency keys, entries appended and revised, Hosted checkout with the amount computed server side, confirmed on webhook. 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 - Writing the calculation logic twice, once for the live preview and once for the server, produces two evaluators that agree in testing and disagree on the rounding of a percentage discount, and the customer is charged a total that is not the one they were shown. Share one module, and do arithmetic on integers. - A phone submits, the connection drops after the server committed, the browser shows an error, the person taps again, and there are two entries and later two charges. The idempotency key turns the second attempt into a read of the first. - Dropping a submission the filter dislikes discards the real customer who used autofill and submitted in four seconds, and nobody ever learns it happened. Quarantine with the reason, show the count, and make releasing one a single action. - The provider retries a webhook for up to three days and does not promise delivery order, so an integration that treats the first event as the whole story, or processes the same event twice, marks entries paid twice or never. Verify the signature, record processed event ids, and derive state from the entry rather than from which event came first. ===== BUILD_PLAN.md ===== # Build plan · Cognito Forms A form builder whose difficulty is not the fields but the contract around them: calculations evaluated identically on both sides with the server authoritative, submissions that survive retries and spam filters, and a payment that confirms from the provider's webhook rather than the browser. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · One engine for calculations and conditions The browser and the server compute the same result from the same schema. ### Steps 1. Write the evaluator once Imported by the browser bundle and the server alike. Files: `src/schema/evaluate.ts`, `src/schema/evaluate.test.ts` 2. Keep money as integers A percentage of a float is a wrong price waiting to happen. Files: `src/schema/money.ts` 3. Reject circular calculations at save At runtime it is an outage on a live form. Files: `src/schema/validate.ts` ### Done when - [ ] Browser and server evaluate the golden set identically - [ ] Money is integer minor units with a stated rounding rule - [ ] Hidden field references follow a stated rule - [ ] Circular calculations are rejected at save ### Watch out - Writing the calculation logic twice, once for the live preview and once for the server, produces two evaluators that agree in testing and disagree on the rounding of a percentage discount, and the customer is charged a total that is not the one they were shown. Share one module, and do arithmetic on integers. ## Phase 2 · Submissions that survive the network A retry, a double tap or a crash after commit produces exactly one entry. ### Steps 1. Key every submission from the browser Generated when the form opens, sent on every attempt. Files: `src/public/form.ts`, `src/entries/create.ts` 2. Test concurrent duplicate submissions Twenty parallel posts with one key produce one entry. Files: `tests/idempotency.test.ts` 3. Upload first, reference second An entry must never point at a file that did not finish. Files: `src/uploads.ts` ### Done when - [ ] Concurrent duplicate posts yield one entry - [ ] A retry after commit returns the entry - [ ] Orphaned uploads are removed after a period - [ ] Entries render against their own schema version ### Watch out - A phone submits, the connection drops after the server committed, the browser shows an error, the person taps again, and there are two entries and later two charges. The idempotency key turns the second attempt into a read of the first. ## Phase 3 · Spam that cannot eat a real entry Junk stays out of the inbox without a genuine submission ever being discarded. ### Steps 1. Quarantine, never drop With the reason, and a count the owner sees daily. Files: `src/spam/rules.ts`, `src/entries/quarantine.ts` 2. Hide the honeypot from people and screen readers A honeypot a screen reader announces quarantines real submissions. Files: `src/public/honeypot.ts` 3. Rate limit with a message A refusal the person can read beats a silent discard. Files: `src/spam/ratelimit.ts` ### Done when - [ ] Tripped rules quarantine with a reason - [ ] Releasing an entry makes it received - [ ] The honeypot is hidden from assistive technology - [ ] Bursts are refused with a message ### Watch out - Dropping a submission the filter dislikes discards the real customer who used autofill and submitted in four seconds, and nobody ever learns it happened. Quarantine with the reason, show the count, and make releasing one a single action. ## Phase 4 · Payment the server priced The amount charged is computed on the server and the entry is paid only when the provider says so. ### Steps 1. Price on the server from the stored version The posted total is discarded, tested by tampering with it. Files: `src/payments/price.ts`, `tests/tampered-total.test.ts` 2. Verify, deduplicate and reorder webhooks Signature, event ids, and state derived from the entry. Files: `src/payments/webhook.ts`, `tests/webhook-order.test.ts` 3. Confirm with the tab closed The webhook alone must be enough. Files: `tests/payment-tab-closed.test.ts` ```sh stripe listen --forward-to localhost:3000/api/payments/webhook ``` ### Done when - [ ] A tampered total is ignored - [ ] Duplicate and out-of-order events settle correctly - [ ] Payment confirms with the browser closed - [ ] Bad signatures are rejected and logged ### Watch out - The provider retries a webhook for up to three days and does not promise delivery order, so an integration that treats the first event as the whole story, or processes the same event twice, marks entries paid twice or never. Verify the signature, record processed event ids, and derive state from the entry rather than from which event came first. ## Not in this build - Compliance certifications and processing agreements. - Multi-user approval workflows. - A third-party challenge service by default. - Hundreds of integrations. ## After v1, if you want it - Save and resume for long forms, which the idempotency key already half provides - Repeating sections with calculations over their rows, the feature that separates a form tool from a survey tool - Conditional notification recipients, which is the first workflow anyone actually asks for ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Entries, revisions and payments. Back it up with the uploads directory. DATABASE_PATH=./data/forms.db # Required. Files stored by content hash, referenced from entries once complete. UPLOAD_DIR=./data/uploads # Required · secret. Creates checkout sessions for the server's amount. STRIPE_SECRET_KEY=sk_test_...redacted # Required · secret. Entries confirm from webhooks, so an unverified endpoint marks entries paid. STRIPE_WEBHOOK_SECRET=whsec_...redacted # Required · secret. Notifications and the daily quarantine count. SMTP_URL=smtp://user:pass@mail.example.com:587 # Required. Faster than this is quarantined, not dropped. Autofill is fast. MIN_SUBMIT_SECONDS=3 # Required. An upload no entry referenced after this is removed. ORPHAN_UPLOAD_HOURS=24 # Required. Present as a constant. The price is whatever the server computes. TRUST_CLIENT_TOTAL=never
You are building a lean indie version of Cognito Forms. 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 ===== # Cognito Forms · indie build A form builder whose difficulty is not the fields but the contract around them: calculations evaluated identically on both sides with the server authoritative, submissions that survive retries and spam filters, and a payment that confirms from the provider's webhook rather than the browser. 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 | TypeScript with SQLite in WAL mode | one person's forms and entries fit in one file that is trivial to back up | | Schema | A JSON form definition with fields, conditions and calculations | the definition is data, so it can be versioned and evaluated anywhere | | Evaluation | One expression engine shared by browser and server, money in integer minor units | two implementations drift, and the drift is a wrong price | | Submission | Client-generated idempotency keys, entries appended and revised | retries and double taps must produce one entry | | Payment | Hosted checkout with the amount computed server side, confirmed on webhook | the redirect is a convenience and the webhook is the truth | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server, the form pages and the test suite. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **SQLite with WAL mode** · free - Why: Entries, revisions and payments for one person fit in one file, and WAL lets the public form write while you read. - Get it: Bundled with most runtimes; enable WAL on open. - Verify: PRAGMA journal_mode returns wal - [ ] **A payment provider account in test mode** · free to test - Why: Webhook confirmation, duplicate events and out-of-order delivery only appear against a real provider flow. - Get it: Sign up, use test keys, register a webhook, and install the CLI to forward events locally. - Verify: A test payment with the browser closed still marks an entry paid - [ ] **An email provider for notifications** · free tier or pennies - Why: Received entries and the daily quarantine count go out by email, and a notification that never arrives is how a quarantined real entry stays unnoticed. - Get it: Any provider with SMTP or an API key, and a verified sender. - Verify: A notification arrives for a test entry - [ ] **Three real forms including one with a discount calculation** · free - Why: The golden set and the CSV alignment test need schemas that changed over time and calculations with awkward rounding, which invented forms never have. - Get it: Rebuild three forms you actually use, and change one after collecting a few entries. - Verify: The golden set covers all three and one form has two published versions - [ ] **Your rounding rule and your hidden-field rule** · free - Why: A percentage discount and a hidden calculated field each have several defensible behaviours, and choosing after seeing the totals means choosing the flattering one. - Get it: Write down how percentages round and whether hidden fields contribute to calculations. - Verify: Both rules are documented and asserted in the golden set ## Quick start ```sh stripe listen --forward-to localhost:3000/api/payments/webhook ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Compliance certifications and processing agreements. - Multi-user approval workflows. - A third-party challenge service by default. - Hundreds of integrations. - compliance controls and guaranteed delivery - enterprise workflow logic - spam and abuse defense at scale - large integration and template catalog If one of those is essential to you, that is the reason to keep paying for Cognito Forms, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Cognito Forms 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 with calculations, conditional fields and payment where no submission is ever lost to replace Cognito Forms. 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 Two things are not negotiable in a form tool, and both are invisible until they fail. A submission that reached the server is never lost. Not to a double tap, not to a dropped connection after the write committed, not to a spam filter that guessed wrong, and not to an edit. Entries are appended and revised, never overwritten, and suspected spam is quarantined where it can be seen rather than dropped. And the amount charged is never the client's number. A calculated total lives in the browser so the person can watch it change, but the server recomputes it from the stored schema and the submitted answers, and only that figure reaches the payment provider. A form that trusts a posted total is a form where the price is whatever the buyer types. ### Stack (fixed, do not substitute) - Runtime: TypeScript with SQLite in WAL mode · one person's forms and entries fit in one file that is trivial to back up - Schema: A JSON form definition with fields, conditions and calculations · the definition is data, so it can be versioned and evaluated anywhere - Evaluation: One expression engine shared by browser and server, money in integer minor units · two implementations drift, and the drift is a wrong price - Submission: Client-generated idempotency keys, entries appended and revised · retries and double taps must produce one entry - Payment: Hosted checkout with the amount computed server side, confirmed on webhook · the redirect is a convenience and the webhook is the truth ### Data model (create this before Phase 1) - forms(id, slug, name, published_version, created_at, archived_at) - form_versions(form_id, version, schema_json, created_at) · every published schema kept, since entries reference the version they were filled against - entries(id, form_id, form_version, idempotency_key, state, created_at, archived_at) · state is received, quarantined, pending_payment, paid or complete - entry_revisions(entry_id, ordinal, answers_json, calculated_json, edited_by, at) · answers are never overwritten - uploads(id, entry_id, field_id, filename, bytes, sha256, stored_path, completed_at) - payments(id, entry_id, amount_minor, currency, provider_session_id, processed_event_ids_json, state, confirmed_at) - The idempotency key is unique per form, so a retried submission returns the existing entry rather than creating a second one. ### Phase 1 · One engine for calculations and conditions Build: Define the schema: field types, show-when conditions and calculated fields with a small expression grammar. Implement the evaluator once as a shared module, run it in the browser for live totals and on the server as the authority. Represent money as integer minor units. Test against a golden set of schemas and answers. Trap: Writing the calculation logic twice, once for the live preview and once for the server, produces two evaluators that agree in testing and disagree on the rounding of a percentage discount, and the customer is charged a total that is not the one they were shown. Share one module, and do arithmetic on integers. Done when: A golden set of at least thirty schema and answer pairs evaluates identically in the browser and on the server, asserted in the suite. Money fields are integer minor units and a percentage discount rounds by a stated rule. A condition referencing a hidden field behaves by a stated rule. A circular calculation is rejected when the schema is saved rather than at runtime. Do not build yet: No public form, submissions, uploads, spam handling or payment. ### Phase 2 · Submissions that survive the network Build: A public form page rendered from the published version. The browser generates an idempotency key when the form opens and sends it with every attempt; the server inserts the entry keyed on it and returns the same entry on a retry. Uploads go first with their own ids and the entry references them. Store the form version with the entry. Trap: A phone submits, the connection drops after the server committed, the browser shows an error, the person taps again, and there are two entries and later two charges. The idempotency key turns the second attempt into a read of the first. Done when: Submitting twice with the same key produces one entry, tested with concurrent requests. Killing the connection after commit and retrying returns the committed entry rather than an error. Uploads complete before the entry references them and an orphaned upload is removed after a stated period. An entry records the schema version it was filled against and renders correctly after the form changes. Do not build yet: No spam handling, payment, notifications or export. ### Phase 3 · Spam that cannot eat a real entry Build: A honeypot field, a minimum time between form open and submit, and a per-address rate limit, with no third-party challenge by default. Anything that trips a rule is stored as quarantined and listed with the reason. Notify on received entries and send a daily count of quarantined ones. Trap: Dropping a submission the filter dislikes discards the real customer who used autofill and submitted in four seconds, and nobody ever learns it happened. Quarantine with the reason, show the count, and make releasing one a single action. Done when: A submission tripping a rule is stored as quarantined with the reason and is never deleted. Releasing a quarantined entry makes it a received entry and triggers its notification. The honeypot is invisible to people and to assistive technology, checked by inspection. A burst from one address is rate limited with a readable message rather than silently discarded. Do not build yet: No payment, document generation, outbound webhooks or export. ### Phase 4 · Payment the server priced Build: Compute the amount on the server from the stored schema version and the submitted answers, create a hosted checkout session for that amount, and mark the entry pending. Confirm on the signed webhook, deduplicate by event id, and tolerate events arriving out of order. Handle a payment that arrives after the person closed the tab. Trap: The provider retries a webhook for up to three days and does not promise delivery order, so an integration that treats the first event as the whole story, or processes the same event twice, marks entries paid twice or never. Verify the signature, record processed event ids, and derive state from the entry rather than from which event came first. Done when: The checkout amount equals the server's calculation and never the client's, tested by posting a tampered total. A duplicate webhook is ignored by event id and an out-of-order pair leaves the correct final state. Closing the browser after paying still marks the entry paid, tested by confirming through the webhook alone. A webhook with a bad signature is rejected and logged. Do not build yet: No document generation, outbound webhooks or export. ### Phase 5 · Getting entries out, and telling other systems Build: CSV export keyed by field id with a header mapping, so entries filled against older versions align. Document generation from an HTML template to PDF per entry. Outbound webhooks per entry with a signed body and retries. Archive rather than delete, and back up the database file and uploads with a restore exercised in the suite. Trap: Exporting columns in the current schema's order silently misaligns entries filled before a field was renamed or inserted, and the spreadsheet looks fine while every older row is shifted by one. Key columns by field id and include a mapping row. Done when: A CSV export of entries spanning three schema versions aligns every answer under the right field, asserted in the suite. A generated document reproduces the entry's stored answers and calculated values from a template. Outbound webhooks are signed, retried with backoff and recorded per attempt. A restore from backup reopens every entry and its uploads, exercised in the suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Compliance certifications and signed processing agreements, which are organisational commitments rather than code. - Multi-user workflows with approvals and roles, which change the entry model and are where the paid product's value lives. - A third-party challenge service by default, which adds a dependency and an accessibility cost a personal form rarely needs. - Hundreds of integrations, which are maintenance rather than design; the signed outbound webhook is the integration. ### README must contain - That one evaluator runs in the browser and on the server, and only the server's result reaches the payment provider - That every submission carries an idempotency key, so a retry or a double tap yields one entry - That suspected spam is quarantined with a reason and never dropped - That the payment amount is computed on the server from the stored schema version and confirmed by webhook - That CSV export is keyed by field id, because schema order shifts older rows silently ===== AGENTS.md ===== # Agent instructions · Cognito Forms indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with SQLite in WAL mode, A JSON form definition with fields, conditions and calculations, One expression engine shared by browser and server, money in integer minor units, Client-generated idempotency keys, entries appended and revised, Hosted checkout with the amount computed server side, confirmed on webhook. 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 - Writing the calculation logic twice, once for the live preview and once for the server, produces two evaluators that agree in testing and disagree on the rounding of a percentage discount, and the customer is charged a total that is not the one they were shown. Share one module, and do arithmetic on integers. - A phone submits, the connection drops after the server committed, the browser shows an error, the person taps again, and there are two entries and later two charges. The idempotency key turns the second attempt into a read of the first. - Dropping a submission the filter dislikes discards the real customer who used autofill and submitted in four seconds, and nobody ever learns it happened. Quarantine with the reason, show the count, and make releasing one a single action. - The provider retries a webhook for up to three days and does not promise delivery order, so an integration that treats the first event as the whole story, or processes the same event twice, marks entries paid twice or never. Verify the signature, record processed event ids, and derive state from the entry rather than from which event came first. ===== BUILD_PLAN.md ===== # Build plan · Cognito Forms A form builder whose difficulty is not the fields but the contract around them: calculations evaluated identically on both sides with the server authoritative, submissions that survive retries and spam filters, and a payment that confirms from the provider's webhook rather than the browser. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · One engine for calculations and conditions The browser and the server compute the same result from the same schema. ### Steps 1. Write the evaluator once Imported by the browser bundle and the server alike. Files: `src/schema/evaluate.ts`, `src/schema/evaluate.test.ts` 2. Keep money as integers A percentage of a float is a wrong price waiting to happen. Files: `src/schema/money.ts` 3. Reject circular calculations at save At runtime it is an outage on a live form. Files: `src/schema/validate.ts` ### Done when - [ ] Browser and server evaluate the golden set identically - [ ] Money is integer minor units with a stated rounding rule - [ ] Hidden field references follow a stated rule - [ ] Circular calculations are rejected at save ### Watch out - Writing the calculation logic twice, once for the live preview and once for the server, produces two evaluators that agree in testing and disagree on the rounding of a percentage discount, and the customer is charged a total that is not the one they were shown. Share one module, and do arithmetic on integers. ## Phase 2 · Submissions that survive the network A retry, a double tap or a crash after commit produces exactly one entry. ### Steps 1. Key every submission from the browser Generated when the form opens, sent on every attempt. Files: `src/public/form.ts`, `src/entries/create.ts` 2. Test concurrent duplicate submissions Twenty parallel posts with one key produce one entry. Files: `tests/idempotency.test.ts` 3. Upload first, reference second An entry must never point at a file that did not finish. Files: `src/uploads.ts` ### Done when - [ ] Concurrent duplicate posts yield one entry - [ ] A retry after commit returns the entry - [ ] Orphaned uploads are removed after a period - [ ] Entries render against their own schema version ### Watch out - A phone submits, the connection drops after the server committed, the browser shows an error, the person taps again, and there are two entries and later two charges. The idempotency key turns the second attempt into a read of the first. ## Phase 3 · Spam that cannot eat a real entry Junk stays out of the inbox without a genuine submission ever being discarded. ### Steps 1. Quarantine, never drop With the reason, and a count the owner sees daily. Files: `src/spam/rules.ts`, `src/entries/quarantine.ts` 2. Hide the honeypot from people and screen readers A honeypot a screen reader announces quarantines real submissions. Files: `src/public/honeypot.ts` 3. Rate limit with a message A refusal the person can read beats a silent discard. Files: `src/spam/ratelimit.ts` ### Done when - [ ] Tripped rules quarantine with a reason - [ ] Releasing an entry makes it received - [ ] The honeypot is hidden from assistive technology - [ ] Bursts are refused with a message ### Watch out - Dropping a submission the filter dislikes discards the real customer who used autofill and submitted in four seconds, and nobody ever learns it happened. Quarantine with the reason, show the count, and make releasing one a single action. ## Phase 4 · Payment the server priced The amount charged is computed on the server and the entry is paid only when the provider says so. ### Steps 1. Price on the server from the stored version The posted total is discarded, tested by tampering with it. Files: `src/payments/price.ts`, `tests/tampered-total.test.ts` 2. Verify, deduplicate and reorder webhooks Signature, event ids, and state derived from the entry. Files: `src/payments/webhook.ts`, `tests/webhook-order.test.ts` 3. Confirm with the tab closed The webhook alone must be enough. Files: `tests/payment-tab-closed.test.ts` ```sh stripe listen --forward-to localhost:3000/api/payments/webhook ``` ### Done when - [ ] A tampered total is ignored - [ ] Duplicate and out-of-order events settle correctly - [ ] Payment confirms with the browser closed - [ ] Bad signatures are rejected and logged ### Watch out - The provider retries a webhook for up to three days and does not promise delivery order, so an integration that treats the first event as the whole story, or processes the same event twice, marks entries paid twice or never. Verify the signature, record processed event ids, and derive state from the entry rather than from which event came first. ## Not in this build - Compliance certifications and processing agreements. - Multi-user approval workflows. - A third-party challenge service by default. - Hundreds of integrations. ## After v1, if you want it - Save and resume for long forms, which the idempotency key already half provides - Repeating sections with calculations over their rows, the feature that separates a form tool from a survey tool - Conditional notification recipients, which is the first workflow anyone actually asks for ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Entries, revisions and payments. Back it up with the uploads directory. DATABASE_PATH=./data/forms.db # Required. Files stored by content hash, referenced from entries once complete. UPLOAD_DIR=./data/uploads # Required · secret. Creates checkout sessions for the server's amount. STRIPE_SECRET_KEY=sk_test_...redacted # Required · secret. Entries confirm from webhooks, so an unverified endpoint marks entries paid. STRIPE_WEBHOOK_SECRET=whsec_...redacted # Required · secret. Notifications and the daily quarantine count. SMTP_URL=smtp://user:pass@mail.example.com:587 # Required. Faster than this is quarantined, not dropped. Autofill is fast. MIN_SUBMIT_SECONDS=3 # Required. An upload no entry referenced after this is removed. ORPHAN_UPLOAD_HOURS=24 # Required. Present as a constant. The price is whatever the server computes. TRUST_CLIENT_TOTAL=never
You are building a production product version of Cognito Forms. 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 ===== # Cognito Forms · product brief ## Problem Cognito Forms's solo core is compact: build a advanced forms tool with a schema-based form editor, public share link, validation, response table, CSV export, and webhook. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on delivery, integrations, compliance. ## Product outcome A form tool where the total the customer saw is the total the server charged, a flaky connection produces one entry rather than two, and a suspicious submission is set aside rather than lost. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - deploy target - SMTP or transactional email key in .env ## Explicit non-goals for v1 - Compliance certifications and processing agreements. - Multi-user approval workflows. - A third-party challenge service by default. - Hundreds of integrations. - compliance controls and guaranteed delivery - enterprise workflow logic - spam and abuse defense at scale - large integration and template catalog ## Success criteria - Browser and server are confirmed to evaluate the golden set identically - Concurrent duplicate submissions are confirmed to yield one entry - Tripped spam rules are confirmed to quarantine rather than drop - A tampered total is confirmed to be ignored - Payment is confirmed to complete with the browser closed ===== BRIEF.md ===== # Build brief · Cognito Forms 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 with calculations, conditional fields and payment where no submission is ever lost to replace Cognito Forms. 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 Two things are not negotiable in a form tool, and both are invisible until they fail. A submission that reached the server is never lost. Not to a double tap, not to a dropped connection after the write committed, not to a spam filter that guessed wrong, and not to an edit. Entries are appended and revised, never overwritten, and suspected spam is quarantined where it can be seen rather than dropped. And the amount charged is never the client's number. A calculated total lives in the browser so the person can watch it change, but the server recomputes it from the stored schema and the submitted answers, and only that figure reaches the payment provider. A form that trusts a posted total is a form where the price is whatever the buyer types. ### Stack (fixed, do not substitute) - Runtime: TypeScript with SQLite in WAL mode · one person's forms and entries fit in one file that is trivial to back up - Schema: A JSON form definition with fields, conditions and calculations · the definition is data, so it can be versioned and evaluated anywhere - Evaluation: One expression engine shared by browser and server, money in integer minor units · two implementations drift, and the drift is a wrong price - Submission: Client-generated idempotency keys, entries appended and revised · retries and double taps must produce one entry - Payment: Hosted checkout with the amount computed server side, confirmed on webhook · the redirect is a convenience and the webhook is the truth ### Data model (create this before Phase 1) - forms(id, slug, name, published_version, created_at, archived_at) - form_versions(form_id, version, schema_json, created_at) · every published schema kept, since entries reference the version they were filled against - entries(id, form_id, form_version, idempotency_key, state, created_at, archived_at) · state is received, quarantined, pending_payment, paid or complete - entry_revisions(entry_id, ordinal, answers_json, calculated_json, edited_by, at) · answers are never overwritten - uploads(id, entry_id, field_id, filename, bytes, sha256, stored_path, completed_at) - payments(id, entry_id, amount_minor, currency, provider_session_id, processed_event_ids_json, state, confirmed_at) - The idempotency key is unique per form, so a retried submission returns the existing entry rather than creating a second one. ### Phase 1 · One engine for calculations and conditions Build: Define the schema: field types, show-when conditions and calculated fields with a small expression grammar. Implement the evaluator once as a shared module, run it in the browser for live totals and on the server as the authority. Represent money as integer minor units. Test against a golden set of schemas and answers. Trap: Writing the calculation logic twice, once for the live preview and once for the server, produces two evaluators that agree in testing and disagree on the rounding of a percentage discount, and the customer is charged a total that is not the one they were shown. Share one module, and do arithmetic on integers. Done when: A golden set of at least thirty schema and answer pairs evaluates identically in the browser and on the server, asserted in the suite. Money fields are integer minor units and a percentage discount rounds by a stated rule. A condition referencing a hidden field behaves by a stated rule. A circular calculation is rejected when the schema is saved rather than at runtime. Do not build yet: No public form, submissions, uploads, spam handling or payment. ### Phase 2 · Submissions that survive the network Build: A public form page rendered from the published version. The browser generates an idempotency key when the form opens and sends it with every attempt; the server inserts the entry keyed on it and returns the same entry on a retry. Uploads go first with their own ids and the entry references them. Store the form version with the entry. Trap: A phone submits, the connection drops after the server committed, the browser shows an error, the person taps again, and there are two entries and later two charges. The idempotency key turns the second attempt into a read of the first. Done when: Submitting twice with the same key produces one entry, tested with concurrent requests. Killing the connection after commit and retrying returns the committed entry rather than an error. Uploads complete before the entry references them and an orphaned upload is removed after a stated period. An entry records the schema version it was filled against and renders correctly after the form changes. Do not build yet: No spam handling, payment, notifications or export. ### Phase 3 · Spam that cannot eat a real entry Build: A honeypot field, a minimum time between form open and submit, and a per-address rate limit, with no third-party challenge by default. Anything that trips a rule is stored as quarantined and listed with the reason. Notify on received entries and send a daily count of quarantined ones. Trap: Dropping a submission the filter dislikes discards the real customer who used autofill and submitted in four seconds, and nobody ever learns it happened. Quarantine with the reason, show the count, and make releasing one a single action. Done when: A submission tripping a rule is stored as quarantined with the reason and is never deleted. Releasing a quarantined entry makes it a received entry and triggers its notification. The honeypot is invisible to people and to assistive technology, checked by inspection. A burst from one address is rate limited with a readable message rather than silently discarded. Do not build yet: No payment, document generation, outbound webhooks or export. ### Phase 4 · Payment the server priced Build: Compute the amount on the server from the stored schema version and the submitted answers, create a hosted checkout session for that amount, and mark the entry pending. Confirm on the signed webhook, deduplicate by event id, and tolerate events arriving out of order. Handle a payment that arrives after the person closed the tab. Trap: The provider retries a webhook for up to three days and does not promise delivery order, so an integration that treats the first event as the whole story, or processes the same event twice, marks entries paid twice or never. Verify the signature, record processed event ids, and derive state from the entry rather than from which event came first. Done when: The checkout amount equals the server's calculation and never the client's, tested by posting a tampered total. A duplicate webhook is ignored by event id and an out-of-order pair leaves the correct final state. Closing the browser after paying still marks the entry paid, tested by confirming through the webhook alone. A webhook with a bad signature is rejected and logged. Do not build yet: No document generation, outbound webhooks or export. ### Phase 5 · Getting entries out, and telling other systems Build: CSV export keyed by field id with a header mapping, so entries filled against older versions align. Document generation from an HTML template to PDF per entry. Outbound webhooks per entry with a signed body and retries. Archive rather than delete, and back up the database file and uploads with a restore exercised in the suite. Trap: Exporting columns in the current schema's order silently misaligns entries filled before a field was renamed or inserted, and the spreadsheet looks fine while every older row is shifted by one. Key columns by field id and include a mapping row. Done when: A CSV export of entries spanning three schema versions aligns every answer under the right field, asserted in the suite. A generated document reproduces the entry's stored answers and calculated values from a template. Outbound webhooks are signed, retried with backoff and recorded per attempt. A restore from backup reopens every entry and its uploads, exercised in the suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Compliance certifications and signed processing agreements, which are organisational commitments rather than code. - Multi-user workflows with approvals and roles, which change the entry model and are where the paid product's value lives. - A third-party challenge service by default, which adds a dependency and an accessibility cost a personal form rarely needs. - Hundreds of integrations, which are maintenance rather than design; the signed outbound webhook is the integration. ### README must contain - That one evaluator runs in the browser and on the server, and only the server's result reaches the payment provider - That every submission carries an idempotency key, so a retry or a double tap yields one entry - That suspected spam is quarantined with a reason and never dropped - That the payment amount is computed on the server from the stored schema version and confirmed by webhook - That CSV export is keyed by field id, because schema order shifts older rows silently ===== ARCHITECTURE.md ===== # Architecture · Cognito Forms ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with SQLite in WAL mode | one person's forms and entries fit in one file that is trivial to back up | | Schema | A JSON form definition with fields, conditions and calculations | the definition is data, so it can be versioned and evaluated anywhere | | Evaluation | One expression engine shared by browser and server, money in integer minor units | two implementations drift, and the drift is a wrong price | | Submission | Client-generated idempotency keys, entries appended and revised | retries and double taps must produce one entry | | Payment | Hosted checkout with the amount computed server side, confirmed on webhook | the redirect is a convenience and the webhook is the truth | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Schema | Versioned definitions with conditions and calculations. | Any format, provided every published version is kept. | | Evaluation | One engine for browser and server. | Not swappable for two implementations, which drift into a wrong price. | | Submission | Idempotent creation and appended revisions. | Any store, provided a retry returns the existing entry. | | Spam | Rules that quarantine with a reason. | Any rules, including a challenge service, provided nothing is dropped. | | Payment | Server-side pricing and webhook confirmation. | Any provider, provided the client's total is never used. | ## 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 · Entries, revisions and payments. Back it up with the uploads directory. - `UPLOAD_DIR` · required · Files stored by content hash, referenced from entries once complete. - `STRIPE_SECRET_KEY` · required, secret · Creates checkout sessions for the server's amount. - `STRIPE_WEBHOOK_SECRET` · required, secret · Entries confirm from webhooks, so an unverified endpoint marks entries paid. - `SMTP_URL` · required, secret · Notifications and the daily quarantine count. - `MIN_SUBMIT_SECONDS` · required · Faster than this is quarantined, not dropped. Autofill is fast. - `ORPHAN_UPLOAD_HOURS` · required · An upload no entry referenced after this is removed. - `TRUST_CLIENT_TOTAL` · required · Present as a constant. The price is whatever the server computes. ## 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 · Cognito Forms product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with SQLite in WAL mode, A JSON form definition with fields, conditions and calculations, One expression engine shared by browser and server, money in integer minor units, Client-generated idempotency keys, entries appended and revised, Hosted checkout with the amount computed server side, confirmed on webhook. - 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 - Writing the calculation logic twice, once for the live preview and once for the server, produces two evaluators that agree in testing and disagree on the rounding of a percentage discount, and the customer is charged a total that is not the one they were shown. Share one module, and do arithmetic on integers. - A phone submits, the connection drops after the server committed, the browser shows an error, the person taps again, and there are two entries and later two charges. The idempotency key turns the second attempt into a read of the first. - Dropping a submission the filter dislikes discards the real customer who used autofill and submitted in four seconds, and nobody ever learns it happened. Quarantine with the reason, show the count, and make releasing one a single action. - The provider retries a webhook for up to three days and does not promise delivery order, so an integration that treats the first event as the whole story, or processes the same event twice, marks entries paid twice or never. Verify the signature, record processed event ids, and derive state from the entry rather than from which event came first. - Exporting columns in the current schema's order silently misaligns entries filled before a field was renamed or inserted, and the spreadsheet looks fine while every older row is shifted by one. Key columns by field id and include a mapping row. ===== MILESTONES.md ===== # Delivery milestones · Cognito Forms Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · One engine for calculations and conditions The browser and the server compute the same result from the same schema. ### Steps 1. Write the evaluator once Imported by the browser bundle and the server alike. Files: `src/schema/evaluate.ts`, `src/schema/evaluate.test.ts` 2. Keep money as integers A percentage of a float is a wrong price waiting to happen. Files: `src/schema/money.ts` 3. Reject circular calculations at save At runtime it is an outage on a live form. Files: `src/schema/validate.ts` ### Done when - [ ] Browser and server evaluate the golden set identically - [ ] Money is integer minor units with a stated rounding rule - [ ] Hidden field references follow a stated rule - [ ] Circular calculations are rejected at save ### Watch out - Writing the calculation logic twice, once for the live preview and once for the server, produces two evaluators that agree in testing and disagree on the rounding of a percentage discount, and the customer is charged a total that is not the one they were shown. Share one module, and do arithmetic on integers. ## M2 · Submissions that survive the network A retry, a double tap or a crash after commit produces exactly one entry. ### Steps 1. Key every submission from the browser Generated when the form opens, sent on every attempt. Files: `src/public/form.ts`, `src/entries/create.ts` 2. Test concurrent duplicate submissions Twenty parallel posts with one key produce one entry. Files: `tests/idempotency.test.ts` 3. Upload first, reference second An entry must never point at a file that did not finish. Files: `src/uploads.ts` ### Done when - [ ] Concurrent duplicate posts yield one entry - [ ] A retry after commit returns the entry - [ ] Orphaned uploads are removed after a period - [ ] Entries render against their own schema version ### Watch out - A phone submits, the connection drops after the server committed, the browser shows an error, the person taps again, and there are two entries and later two charges. The idempotency key turns the second attempt into a read of the first. ## M3 · Spam that cannot eat a real entry Junk stays out of the inbox without a genuine submission ever being discarded. ### Steps 1. Quarantine, never drop With the reason, and a count the owner sees daily. Files: `src/spam/rules.ts`, `src/entries/quarantine.ts` 2. Hide the honeypot from people and screen readers A honeypot a screen reader announces quarantines real submissions. Files: `src/public/honeypot.ts` 3. Rate limit with a message A refusal the person can read beats a silent discard. Files: `src/spam/ratelimit.ts` ### Done when - [ ] Tripped rules quarantine with a reason - [ ] Releasing an entry makes it received - [ ] The honeypot is hidden from assistive technology - [ ] Bursts are refused with a message ### Watch out - Dropping a submission the filter dislikes discards the real customer who used autofill and submitted in four seconds, and nobody ever learns it happened. Quarantine with the reason, show the count, and make releasing one a single action. ## M4 · Payment the server priced The amount charged is computed on the server and the entry is paid only when the provider says so. ### Steps 1. Price on the server from the stored version The posted total is discarded, tested by tampering with it. Files: `src/payments/price.ts`, `tests/tampered-total.test.ts` 2. Verify, deduplicate and reorder webhooks Signature, event ids, and state derived from the entry. Files: `src/payments/webhook.ts`, `tests/webhook-order.test.ts` 3. Confirm with the tab closed The webhook alone must be enough. Files: `tests/payment-tab-closed.test.ts` ```sh stripe listen --forward-to localhost:3000/api/payments/webhook ``` ### Done when - [ ] A tampered total is ignored - [ ] Duplicate and out-of-order events settle correctly - [ ] Payment confirms with the browser closed - [ ] Bad signatures are rejected and logged ### Watch out - The provider retries a webhook for up to three days and does not promise delivery order, so an integration that treats the first event as the whole story, or processes the same event twice, marks entries paid twice or never. Verify the signature, record processed event ids, and derive state from the entry rather than from which event came first. ## M5 · Getting entries out, and telling other systems (production only) Every entry leaves in a stable shape and nothing depends on this tool staying up. ### Steps 1. Export by field id with a mapping row Schema order shifts older rows silently. Files: `src/export/csv.ts`, `tests/csv-versions.test.ts` 2. Generate documents from stored values From the entry's answers and calculated values, not a re-evaluation. Files: `src/documents/render.ts` 3. Sign and retry outbound webhooks Recorded per attempt so a failure is visible rather than silent. Files: `src/webhooks/out.ts` ### Done when - [ ] CSV aligns entries across schema versions - [ ] Documents render from stored values - [ ] Outbound webhooks are signed, retried and recorded - [ ] Restore reopens entries and uploads ### Watch out - Exporting columns in the current schema's order silently misaligns entries filled before a field was renamed or inserted, and the spreadsheet looks fine while every older row is shifted by one. Key columns by field id and include a mapping row. ===== OPERATIONS.md ===== # Operations · Cognito Forms ## Backup The database file and the uploads directory together, nightly, since an entry without its upload is half an entry. The restore is exercised in the suite rather than documented. ## Restore Restore both, then confirm every paid entry still has its payment record and every upload referenced by an entry exists on disk, since either mismatch means someone paid for something you cannot see. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring The quarantine count and the webhook failure count. A rising quarantine count with no spam in it means a rule is too tight, and a webhook failure means an entry is pending payment that the provider considers paid. ## Incident checklist A customer who paid and has no entry is the failure that matters, and it means the webhook was rejected or never verified. The processed event ids and the provider's delivery log together make it recoverable in minutes. 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 - [ ] Browser and server are confirmed to evaluate the golden set identically - [ ] Concurrent duplicate submissions are confirmed to yield one entry - [ ] Tripped spam rules are confirmed to quarantine rather than drop - [ ] A tampered total is confirmed to be ignored - [ ] Payment is confirmed to complete with the browser closed ## Launch constraint Do not market omitted Cognito Forms 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. Entries, revisions and payments. Back it up with the uploads directory. DATABASE_PATH=./data/forms.db # Required. Files stored by content hash, referenced from entries once complete. UPLOAD_DIR=./data/uploads # Required · secret. Creates checkout sessions for the server's amount. STRIPE_SECRET_KEY=sk_test_...redacted # Required · secret. Entries confirm from webhooks, so an unverified endpoint marks entries paid. STRIPE_WEBHOOK_SECRET=whsec_...redacted # Required · secret. Notifications and the daily quarantine count. SMTP_URL=smtp://user:pass@mail.example.com:587 # Required. Faster than this is quarantined, not dropped. Autofill is fast. MIN_SUBMIT_SECONDS=3 # Required. An upload no entry referenced after this is removed. ORPHAN_UPLOAD_HOURS=24 # Required. Present as a constant. The price is whatever the server computes. TRUST_CLIENT_TOTAL=never
# Cognito Forms · indie build A form builder whose difficulty is not the fields but the contract around them: calculations evaluated identically on both sides with the server authoritative, submissions that survive retries and spam filters, and a payment that confirms from the provider's webhook rather than the browser. 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 | TypeScript with SQLite in WAL mode | one person's forms and entries fit in one file that is trivial to back up | | Schema | A JSON form definition with fields, conditions and calculations | the definition is data, so it can be versioned and evaluated anywhere | | Evaluation | One expression engine shared by browser and server, money in integer minor units | two implementations drift, and the drift is a wrong price | | Submission | Client-generated idempotency keys, entries appended and revised | retries and double taps must produce one entry | | Payment | Hosted checkout with the amount computed server side, confirmed on webhook | the redirect is a convenience and the webhook is the truth | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server, the form pages and the test suite. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **SQLite with WAL mode** · free - Why: Entries, revisions and payments for one person fit in one file, and WAL lets the public form write while you read. - Get it: Bundled with most runtimes; enable WAL on open. - Verify: PRAGMA journal_mode returns wal - [ ] **A payment provider account in test mode** · free to test - Why: Webhook confirmation, duplicate events and out-of-order delivery only appear against a real provider flow. - Get it: Sign up, use test keys, register a webhook, and install the CLI to forward events locally. - Verify: A test payment with the browser closed still marks an entry paid - [ ] **An email provider for notifications** · free tier or pennies - Why: Received entries and the daily quarantine count go out by email, and a notification that never arrives is how a quarantined real entry stays unnoticed. - Get it: Any provider with SMTP or an API key, and a verified sender. - Verify: A notification arrives for a test entry - [ ] **Three real forms including one with a discount calculation** · free - Why: The golden set and the CSV alignment test need schemas that changed over time and calculations with awkward rounding, which invented forms never have. - Get it: Rebuild three forms you actually use, and change one after collecting a few entries. - Verify: The golden set covers all three and one form has two published versions - [ ] **Your rounding rule and your hidden-field rule** · free - Why: A percentage discount and a hidden calculated field each have several defensible behaviours, and choosing after seeing the totals means choosing the flattering one. - Get it: Write down how percentages round and whether hidden fields contribute to calculations. - Verify: Both rules are documented and asserted in the golden set ## Quick start ```sh stripe listen --forward-to localhost:3000/api/payments/webhook ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Compliance certifications and processing agreements. - Multi-user approval workflows. - A third-party challenge service by default. - Hundreds of integrations. - compliance controls and guaranteed delivery - enterprise workflow logic - spam and abuse defense at scale - large integration and template catalog If one of those is essential to you, that is the reason to keep paying for Cognito Forms, and the README should say so rather than pretend.
# Build brief · Cognito Forms 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 with calculations, conditional fields and payment where no submission is ever lost to replace Cognito Forms. 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 Two things are not negotiable in a form tool, and both are invisible until they fail. A submission that reached the server is never lost. Not to a double tap, not to a dropped connection after the write committed, not to a spam filter that guessed wrong, and not to an edit. Entries are appended and revised, never overwritten, and suspected spam is quarantined where it can be seen rather than dropped. And the amount charged is never the client's number. A calculated total lives in the browser so the person can watch it change, but the server recomputes it from the stored schema and the submitted answers, and only that figure reaches the payment provider. A form that trusts a posted total is a form where the price is whatever the buyer types. ### Stack (fixed, do not substitute) - Runtime: TypeScript with SQLite in WAL mode · one person's forms and entries fit in one file that is trivial to back up - Schema: A JSON form definition with fields, conditions and calculations · the definition is data, so it can be versioned and evaluated anywhere - Evaluation: One expression engine shared by browser and server, money in integer minor units · two implementations drift, and the drift is a wrong price - Submission: Client-generated idempotency keys, entries appended and revised · retries and double taps must produce one entry - Payment: Hosted checkout with the amount computed server side, confirmed on webhook · the redirect is a convenience and the webhook is the truth ### Data model (create this before Phase 1) - forms(id, slug, name, published_version, created_at, archived_at) - form_versions(form_id, version, schema_json, created_at) · every published schema kept, since entries reference the version they were filled against - entries(id, form_id, form_version, idempotency_key, state, created_at, archived_at) · state is received, quarantined, pending_payment, paid or complete - entry_revisions(entry_id, ordinal, answers_json, calculated_json, edited_by, at) · answers are never overwritten - uploads(id, entry_id, field_id, filename, bytes, sha256, stored_path, completed_at) - payments(id, entry_id, amount_minor, currency, provider_session_id, processed_event_ids_json, state, confirmed_at) - The idempotency key is unique per form, so a retried submission returns the existing entry rather than creating a second one. ### Phase 1 · One engine for calculations and conditions Build: Define the schema: field types, show-when conditions and calculated fields with a small expression grammar. Implement the evaluator once as a shared module, run it in the browser for live totals and on the server as the authority. Represent money as integer minor units. Test against a golden set of schemas and answers. Trap: Writing the calculation logic twice, once for the live preview and once for the server, produces two evaluators that agree in testing and disagree on the rounding of a percentage discount, and the customer is charged a total that is not the one they were shown. Share one module, and do arithmetic on integers. Done when: A golden set of at least thirty schema and answer pairs evaluates identically in the browser and on the server, asserted in the suite. Money fields are integer minor units and a percentage discount rounds by a stated rule. A condition referencing a hidden field behaves by a stated rule. A circular calculation is rejected when the schema is saved rather than at runtime. Do not build yet: No public form, submissions, uploads, spam handling or payment. ### Phase 2 · Submissions that survive the network Build: A public form page rendered from the published version. The browser generates an idempotency key when the form opens and sends it with every attempt; the server inserts the entry keyed on it and returns the same entry on a retry. Uploads go first with their own ids and the entry references them. Store the form version with the entry. Trap: A phone submits, the connection drops after the server committed, the browser shows an error, the person taps again, and there are two entries and later two charges. The idempotency key turns the second attempt into a read of the first. Done when: Submitting twice with the same key produces one entry, tested with concurrent requests. Killing the connection after commit and retrying returns the committed entry rather than an error. Uploads complete before the entry references them and an orphaned upload is removed after a stated period. An entry records the schema version it was filled against and renders correctly after the form changes. Do not build yet: No spam handling, payment, notifications or export. ### Phase 3 · Spam that cannot eat a real entry Build: A honeypot field, a minimum time between form open and submit, and a per-address rate limit, with no third-party challenge by default. Anything that trips a rule is stored as quarantined and listed with the reason. Notify on received entries and send a daily count of quarantined ones. Trap: Dropping a submission the filter dislikes discards the real customer who used autofill and submitted in four seconds, and nobody ever learns it happened. Quarantine with the reason, show the count, and make releasing one a single action. Done when: A submission tripping a rule is stored as quarantined with the reason and is never deleted. Releasing a quarantined entry makes it a received entry and triggers its notification. The honeypot is invisible to people and to assistive technology, checked by inspection. A burst from one address is rate limited with a readable message rather than silently discarded. Do not build yet: No payment, document generation, outbound webhooks or export. ### Phase 4 · Payment the server priced Build: Compute the amount on the server from the stored schema version and the submitted answers, create a hosted checkout session for that amount, and mark the entry pending. Confirm on the signed webhook, deduplicate by event id, and tolerate events arriving out of order. Handle a payment that arrives after the person closed the tab. Trap: The provider retries a webhook for up to three days and does not promise delivery order, so an integration that treats the first event as the whole story, or processes the same event twice, marks entries paid twice or never. Verify the signature, record processed event ids, and derive state from the entry rather than from which event came first. Done when: The checkout amount equals the server's calculation and never the client's, tested by posting a tampered total. A duplicate webhook is ignored by event id and an out-of-order pair leaves the correct final state. Closing the browser after paying still marks the entry paid, tested by confirming through the webhook alone. A webhook with a bad signature is rejected and logged. Do not build yet: No document generation, outbound webhooks or export. ### Phase 5 · Getting entries out, and telling other systems Build: CSV export keyed by field id with a header mapping, so entries filled against older versions align. Document generation from an HTML template to PDF per entry. Outbound webhooks per entry with a signed body and retries. Archive rather than delete, and back up the database file and uploads with a restore exercised in the suite. Trap: Exporting columns in the current schema's order silently misaligns entries filled before a field was renamed or inserted, and the spreadsheet looks fine while every older row is shifted by one. Key columns by field id and include a mapping row. Done when: A CSV export of entries spanning three schema versions aligns every answer under the right field, asserted in the suite. A generated document reproduces the entry's stored answers and calculated values from a template. Outbound webhooks are signed, retried with backoff and recorded per attempt. A restore from backup reopens every entry and its uploads, exercised in the suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Compliance certifications and signed processing agreements, which are organisational commitments rather than code. - Multi-user workflows with approvals and roles, which change the entry model and are where the paid product's value lives. - A third-party challenge service by default, which adds a dependency and an accessibility cost a personal form rarely needs. - Hundreds of integrations, which are maintenance rather than design; the signed outbound webhook is the integration. ### README must contain - That one evaluator runs in the browser and on the server, and only the server's result reaches the payment provider - That every submission carries an idempotency key, so a retry or a double tap yields one entry - That suspected spam is quarantined with a reason and never dropped - That the payment amount is computed on the server from the stored schema version and confirmed by webhook - That CSV export is keyed by field id, because schema order shifts older rows silently
# Agent instructions · Cognito Forms indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with SQLite in WAL mode, A JSON form definition with fields, conditions and calculations, One expression engine shared by browser and server, money in integer minor units, Client-generated idempotency keys, entries appended and revised, Hosted checkout with the amount computed server side, confirmed on webhook. 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 - Writing the calculation logic twice, once for the live preview and once for the server, produces two evaluators that agree in testing and disagree on the rounding of a percentage discount, and the customer is charged a total that is not the one they were shown. Share one module, and do arithmetic on integers. - A phone submits, the connection drops after the server committed, the browser shows an error, the person taps again, and there are two entries and later two charges. The idempotency key turns the second attempt into a read of the first. - Dropping a submission the filter dislikes discards the real customer who used autofill and submitted in four seconds, and nobody ever learns it happened. Quarantine with the reason, show the count, and make releasing one a single action. - The provider retries a webhook for up to three days and does not promise delivery order, so an integration that treats the first event as the whole story, or processes the same event twice, marks entries paid twice or never. Verify the signature, record processed event ids, and derive state from the entry rather than from which event came first.
# Build plan · Cognito Forms A form builder whose difficulty is not the fields but the contract around them: calculations evaluated identically on both sides with the server authoritative, submissions that survive retries and spam filters, and a payment that confirms from the provider's webhook rather than the browser. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · One engine for calculations and conditions The browser and the server compute the same result from the same schema. ### Steps 1. Write the evaluator once Imported by the browser bundle and the server alike. Files: `src/schema/evaluate.ts`, `src/schema/evaluate.test.ts` 2. Keep money as integers A percentage of a float is a wrong price waiting to happen. Files: `src/schema/money.ts` 3. Reject circular calculations at save At runtime it is an outage on a live form. Files: `src/schema/validate.ts` ### Done when - [ ] Browser and server evaluate the golden set identically - [ ] Money is integer minor units with a stated rounding rule - [ ] Hidden field references follow a stated rule - [ ] Circular calculations are rejected at save ### Watch out - Writing the calculation logic twice, once for the live preview and once for the server, produces two evaluators that agree in testing and disagree on the rounding of a percentage discount, and the customer is charged a total that is not the one they were shown. Share one module, and do arithmetic on integers. ## Phase 2 · Submissions that survive the network A retry, a double tap or a crash after commit produces exactly one entry. ### Steps 1. Key every submission from the browser Generated when the form opens, sent on every attempt. Files: `src/public/form.ts`, `src/entries/create.ts` 2. Test concurrent duplicate submissions Twenty parallel posts with one key produce one entry. Files: `tests/idempotency.test.ts` 3. Upload first, reference second An entry must never point at a file that did not finish. Files: `src/uploads.ts` ### Done when - [ ] Concurrent duplicate posts yield one entry - [ ] A retry after commit returns the entry - [ ] Orphaned uploads are removed after a period - [ ] Entries render against their own schema version ### Watch out - A phone submits, the connection drops after the server committed, the browser shows an error, the person taps again, and there are two entries and later two charges. The idempotency key turns the second attempt into a read of the first. ## Phase 3 · Spam that cannot eat a real entry Junk stays out of the inbox without a genuine submission ever being discarded. ### Steps 1. Quarantine, never drop With the reason, and a count the owner sees daily. Files: `src/spam/rules.ts`, `src/entries/quarantine.ts` 2. Hide the honeypot from people and screen readers A honeypot a screen reader announces quarantines real submissions. Files: `src/public/honeypot.ts` 3. Rate limit with a message A refusal the person can read beats a silent discard. Files: `src/spam/ratelimit.ts` ### Done when - [ ] Tripped rules quarantine with a reason - [ ] Releasing an entry makes it received - [ ] The honeypot is hidden from assistive technology - [ ] Bursts are refused with a message ### Watch out - Dropping a submission the filter dislikes discards the real customer who used autofill and submitted in four seconds, and nobody ever learns it happened. Quarantine with the reason, show the count, and make releasing one a single action. ## Phase 4 · Payment the server priced The amount charged is computed on the server and the entry is paid only when the provider says so. ### Steps 1. Price on the server from the stored version The posted total is discarded, tested by tampering with it. Files: `src/payments/price.ts`, `tests/tampered-total.test.ts` 2. Verify, deduplicate and reorder webhooks Signature, event ids, and state derived from the entry. Files: `src/payments/webhook.ts`, `tests/webhook-order.test.ts` 3. Confirm with the tab closed The webhook alone must be enough. Files: `tests/payment-tab-closed.test.ts` ```sh stripe listen --forward-to localhost:3000/api/payments/webhook ``` ### Done when - [ ] A tampered total is ignored - [ ] Duplicate and out-of-order events settle correctly - [ ] Payment confirms with the browser closed - [ ] Bad signatures are rejected and logged ### Watch out - The provider retries a webhook for up to three days and does not promise delivery order, so an integration that treats the first event as the whole story, or processes the same event twice, marks entries paid twice or never. Verify the signature, record processed event ids, and derive state from the entry rather than from which event came first. ## Not in this build - Compliance certifications and processing agreements. - Multi-user approval workflows. - A third-party challenge service by default. - Hundreds of integrations. ## After v1, if you want it - Save and resume for long forms, which the idempotency key already half provides - Repeating sections with calculations over their rows, the feature that separates a form tool from a survey tool - Conditional notification recipients, which is the first workflow anyone actually asks for
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Entries, revisions and payments. Back it up with the uploads directory. DATABASE_PATH=./data/forms.db # Required. Files stored by content hash, referenced from entries once complete. UPLOAD_DIR=./data/uploads # Required · secret. Creates checkout sessions for the server's amount. STRIPE_SECRET_KEY=sk_test_...redacted # Required · secret. Entries confirm from webhooks, so an unverified endpoint marks entries paid. STRIPE_WEBHOOK_SECRET=whsec_...redacted # Required · secret. Notifications and the daily quarantine count. SMTP_URL=smtp://user:pass@mail.example.com:587 # Required. Faster than this is quarantined, not dropped. Autofill is fast. MIN_SUBMIT_SECONDS=3 # Required. An upload no entry referenced after this is removed. ORPHAN_UPLOAD_HOURS=24 # Required. Present as a constant. The price is whatever the server computes. TRUST_CLIENT_TOTAL=never
# Cognito Forms · product brief ## Problem Cognito Forms's solo core is compact: build a advanced forms tool with a schema-based form editor, public share link, validation, response table, CSV export, and webhook. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on delivery, integrations, compliance. ## Product outcome A form tool where the total the customer saw is the total the server charged, a flaky connection produces one entry rather than two, and a suspicious submission is set aside rather than lost. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - deploy target - SMTP or transactional email key in .env ## Explicit non-goals for v1 - Compliance certifications and processing agreements. - Multi-user approval workflows. - A third-party challenge service by default. - Hundreds of integrations. - compliance controls and guaranteed delivery - enterprise workflow logic - spam and abuse defense at scale - large integration and template catalog ## Success criteria - Browser and server are confirmed to evaluate the golden set identically - Concurrent duplicate submissions are confirmed to yield one entry - Tripped spam rules are confirmed to quarantine rather than drop - A tampered total is confirmed to be ignored - Payment is confirmed to complete with the browser closed
# Build brief · Cognito Forms 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 with calculations, conditional fields and payment where no submission is ever lost to replace Cognito Forms. 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 Two things are not negotiable in a form tool, and both are invisible until they fail. A submission that reached the server is never lost. Not to a double tap, not to a dropped connection after the write committed, not to a spam filter that guessed wrong, and not to an edit. Entries are appended and revised, never overwritten, and suspected spam is quarantined where it can be seen rather than dropped. And the amount charged is never the client's number. A calculated total lives in the browser so the person can watch it change, but the server recomputes it from the stored schema and the submitted answers, and only that figure reaches the payment provider. A form that trusts a posted total is a form where the price is whatever the buyer types. ### Stack (fixed, do not substitute) - Runtime: TypeScript with SQLite in WAL mode · one person's forms and entries fit in one file that is trivial to back up - Schema: A JSON form definition with fields, conditions and calculations · the definition is data, so it can be versioned and evaluated anywhere - Evaluation: One expression engine shared by browser and server, money in integer minor units · two implementations drift, and the drift is a wrong price - Submission: Client-generated idempotency keys, entries appended and revised · retries and double taps must produce one entry - Payment: Hosted checkout with the amount computed server side, confirmed on webhook · the redirect is a convenience and the webhook is the truth ### Data model (create this before Phase 1) - forms(id, slug, name, published_version, created_at, archived_at) - form_versions(form_id, version, schema_json, created_at) · every published schema kept, since entries reference the version they were filled against - entries(id, form_id, form_version, idempotency_key, state, created_at, archived_at) · state is received, quarantined, pending_payment, paid or complete - entry_revisions(entry_id, ordinal, answers_json, calculated_json, edited_by, at) · answers are never overwritten - uploads(id, entry_id, field_id, filename, bytes, sha256, stored_path, completed_at) - payments(id, entry_id, amount_minor, currency, provider_session_id, processed_event_ids_json, state, confirmed_at) - The idempotency key is unique per form, so a retried submission returns the existing entry rather than creating a second one. ### Phase 1 · One engine for calculations and conditions Build: Define the schema: field types, show-when conditions and calculated fields with a small expression grammar. Implement the evaluator once as a shared module, run it in the browser for live totals and on the server as the authority. Represent money as integer minor units. Test against a golden set of schemas and answers. Trap: Writing the calculation logic twice, once for the live preview and once for the server, produces two evaluators that agree in testing and disagree on the rounding of a percentage discount, and the customer is charged a total that is not the one they were shown. Share one module, and do arithmetic on integers. Done when: A golden set of at least thirty schema and answer pairs evaluates identically in the browser and on the server, asserted in the suite. Money fields are integer minor units and a percentage discount rounds by a stated rule. A condition referencing a hidden field behaves by a stated rule. A circular calculation is rejected when the schema is saved rather than at runtime. Do not build yet: No public form, submissions, uploads, spam handling or payment. ### Phase 2 · Submissions that survive the network Build: A public form page rendered from the published version. The browser generates an idempotency key when the form opens and sends it with every attempt; the server inserts the entry keyed on it and returns the same entry on a retry. Uploads go first with their own ids and the entry references them. Store the form version with the entry. Trap: A phone submits, the connection drops after the server committed, the browser shows an error, the person taps again, and there are two entries and later two charges. The idempotency key turns the second attempt into a read of the first. Done when: Submitting twice with the same key produces one entry, tested with concurrent requests. Killing the connection after commit and retrying returns the committed entry rather than an error. Uploads complete before the entry references them and an orphaned upload is removed after a stated period. An entry records the schema version it was filled against and renders correctly after the form changes. Do not build yet: No spam handling, payment, notifications or export. ### Phase 3 · Spam that cannot eat a real entry Build: A honeypot field, a minimum time between form open and submit, and a per-address rate limit, with no third-party challenge by default. Anything that trips a rule is stored as quarantined and listed with the reason. Notify on received entries and send a daily count of quarantined ones. Trap: Dropping a submission the filter dislikes discards the real customer who used autofill and submitted in four seconds, and nobody ever learns it happened. Quarantine with the reason, show the count, and make releasing one a single action. Done when: A submission tripping a rule is stored as quarantined with the reason and is never deleted. Releasing a quarantined entry makes it a received entry and triggers its notification. The honeypot is invisible to people and to assistive technology, checked by inspection. A burst from one address is rate limited with a readable message rather than silently discarded. Do not build yet: No payment, document generation, outbound webhooks or export. ### Phase 4 · Payment the server priced Build: Compute the amount on the server from the stored schema version and the submitted answers, create a hosted checkout session for that amount, and mark the entry pending. Confirm on the signed webhook, deduplicate by event id, and tolerate events arriving out of order. Handle a payment that arrives after the person closed the tab. Trap: The provider retries a webhook for up to three days and does not promise delivery order, so an integration that treats the first event as the whole story, or processes the same event twice, marks entries paid twice or never. Verify the signature, record processed event ids, and derive state from the entry rather than from which event came first. Done when: The checkout amount equals the server's calculation and never the client's, tested by posting a tampered total. A duplicate webhook is ignored by event id and an out-of-order pair leaves the correct final state. Closing the browser after paying still marks the entry paid, tested by confirming through the webhook alone. A webhook with a bad signature is rejected and logged. Do not build yet: No document generation, outbound webhooks or export. ### Phase 5 · Getting entries out, and telling other systems Build: CSV export keyed by field id with a header mapping, so entries filled against older versions align. Document generation from an HTML template to PDF per entry. Outbound webhooks per entry with a signed body and retries. Archive rather than delete, and back up the database file and uploads with a restore exercised in the suite. Trap: Exporting columns in the current schema's order silently misaligns entries filled before a field was renamed or inserted, and the spreadsheet looks fine while every older row is shifted by one. Key columns by field id and include a mapping row. Done when: A CSV export of entries spanning three schema versions aligns every answer under the right field, asserted in the suite. A generated document reproduces the entry's stored answers and calculated values from a template. Outbound webhooks are signed, retried with backoff and recorded per attempt. A restore from backup reopens every entry and its uploads, exercised in the suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Compliance certifications and signed processing agreements, which are organisational commitments rather than code. - Multi-user workflows with approvals and roles, which change the entry model and are where the paid product's value lives. - A third-party challenge service by default, which adds a dependency and an accessibility cost a personal form rarely needs. - Hundreds of integrations, which are maintenance rather than design; the signed outbound webhook is the integration. ### README must contain - That one evaluator runs in the browser and on the server, and only the server's result reaches the payment provider - That every submission carries an idempotency key, so a retry or a double tap yields one entry - That suspected spam is quarantined with a reason and never dropped - That the payment amount is computed on the server from the stored schema version and confirmed by webhook - That CSV export is keyed by field id, because schema order shifts older rows silently
# Architecture · Cognito Forms ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with SQLite in WAL mode | one person's forms and entries fit in one file that is trivial to back up | | Schema | A JSON form definition with fields, conditions and calculations | the definition is data, so it can be versioned and evaluated anywhere | | Evaluation | One expression engine shared by browser and server, money in integer minor units | two implementations drift, and the drift is a wrong price | | Submission | Client-generated idempotency keys, entries appended and revised | retries and double taps must produce one entry | | Payment | Hosted checkout with the amount computed server side, confirmed on webhook | the redirect is a convenience and the webhook is the truth | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Schema | Versioned definitions with conditions and calculations. | Any format, provided every published version is kept. | | Evaluation | One engine for browser and server. | Not swappable for two implementations, which drift into a wrong price. | | Submission | Idempotent creation and appended revisions. | Any store, provided a retry returns the existing entry. | | Spam | Rules that quarantine with a reason. | Any rules, including a challenge service, provided nothing is dropped. | | Payment | Server-side pricing and webhook confirmation. | Any provider, provided the client's total is never used. | ## 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 · Entries, revisions and payments. Back it up with the uploads directory. - `UPLOAD_DIR` · required · Files stored by content hash, referenced from entries once complete. - `STRIPE_SECRET_KEY` · required, secret · Creates checkout sessions for the server's amount. - `STRIPE_WEBHOOK_SECRET` · required, secret · Entries confirm from webhooks, so an unverified endpoint marks entries paid. - `SMTP_URL` · required, secret · Notifications and the daily quarantine count. - `MIN_SUBMIT_SECONDS` · required · Faster than this is quarantined, not dropped. Autofill is fast. - `ORPHAN_UPLOAD_HOURS` · required · An upload no entry referenced after this is removed. - `TRUST_CLIENT_TOTAL` · required · Present as a constant. The price is whatever the server computes. ## 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 · Cognito Forms product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with SQLite in WAL mode, A JSON form definition with fields, conditions and calculations, One expression engine shared by browser and server, money in integer minor units, Client-generated idempotency keys, entries appended and revised, Hosted checkout with the amount computed server side, confirmed on webhook. - 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 - Writing the calculation logic twice, once for the live preview and once for the server, produces two evaluators that agree in testing and disagree on the rounding of a percentage discount, and the customer is charged a total that is not the one they were shown. Share one module, and do arithmetic on integers. - A phone submits, the connection drops after the server committed, the browser shows an error, the person taps again, and there are two entries and later two charges. The idempotency key turns the second attempt into a read of the first. - Dropping a submission the filter dislikes discards the real customer who used autofill and submitted in four seconds, and nobody ever learns it happened. Quarantine with the reason, show the count, and make releasing one a single action. - The provider retries a webhook for up to three days and does not promise delivery order, so an integration that treats the first event as the whole story, or processes the same event twice, marks entries paid twice or never. Verify the signature, record processed event ids, and derive state from the entry rather than from which event came first. - Exporting columns in the current schema's order silently misaligns entries filled before a field was renamed or inserted, and the spreadsheet looks fine while every older row is shifted by one. Key columns by field id and include a mapping row.
# Delivery milestones · Cognito Forms Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · One engine for calculations and conditions The browser and the server compute the same result from the same schema. ### Steps 1. Write the evaluator once Imported by the browser bundle and the server alike. Files: `src/schema/evaluate.ts`, `src/schema/evaluate.test.ts` 2. Keep money as integers A percentage of a float is a wrong price waiting to happen. Files: `src/schema/money.ts` 3. Reject circular calculations at save At runtime it is an outage on a live form. Files: `src/schema/validate.ts` ### Done when - [ ] Browser and server evaluate the golden set identically - [ ] Money is integer minor units with a stated rounding rule - [ ] Hidden field references follow a stated rule - [ ] Circular calculations are rejected at save ### Watch out - Writing the calculation logic twice, once for the live preview and once for the server, produces two evaluators that agree in testing and disagree on the rounding of a percentage discount, and the customer is charged a total that is not the one they were shown. Share one module, and do arithmetic on integers. ## M2 · Submissions that survive the network A retry, a double tap or a crash after commit produces exactly one entry. ### Steps 1. Key every submission from the browser Generated when the form opens, sent on every attempt. Files: `src/public/form.ts`, `src/entries/create.ts` 2. Test concurrent duplicate submissions Twenty parallel posts with one key produce one entry. Files: `tests/idempotency.test.ts` 3. Upload first, reference second An entry must never point at a file that did not finish. Files: `src/uploads.ts` ### Done when - [ ] Concurrent duplicate posts yield one entry - [ ] A retry after commit returns the entry - [ ] Orphaned uploads are removed after a period - [ ] Entries render against their own schema version ### Watch out - A phone submits, the connection drops after the server committed, the browser shows an error, the person taps again, and there are two entries and later two charges. The idempotency key turns the second attempt into a read of the first. ## M3 · Spam that cannot eat a real entry Junk stays out of the inbox without a genuine submission ever being discarded. ### Steps 1. Quarantine, never drop With the reason, and a count the owner sees daily. Files: `src/spam/rules.ts`, `src/entries/quarantine.ts` 2. Hide the honeypot from people and screen readers A honeypot a screen reader announces quarantines real submissions. Files: `src/public/honeypot.ts` 3. Rate limit with a message A refusal the person can read beats a silent discard. Files: `src/spam/ratelimit.ts` ### Done when - [ ] Tripped rules quarantine with a reason - [ ] Releasing an entry makes it received - [ ] The honeypot is hidden from assistive technology - [ ] Bursts are refused with a message ### Watch out - Dropping a submission the filter dislikes discards the real customer who used autofill and submitted in four seconds, and nobody ever learns it happened. Quarantine with the reason, show the count, and make releasing one a single action. ## M4 · Payment the server priced The amount charged is computed on the server and the entry is paid only when the provider says so. ### Steps 1. Price on the server from the stored version The posted total is discarded, tested by tampering with it. Files: `src/payments/price.ts`, `tests/tampered-total.test.ts` 2. Verify, deduplicate and reorder webhooks Signature, event ids, and state derived from the entry. Files: `src/payments/webhook.ts`, `tests/webhook-order.test.ts` 3. Confirm with the tab closed The webhook alone must be enough. Files: `tests/payment-tab-closed.test.ts` ```sh stripe listen --forward-to localhost:3000/api/payments/webhook ``` ### Done when - [ ] A tampered total is ignored - [ ] Duplicate and out-of-order events settle correctly - [ ] Payment confirms with the browser closed - [ ] Bad signatures are rejected and logged ### Watch out - The provider retries a webhook for up to three days and does not promise delivery order, so an integration that treats the first event as the whole story, or processes the same event twice, marks entries paid twice or never. Verify the signature, record processed event ids, and derive state from the entry rather than from which event came first. ## M5 · Getting entries out, and telling other systems (production only) Every entry leaves in a stable shape and nothing depends on this tool staying up. ### Steps 1. Export by field id with a mapping row Schema order shifts older rows silently. Files: `src/export/csv.ts`, `tests/csv-versions.test.ts` 2. Generate documents from stored values From the entry's answers and calculated values, not a re-evaluation. Files: `src/documents/render.ts` 3. Sign and retry outbound webhooks Recorded per attempt so a failure is visible rather than silent. Files: `src/webhooks/out.ts` ### Done when - [ ] CSV aligns entries across schema versions - [ ] Documents render from stored values - [ ] Outbound webhooks are signed, retried and recorded - [ ] Restore reopens entries and uploads ### Watch out - Exporting columns in the current schema's order silently misaligns entries filled before a field was renamed or inserted, and the spreadsheet looks fine while every older row is shifted by one. Key columns by field id and include a mapping row.
# Operations · Cognito Forms ## Backup The database file and the uploads directory together, nightly, since an entry without its upload is half an entry. The restore is exercised in the suite rather than documented. ## Restore Restore both, then confirm every paid entry still has its payment record and every upload referenced by an entry exists on disk, since either mismatch means someone paid for something you cannot see. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring The quarantine count and the webhook failure count. A rising quarantine count with no spam in it means a rule is too tight, and a webhook failure means an entry is pending payment that the provider considers paid. ## Incident checklist A customer who paid and has no entry is the failure that matters, and it means the webhook was rejected or never verified. The processed event ids and the provider's delivery log together make it recoverable in minutes. 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 - [ ] Browser and server are confirmed to evaluate the golden set identically - [ ] Concurrent duplicate submissions are confirmed to yield one entry - [ ] Tripped spam rules are confirmed to quarantine rather than drop - [ ] A tampered total is confirmed to be ignored - [ ] Payment is confirmed to complete with the browser closed ## Launch constraint Do not market omitted Cognito Forms 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. Entries, revisions and payments. Back it up with the uploads directory. DATABASE_PATH=./data/forms.db # Required. Files stored by content hash, referenced from entries once complete. UPLOAD_DIR=./data/uploads # Required · secret. Creates checkout sessions for the server's amount. STRIPE_SECRET_KEY=sk_test_...redacted # Required · secret. Entries confirm from webhooks, so an unverified endpoint marks entries paid. STRIPE_WEBHOOK_SECRET=whsec_...redacted # Required · secret. Notifications and the daily quarantine count. SMTP_URL=smtp://user:pass@mail.example.com:587 # Required. Faster than this is quarantined, not dropped. Autofill is fast. MIN_SUBMIT_SECONDS=3 # Required. An upload no entry referenced after this is removed. ORPHAN_UPLOAD_HOURS=24 # Required. Present as a constant. The price is whatever the server computes. TRUST_CLIENT_TOTAL=never
$ choose a build depth, inspect the files, then open the complete pack in your agent
Cognito Forms: Customers pay for dependable collection, spam control, notifications, integrations, and confidence that responses will not disappear.
xcompliance controls and guaranteed delivery
xenterprise workflow logic
xspam and abuse defense at scale
xlarge integration and template catalog
Cognito Forms pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| individual | $0/workspace | $0/workspace | 1 user, 0 guests, unlimited forms, 100 entries/month, and 100MB storage |
| pro | $24/workspace | $19/workspace | 2 users, 2,000 entries/month, and 1GB storage |
| team | $49/workspace | $39/workspace | 5 users, 5 guests, 10,000 entries/month, and 10GB storage |
| enterprise | $174/workspace | $129/workspace | 20 users, 20 guests, unlimited entries, and 100GB storage |
free tier1 user; 0 guests; unlimited forms; 100 entries/month; 100MB storage
billingmonthly + annual; each organization/workspace is subscribed and billed separately; annual plans prepay an usage wallet for metered extras
hidden costsExtra users are $5/user/month. Guest access costs $25/month for up to 50 guests, $50 for 100, $100 for 250, $150 for 500, $200 for 1,000, $250 for 2,500, then $100 per additional 1,000. Enterprise storage above 100GB is automatically billed at $25 per 50GB.
verified 2026-08-12 · source ↗
Vibecode Cognito Forms
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Cognito Forms replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Cognito Forms cost?
Cognito Forms costs about $19/month (Pro, checked 2026-07-31), which is $228 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Cognito Forms?
Honestly: compliance controls and guaranteed delivery; enterprise workflow logic; spam and abuse defense at scale; large integration and template catalog. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Cognito Forms?
Yes: Formbricks (Open-source surveys and experience management.), HeyForm (Open-source form builder and collection backend.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.