Vibecode GetWaitlist
track this build6 phases, 20 steps, beginner friendly0%Email capture with a referral counter. This site's waitlist was built exactly this way; it took one prompt.
You are building a lean indie version of GetWaitlist. 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 ===== # GetWaitlist · indie build A waitlist you own: one HTML snippet pasted into any landing page posts emails to your server, each signup gets a position and a personal referral link that moves them up the queue, bots are turned away, and an admin page shows growth and exports CSV. No third-party service holds your list. Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, node:http and node:sqlite | a signup is one INSERT; a framework would outweigh the app | | Database | SQLite in WAL mode | one file, transactional, easy to back up and export | | Front end | A plain HTML form snippet | works pasted into any page with JavaScript disabled | | Hosting | One small VPS behind Caddy | the snippet needs a public HTTPS endpoint | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Everything in this build runs on it: the server, the scripts, the tests. - Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards. - Verify: node --version prints v22 or higher - [ ] **A terminal and a code editor** · free - Why: Every step below is a command you type or a file you edit. - Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal. - Verify: You can open a folder and run a command in its terminal - [ ] **Git** · free - Why: History for your code, and the way most hosts deploy. - Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists. - Verify: git --version prints a version - [ ] **The landing page you will paste the form into** · free - Why: Phase 4 is tested by pasting the snippet into a real page on a different origin, so you need one to paste into. - Get it: Any page you control: your site, a Carrd, a Notion page with an embed, or a blank HTML file served locally is enough to start. - [ ] **A disposable-email domain list** · free - Why: Phase 1 rejects throwaway addresses. A maintained list saves you writing one. - Get it: Download the domains file from the disposable-email-domains project on GitHub into your repo as data/disposable.txt. - [ ] **A random salt for hashing IPs** · free - Why: You store a salted hash of the signer's IP for rate limiting, never the raw address. - Get it: Generate once with openssl rand -hex 32 and put it in .env as IP_SALT. Changing it later resets the rate limiter, nothing else. - [ ] **A small always-on server (VPS)** (optional) · about $5 a month - Why: This needs one process running all the time with a public address. - Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first. - [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain - Why: The snippet posts to this address from every page it lives on. - Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase. - [ ] **Caddy on the server** (optional) · free - Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work. - Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config. - Verify: caddy version prints a version on the server ## Quick start ```sh mkdir waitlist && cd waitlist && git init && npm init -y && npm pkg set type=module mkdir data && cp .env.example .env ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Sending email: no welcome mail, no blasts, no confirmation link. Deliverability is a whole product; export the CSV into a real sender. - Their referral-widget template gallery. - Spam filtering beyond a honeypot and rate limits: this stops bots, not a determined human. - their referral-widget templates - built-in email blasts to the list - spam filtering you didn't tune yourself If one of those is essential to you, that is the reason to keep paying for GetWaitlist, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · GetWaitlist 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 waitlist signup system like GetWaitlist. Build it in phases, in the order below. Do not write the whole system in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Node 22 with `node:http` and `node:sqlite`, or Python 3.12 with stdlib `http.server` and `sqlite3`. Pick one, no web framework. - Server-rendered HTML. No React, no client framework. The embed snippet is a plain HTML form that works with JavaScript disabled. - SQLite file at a path from `.env`. ### Data model (create this before Phase 1) - `signups`: id, email (unique, stored lowercased and trimmed), created_at, referral_code (unique short slug), referred_by (nullable referral_code), confirmed (bool), ip_hash, user_agent - `referral_code` is 8 characters from an unambiguous alphabet (no 0/O/1/l/I), generated with a CSPRNG, retried on collision. Store `ip_hash` as a salted hash, never the raw IP · you are collecting emails from strangers and the raw address buys you nothing you need. ### Phase 1 · Signup and storage Build: `POST /api/waitlist` accepting `email`. Normalize (trim, lowercase), validate with a single conservative regex plus a length cap of 254, reject disposable-domain matches from a small local list. Dedupe on email · a repeat signup returns the existing position rather than an error, because a duplicate submit is a user who lost the tab, not an attack. Wrap the insert in a transaction so a race cannot mint two codes for one email. Done when: the same email posted twice yields one row and two identical responses, a malformed address is rejected with a readable message, and a concurrent double-submit still leaves exactly one row. Do not build yet: referrals, positions, UI, admin. ### Phase 2 · Referral mechanics Build: on signup, mint the referral_code. When the request carries `?ref=CODE`, resolve it and set `referred_by` · only if the code exists, is not the signer's own, and the pair is not already recorded. Then implement position: `position = base_position - (5 * confirmed_referrals)`, floored at 1 where `base_position` is the signup's rank by created_at. Compute it in one SQL query, not in application code looping over rows · at 10,000 signups the loop is the bug that takes the page down. Done when: three people signing up through one person's link move that person up exactly 15 places, a self-referral is ignored, an unknown ref code is ignored without erroring, and positions are unique and gap-free when nobody has referred. Do not build yet: the confirmation screen. ### Phase 3 · Abuse controls Build: a honeypot field hidden with CSS (never `type=hidden`), a minimum fill time of 2 seconds carried as a signed timestamp, and a per-IP rate limit of 5 signups per hour and 20 per day held in SQLite so it survives a restart. Cap the request body at 4KB. Done when: a filled honeypot returns the normal success screen but writes no row, a sub-2-second submission is rejected, and the sixth signup from one IP in an hour is refused while a seventh from a different IP succeeds. ### Phase 4 · Public surface Build: the drop-in snippet · one block of HTML anyone can paste into any landing page, posting to your endpoint, with no script tag required. Then the confirmation screen: the signer's position, their personal referral link, a copy button, and share links for X, WhatsApp and email. Style it to inherit the host page's font rather than imposing one. Done when: the snippet works pasted into a blank HTML file on another origin (add the CORS header the form post needs), the confirmation shows the correct position, and the copy button puts a working link on the clipboard. ### Phase 5 · Admin Build: `/admin` behind basic auth from `.env` · total count, signups-per-day bar chart as inline SVG (no chart library), the top referrers table, a searchable list with delete per row, and CSV export streamed rather than buffered. Done when: the chart matches a `GROUP BY date` query, export opens correctly in a spreadsheet with 10,000 rows, and deleting a referrer leaves their referees' rows intact rather than orphaning a foreign key. ### Phase 6 · Deploy and document Build: a `/healthz` endpoint, a systemd unit, a nightly SQLite backup command, and the README. Done when: a reader goes from clone to a live embedded form on their own landing page using only the README. ### Out of scope (and why) - Sending email · no welcome mail, no blasts, no confirmation link in v1. Email delivery is a whole product (domain auth, SPF/DKIM, reputation) and bolting a half version onto this is how you end up in spam folders. Export the CSV into a real sender when you are ready, and say so in the README. - Their referral-widget template gallery. - Spam filtering you did not tune yourself · the honeypot and rate limits here stop bots, not a determined human. ### README must contain - The paste-in snippet, verbatim and ready to copy. - The exact referral maths, so the numbers on the confirmation screen are explainable to a user who asks why they moved. - A line stating no email is ever sent by this system. ===== AGENTS.md ===== # Agent instructions · GetWaitlist indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, node:http and node:sqlite, SQLite in WAL mode, A plain HTML form snippet, One small VPS behind Caddy. 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 - Wrap the insert in a transaction. Without it a fast double click can mint two referral codes for one person. ===== BUILD_PLAN.md ===== # Build plan · GetWaitlist A waitlist you own: one HTML snippet pasted into any landing page posts emails to your server, each signup gets a position and a personal referral link that moves them up the queue, bots are turned away, and an admin page shows growth and exports CSV. No third-party service holds your list. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Signup and storage Accept an email, normalise it, store it once, and treat a repeat signup as a lost tab rather than an attack. ### Steps 1. Create the project and the signups table signups (id, email unique, created_at, referral_code unique, referred_by, confirmed, ip_hash, user_agent). Emails stored lowercased and trimmed. Files: `server.mjs`, `db.mjs` ```sh mkdir waitlist && cd waitlist && git init && npm init -y && npm pkg set type=module mkdir data && cp .env.example .env ``` 2. Implement POST /api/waitlist Accept form-encoded and JSON. Normalise, validate with one conservative regex and a 254-character cap, reject domains in data/disposable.txt. 3. Make duplicates a friendly no-op Insert inside a transaction. On a unique conflict, return the existing row's position rather than an error, because a double submit is a user who lost the tab. 4. Mint the referral code on insert Eight characters from an alphabet without 0/O/1/l/I, from crypto.randomBytes, retried on collision. ### Done when - [ ] The same email posted twice yields one row and two identical responses - [ ] A malformed address is rejected with a readable message - [ ] An address at a disposable domain is rejected - [ ] Two concurrent posts of one new email still leave exactly one row ### Watch out - Wrap the insert in a transaction. Without it a fast double click can mint two referral codes for one person. ## Phase 2 · Referral mechanics Referrals move people up the queue by exactly REFERRAL_BOOST places each, computed in one query so it stays fast at ten thousand signups. ### Steps 1. Resolve ?ref=CODE on signup Set referred_by only if the code exists, is not the signer's own, and the pair is not already recorded. Unknown or self codes are ignored silently. 2. Compute position in SQL position = rank by created_at minus REFERRAL_BOOST times that signup's confirmed referrals, floored at 1. One query with a window function or a correlated count. Never a loop in application code. 3. Return position and referral link from the signup endpoint The link is SITE_URL plus ?ref=CODE. Also expose GET /api/position?code= so a returning signer can check. ### Done when - [ ] Three people signing up through one link move that person up exactly 15 places (with the default boost) - [ ] A self-referral is ignored - [ ] An unknown ref code is ignored without an error - [ ] Positions are unique and gap-free when nobody has referred anyone ## Phase 3 · Abuse controls Stop the bots a public form attracts without ever refusing a real person. ### Steps 1. Add a CSS-hidden honeypot field A text input named website, hidden with CSS (not type=hidden). Filled means: return the normal success screen, store nothing. 2. Require a minimum fill time A signed timestamp in a hidden field; submissions under 2 seconds old are rejected. Sign it with IP_SALT so it cannot be forged. 3. Rate limit per IP in SQLite 5 per hour and 20 per day keyed by ip_hash, stored in a table so it survives a restart. 4. Cap the body and check the Origin header 4 KB body cap. Refuse any Origin not in ALLOWED_ORIGINS with 403. ### Done when - [ ] A filled honeypot returns success and writes no row - [ ] A sub-2-second submission is rejected - [ ] The sixth signup from one IP in an hour is refused while a different IP succeeds - [ ] A post from an origin not in the allowlist gets 403 ## Phase 4 · Public surface The paste-in snippet and the confirmation screen with a working referral link. ### Steps 1. Write the snippet One block of HTML: a form posting to SITE_URL/api/waitlist with email, the honeypot and the timestamp field. No script tag required. Styled with inherit so it takes the host page's font. 2. Add the CORS headers the cross-origin post needs Access-Control-Allow-Origin for origins in ALLOWED_ORIGINS, and handle the OPTIONS preflight. 3. Build the confirmation page Position, the personal referral link with a copy button, and share links for X, WhatsApp and email with the link pre-filled. ### Done when - [ ] The snippet works pasted into a blank HTML file served on another port - [ ] The confirmation shows the correct position - [ ] The copy button puts a working link on the clipboard - [ ] Everything works with JavaScript disabled except the copy button ## Phase 5 · Admin See growth, find people, export, delete. ### Steps 1. Basic-auth /admin with the total and a signups-per-day bar chart as inline SVG The chart is a GROUP BY on the date of created_at; no chart library. 2. Add the top-referrers table and a searchable list with delete Deleting a referrer must leave their referees' rows intact: null out referred_by rather than cascading. 3. Stream a CSV export Write rows as you read them; do not build the whole file in memory. ### Done when - [ ] The chart matches a GROUP BY date query - [ ] A 10,000-row export opens in a spreadsheet and the process memory does not grow - [ ] Deleting a referrer leaves the people they referred in place ## Phase 6 · Deploy and document Live on your domain, embedded on your real landing page, documented. ### Steps 1. Install on the VPS with systemd and Caddy Files: `deploy/waitlist.service`, `Caddyfile` ```sh sudo cp deploy/waitlist.service /etc/systemd/system/ && sudo systemctl enable --now waitlist ``` 2. Add /healthz and a nightly SQLite backup command ```sh sqlite3 data/waitlist.db ".backup '/tmp/waitlist-$(date +%F).db'" ``` 3. Paste the snippet into the real landing page and write the README README: the snippet verbatim, the exact referral maths, the ALLOWED_ORIGINS step, and one line stating no email is ever sent by this system. Files: `README.md` ### Done when - [ ] A signup from the real landing page appears in /admin - [ ] A reader goes from clone to a live embedded form using only the README - [ ] The README states that no email is sent ## Not in this build - Sending email: no welcome mail, no blasts, no confirmation link. Deliverability is a whole product; export the CSV into a real sender. - Their referral-widget template gallery. - Spam filtering beyond a honeypot and rate limits: this stops bots, not a determined human. ## After v1, if you want it - Double opt-in via a transactional email provider, behind an interface so the sender is swappable - A public counter widget showing total signups ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port; Caddy proxies to it. PORT=3000 # Required. SQLite file. Back it up; it is the list. DATABASE_PATH=./data/waitlist.db # Required · secret. openssl rand -hex 32, once. IP_SALT=hex-from-openssl-rand # Required. Public base URL, used in referral links. SITE_URL=https://join.yourdomain.com # Required. Comma-separated origins allowed to post the form. Anything else is refused. ALLOWED_ORIGINS=https://yoursite.com,https://www.yoursite.com # Optional. Positions gained per confirmed referral. REFERRAL_BOOST=5 # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string
You are building a lean indie version of GetWaitlist. 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 ===== # GetWaitlist · indie build A waitlist you own: one HTML snippet pasted into any landing page posts emails to your server, each signup gets a position and a personal referral link that moves them up the queue, bots are turned away, and an admin page shows growth and exports CSV. No third-party service holds your list. Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, node:http and node:sqlite | a signup is one INSERT; a framework would outweigh the app | | Database | SQLite in WAL mode | one file, transactional, easy to back up and export | | Front end | A plain HTML form snippet | works pasted into any page with JavaScript disabled | | Hosting | One small VPS behind Caddy | the snippet needs a public HTTPS endpoint | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Everything in this build runs on it: the server, the scripts, the tests. - Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards. - Verify: node --version prints v22 or higher - [ ] **A terminal and a code editor** · free - Why: Every step below is a command you type or a file you edit. - Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal. - Verify: You can open a folder and run a command in its terminal - [ ] **Git** · free - Why: History for your code, and the way most hosts deploy. - Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists. - Verify: git --version prints a version - [ ] **The landing page you will paste the form into** · free - Why: Phase 4 is tested by pasting the snippet into a real page on a different origin, so you need one to paste into. - Get it: Any page you control: your site, a Carrd, a Notion page with an embed, or a blank HTML file served locally is enough to start. - [ ] **A disposable-email domain list** · free - Why: Phase 1 rejects throwaway addresses. A maintained list saves you writing one. - Get it: Download the domains file from the disposable-email-domains project on GitHub into your repo as data/disposable.txt. - [ ] **A random salt for hashing IPs** · free - Why: You store a salted hash of the signer's IP for rate limiting, never the raw address. - Get it: Generate once with openssl rand -hex 32 and put it in .env as IP_SALT. Changing it later resets the rate limiter, nothing else. - [ ] **A small always-on server (VPS)** (optional) · about $5 a month - Why: This needs one process running all the time with a public address. - Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first. - [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain - Why: The snippet posts to this address from every page it lives on. - Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase. - [ ] **Caddy on the server** (optional) · free - Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work. - Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config. - Verify: caddy version prints a version on the server ## Quick start ```sh mkdir waitlist && cd waitlist && git init && npm init -y && npm pkg set type=module mkdir data && cp .env.example .env ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Sending email: no welcome mail, no blasts, no confirmation link. Deliverability is a whole product; export the CSV into a real sender. - Their referral-widget template gallery. - Spam filtering beyond a honeypot and rate limits: this stops bots, not a determined human. - their referral-widget templates - built-in email blasts to the list - spam filtering you didn't tune yourself If one of those is essential to you, that is the reason to keep paying for GetWaitlist, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · GetWaitlist 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 waitlist signup system like GetWaitlist. Build it in phases, in the order below. Do not write the whole system in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Node 22 with `node:http` and `node:sqlite`, or Python 3.12 with stdlib `http.server` and `sqlite3`. Pick one, no web framework. - Server-rendered HTML. No React, no client framework. The embed snippet is a plain HTML form that works with JavaScript disabled. - SQLite file at a path from `.env`. ### Data model (create this before Phase 1) - `signups`: id, email (unique, stored lowercased and trimmed), created_at, referral_code (unique short slug), referred_by (nullable referral_code), confirmed (bool), ip_hash, user_agent - `referral_code` is 8 characters from an unambiguous alphabet (no 0/O/1/l/I), generated with a CSPRNG, retried on collision. Store `ip_hash` as a salted hash, never the raw IP · you are collecting emails from strangers and the raw address buys you nothing you need. ### Phase 1 · Signup and storage Build: `POST /api/waitlist` accepting `email`. Normalize (trim, lowercase), validate with a single conservative regex plus a length cap of 254, reject disposable-domain matches from a small local list. Dedupe on email · a repeat signup returns the existing position rather than an error, because a duplicate submit is a user who lost the tab, not an attack. Wrap the insert in a transaction so a race cannot mint two codes for one email. Done when: the same email posted twice yields one row and two identical responses, a malformed address is rejected with a readable message, and a concurrent double-submit still leaves exactly one row. Do not build yet: referrals, positions, UI, admin. ### Phase 2 · Referral mechanics Build: on signup, mint the referral_code. When the request carries `?ref=CODE`, resolve it and set `referred_by` · only if the code exists, is not the signer's own, and the pair is not already recorded. Then implement position: `position = base_position - (5 * confirmed_referrals)`, floored at 1 where `base_position` is the signup's rank by created_at. Compute it in one SQL query, not in application code looping over rows · at 10,000 signups the loop is the bug that takes the page down. Done when: three people signing up through one person's link move that person up exactly 15 places, a self-referral is ignored, an unknown ref code is ignored without erroring, and positions are unique and gap-free when nobody has referred. Do not build yet: the confirmation screen. ### Phase 3 · Abuse controls Build: a honeypot field hidden with CSS (never `type=hidden`), a minimum fill time of 2 seconds carried as a signed timestamp, and a per-IP rate limit of 5 signups per hour and 20 per day held in SQLite so it survives a restart. Cap the request body at 4KB. Done when: a filled honeypot returns the normal success screen but writes no row, a sub-2-second submission is rejected, and the sixth signup from one IP in an hour is refused while a seventh from a different IP succeeds. ### Phase 4 · Public surface Build: the drop-in snippet · one block of HTML anyone can paste into any landing page, posting to your endpoint, with no script tag required. Then the confirmation screen: the signer's position, their personal referral link, a copy button, and share links for X, WhatsApp and email. Style it to inherit the host page's font rather than imposing one. Done when: the snippet works pasted into a blank HTML file on another origin (add the CORS header the form post needs), the confirmation shows the correct position, and the copy button puts a working link on the clipboard. ### Phase 5 · Admin Build: `/admin` behind basic auth from `.env` · total count, signups-per-day bar chart as inline SVG (no chart library), the top referrers table, a searchable list with delete per row, and CSV export streamed rather than buffered. Done when: the chart matches a `GROUP BY date` query, export opens correctly in a spreadsheet with 10,000 rows, and deleting a referrer leaves their referees' rows intact rather than orphaning a foreign key. ### Phase 6 · Deploy and document Build: a `/healthz` endpoint, a systemd unit, a nightly SQLite backup command, and the README. Done when: a reader goes from clone to a live embedded form on their own landing page using only the README. ### Out of scope (and why) - Sending email · no welcome mail, no blasts, no confirmation link in v1. Email delivery is a whole product (domain auth, SPF/DKIM, reputation) and bolting a half version onto this is how you end up in spam folders. Export the CSV into a real sender when you are ready, and say so in the README. - Their referral-widget template gallery. - Spam filtering you did not tune yourself · the honeypot and rate limits here stop bots, not a determined human. ### README must contain - The paste-in snippet, verbatim and ready to copy. - The exact referral maths, so the numbers on the confirmation screen are explainable to a user who asks why they moved. - A line stating no email is ever sent by this system. ===== AGENTS.md ===== # Agent instructions · GetWaitlist indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, node:http and node:sqlite, SQLite in WAL mode, A plain HTML form snippet, One small VPS behind Caddy. 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 - Wrap the insert in a transaction. Without it a fast double click can mint two referral codes for one person. ===== BUILD_PLAN.md ===== # Build plan · GetWaitlist A waitlist you own: one HTML snippet pasted into any landing page posts emails to your server, each signup gets a position and a personal referral link that moves them up the queue, bots are turned away, and an admin page shows growth and exports CSV. No third-party service holds your list. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Signup and storage Accept an email, normalise it, store it once, and treat a repeat signup as a lost tab rather than an attack. ### Steps 1. Create the project and the signups table signups (id, email unique, created_at, referral_code unique, referred_by, confirmed, ip_hash, user_agent). Emails stored lowercased and trimmed. Files: `server.mjs`, `db.mjs` ```sh mkdir waitlist && cd waitlist && git init && npm init -y && npm pkg set type=module mkdir data && cp .env.example .env ``` 2. Implement POST /api/waitlist Accept form-encoded and JSON. Normalise, validate with one conservative regex and a 254-character cap, reject domains in data/disposable.txt. 3. Make duplicates a friendly no-op Insert inside a transaction. On a unique conflict, return the existing row's position rather than an error, because a double submit is a user who lost the tab. 4. Mint the referral code on insert Eight characters from an alphabet without 0/O/1/l/I, from crypto.randomBytes, retried on collision. ### Done when - [ ] The same email posted twice yields one row and two identical responses - [ ] A malformed address is rejected with a readable message - [ ] An address at a disposable domain is rejected - [ ] Two concurrent posts of one new email still leave exactly one row ### Watch out - Wrap the insert in a transaction. Without it a fast double click can mint two referral codes for one person. ## Phase 2 · Referral mechanics Referrals move people up the queue by exactly REFERRAL_BOOST places each, computed in one query so it stays fast at ten thousand signups. ### Steps 1. Resolve ?ref=CODE on signup Set referred_by only if the code exists, is not the signer's own, and the pair is not already recorded. Unknown or self codes are ignored silently. 2. Compute position in SQL position = rank by created_at minus REFERRAL_BOOST times that signup's confirmed referrals, floored at 1. One query with a window function or a correlated count. Never a loop in application code. 3. Return position and referral link from the signup endpoint The link is SITE_URL plus ?ref=CODE. Also expose GET /api/position?code= so a returning signer can check. ### Done when - [ ] Three people signing up through one link move that person up exactly 15 places (with the default boost) - [ ] A self-referral is ignored - [ ] An unknown ref code is ignored without an error - [ ] Positions are unique and gap-free when nobody has referred anyone ## Phase 3 · Abuse controls Stop the bots a public form attracts without ever refusing a real person. ### Steps 1. Add a CSS-hidden honeypot field A text input named website, hidden with CSS (not type=hidden). Filled means: return the normal success screen, store nothing. 2. Require a minimum fill time A signed timestamp in a hidden field; submissions under 2 seconds old are rejected. Sign it with IP_SALT so it cannot be forged. 3. Rate limit per IP in SQLite 5 per hour and 20 per day keyed by ip_hash, stored in a table so it survives a restart. 4. Cap the body and check the Origin header 4 KB body cap. Refuse any Origin not in ALLOWED_ORIGINS with 403. ### Done when - [ ] A filled honeypot returns success and writes no row - [ ] A sub-2-second submission is rejected - [ ] The sixth signup from one IP in an hour is refused while a different IP succeeds - [ ] A post from an origin not in the allowlist gets 403 ## Phase 4 · Public surface The paste-in snippet and the confirmation screen with a working referral link. ### Steps 1. Write the snippet One block of HTML: a form posting to SITE_URL/api/waitlist with email, the honeypot and the timestamp field. No script tag required. Styled with inherit so it takes the host page's font. 2. Add the CORS headers the cross-origin post needs Access-Control-Allow-Origin for origins in ALLOWED_ORIGINS, and handle the OPTIONS preflight. 3. Build the confirmation page Position, the personal referral link with a copy button, and share links for X, WhatsApp and email with the link pre-filled. ### Done when - [ ] The snippet works pasted into a blank HTML file served on another port - [ ] The confirmation shows the correct position - [ ] The copy button puts a working link on the clipboard - [ ] Everything works with JavaScript disabled except the copy button ## Phase 5 · Admin See growth, find people, export, delete. ### Steps 1. Basic-auth /admin with the total and a signups-per-day bar chart as inline SVG The chart is a GROUP BY on the date of created_at; no chart library. 2. Add the top-referrers table and a searchable list with delete Deleting a referrer must leave their referees' rows intact: null out referred_by rather than cascading. 3. Stream a CSV export Write rows as you read them; do not build the whole file in memory. ### Done when - [ ] The chart matches a GROUP BY date query - [ ] A 10,000-row export opens in a spreadsheet and the process memory does not grow - [ ] Deleting a referrer leaves the people they referred in place ## Phase 6 · Deploy and document Live on your domain, embedded on your real landing page, documented. ### Steps 1. Install on the VPS with systemd and Caddy Files: `deploy/waitlist.service`, `Caddyfile` ```sh sudo cp deploy/waitlist.service /etc/systemd/system/ && sudo systemctl enable --now waitlist ``` 2. Add /healthz and a nightly SQLite backup command ```sh sqlite3 data/waitlist.db ".backup '/tmp/waitlist-$(date +%F).db'" ``` 3. Paste the snippet into the real landing page and write the README README: the snippet verbatim, the exact referral maths, the ALLOWED_ORIGINS step, and one line stating no email is ever sent by this system. Files: `README.md` ### Done when - [ ] A signup from the real landing page appears in /admin - [ ] A reader goes from clone to a live embedded form using only the README - [ ] The README states that no email is sent ## Not in this build - Sending email: no welcome mail, no blasts, no confirmation link. Deliverability is a whole product; export the CSV into a real sender. - Their referral-widget template gallery. - Spam filtering beyond a honeypot and rate limits: this stops bots, not a determined human. ## After v1, if you want it - Double opt-in via a transactional email provider, behind an interface so the sender is swappable - A public counter widget showing total signups ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port; Caddy proxies to it. PORT=3000 # Required. SQLite file. Back it up; it is the list. DATABASE_PATH=./data/waitlist.db # Required · secret. openssl rand -hex 32, once. IP_SALT=hex-from-openssl-rand # Required. Public base URL, used in referral links. SITE_URL=https://join.yourdomain.com # Required. Comma-separated origins allowed to post the form. Anything else is refused. ALLOWED_ORIGINS=https://yoursite.com,https://www.yoursite.com # Optional. Positions gained per confirmed referral. REFERRAL_BOOST=5 # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string
You are building a production product version of GetWaitlist. 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 ===== # GetWaitlist · product brief ## Problem Email capture with a referral counter. This site's waitlist was built exactly this way; it took one prompt. ## Product outcome A waitlist service you could run for several launches: one endpoint per origin list, referral maths that holds at scale, and a list you can export into any sender. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Implement the core workflow described in ARCHITECTURE.md ## Explicit non-goals for v1 - Sending email: no welcome mail, no blasts, no confirmation link. Deliverability is a whole product; export the CSV into a real sender. - Their referral-widget template gallery. - Spam filtering beyond a honeypot and rate limits: this stops bots, not a determined human. - their referral-widget templates - built-in email blasts to the list - spam filtering you didn't tune yourself ## Success criteria - A clean clone reaches an embedded form using only the README - Referral positions verified against a hand calculation on a seeded fixture of 1,000 signups - One restore drill performed and dated - The CSV imports cleanly into the email tool you intend to use ===== BRIEF.md ===== # Build brief · GetWaitlist 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 waitlist signup system like GetWaitlist. Build it in phases, in the order below. Do not write the whole system in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Node 22 with `node:http` and `node:sqlite`, or Python 3.12 with stdlib `http.server` and `sqlite3`. Pick one, no web framework. - Server-rendered HTML. No React, no client framework. The embed snippet is a plain HTML form that works with JavaScript disabled. - SQLite file at a path from `.env`. ### Data model (create this before Phase 1) - `signups`: id, email (unique, stored lowercased and trimmed), created_at, referral_code (unique short slug), referred_by (nullable referral_code), confirmed (bool), ip_hash, user_agent - `referral_code` is 8 characters from an unambiguous alphabet (no 0/O/1/l/I), generated with a CSPRNG, retried on collision. Store `ip_hash` as a salted hash, never the raw IP · you are collecting emails from strangers and the raw address buys you nothing you need. ### Phase 1 · Signup and storage Build: `POST /api/waitlist` accepting `email`. Normalize (trim, lowercase), validate with a single conservative regex plus a length cap of 254, reject disposable-domain matches from a small local list. Dedupe on email · a repeat signup returns the existing position rather than an error, because a duplicate submit is a user who lost the tab, not an attack. Wrap the insert in a transaction so a race cannot mint two codes for one email. Done when: the same email posted twice yields one row and two identical responses, a malformed address is rejected with a readable message, and a concurrent double-submit still leaves exactly one row. Do not build yet: referrals, positions, UI, admin. ### Phase 2 · Referral mechanics Build: on signup, mint the referral_code. When the request carries `?ref=CODE`, resolve it and set `referred_by` · only if the code exists, is not the signer's own, and the pair is not already recorded. Then implement position: `position = base_position - (5 * confirmed_referrals)`, floored at 1 where `base_position` is the signup's rank by created_at. Compute it in one SQL query, not in application code looping over rows · at 10,000 signups the loop is the bug that takes the page down. Done when: three people signing up through one person's link move that person up exactly 15 places, a self-referral is ignored, an unknown ref code is ignored without erroring, and positions are unique and gap-free when nobody has referred. Do not build yet: the confirmation screen. ### Phase 3 · Abuse controls Build: a honeypot field hidden with CSS (never `type=hidden`), a minimum fill time of 2 seconds carried as a signed timestamp, and a per-IP rate limit of 5 signups per hour and 20 per day held in SQLite so it survives a restart. Cap the request body at 4KB. Done when: a filled honeypot returns the normal success screen but writes no row, a sub-2-second submission is rejected, and the sixth signup from one IP in an hour is refused while a seventh from a different IP succeeds. ### Phase 4 · Public surface Build: the drop-in snippet · one block of HTML anyone can paste into any landing page, posting to your endpoint, with no script tag required. Then the confirmation screen: the signer's position, their personal referral link, a copy button, and share links for X, WhatsApp and email. Style it to inherit the host page's font rather than imposing one. Done when: the snippet works pasted into a blank HTML file on another origin (add the CORS header the form post needs), the confirmation shows the correct position, and the copy button puts a working link on the clipboard. ### Phase 5 · Admin Build: `/admin` behind basic auth from `.env` · total count, signups-per-day bar chart as inline SVG (no chart library), the top referrers table, a searchable list with delete per row, and CSV export streamed rather than buffered. Done when: the chart matches a `GROUP BY date` query, export opens correctly in a spreadsheet with 10,000 rows, and deleting a referrer leaves their referees' rows intact rather than orphaning a foreign key. ### Phase 6 · Deploy and document Build: a `/healthz` endpoint, a systemd unit, a nightly SQLite backup command, and the README. Done when: a reader goes from clone to a live embedded form on their own landing page using only the README. ### Out of scope (and why) - Sending email · no welcome mail, no blasts, no confirmation link in v1. Email delivery is a whole product (domain auth, SPF/DKIM, reputation) and bolting a half version onto this is how you end up in spam folders. Export the CSV into a real sender when you are ready, and say so in the README. - Their referral-widget template gallery. - Spam filtering you did not tune yourself · the honeypot and rate limits here stop bots, not a determined human. ### README must contain - The paste-in snippet, verbatim and ready to copy. - The exact referral maths, so the numbers on the confirmation screen are explainable to a user who asks why they moved. - A line stating no email is ever sent by this system. ===== ARCHITECTURE.md ===== # Architecture · GetWaitlist ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, node:http and node:sqlite | a signup is one INSERT; a framework would outweigh the app | | Database | SQLite in WAL mode | one file, transactional, easy to back up and export | | Front end | A plain HTML form snippet | works pasted into any page with JavaScript disabled | | Hosting | One small VPS behind Caddy | the snippet needs a public HTTPS endpoint | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Intake | validation, dedupe, honeypot, rate limits | Any handler producing the same signups rows | | Ranking | the position query | One SQL statement; change the boost or the rule here only | | Embed | the snippet, CORS and the confirmation page | A JavaScript widget could replace the plain form without touching intake | | Admin | chart, search, export, delete | Any UI over the same table | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `PORT` · required · Any free port; Caddy proxies to it. - `DATABASE_PATH` · required · SQLite file. Back it up; it is the list. - `IP_SALT` · required, secret · openssl rand -hex 32, once. - `SITE_URL` · required · Public base URL, used in referral links. - `ALLOWED_ORIGINS` · required · Comma-separated origins allowed to post the form. Anything else is refused. - `REFERRAL_BOOST` · optional · Positions gained per confirmed referral. - `ADMIN_USER` · required · Any username for the basic-auth admin pages. - `ADMIN_PASS` · required, secret · Generate one: openssl rand -base64 24. Never reuse a real password. ## 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 · GetWaitlist product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22, node:http and node:sqlite, SQLite in WAL mode, A plain HTML form snippet, One small VPS behind Caddy. - 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 - Wrap the insert in a transaction. Without it a fast double click can mint two referral codes for one person. ===== MILESTONES.md ===== # Delivery milestones · GetWaitlist Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Signup and storage Accept an email, normalise it, store it once, and treat a repeat signup as a lost tab rather than an attack. ### Steps 1. Create the project and the signups table signups (id, email unique, created_at, referral_code unique, referred_by, confirmed, ip_hash, user_agent). Emails stored lowercased and trimmed. Files: `server.mjs`, `db.mjs` ```sh mkdir waitlist && cd waitlist && git init && npm init -y && npm pkg set type=module mkdir data && cp .env.example .env ``` 2. Implement POST /api/waitlist Accept form-encoded and JSON. Normalise, validate with one conservative regex and a 254-character cap, reject domains in data/disposable.txt. 3. Make duplicates a friendly no-op Insert inside a transaction. On a unique conflict, return the existing row's position rather than an error, because a double submit is a user who lost the tab. 4. Mint the referral code on insert Eight characters from an alphabet without 0/O/1/l/I, from crypto.randomBytes, retried on collision. ### Done when - [ ] The same email posted twice yields one row and two identical responses - [ ] A malformed address is rejected with a readable message - [ ] An address at a disposable domain is rejected - [ ] Two concurrent posts of one new email still leave exactly one row ### Watch out - Wrap the insert in a transaction. Without it a fast double click can mint two referral codes for one person. ## M2 · Referral mechanics Referrals move people up the queue by exactly REFERRAL_BOOST places each, computed in one query so it stays fast at ten thousand signups. ### Steps 1. Resolve ?ref=CODE on signup Set referred_by only if the code exists, is not the signer's own, and the pair is not already recorded. Unknown or self codes are ignored silently. 2. Compute position in SQL position = rank by created_at minus REFERRAL_BOOST times that signup's confirmed referrals, floored at 1. One query with a window function or a correlated count. Never a loop in application code. 3. Return position and referral link from the signup endpoint The link is SITE_URL plus ?ref=CODE. Also expose GET /api/position?code= so a returning signer can check. ### Done when - [ ] Three people signing up through one link move that person up exactly 15 places (with the default boost) - [ ] A self-referral is ignored - [ ] An unknown ref code is ignored without an error - [ ] Positions are unique and gap-free when nobody has referred anyone ## M3 · Abuse controls Stop the bots a public form attracts without ever refusing a real person. ### Steps 1. Add a CSS-hidden honeypot field A text input named website, hidden with CSS (not type=hidden). Filled means: return the normal success screen, store nothing. 2. Require a minimum fill time A signed timestamp in a hidden field; submissions under 2 seconds old are rejected. Sign it with IP_SALT so it cannot be forged. 3. Rate limit per IP in SQLite 5 per hour and 20 per day keyed by ip_hash, stored in a table so it survives a restart. 4. Cap the body and check the Origin header 4 KB body cap. Refuse any Origin not in ALLOWED_ORIGINS with 403. ### Done when - [ ] A filled honeypot returns success and writes no row - [ ] A sub-2-second submission is rejected - [ ] The sixth signup from one IP in an hour is refused while a different IP succeeds - [ ] A post from an origin not in the allowlist gets 403 ## M4 · Public surface The paste-in snippet and the confirmation screen with a working referral link. ### Steps 1. Write the snippet One block of HTML: a form posting to SITE_URL/api/waitlist with email, the honeypot and the timestamp field. No script tag required. Styled with inherit so it takes the host page's font. 2. Add the CORS headers the cross-origin post needs Access-Control-Allow-Origin for origins in ALLOWED_ORIGINS, and handle the OPTIONS preflight. 3. Build the confirmation page Position, the personal referral link with a copy button, and share links for X, WhatsApp and email with the link pre-filled. ### Done when - [ ] The snippet works pasted into a blank HTML file served on another port - [ ] The confirmation shows the correct position - [ ] The copy button puts a working link on the clipboard - [ ] Everything works with JavaScript disabled except the copy button ## M5 · Admin See growth, find people, export, delete. ### Steps 1. Basic-auth /admin with the total and a signups-per-day bar chart as inline SVG The chart is a GROUP BY on the date of created_at; no chart library. 2. Add the top-referrers table and a searchable list with delete Deleting a referrer must leave their referees' rows intact: null out referred_by rather than cascading. 3. Stream a CSV export Write rows as you read them; do not build the whole file in memory. ### Done when - [ ] The chart matches a GROUP BY date query - [ ] A 10,000-row export opens in a spreadsheet and the process memory does not grow - [ ] Deleting a referrer leaves the people they referred in place ## M6 · Deploy and document Live on your domain, embedded on your real landing page, documented. ### Steps 1. Install on the VPS with systemd and Caddy Files: `deploy/waitlist.service`, `Caddyfile` ```sh sudo cp deploy/waitlist.service /etc/systemd/system/ && sudo systemctl enable --now waitlist ``` 2. Add /healthz and a nightly SQLite backup command ```sh sqlite3 data/waitlist.db ".backup '/tmp/waitlist-$(date +%F).db'" ``` 3. Paste the snippet into the real landing page and write the README README: the snippet verbatim, the exact referral maths, the ALLOWED_ORIGINS step, and one line stating no email is ever sent by this system. Files: `README.md` ### Done when - [ ] A signup from the real landing page appears in /admin - [ ] A reader goes from clone to a live embedded form using only the README - [ ] The README states that no email is sent ## M7 · Operate it like a product (production only) Only for the product-builder path: know when the signup endpoint is down, never lose the database, and keep the server patched. ### Steps 1. Add a /healthz endpoint and an external uptime check against it Answer 200 with the build id and a quick database read. Point a free uptime monitor (or your own, from the Healthchecks entry on this site) at it so an outage is noticed before a user notices. 2. Write structured request logs and rotate them One JSON line per request: method, path, status, duration, no raw IPs. Rotate weekly with logrotate, keep eight. 3. Back the SQLite file up off the machine nightly and test a restore SQLite's .backup command makes a consistent copy while the app runs. Copy it to object storage or a second machine; then, once, restore it into a fresh checkout and confirm the app reads it. ```sh sqlite3 data/app.db ".backup '/tmp/app-$(date +%F).db'" rclone copy /tmp/app-$(date +%F).db remote:backups/ ``` 4. Lock the box down Firewall allowing only 22, 80 and 443; unattended security updates on; the app running as an unprivileged user under systemd with Restart=on-failure. ### Done when - [ ] Stopping the service triggers an uptime alert within a few minutes - [ ] A restore from last night's backup contains yesterday's data - [ ] A port scan from another machine shows only 22, 80 and 443 ===== OPERATIONS.md ===== # Operations · GetWaitlist ## Backup SQLite .backup nightly, off the box, thirty days kept. The list is the whole asset. ## Restore Copy the backup to DATABASE_PATH, start, confirm the total in /admin matches the last known count. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Uptime check on /healthz; alert if signups per day drops to zero during a campaign (a broken snippet looks exactly like no interest). ## Incident checklist If a bot flood gets through: export, dedupe by ip_hash and timestamp pattern, delete the burst, tighten the rate limit. If IP_SALT leaks, rotate it; it only affects rate limiting. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A clean clone reaches an embedded form using only the README - [ ] Referral positions verified against a hand calculation on a seeded fixture of 1,000 signups - [ ] One restore drill performed and dated - [ ] The CSV imports cleanly into the email tool you intend to use ## Launch constraint Do not market omitted GetWaitlist 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. Any free port; Caddy proxies to it. PORT=3000 # Required. SQLite file. Back it up; it is the list. DATABASE_PATH=./data/waitlist.db # Required · secret. openssl rand -hex 32, once. IP_SALT=hex-from-openssl-rand # Required. Public base URL, used in referral links. SITE_URL=https://join.yourdomain.com # Required. Comma-separated origins allowed to post the form. Anything else is refused. ALLOWED_ORIGINS=https://yoursite.com,https://www.yoursite.com # Optional. Positions gained per confirmed referral. REFERRAL_BOOST=5 # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string
# GetWaitlist · indie build A waitlist you own: one HTML snippet pasted into any landing page posts emails to your server, each signup gets a position and a personal referral link that moves them up the queue, bots are turned away, and an admin page shows growth and exports CSV. No third-party service holds your list. Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, node:http and node:sqlite | a signup is one INSERT; a framework would outweigh the app | | Database | SQLite in WAL mode | one file, transactional, easy to back up and export | | Front end | A plain HTML form snippet | works pasted into any page with JavaScript disabled | | Hosting | One small VPS behind Caddy | the snippet needs a public HTTPS endpoint | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Everything in this build runs on it: the server, the scripts, the tests. - Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards. - Verify: node --version prints v22 or higher - [ ] **A terminal and a code editor** · free - Why: Every step below is a command you type or a file you edit. - Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal. - Verify: You can open a folder and run a command in its terminal - [ ] **Git** · free - Why: History for your code, and the way most hosts deploy. - Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists. - Verify: git --version prints a version - [ ] **The landing page you will paste the form into** · free - Why: Phase 4 is tested by pasting the snippet into a real page on a different origin, so you need one to paste into. - Get it: Any page you control: your site, a Carrd, a Notion page with an embed, or a blank HTML file served locally is enough to start. - [ ] **A disposable-email domain list** · free - Why: Phase 1 rejects throwaway addresses. A maintained list saves you writing one. - Get it: Download the domains file from the disposable-email-domains project on GitHub into your repo as data/disposable.txt. - [ ] **A random salt for hashing IPs** · free - Why: You store a salted hash of the signer's IP for rate limiting, never the raw address. - Get it: Generate once with openssl rand -hex 32 and put it in .env as IP_SALT. Changing it later resets the rate limiter, nothing else. - [ ] **A small always-on server (VPS)** (optional) · about $5 a month - Why: This needs one process running all the time with a public address. - Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first. - [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain - Why: The snippet posts to this address from every page it lives on. - Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase. - [ ] **Caddy on the server** (optional) · free - Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work. - Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config. - Verify: caddy version prints a version on the server ## Quick start ```sh mkdir waitlist && cd waitlist && git init && npm init -y && npm pkg set type=module mkdir data && cp .env.example .env ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Sending email: no welcome mail, no blasts, no confirmation link. Deliverability is a whole product; export the CSV into a real sender. - Their referral-widget template gallery. - Spam filtering beyond a honeypot and rate limits: this stops bots, not a determined human. - their referral-widget templates - built-in email blasts to the list - spam filtering you didn't tune yourself If one of those is essential to you, that is the reason to keep paying for GetWaitlist, and the README should say so rather than pretend.
# Build brief · GetWaitlist 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 waitlist signup system like GetWaitlist. Build it in phases, in the order below. Do not write the whole system in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Node 22 with `node:http` and `node:sqlite`, or Python 3.12 with stdlib `http.server` and `sqlite3`. Pick one, no web framework. - Server-rendered HTML. No React, no client framework. The embed snippet is a plain HTML form that works with JavaScript disabled. - SQLite file at a path from `.env`. ### Data model (create this before Phase 1) - `signups`: id, email (unique, stored lowercased and trimmed), created_at, referral_code (unique short slug), referred_by (nullable referral_code), confirmed (bool), ip_hash, user_agent - `referral_code` is 8 characters from an unambiguous alphabet (no 0/O/1/l/I), generated with a CSPRNG, retried on collision. Store `ip_hash` as a salted hash, never the raw IP · you are collecting emails from strangers and the raw address buys you nothing you need. ### Phase 1 · Signup and storage Build: `POST /api/waitlist` accepting `email`. Normalize (trim, lowercase), validate with a single conservative regex plus a length cap of 254, reject disposable-domain matches from a small local list. Dedupe on email · a repeat signup returns the existing position rather than an error, because a duplicate submit is a user who lost the tab, not an attack. Wrap the insert in a transaction so a race cannot mint two codes for one email. Done when: the same email posted twice yields one row and two identical responses, a malformed address is rejected with a readable message, and a concurrent double-submit still leaves exactly one row. Do not build yet: referrals, positions, UI, admin. ### Phase 2 · Referral mechanics Build: on signup, mint the referral_code. When the request carries `?ref=CODE`, resolve it and set `referred_by` · only if the code exists, is not the signer's own, and the pair is not already recorded. Then implement position: `position = base_position - (5 * confirmed_referrals)`, floored at 1 where `base_position` is the signup's rank by created_at. Compute it in one SQL query, not in application code looping over rows · at 10,000 signups the loop is the bug that takes the page down. Done when: three people signing up through one person's link move that person up exactly 15 places, a self-referral is ignored, an unknown ref code is ignored without erroring, and positions are unique and gap-free when nobody has referred. Do not build yet: the confirmation screen. ### Phase 3 · Abuse controls Build: a honeypot field hidden with CSS (never `type=hidden`), a minimum fill time of 2 seconds carried as a signed timestamp, and a per-IP rate limit of 5 signups per hour and 20 per day held in SQLite so it survives a restart. Cap the request body at 4KB. Done when: a filled honeypot returns the normal success screen but writes no row, a sub-2-second submission is rejected, and the sixth signup from one IP in an hour is refused while a seventh from a different IP succeeds. ### Phase 4 · Public surface Build: the drop-in snippet · one block of HTML anyone can paste into any landing page, posting to your endpoint, with no script tag required. Then the confirmation screen: the signer's position, their personal referral link, a copy button, and share links for X, WhatsApp and email. Style it to inherit the host page's font rather than imposing one. Done when: the snippet works pasted into a blank HTML file on another origin (add the CORS header the form post needs), the confirmation shows the correct position, and the copy button puts a working link on the clipboard. ### Phase 5 · Admin Build: `/admin` behind basic auth from `.env` · total count, signups-per-day bar chart as inline SVG (no chart library), the top referrers table, a searchable list with delete per row, and CSV export streamed rather than buffered. Done when: the chart matches a `GROUP BY date` query, export opens correctly in a spreadsheet with 10,000 rows, and deleting a referrer leaves their referees' rows intact rather than orphaning a foreign key. ### Phase 6 · Deploy and document Build: a `/healthz` endpoint, a systemd unit, a nightly SQLite backup command, and the README. Done when: a reader goes from clone to a live embedded form on their own landing page using only the README. ### Out of scope (and why) - Sending email · no welcome mail, no blasts, no confirmation link in v1. Email delivery is a whole product (domain auth, SPF/DKIM, reputation) and bolting a half version onto this is how you end up in spam folders. Export the CSV into a real sender when you are ready, and say so in the README. - Their referral-widget template gallery. - Spam filtering you did not tune yourself · the honeypot and rate limits here stop bots, not a determined human. ### README must contain - The paste-in snippet, verbatim and ready to copy. - The exact referral maths, so the numbers on the confirmation screen are explainable to a user who asks why they moved. - A line stating no email is ever sent by this system.
# Agent instructions · GetWaitlist indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, node:http and node:sqlite, SQLite in WAL mode, A plain HTML form snippet, One small VPS behind Caddy. 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 - Wrap the insert in a transaction. Without it a fast double click can mint two referral codes for one person.
# Build plan · GetWaitlist A waitlist you own: one HTML snippet pasted into any landing page posts emails to your server, each signup gets a position and a personal referral link that moves them up the queue, bots are turned away, and an admin page shows growth and exports CSV. No third-party service holds your list. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Signup and storage Accept an email, normalise it, store it once, and treat a repeat signup as a lost tab rather than an attack. ### Steps 1. Create the project and the signups table signups (id, email unique, created_at, referral_code unique, referred_by, confirmed, ip_hash, user_agent). Emails stored lowercased and trimmed. Files: `server.mjs`, `db.mjs` ```sh mkdir waitlist && cd waitlist && git init && npm init -y && npm pkg set type=module mkdir data && cp .env.example .env ``` 2. Implement POST /api/waitlist Accept form-encoded and JSON. Normalise, validate with one conservative regex and a 254-character cap, reject domains in data/disposable.txt. 3. Make duplicates a friendly no-op Insert inside a transaction. On a unique conflict, return the existing row's position rather than an error, because a double submit is a user who lost the tab. 4. Mint the referral code on insert Eight characters from an alphabet without 0/O/1/l/I, from crypto.randomBytes, retried on collision. ### Done when - [ ] The same email posted twice yields one row and two identical responses - [ ] A malformed address is rejected with a readable message - [ ] An address at a disposable domain is rejected - [ ] Two concurrent posts of one new email still leave exactly one row ### Watch out - Wrap the insert in a transaction. Without it a fast double click can mint two referral codes for one person. ## Phase 2 · Referral mechanics Referrals move people up the queue by exactly REFERRAL_BOOST places each, computed in one query so it stays fast at ten thousand signups. ### Steps 1. Resolve ?ref=CODE on signup Set referred_by only if the code exists, is not the signer's own, and the pair is not already recorded. Unknown or self codes are ignored silently. 2. Compute position in SQL position = rank by created_at minus REFERRAL_BOOST times that signup's confirmed referrals, floored at 1. One query with a window function or a correlated count. Never a loop in application code. 3. Return position and referral link from the signup endpoint The link is SITE_URL plus ?ref=CODE. Also expose GET /api/position?code= so a returning signer can check. ### Done when - [ ] Three people signing up through one link move that person up exactly 15 places (with the default boost) - [ ] A self-referral is ignored - [ ] An unknown ref code is ignored without an error - [ ] Positions are unique and gap-free when nobody has referred anyone ## Phase 3 · Abuse controls Stop the bots a public form attracts without ever refusing a real person. ### Steps 1. Add a CSS-hidden honeypot field A text input named website, hidden with CSS (not type=hidden). Filled means: return the normal success screen, store nothing. 2. Require a minimum fill time A signed timestamp in a hidden field; submissions under 2 seconds old are rejected. Sign it with IP_SALT so it cannot be forged. 3. Rate limit per IP in SQLite 5 per hour and 20 per day keyed by ip_hash, stored in a table so it survives a restart. 4. Cap the body and check the Origin header 4 KB body cap. Refuse any Origin not in ALLOWED_ORIGINS with 403. ### Done when - [ ] A filled honeypot returns success and writes no row - [ ] A sub-2-second submission is rejected - [ ] The sixth signup from one IP in an hour is refused while a different IP succeeds - [ ] A post from an origin not in the allowlist gets 403 ## Phase 4 · Public surface The paste-in snippet and the confirmation screen with a working referral link. ### Steps 1. Write the snippet One block of HTML: a form posting to SITE_URL/api/waitlist with email, the honeypot and the timestamp field. No script tag required. Styled with inherit so it takes the host page's font. 2. Add the CORS headers the cross-origin post needs Access-Control-Allow-Origin for origins in ALLOWED_ORIGINS, and handle the OPTIONS preflight. 3. Build the confirmation page Position, the personal referral link with a copy button, and share links for X, WhatsApp and email with the link pre-filled. ### Done when - [ ] The snippet works pasted into a blank HTML file served on another port - [ ] The confirmation shows the correct position - [ ] The copy button puts a working link on the clipboard - [ ] Everything works with JavaScript disabled except the copy button ## Phase 5 · Admin See growth, find people, export, delete. ### Steps 1. Basic-auth /admin with the total and a signups-per-day bar chart as inline SVG The chart is a GROUP BY on the date of created_at; no chart library. 2. Add the top-referrers table and a searchable list with delete Deleting a referrer must leave their referees' rows intact: null out referred_by rather than cascading. 3. Stream a CSV export Write rows as you read them; do not build the whole file in memory. ### Done when - [ ] The chart matches a GROUP BY date query - [ ] A 10,000-row export opens in a spreadsheet and the process memory does not grow - [ ] Deleting a referrer leaves the people they referred in place ## Phase 6 · Deploy and document Live on your domain, embedded on your real landing page, documented. ### Steps 1. Install on the VPS with systemd and Caddy Files: `deploy/waitlist.service`, `Caddyfile` ```sh sudo cp deploy/waitlist.service /etc/systemd/system/ && sudo systemctl enable --now waitlist ``` 2. Add /healthz and a nightly SQLite backup command ```sh sqlite3 data/waitlist.db ".backup '/tmp/waitlist-$(date +%F).db'" ``` 3. Paste the snippet into the real landing page and write the README README: the snippet verbatim, the exact referral maths, the ALLOWED_ORIGINS step, and one line stating no email is ever sent by this system. Files: `README.md` ### Done when - [ ] A signup from the real landing page appears in /admin - [ ] A reader goes from clone to a live embedded form using only the README - [ ] The README states that no email is sent ## Not in this build - Sending email: no welcome mail, no blasts, no confirmation link. Deliverability is a whole product; export the CSV into a real sender. - Their referral-widget template gallery. - Spam filtering beyond a honeypot and rate limits: this stops bots, not a determined human. ## After v1, if you want it - Double opt-in via a transactional email provider, behind an interface so the sender is swappable - A public counter widget showing total signups
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port; Caddy proxies to it. PORT=3000 # Required. SQLite file. Back it up; it is the list. DATABASE_PATH=./data/waitlist.db # Required · secret. openssl rand -hex 32, once. IP_SALT=hex-from-openssl-rand # Required. Public base URL, used in referral links. SITE_URL=https://join.yourdomain.com # Required. Comma-separated origins allowed to post the form. Anything else is refused. ALLOWED_ORIGINS=https://yoursite.com,https://www.yoursite.com # Optional. Positions gained per confirmed referral. REFERRAL_BOOST=5 # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string
# GetWaitlist · product brief ## Problem Email capture with a referral counter. This site's waitlist was built exactly this way; it took one prompt. ## Product outcome A waitlist service you could run for several launches: one endpoint per origin list, referral maths that holds at scale, and a list you can export into any sender. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Implement the core workflow described in ARCHITECTURE.md ## Explicit non-goals for v1 - Sending email: no welcome mail, no blasts, no confirmation link. Deliverability is a whole product; export the CSV into a real sender. - Their referral-widget template gallery. - Spam filtering beyond a honeypot and rate limits: this stops bots, not a determined human. - their referral-widget templates - built-in email blasts to the list - spam filtering you didn't tune yourself ## Success criteria - A clean clone reaches an embedded form using only the README - Referral positions verified against a hand calculation on a seeded fixture of 1,000 signups - One restore drill performed and dated - The CSV imports cleanly into the email tool you intend to use
# Build brief · GetWaitlist 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 waitlist signup system like GetWaitlist. Build it in phases, in the order below. Do not write the whole system in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Node 22 with `node:http` and `node:sqlite`, or Python 3.12 with stdlib `http.server` and `sqlite3`. Pick one, no web framework. - Server-rendered HTML. No React, no client framework. The embed snippet is a plain HTML form that works with JavaScript disabled. - SQLite file at a path from `.env`. ### Data model (create this before Phase 1) - `signups`: id, email (unique, stored lowercased and trimmed), created_at, referral_code (unique short slug), referred_by (nullable referral_code), confirmed (bool), ip_hash, user_agent - `referral_code` is 8 characters from an unambiguous alphabet (no 0/O/1/l/I), generated with a CSPRNG, retried on collision. Store `ip_hash` as a salted hash, never the raw IP · you are collecting emails from strangers and the raw address buys you nothing you need. ### Phase 1 · Signup and storage Build: `POST /api/waitlist` accepting `email`. Normalize (trim, lowercase), validate with a single conservative regex plus a length cap of 254, reject disposable-domain matches from a small local list. Dedupe on email · a repeat signup returns the existing position rather than an error, because a duplicate submit is a user who lost the tab, not an attack. Wrap the insert in a transaction so a race cannot mint two codes for one email. Done when: the same email posted twice yields one row and two identical responses, a malformed address is rejected with a readable message, and a concurrent double-submit still leaves exactly one row. Do not build yet: referrals, positions, UI, admin. ### Phase 2 · Referral mechanics Build: on signup, mint the referral_code. When the request carries `?ref=CODE`, resolve it and set `referred_by` · only if the code exists, is not the signer's own, and the pair is not already recorded. Then implement position: `position = base_position - (5 * confirmed_referrals)`, floored at 1 where `base_position` is the signup's rank by created_at. Compute it in one SQL query, not in application code looping over rows · at 10,000 signups the loop is the bug that takes the page down. Done when: three people signing up through one person's link move that person up exactly 15 places, a self-referral is ignored, an unknown ref code is ignored without erroring, and positions are unique and gap-free when nobody has referred. Do not build yet: the confirmation screen. ### Phase 3 · Abuse controls Build: a honeypot field hidden with CSS (never `type=hidden`), a minimum fill time of 2 seconds carried as a signed timestamp, and a per-IP rate limit of 5 signups per hour and 20 per day held in SQLite so it survives a restart. Cap the request body at 4KB. Done when: a filled honeypot returns the normal success screen but writes no row, a sub-2-second submission is rejected, and the sixth signup from one IP in an hour is refused while a seventh from a different IP succeeds. ### Phase 4 · Public surface Build: the drop-in snippet · one block of HTML anyone can paste into any landing page, posting to your endpoint, with no script tag required. Then the confirmation screen: the signer's position, their personal referral link, a copy button, and share links for X, WhatsApp and email. Style it to inherit the host page's font rather than imposing one. Done when: the snippet works pasted into a blank HTML file on another origin (add the CORS header the form post needs), the confirmation shows the correct position, and the copy button puts a working link on the clipboard. ### Phase 5 · Admin Build: `/admin` behind basic auth from `.env` · total count, signups-per-day bar chart as inline SVG (no chart library), the top referrers table, a searchable list with delete per row, and CSV export streamed rather than buffered. Done when: the chart matches a `GROUP BY date` query, export opens correctly in a spreadsheet with 10,000 rows, and deleting a referrer leaves their referees' rows intact rather than orphaning a foreign key. ### Phase 6 · Deploy and document Build: a `/healthz` endpoint, a systemd unit, a nightly SQLite backup command, and the README. Done when: a reader goes from clone to a live embedded form on their own landing page using only the README. ### Out of scope (and why) - Sending email · no welcome mail, no blasts, no confirmation link in v1. Email delivery is a whole product (domain auth, SPF/DKIM, reputation) and bolting a half version onto this is how you end up in spam folders. Export the CSV into a real sender when you are ready, and say so in the README. - Their referral-widget template gallery. - Spam filtering you did not tune yourself · the honeypot and rate limits here stop bots, not a determined human. ### README must contain - The paste-in snippet, verbatim and ready to copy. - The exact referral maths, so the numbers on the confirmation screen are explainable to a user who asks why they moved. - A line stating no email is ever sent by this system.
# Architecture · GetWaitlist ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, node:http and node:sqlite | a signup is one INSERT; a framework would outweigh the app | | Database | SQLite in WAL mode | one file, transactional, easy to back up and export | | Front end | A plain HTML form snippet | works pasted into any page with JavaScript disabled | | Hosting | One small VPS behind Caddy | the snippet needs a public HTTPS endpoint | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Intake | validation, dedupe, honeypot, rate limits | Any handler producing the same signups rows | | Ranking | the position query | One SQL statement; change the boost or the rule here only | | Embed | the snippet, CORS and the confirmation page | A JavaScript widget could replace the plain form without touching intake | | Admin | chart, search, export, delete | Any UI over the same table | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `PORT` · required · Any free port; Caddy proxies to it. - `DATABASE_PATH` · required · SQLite file. Back it up; it is the list. - `IP_SALT` · required, secret · openssl rand -hex 32, once. - `SITE_URL` · required · Public base URL, used in referral links. - `ALLOWED_ORIGINS` · required · Comma-separated origins allowed to post the form. Anything else is refused. - `REFERRAL_BOOST` · optional · Positions gained per confirmed referral. - `ADMIN_USER` · required · Any username for the basic-auth admin pages. - `ADMIN_PASS` · required, secret · Generate one: openssl rand -base64 24. Never reuse a real password. ## 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 · GetWaitlist product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22, node:http and node:sqlite, SQLite in WAL mode, A plain HTML form snippet, One small VPS behind Caddy. - 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 - Wrap the insert in a transaction. Without it a fast double click can mint two referral codes for one person.
# Delivery milestones · GetWaitlist Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Signup and storage Accept an email, normalise it, store it once, and treat a repeat signup as a lost tab rather than an attack. ### Steps 1. Create the project and the signups table signups (id, email unique, created_at, referral_code unique, referred_by, confirmed, ip_hash, user_agent). Emails stored lowercased and trimmed. Files: `server.mjs`, `db.mjs` ```sh mkdir waitlist && cd waitlist && git init && npm init -y && npm pkg set type=module mkdir data && cp .env.example .env ``` 2. Implement POST /api/waitlist Accept form-encoded and JSON. Normalise, validate with one conservative regex and a 254-character cap, reject domains in data/disposable.txt. 3. Make duplicates a friendly no-op Insert inside a transaction. On a unique conflict, return the existing row's position rather than an error, because a double submit is a user who lost the tab. 4. Mint the referral code on insert Eight characters from an alphabet without 0/O/1/l/I, from crypto.randomBytes, retried on collision. ### Done when - [ ] The same email posted twice yields one row and two identical responses - [ ] A malformed address is rejected with a readable message - [ ] An address at a disposable domain is rejected - [ ] Two concurrent posts of one new email still leave exactly one row ### Watch out - Wrap the insert in a transaction. Without it a fast double click can mint two referral codes for one person. ## M2 · Referral mechanics Referrals move people up the queue by exactly REFERRAL_BOOST places each, computed in one query so it stays fast at ten thousand signups. ### Steps 1. Resolve ?ref=CODE on signup Set referred_by only if the code exists, is not the signer's own, and the pair is not already recorded. Unknown or self codes are ignored silently. 2. Compute position in SQL position = rank by created_at minus REFERRAL_BOOST times that signup's confirmed referrals, floored at 1. One query with a window function or a correlated count. Never a loop in application code. 3. Return position and referral link from the signup endpoint The link is SITE_URL plus ?ref=CODE. Also expose GET /api/position?code= so a returning signer can check. ### Done when - [ ] Three people signing up through one link move that person up exactly 15 places (with the default boost) - [ ] A self-referral is ignored - [ ] An unknown ref code is ignored without an error - [ ] Positions are unique and gap-free when nobody has referred anyone ## M3 · Abuse controls Stop the bots a public form attracts without ever refusing a real person. ### Steps 1. Add a CSS-hidden honeypot field A text input named website, hidden with CSS (not type=hidden). Filled means: return the normal success screen, store nothing. 2. Require a minimum fill time A signed timestamp in a hidden field; submissions under 2 seconds old are rejected. Sign it with IP_SALT so it cannot be forged. 3. Rate limit per IP in SQLite 5 per hour and 20 per day keyed by ip_hash, stored in a table so it survives a restart. 4. Cap the body and check the Origin header 4 KB body cap. Refuse any Origin not in ALLOWED_ORIGINS with 403. ### Done when - [ ] A filled honeypot returns success and writes no row - [ ] A sub-2-second submission is rejected - [ ] The sixth signup from one IP in an hour is refused while a different IP succeeds - [ ] A post from an origin not in the allowlist gets 403 ## M4 · Public surface The paste-in snippet and the confirmation screen with a working referral link. ### Steps 1. Write the snippet One block of HTML: a form posting to SITE_URL/api/waitlist with email, the honeypot and the timestamp field. No script tag required. Styled with inherit so it takes the host page's font. 2. Add the CORS headers the cross-origin post needs Access-Control-Allow-Origin for origins in ALLOWED_ORIGINS, and handle the OPTIONS preflight. 3. Build the confirmation page Position, the personal referral link with a copy button, and share links for X, WhatsApp and email with the link pre-filled. ### Done when - [ ] The snippet works pasted into a blank HTML file served on another port - [ ] The confirmation shows the correct position - [ ] The copy button puts a working link on the clipboard - [ ] Everything works with JavaScript disabled except the copy button ## M5 · Admin See growth, find people, export, delete. ### Steps 1. Basic-auth /admin with the total and a signups-per-day bar chart as inline SVG The chart is a GROUP BY on the date of created_at; no chart library. 2. Add the top-referrers table and a searchable list with delete Deleting a referrer must leave their referees' rows intact: null out referred_by rather than cascading. 3. Stream a CSV export Write rows as you read them; do not build the whole file in memory. ### Done when - [ ] The chart matches a GROUP BY date query - [ ] A 10,000-row export opens in a spreadsheet and the process memory does not grow - [ ] Deleting a referrer leaves the people they referred in place ## M6 · Deploy and document Live on your domain, embedded on your real landing page, documented. ### Steps 1. Install on the VPS with systemd and Caddy Files: `deploy/waitlist.service`, `Caddyfile` ```sh sudo cp deploy/waitlist.service /etc/systemd/system/ && sudo systemctl enable --now waitlist ``` 2. Add /healthz and a nightly SQLite backup command ```sh sqlite3 data/waitlist.db ".backup '/tmp/waitlist-$(date +%F).db'" ``` 3. Paste the snippet into the real landing page and write the README README: the snippet verbatim, the exact referral maths, the ALLOWED_ORIGINS step, and one line stating no email is ever sent by this system. Files: `README.md` ### Done when - [ ] A signup from the real landing page appears in /admin - [ ] A reader goes from clone to a live embedded form using only the README - [ ] The README states that no email is sent ## M7 · Operate it like a product (production only) Only for the product-builder path: know when the signup endpoint is down, never lose the database, and keep the server patched. ### Steps 1. Add a /healthz endpoint and an external uptime check against it Answer 200 with the build id and a quick database read. Point a free uptime monitor (or your own, from the Healthchecks entry on this site) at it so an outage is noticed before a user notices. 2. Write structured request logs and rotate them One JSON line per request: method, path, status, duration, no raw IPs. Rotate weekly with logrotate, keep eight. 3. Back the SQLite file up off the machine nightly and test a restore SQLite's .backup command makes a consistent copy while the app runs. Copy it to object storage or a second machine; then, once, restore it into a fresh checkout and confirm the app reads it. ```sh sqlite3 data/app.db ".backup '/tmp/app-$(date +%F).db'" rclone copy /tmp/app-$(date +%F).db remote:backups/ ``` 4. Lock the box down Firewall allowing only 22, 80 and 443; unattended security updates on; the app running as an unprivileged user under systemd with Restart=on-failure. ### Done when - [ ] Stopping the service triggers an uptime alert within a few minutes - [ ] A restore from last night's backup contains yesterday's data - [ ] A port scan from another machine shows only 22, 80 and 443
# Operations · GetWaitlist ## Backup SQLite .backup nightly, off the box, thirty days kept. The list is the whole asset. ## Restore Copy the backup to DATABASE_PATH, start, confirm the total in /admin matches the last known count. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Uptime check on /healthz; alert if signups per day drops to zero during a campaign (a broken snippet looks exactly like no interest). ## Incident checklist If a bot flood gets through: export, dedupe by ip_hash and timestamp pattern, delete the burst, tighten the rate limit. If IP_SALT leaks, rotate it; it only affects rate limiting. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A clean clone reaches an embedded form using only the README - [ ] Referral positions verified against a hand calculation on a seeded fixture of 1,000 signups - [ ] One restore drill performed and dated - [ ] The CSV imports cleanly into the email tool you intend to use ## Launch constraint Do not market omitted GetWaitlist 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. Any free port; Caddy proxies to it. PORT=3000 # Required. SQLite file. Back it up; it is the list. DATABASE_PATH=./data/waitlist.db # Required · secret. openssl rand -hex 32, once. IP_SALT=hex-from-openssl-rand # Required. Public base URL, used in referral links. SITE_URL=https://join.yourdomain.com # Required. Comma-separated origins allowed to post the form. Anything else is refused. ALLOWED_ORIGINS=https://yoursite.com,https://www.yoursite.com # Optional. Positions gained per confirmed referral. REFERRAL_BOOST=5 # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string
$ choose a build depth, inspect the files, then open the complete pack in your agent
xtheir referral-widget templates
xbuilt-in email blasts to the list
xspam filtering you didn't tune yourself
Nothing worth pointing at. That's why the prompt exists.
GetWaitlist pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| legacy free | $0/workspace | $0/workspace | Unlimited signups; available only to accounts created before 2025-06-12. |
| basic | $15/workspace | — | Unlimited signups; 7-day trial. |
| advanced | $50/workspace | — | Unlimited signups; removes GetWaitlist branding. |
| pro | $250/workspace | — | Unlimited signups; custom email/domain; advanced analytics; multi-user access; unlimited custom email blasts. |
free tierNo free tier for new accounts. Legacy accounts created before 2025-06-12 retain a free plan with unlimited signups.
billingMonthly + annual; the page advertises 33% savings annually but did not expose the exact annual charges in the checked public output.
verified 2026-08-12 · source ↗
Vibecode GetWaitlist / LaunchList
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal GetWaitlist / LaunchList replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does GetWaitlist / LaunchList cost?
GetWaitlist / LaunchList costs about $15/month (paid plan, checked 2026-07-29), which is $180 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing GetWaitlist / LaunchList?
Honestly: their referral-widget templates; built-in email blasts to the list; spam filtering you didn't tune yourself. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to GetWaitlist / LaunchList?
No mature open-source alternative worth pointing at, which is exactly why the one-shot prompt on this page exists.