Vibecode WishKit
track this build4 phases, 12 steps, beginner friendly0%The data model here is four tables and a sort function: wishes, votes, comments, statuses. An agent can produce the whole web board plus a REST API in a single sitting, and it will work fine for a side project. The part that is actually work is everything after that: a native in-app widget for each platform you ship on, anonymous device identity so the same user cannot vote fifty times, spam and profanity handling, and an endpoint that stays up for people who already installed your binary. Nothing there is hard, it is just a real backend you now own forever. Build it if you like owning it, pay if your roadmap board is not the thing you want to be on call for.
You are building a lean indie version of WishKit. 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 ===== # WishKit · indie build A feature request board whose hard problems are an API that must keep working for binaries you shipped last year, a device identifier that is a hint rather than an identity, and abuse handling on endpoints anybody on the internet can post to. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with the App Router and TypeScript | one deployment serving a board, an admin view and an API | | Storage | SQLite through Drizzle, one file | a board is small and a single file is the easiest thing to back up | | Identity | A client-generated identifier treated as a hint, never as proof | anything a client sends about itself is a claim | | API | A versioned path with fields that are only ever added | an old build of your app cannot be asked to update | | Admin | One password from the environment behind a signed cookie | the smallest thing that is not nothing | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the board, the admin view and the API. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A small host with persistent disk and a stable name** · a few pounds a month - Why: The endpoint has to answer for as long as an old build of your app is installed somewhere, which is longer than you think and not something you can shorten. - Get it: Any small virtual machine or container platform with a volume and a domain you control. - Verify: The health endpoint answers over TLS at a name you own - [ ] **A proxy or platform that can rate limit** · free with most hosts - Why: Application rate limiting protects the database and does nothing about the bandwidth, so the layer in front needs a limit too before a public write endpoint meets its first script. - Get it: Any reverse proxy or platform feature that limits requests per source. - Verify: A burst from one source is refused before it reaches the application - [ ] **Xcode, if you want the native widget** (optional) · free, on a Mac - Why: The small SwiftUI package is the part that makes this worth self-hosting, and it cannot be compiled or tested anywhere else. - Get it: Xcode from the Mac App Store. - Verify: The package builds and the board view lists wishes from your API - [ ] **Your API deprecation window** · free - Why: It is a promise to people running software you can no longer change, and picking the number after the first breaking change means picking it too late. - Get it: Decide a minimum period an API version is served after it is superseded, and write it in the README. - Verify: The figure is in the README and the old version still answers ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Email and push notifications. - Multi-tenant signup and billing. - User accounts and sign-in. - Public roadmap columns. - A maintained SDK per platform, so every new client is your code to write and version - Anti-abuse that someone else already tuned: duplicate votes, spam floods, garbage submissions - Uptime you did not have to think about for users running an old build of your app - Email or push notifications when a request they voted for ships - Duplicate merging, tagging and the other board hygiene features that only matter once you have hundreds of wishes If one of those is essential to you, that is the reason to keep paying for WishKit, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · WishKit 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 self-hosted feature request board with a small API your shipped apps can call to replace WishKit. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with the App Router and TypeScript · one deployment serving a board, an admin view and an API - Storage: SQLite through Drizzle, one file · a board is small and a single file is the easiest thing to back up - Identity: A client-generated identifier treated as a hint, never as proof · anything a client sends about itself is a claim - API: A versioned path with fields that are only ever added · an old build of your app cannot be asked to update - Admin: One password from the environment behind a signed cookie · the smallest thing that is not nothing ### Data model (create this before Phase 1) - projects(id, name, api_key, created_at) · the key identifies an app, not a person, and it will end up public - wishes(id, project_id, title, description, status, created_at, author_device_id, hidden_at, hidden_reason) - votes(wish_id, device_id, created_at) · unique on the pair, which is the only deduplication available to you - comments(id, wish_id, device_id, body, created_at, hidden_at) · hidden rather than deleted, so a moderation decision is reviewable - merges(from_wish_id, into_wish_id, merged_at, votes_moved, votes_dropped) · a merge is a recorded event, not an update - abuse(bucket, window_start, count, blocked_until) · per device and per source, because a public write endpoint attracts the internet - The vote count is the sum of rows in votes, and every row came from a client that told you who it was. The board should present it as a signal, not a measurement. ### Phase 1 · A board where new things are not visible yet Build: Build the public board with a sorted list, a detail page, a submit form and comments. New wishes start pending and are not returned by the public list. Statuses cover pending, approved, in progress, completed and rejected, and a rejected wish says so rather than vanishing. Trap: Publishing submissions immediately means the first time somebody posts abuse to your public board it is live on a page linked from inside your app, visible to every customer, until you happen to look. Default new wishes to pending, show the submitter their own pending wish so the form does not feel broken, and make approval a deliberate act. Done when: A newly submitted wish is absent from the public list and visible to its submitter as pending, tested from two device identifiers. A rejected wish shows a status rather than disappearing, so nobody submits it three more times. Sorting by votes and by recency both produce a stable order with ties broken deterministically. Comments on a hidden wish are unreachable from the public board. Do not build yet: No API, rate limiting, merging or admin tooling. ### Phase 2 · An API you cannot take back Build: Expose a versioned path with a project key header and a device identifier header. Only ever add fields to a response, never remove or retype one. Return a clear error shape. Document every endpoint with a working command line example. Trap: Renaming a response field or tightening a validation rule breaks every copy of your app that people already installed, and unlike a website you cannot fix them, you can only wait for updates that some users will never install. Version the path, add fields rather than changing them, and treat a shipped client as a contract you signed. Done when: A recorded response from an earlier version still parses against the current endpoint, asserted by a test holding a stored fixture. Every documented endpoint has a working command line example in the README that is exercised by the test suite. An unknown project key returns the same error shape as a malformed body, with no detail about which projects exist. The API key is treated as public, so every endpoint it reaches is one a stranger may reach. Do not build yet: No abuse handling, merging or admin tooling yet. ### Phase 3 · A device identifier is a hint Build: Accept a client-generated identifier, store it, and make the vote table unique per wish and identifier. Rate limit writes per identifier and per source. State on the board that counts are approximate, and never present a vote total as a headcount. Trap: Treating a device identifier as a person produces a vote count people make roadmap decisions with, and it is wrong in both directions. Apple documents that the vendor identifier changes when a user deletes all of that vendor's apps and reinstalls, and that it can be nil after a restart before the device is unlocked, so an honest user can vote twice; meanwhile anyone can send whatever identifier they like to a public endpoint. Deduplicate on it, rate limit around it, and describe the number as interest rather than as a count of people. Done when: Voting twice from one identifier changes nothing and returns the same result, so the client can be naive about retries. A missing or malformed identifier is rejected with a clear error rather than being generated server side. Rate limits apply per identifier and per source and a blocked caller is told the limit rather than being silently dropped. The board and the README both describe vote counts as approximate and say why. Do not build yet: No merging, admin tooling or export yet. ### Phase 4 · Merging duplicates without losing votes Build: Merge a wish into another, moving votes and deduplicating identifiers that voted for both. Record how many votes moved and how many were dropped as duplicates. Leave a redirect from the merged wish so an old link still lands somewhere sensible. Trap: Adding the two vote totals together after a merge double counts everybody who voted for both wishes, and since duplicates are exactly the wishes the same people vote for, the merged total is inflated precisely where it matters most. Move rows and let the unique constraint drop the overlap, then show both numbers. Done when: Merging two wishes with overlapping voters produces a total equal to the distinct voters, asserted with a crafted overlap. The merge record stores votes moved and votes dropped and both are shown in the admin view. A link to the merged wish redirects to the surviving one rather than returning not found. A merge cannot be performed into a wish that has itself been merged away. Do not build yet: No operations, backup or export yet. ### Phase 5 · Running it for as long as the app is installed Build: Add a health endpoint, structured request logs without bodies, an automated backup with a tested restore, and a full JSON export. Write a deprecation policy for the API and a procedure for a deletion request that covers wishes, votes and comments by identifier. Trap: Turning off an old API version because nothing in your current app uses it breaks the copies of the app still installed on phones that have not updated in two years, and the failure looks to those users like your product is broken. Publish a deprecation window, serve the old version until it expires, and make the client show a quiet message rather than an error. Done when: A restore from an automated backup is exercised in the suite and the restored board serves the API. Export writes projects, wishes, votes, comments and merges to JSON and the counts match the database. A deletion request removes every row carrying a given identifier and the removal is verified by querying for it afterwards. The deprecation policy is in the README with a stated minimum window and the health endpoint reports version and database reachability. Do not build yet: Nothing after this. ### Out of scope (and why) - Email and push notifications, which turn a board into a message sender with deliverability, unsubscribes and a reputation to protect. - Multi-tenant signup and billing, which is the moment you stop running a board and start running a service for other people. - Accounts and sign-in, since the whole appeal here is a widget that works without asking a user to make yet another account. - Roadmap columns and public planning, which is a promise to strangers about what you will build and harder to retract than it looks. ### README must contain - That the project key ships inside your binary and is therefore public, and what that means for every endpoint - That vote counts are approximate, and the specific reasons a device identifier is not a person - A working command line example for every endpoint, matching the tested fixtures - The API deprecation window, because users cannot be made to update a shipped app - Where the database file lives, how backups run, and that the restore is tested ===== AGENTS.md ===== # Agent instructions · WishKit indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with the App Router and TypeScript, SQLite through Drizzle, one file, A client-generated identifier treated as a hint, never as proof, A versioned path with fields that are only ever added, One password from the environment behind a signed cookie. 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 - Publishing submissions immediately means the first time somebody posts abuse to your public board it is live on a page linked from inside your app, visible to every customer, until you happen to look. Default new wishes to pending, show the submitter their own pending wish so the form does not feel broken, and make approval a deliberate act. - Renaming a response field or tightening a validation rule breaks every copy of your app that people already installed, and unlike a website you cannot fix them, you can only wait for updates that some users will never install. Version the path, add fields rather than changing them, and treat a shipped client as a contract you signed. - Treating a device identifier as a person produces a vote count people make roadmap decisions with, and it is wrong in both directions. Apple documents that the vendor identifier changes when a user deletes all of that vendor's apps and reinstalls, and that it can be nil after a restart before the device is unlocked, so an honest user can vote twice; meanwhile anyone can send whatever identifier they like to a public endpoint. Deduplicate on it, rate limit around it, and describe the number as interest rather than as a count of people. - Adding the two vote totals together after a merge double counts everybody who voted for both wishes, and since duplicates are exactly the wishes the same people vote for, the merged total is inflated precisely where it matters most. Move rows and let the unique constraint drop the overlap, then show both numbers. ===== BUILD_PLAN.md ===== # Build plan · WishKit A feature request board whose hard problems are an API that must keep working for binaries you shipped last year, a device identifier that is a hint rather than an identity, and abuse handling on endpoints anybody on the internet can post to. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A board where new things are not visible yet Wishes, votes and comments work and nothing a stranger submits appears without a decision. ### Steps 1. Default to pending, show the submitter Moderation by default, without a form that looks broken. Files: `src/board/submit.ts` 2. Break sort ties deterministically Two wishes with five votes should not swap places on every refresh. Files: `src/board/sort.ts` 3. Make rejection visible A wish that vanishes gets submitted again by the same person next week. Files: `src/board/statuses.ts` ### Done when - [ ] New wishes are pending and hidden from the list - [ ] A submitter sees their own pending wish - [ ] Sort order is stable and tie-broken - [ ] Hidden wishes take their comments with them ### Watch out - Publishing submissions immediately means the first time somebody posts abuse to your public board it is live on a page linked from inside your app, visible to every customer, until you happen to look. Default new wishes to pending, show the submitter their own pending wish so the form does not feel broken, and make approval a deliberate act. ## Phase 2 · An API you cannot take back The endpoints are versioned and shaped so a build shipped today keeps working next year. ### Steps 1. Version the path from the first release The cheapest thing you will ever do for the version of yourself who ships a change. Files: `src/app/api/v1/route.ts` 2. Keep old response fixtures and test against them The fixture is the shipped client you cannot run. Files: `test/fixtures/v1`, `test/compat.test.ts` 3. Assume the key is public It is inside a binary anybody can download and unpack. Files: `docs/api.md` ### Done when - [ ] An old response fixture still parses - [ ] Every endpoint has a tested example - [ ] Errors leak nothing about other projects - [ ] No endpoint trusts the key as a secret ### Watch out - Renaming a response field or tightening a validation rule breaks every copy of your app that people already installed, and unlike a website you cannot fix them, you can only wait for updates that some users will never install. Version the path, add fields rather than changing them, and treat a shipped client as a contract you signed. ## Phase 3 · A device identifier is a hint Duplicate votes are discouraged honestly and the board never claims the count is exact. ### Steps 1. Make voting idempotent per identifier A flaky mobile network retries, and a retry must not be a second vote. Files: `src/api/vote.ts`, `test/idempotent-vote.test.ts` 2. Rate limit per identifier and per source Either one alone is trivially defeated by the other. Files: `src/abuse/limits.ts` 3. Say the count is approximate, on the page Honesty here costs one sentence and prevents one bad roadmap. Files: `src/board/VoteCount.tsx` ### Done when - [ ] A repeated vote is idempotent - [ ] A missing identifier is rejected clearly - [ ] Limits apply per identifier and per source - [ ] The count is described as approximate in the interface ### Watch out - Treating a device identifier as a person produces a vote count people make roadmap decisions with, and it is wrong in both directions. Apple documents that the vendor identifier changes when a user deletes all of that vendor's apps and reinstalls, and that it can be nil after a restart before the device is unlocked, so an honest user can vote twice; meanwhile anyone can send whatever identifier they like to a public endpoint. Deduplicate on it, rate limit around it, and describe the number as interest rather than as a count of people. ## Phase 4 · Merging duplicates without losing votes Two wishes for one idea become one, and the arithmetic is recorded. ### Steps 1. Move rows, let the constraint deduplicate The unique pair is already the right answer; adding totals is not. Files: `src/admin/merge.ts`, `test/merge.test.ts` 2. Record the arithmetic Somebody will ask why the number went down. Files: `src/admin/merge.ts` 3. Redirect the old wish Links live in chat logs and in shipped app builds. Files: `src/board/redirect.ts` ### Done when - [ ] A merge totals distinct voters, not summed votes - [ ] Moved and dropped counts are recorded and shown - [ ] The merged wish redirects rather than 404s - [ ] Merging into an already merged wish is refused ### Watch out - Adding the two vote totals together after a merge double counts everybody who voted for both wishes, and since duplicates are exactly the wishes the same people vote for, the merged total is inflated precisely where it matters most. Move rows and let the unique constraint drop the overlap, then show both numbers. ## Not in this build - Email and push notifications. - Multi-tenant signup and billing. - User accounts and sign-in. - Public roadmap columns. ## After v1, if you want it - A second platform widget, once the API has been stable for a release cycle - Tagging and a duplicate suggestion at submission time, which reduces merges rather than improving them - A digest the admin reads rather than a notification every user receives ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. One file. Backed up on a schedule with a tested restore. DATABASE_PATH=./data/wishes.db # Required · secret. The only credential in the system. Rotate it by changing this and restarting. ADMIN_PASSWORD=a long random string # Required · secret. Signs the admin session cookie. Changing it signs everyone out, which is a feature. COOKIE_SECRET=redacted-32-byte-random # Required. What your shipped apps call. Changing it strands old builds, so choose once. PUBLIC_BASE_URL=https://wishes.example.com # Required. Per device identifier. Stated in the error so a blocked caller knows why. WRITE_LIMIT_PER_HOUR=10 # Required. Titles, descriptions and comments. Enforced before storage, not after. MAX_BODY_CHARS=2000 # Required. How long a superseded API version keeps answering. Published in the README. API_DEPRECATION_DAYS=365
You are building a lean indie version of WishKit. 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 ===== # WishKit · indie build A feature request board whose hard problems are an API that must keep working for binaries you shipped last year, a device identifier that is a hint rather than an identity, and abuse handling on endpoints anybody on the internet can post to. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with the App Router and TypeScript | one deployment serving a board, an admin view and an API | | Storage | SQLite through Drizzle, one file | a board is small and a single file is the easiest thing to back up | | Identity | A client-generated identifier treated as a hint, never as proof | anything a client sends about itself is a claim | | API | A versioned path with fields that are only ever added | an old build of your app cannot be asked to update | | Admin | One password from the environment behind a signed cookie | the smallest thing that is not nothing | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the board, the admin view and the API. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A small host with persistent disk and a stable name** · a few pounds a month - Why: The endpoint has to answer for as long as an old build of your app is installed somewhere, which is longer than you think and not something you can shorten. - Get it: Any small virtual machine or container platform with a volume and a domain you control. - Verify: The health endpoint answers over TLS at a name you own - [ ] **A proxy or platform that can rate limit** · free with most hosts - Why: Application rate limiting protects the database and does nothing about the bandwidth, so the layer in front needs a limit too before a public write endpoint meets its first script. - Get it: Any reverse proxy or platform feature that limits requests per source. - Verify: A burst from one source is refused before it reaches the application - [ ] **Xcode, if you want the native widget** (optional) · free, on a Mac - Why: The small SwiftUI package is the part that makes this worth self-hosting, and it cannot be compiled or tested anywhere else. - Get it: Xcode from the Mac App Store. - Verify: The package builds and the board view lists wishes from your API - [ ] **Your API deprecation window** · free - Why: It is a promise to people running software you can no longer change, and picking the number after the first breaking change means picking it too late. - Get it: Decide a minimum period an API version is served after it is superseded, and write it in the README. - Verify: The figure is in the README and the old version still answers ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Email and push notifications. - Multi-tenant signup and billing. - User accounts and sign-in. - Public roadmap columns. - A maintained SDK per platform, so every new client is your code to write and version - Anti-abuse that someone else already tuned: duplicate votes, spam floods, garbage submissions - Uptime you did not have to think about for users running an old build of your app - Email or push notifications when a request they voted for ships - Duplicate merging, tagging and the other board hygiene features that only matter once you have hundreds of wishes If one of those is essential to you, that is the reason to keep paying for WishKit, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · WishKit 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 self-hosted feature request board with a small API your shipped apps can call to replace WishKit. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with the App Router and TypeScript · one deployment serving a board, an admin view and an API - Storage: SQLite through Drizzle, one file · a board is small and a single file is the easiest thing to back up - Identity: A client-generated identifier treated as a hint, never as proof · anything a client sends about itself is a claim - API: A versioned path with fields that are only ever added · an old build of your app cannot be asked to update - Admin: One password from the environment behind a signed cookie · the smallest thing that is not nothing ### Data model (create this before Phase 1) - projects(id, name, api_key, created_at) · the key identifies an app, not a person, and it will end up public - wishes(id, project_id, title, description, status, created_at, author_device_id, hidden_at, hidden_reason) - votes(wish_id, device_id, created_at) · unique on the pair, which is the only deduplication available to you - comments(id, wish_id, device_id, body, created_at, hidden_at) · hidden rather than deleted, so a moderation decision is reviewable - merges(from_wish_id, into_wish_id, merged_at, votes_moved, votes_dropped) · a merge is a recorded event, not an update - abuse(bucket, window_start, count, blocked_until) · per device and per source, because a public write endpoint attracts the internet - The vote count is the sum of rows in votes, and every row came from a client that told you who it was. The board should present it as a signal, not a measurement. ### Phase 1 · A board where new things are not visible yet Build: Build the public board with a sorted list, a detail page, a submit form and comments. New wishes start pending and are not returned by the public list. Statuses cover pending, approved, in progress, completed and rejected, and a rejected wish says so rather than vanishing. Trap: Publishing submissions immediately means the first time somebody posts abuse to your public board it is live on a page linked from inside your app, visible to every customer, until you happen to look. Default new wishes to pending, show the submitter their own pending wish so the form does not feel broken, and make approval a deliberate act. Done when: A newly submitted wish is absent from the public list and visible to its submitter as pending, tested from two device identifiers. A rejected wish shows a status rather than disappearing, so nobody submits it three more times. Sorting by votes and by recency both produce a stable order with ties broken deterministically. Comments on a hidden wish are unreachable from the public board. Do not build yet: No API, rate limiting, merging or admin tooling. ### Phase 2 · An API you cannot take back Build: Expose a versioned path with a project key header and a device identifier header. Only ever add fields to a response, never remove or retype one. Return a clear error shape. Document every endpoint with a working command line example. Trap: Renaming a response field or tightening a validation rule breaks every copy of your app that people already installed, and unlike a website you cannot fix them, you can only wait for updates that some users will never install. Version the path, add fields rather than changing them, and treat a shipped client as a contract you signed. Done when: A recorded response from an earlier version still parses against the current endpoint, asserted by a test holding a stored fixture. Every documented endpoint has a working command line example in the README that is exercised by the test suite. An unknown project key returns the same error shape as a malformed body, with no detail about which projects exist. The API key is treated as public, so every endpoint it reaches is one a stranger may reach. Do not build yet: No abuse handling, merging or admin tooling yet. ### Phase 3 · A device identifier is a hint Build: Accept a client-generated identifier, store it, and make the vote table unique per wish and identifier. Rate limit writes per identifier and per source. State on the board that counts are approximate, and never present a vote total as a headcount. Trap: Treating a device identifier as a person produces a vote count people make roadmap decisions with, and it is wrong in both directions. Apple documents that the vendor identifier changes when a user deletes all of that vendor's apps and reinstalls, and that it can be nil after a restart before the device is unlocked, so an honest user can vote twice; meanwhile anyone can send whatever identifier they like to a public endpoint. Deduplicate on it, rate limit around it, and describe the number as interest rather than as a count of people. Done when: Voting twice from one identifier changes nothing and returns the same result, so the client can be naive about retries. A missing or malformed identifier is rejected with a clear error rather than being generated server side. Rate limits apply per identifier and per source and a blocked caller is told the limit rather than being silently dropped. The board and the README both describe vote counts as approximate and say why. Do not build yet: No merging, admin tooling or export yet. ### Phase 4 · Merging duplicates without losing votes Build: Merge a wish into another, moving votes and deduplicating identifiers that voted for both. Record how many votes moved and how many were dropped as duplicates. Leave a redirect from the merged wish so an old link still lands somewhere sensible. Trap: Adding the two vote totals together after a merge double counts everybody who voted for both wishes, and since duplicates are exactly the wishes the same people vote for, the merged total is inflated precisely where it matters most. Move rows and let the unique constraint drop the overlap, then show both numbers. Done when: Merging two wishes with overlapping voters produces a total equal to the distinct voters, asserted with a crafted overlap. The merge record stores votes moved and votes dropped and both are shown in the admin view. A link to the merged wish redirects to the surviving one rather than returning not found. A merge cannot be performed into a wish that has itself been merged away. Do not build yet: No operations, backup or export yet. ### Phase 5 · Running it for as long as the app is installed Build: Add a health endpoint, structured request logs without bodies, an automated backup with a tested restore, and a full JSON export. Write a deprecation policy for the API and a procedure for a deletion request that covers wishes, votes and comments by identifier. Trap: Turning off an old API version because nothing in your current app uses it breaks the copies of the app still installed on phones that have not updated in two years, and the failure looks to those users like your product is broken. Publish a deprecation window, serve the old version until it expires, and make the client show a quiet message rather than an error. Done when: A restore from an automated backup is exercised in the suite and the restored board serves the API. Export writes projects, wishes, votes, comments and merges to JSON and the counts match the database. A deletion request removes every row carrying a given identifier and the removal is verified by querying for it afterwards. The deprecation policy is in the README with a stated minimum window and the health endpoint reports version and database reachability. Do not build yet: Nothing after this. ### Out of scope (and why) - Email and push notifications, which turn a board into a message sender with deliverability, unsubscribes and a reputation to protect. - Multi-tenant signup and billing, which is the moment you stop running a board and start running a service for other people. - Accounts and sign-in, since the whole appeal here is a widget that works without asking a user to make yet another account. - Roadmap columns and public planning, which is a promise to strangers about what you will build and harder to retract than it looks. ### README must contain - That the project key ships inside your binary and is therefore public, and what that means for every endpoint - That vote counts are approximate, and the specific reasons a device identifier is not a person - A working command line example for every endpoint, matching the tested fixtures - The API deprecation window, because users cannot be made to update a shipped app - Where the database file lives, how backups run, and that the restore is tested ===== AGENTS.md ===== # Agent instructions · WishKit indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with the App Router and TypeScript, SQLite through Drizzle, one file, A client-generated identifier treated as a hint, never as proof, A versioned path with fields that are only ever added, One password from the environment behind a signed cookie. 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 - Publishing submissions immediately means the first time somebody posts abuse to your public board it is live on a page linked from inside your app, visible to every customer, until you happen to look. Default new wishes to pending, show the submitter their own pending wish so the form does not feel broken, and make approval a deliberate act. - Renaming a response field or tightening a validation rule breaks every copy of your app that people already installed, and unlike a website you cannot fix them, you can only wait for updates that some users will never install. Version the path, add fields rather than changing them, and treat a shipped client as a contract you signed. - Treating a device identifier as a person produces a vote count people make roadmap decisions with, and it is wrong in both directions. Apple documents that the vendor identifier changes when a user deletes all of that vendor's apps and reinstalls, and that it can be nil after a restart before the device is unlocked, so an honest user can vote twice; meanwhile anyone can send whatever identifier they like to a public endpoint. Deduplicate on it, rate limit around it, and describe the number as interest rather than as a count of people. - Adding the two vote totals together after a merge double counts everybody who voted for both wishes, and since duplicates are exactly the wishes the same people vote for, the merged total is inflated precisely where it matters most. Move rows and let the unique constraint drop the overlap, then show both numbers. ===== BUILD_PLAN.md ===== # Build plan · WishKit A feature request board whose hard problems are an API that must keep working for binaries you shipped last year, a device identifier that is a hint rather than an identity, and abuse handling on endpoints anybody on the internet can post to. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A board where new things are not visible yet Wishes, votes and comments work and nothing a stranger submits appears without a decision. ### Steps 1. Default to pending, show the submitter Moderation by default, without a form that looks broken. Files: `src/board/submit.ts` 2. Break sort ties deterministically Two wishes with five votes should not swap places on every refresh. Files: `src/board/sort.ts` 3. Make rejection visible A wish that vanishes gets submitted again by the same person next week. Files: `src/board/statuses.ts` ### Done when - [ ] New wishes are pending and hidden from the list - [ ] A submitter sees their own pending wish - [ ] Sort order is stable and tie-broken - [ ] Hidden wishes take their comments with them ### Watch out - Publishing submissions immediately means the first time somebody posts abuse to your public board it is live on a page linked from inside your app, visible to every customer, until you happen to look. Default new wishes to pending, show the submitter their own pending wish so the form does not feel broken, and make approval a deliberate act. ## Phase 2 · An API you cannot take back The endpoints are versioned and shaped so a build shipped today keeps working next year. ### Steps 1. Version the path from the first release The cheapest thing you will ever do for the version of yourself who ships a change. Files: `src/app/api/v1/route.ts` 2. Keep old response fixtures and test against them The fixture is the shipped client you cannot run. Files: `test/fixtures/v1`, `test/compat.test.ts` 3. Assume the key is public It is inside a binary anybody can download and unpack. Files: `docs/api.md` ### Done when - [ ] An old response fixture still parses - [ ] Every endpoint has a tested example - [ ] Errors leak nothing about other projects - [ ] No endpoint trusts the key as a secret ### Watch out - Renaming a response field or tightening a validation rule breaks every copy of your app that people already installed, and unlike a website you cannot fix them, you can only wait for updates that some users will never install. Version the path, add fields rather than changing them, and treat a shipped client as a contract you signed. ## Phase 3 · A device identifier is a hint Duplicate votes are discouraged honestly and the board never claims the count is exact. ### Steps 1. Make voting idempotent per identifier A flaky mobile network retries, and a retry must not be a second vote. Files: `src/api/vote.ts`, `test/idempotent-vote.test.ts` 2. Rate limit per identifier and per source Either one alone is trivially defeated by the other. Files: `src/abuse/limits.ts` 3. Say the count is approximate, on the page Honesty here costs one sentence and prevents one bad roadmap. Files: `src/board/VoteCount.tsx` ### Done when - [ ] A repeated vote is idempotent - [ ] A missing identifier is rejected clearly - [ ] Limits apply per identifier and per source - [ ] The count is described as approximate in the interface ### Watch out - Treating a device identifier as a person produces a vote count people make roadmap decisions with, and it is wrong in both directions. Apple documents that the vendor identifier changes when a user deletes all of that vendor's apps and reinstalls, and that it can be nil after a restart before the device is unlocked, so an honest user can vote twice; meanwhile anyone can send whatever identifier they like to a public endpoint. Deduplicate on it, rate limit around it, and describe the number as interest rather than as a count of people. ## Phase 4 · Merging duplicates without losing votes Two wishes for one idea become one, and the arithmetic is recorded. ### Steps 1. Move rows, let the constraint deduplicate The unique pair is already the right answer; adding totals is not. Files: `src/admin/merge.ts`, `test/merge.test.ts` 2. Record the arithmetic Somebody will ask why the number went down. Files: `src/admin/merge.ts` 3. Redirect the old wish Links live in chat logs and in shipped app builds. Files: `src/board/redirect.ts` ### Done when - [ ] A merge totals distinct voters, not summed votes - [ ] Moved and dropped counts are recorded and shown - [ ] The merged wish redirects rather than 404s - [ ] Merging into an already merged wish is refused ### Watch out - Adding the two vote totals together after a merge double counts everybody who voted for both wishes, and since duplicates are exactly the wishes the same people vote for, the merged total is inflated precisely where it matters most. Move rows and let the unique constraint drop the overlap, then show both numbers. ## Not in this build - Email and push notifications. - Multi-tenant signup and billing. - User accounts and sign-in. - Public roadmap columns. ## After v1, if you want it - A second platform widget, once the API has been stable for a release cycle - Tagging and a duplicate suggestion at submission time, which reduces merges rather than improving them - A digest the admin reads rather than a notification every user receives ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. One file. Backed up on a schedule with a tested restore. DATABASE_PATH=./data/wishes.db # Required · secret. The only credential in the system. Rotate it by changing this and restarting. ADMIN_PASSWORD=a long random string # Required · secret. Signs the admin session cookie. Changing it signs everyone out, which is a feature. COOKIE_SECRET=redacted-32-byte-random # Required. What your shipped apps call. Changing it strands old builds, so choose once. PUBLIC_BASE_URL=https://wishes.example.com # Required. Per device identifier. Stated in the error so a blocked caller knows why. WRITE_LIMIT_PER_HOUR=10 # Required. Titles, descriptions and comments. Enforced before storage, not after. MAX_BODY_CHARS=2000 # Required. How long a superseded API version keeps answering. Published in the README. API_DEPRECATION_DAYS=365
You are building a production product version of WishKit. 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 ===== # WishKit · product brief ## Problem The data model here is four tables and a sort function: wishes, votes, comments, statuses. An agent can produce the whole web board plus a REST API in a single sitting, and it will work fine for a side project. The part that is actually work is everything after that: a native in-app widget for each platform you ship on, anonymous device identity so the same user cannot vote fifty times, spam and profanity handling, and an endpoint that stays up for people who already installed your binary. Nothing there is hard, it is just a real backend you now own forever. Build it if you like owning it, pay if your roadmap board is not the thing you want to be on call for. ## Product outcome A board you own, an API that will still answer a two year old build of your app, a vote count presented as the approximation it is, and moderation that happens before a stranger's words reach your customers. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - A place to host a small Node service with persistent disk - A domain or subdomain your shipped app can call - Xcode if you want the SwiftUI widget compiled and tested - Basic rate limiting or a proxy in front of the write endpoints ## Explicit non-goals for v1 - Email and push notifications. - Multi-tenant signup and billing. - User accounts and sign-in. - Public roadmap columns. - A maintained SDK per platform, so every new client is your code to write and version - Anti-abuse that someone else already tuned: duplicate votes, spam floods, garbage submissions - Uptime you did not have to think about for users running an old build of your app - Email or push notifications when a request they voted for ships - Duplicate merging, tagging and the other board hygiene features that only matter once you have hundreds of wishes ## Success criteria - New wishes are confirmed hidden from the public list - An old response fixture is confirmed to still parse - A repeated vote is confirmed idempotent - A merge is confirmed to total distinct voters - A restore is confirmed exercised in the suite ===== BRIEF.md ===== # Build brief · WishKit 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 self-hosted feature request board with a small API your shipped apps can call to replace WishKit. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with the App Router and TypeScript · one deployment serving a board, an admin view and an API - Storage: SQLite through Drizzle, one file · a board is small and a single file is the easiest thing to back up - Identity: A client-generated identifier treated as a hint, never as proof · anything a client sends about itself is a claim - API: A versioned path with fields that are only ever added · an old build of your app cannot be asked to update - Admin: One password from the environment behind a signed cookie · the smallest thing that is not nothing ### Data model (create this before Phase 1) - projects(id, name, api_key, created_at) · the key identifies an app, not a person, and it will end up public - wishes(id, project_id, title, description, status, created_at, author_device_id, hidden_at, hidden_reason) - votes(wish_id, device_id, created_at) · unique on the pair, which is the only deduplication available to you - comments(id, wish_id, device_id, body, created_at, hidden_at) · hidden rather than deleted, so a moderation decision is reviewable - merges(from_wish_id, into_wish_id, merged_at, votes_moved, votes_dropped) · a merge is a recorded event, not an update - abuse(bucket, window_start, count, blocked_until) · per device and per source, because a public write endpoint attracts the internet - The vote count is the sum of rows in votes, and every row came from a client that told you who it was. The board should present it as a signal, not a measurement. ### Phase 1 · A board where new things are not visible yet Build: Build the public board with a sorted list, a detail page, a submit form and comments. New wishes start pending and are not returned by the public list. Statuses cover pending, approved, in progress, completed and rejected, and a rejected wish says so rather than vanishing. Trap: Publishing submissions immediately means the first time somebody posts abuse to your public board it is live on a page linked from inside your app, visible to every customer, until you happen to look. Default new wishes to pending, show the submitter their own pending wish so the form does not feel broken, and make approval a deliberate act. Done when: A newly submitted wish is absent from the public list and visible to its submitter as pending, tested from two device identifiers. A rejected wish shows a status rather than disappearing, so nobody submits it three more times. Sorting by votes and by recency both produce a stable order with ties broken deterministically. Comments on a hidden wish are unreachable from the public board. Do not build yet: No API, rate limiting, merging or admin tooling. ### Phase 2 · An API you cannot take back Build: Expose a versioned path with a project key header and a device identifier header. Only ever add fields to a response, never remove or retype one. Return a clear error shape. Document every endpoint with a working command line example. Trap: Renaming a response field or tightening a validation rule breaks every copy of your app that people already installed, and unlike a website you cannot fix them, you can only wait for updates that some users will never install. Version the path, add fields rather than changing them, and treat a shipped client as a contract you signed. Done when: A recorded response from an earlier version still parses against the current endpoint, asserted by a test holding a stored fixture. Every documented endpoint has a working command line example in the README that is exercised by the test suite. An unknown project key returns the same error shape as a malformed body, with no detail about which projects exist. The API key is treated as public, so every endpoint it reaches is one a stranger may reach. Do not build yet: No abuse handling, merging or admin tooling yet. ### Phase 3 · A device identifier is a hint Build: Accept a client-generated identifier, store it, and make the vote table unique per wish and identifier. Rate limit writes per identifier and per source. State on the board that counts are approximate, and never present a vote total as a headcount. Trap: Treating a device identifier as a person produces a vote count people make roadmap decisions with, and it is wrong in both directions. Apple documents that the vendor identifier changes when a user deletes all of that vendor's apps and reinstalls, and that it can be nil after a restart before the device is unlocked, so an honest user can vote twice; meanwhile anyone can send whatever identifier they like to a public endpoint. Deduplicate on it, rate limit around it, and describe the number as interest rather than as a count of people. Done when: Voting twice from one identifier changes nothing and returns the same result, so the client can be naive about retries. A missing or malformed identifier is rejected with a clear error rather than being generated server side. Rate limits apply per identifier and per source and a blocked caller is told the limit rather than being silently dropped. The board and the README both describe vote counts as approximate and say why. Do not build yet: No merging, admin tooling or export yet. ### Phase 4 · Merging duplicates without losing votes Build: Merge a wish into another, moving votes and deduplicating identifiers that voted for both. Record how many votes moved and how many were dropped as duplicates. Leave a redirect from the merged wish so an old link still lands somewhere sensible. Trap: Adding the two vote totals together after a merge double counts everybody who voted for both wishes, and since duplicates are exactly the wishes the same people vote for, the merged total is inflated precisely where it matters most. Move rows and let the unique constraint drop the overlap, then show both numbers. Done when: Merging two wishes with overlapping voters produces a total equal to the distinct voters, asserted with a crafted overlap. The merge record stores votes moved and votes dropped and both are shown in the admin view. A link to the merged wish redirects to the surviving one rather than returning not found. A merge cannot be performed into a wish that has itself been merged away. Do not build yet: No operations, backup or export yet. ### Phase 5 · Running it for as long as the app is installed Build: Add a health endpoint, structured request logs without bodies, an automated backup with a tested restore, and a full JSON export. Write a deprecation policy for the API and a procedure for a deletion request that covers wishes, votes and comments by identifier. Trap: Turning off an old API version because nothing in your current app uses it breaks the copies of the app still installed on phones that have not updated in two years, and the failure looks to those users like your product is broken. Publish a deprecation window, serve the old version until it expires, and make the client show a quiet message rather than an error. Done when: A restore from an automated backup is exercised in the suite and the restored board serves the API. Export writes projects, wishes, votes, comments and merges to JSON and the counts match the database. A deletion request removes every row carrying a given identifier and the removal is verified by querying for it afterwards. The deprecation policy is in the README with a stated minimum window and the health endpoint reports version and database reachability. Do not build yet: Nothing after this. ### Out of scope (and why) - Email and push notifications, which turn a board into a message sender with deliverability, unsubscribes and a reputation to protect. - Multi-tenant signup and billing, which is the moment you stop running a board and start running a service for other people. - Accounts and sign-in, since the whole appeal here is a widget that works without asking a user to make yet another account. - Roadmap columns and public planning, which is a promise to strangers about what you will build and harder to retract than it looks. ### README must contain - That the project key ships inside your binary and is therefore public, and what that means for every endpoint - That vote counts are approximate, and the specific reasons a device identifier is not a person - A working command line example for every endpoint, matching the tested fixtures - The API deprecation window, because users cannot be made to update a shipped app - Where the database file lives, how backups run, and that the restore is tested ===== ARCHITECTURE.md ===== # Architecture · WishKit ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with the App Router and TypeScript | one deployment serving a board, an admin view and an API | | Storage | SQLite through Drizzle, one file | a board is small and a single file is the easiest thing to back up | | Identity | A client-generated identifier treated as a hint, never as proof | anything a client sends about itself is a claim | | API | A versioned path with fields that are only ever added | an old build of your app cannot be asked to update | | Admin | One password from the environment behind a signed cookie | the smallest thing that is not nothing | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Board | Public list, detail and submission with pending by default. | Any presentation, provided new submissions stay hidden. | | API | Versioned endpoints with additive changes only. | Any framework, provided old fixtures still parse. | | Identity | Idempotent votes and honest wording about counts. | Any identifier scheme, provided it is never described as a person. | | Moderation | Approval, hiding and merging with recorded arithmetic. | Any workflow, provided hiding is reversible and recorded. | | Operations | Health, backup, export and deletion by identifier. | Any tooling, provided the restore is exercised. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · One file. Backed up on a schedule with a tested restore. - `ADMIN_PASSWORD` · required, secret · The only credential in the system. Rotate it by changing this and restarting. - `COOKIE_SECRET` · required, secret · Signs the admin session cookie. Changing it signs everyone out, which is a feature. - `PUBLIC_BASE_URL` · required · What your shipped apps call. Changing it strands old builds, so choose once. - `WRITE_LIMIT_PER_HOUR` · required · Per device identifier. Stated in the error so a blocked caller knows why. - `MAX_BODY_CHARS` · required · Titles, descriptions and comments. Enforced before storage, not after. - `API_DEPRECATION_DAYS` · required · How long a superseded API version keeps answering. Published in the README. ## 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 · WishKit product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with the App Router and TypeScript, SQLite through Drizzle, one file, A client-generated identifier treated as a hint, never as proof, A versioned path with fields that are only ever added, One password from the environment behind a signed cookie. - 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 - Publishing submissions immediately means the first time somebody posts abuse to your public board it is live on a page linked from inside your app, visible to every customer, until you happen to look. Default new wishes to pending, show the submitter their own pending wish so the form does not feel broken, and make approval a deliberate act. - Renaming a response field or tightening a validation rule breaks every copy of your app that people already installed, and unlike a website you cannot fix them, you can only wait for updates that some users will never install. Version the path, add fields rather than changing them, and treat a shipped client as a contract you signed. - Treating a device identifier as a person produces a vote count people make roadmap decisions with, and it is wrong in both directions. Apple documents that the vendor identifier changes when a user deletes all of that vendor's apps and reinstalls, and that it can be nil after a restart before the device is unlocked, so an honest user can vote twice; meanwhile anyone can send whatever identifier they like to a public endpoint. Deduplicate on it, rate limit around it, and describe the number as interest rather than as a count of people. - Adding the two vote totals together after a merge double counts everybody who voted for both wishes, and since duplicates are exactly the wishes the same people vote for, the merged total is inflated precisely where it matters most. Move rows and let the unique constraint drop the overlap, then show both numbers. - Turning off an old API version because nothing in your current app uses it breaks the copies of the app still installed on phones that have not updated in two years, and the failure looks to those users like your product is broken. Publish a deprecation window, serve the old version until it expires, and make the client show a quiet message rather than an error. ===== MILESTONES.md ===== # Delivery milestones · WishKit Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A board where new things are not visible yet Wishes, votes and comments work and nothing a stranger submits appears without a decision. ### Steps 1. Default to pending, show the submitter Moderation by default, without a form that looks broken. Files: `src/board/submit.ts` 2. Break sort ties deterministically Two wishes with five votes should not swap places on every refresh. Files: `src/board/sort.ts` 3. Make rejection visible A wish that vanishes gets submitted again by the same person next week. Files: `src/board/statuses.ts` ### Done when - [ ] New wishes are pending and hidden from the list - [ ] A submitter sees their own pending wish - [ ] Sort order is stable and tie-broken - [ ] Hidden wishes take their comments with them ### Watch out - Publishing submissions immediately means the first time somebody posts abuse to your public board it is live on a page linked from inside your app, visible to every customer, until you happen to look. Default new wishes to pending, show the submitter their own pending wish so the form does not feel broken, and make approval a deliberate act. ## M2 · An API you cannot take back The endpoints are versioned and shaped so a build shipped today keeps working next year. ### Steps 1. Version the path from the first release The cheapest thing you will ever do for the version of yourself who ships a change. Files: `src/app/api/v1/route.ts` 2. Keep old response fixtures and test against them The fixture is the shipped client you cannot run. Files: `test/fixtures/v1`, `test/compat.test.ts` 3. Assume the key is public It is inside a binary anybody can download and unpack. Files: `docs/api.md` ### Done when - [ ] An old response fixture still parses - [ ] Every endpoint has a tested example - [ ] Errors leak nothing about other projects - [ ] No endpoint trusts the key as a secret ### Watch out - Renaming a response field or tightening a validation rule breaks every copy of your app that people already installed, and unlike a website you cannot fix them, you can only wait for updates that some users will never install. Version the path, add fields rather than changing them, and treat a shipped client as a contract you signed. ## M3 · A device identifier is a hint Duplicate votes are discouraged honestly and the board never claims the count is exact. ### Steps 1. Make voting idempotent per identifier A flaky mobile network retries, and a retry must not be a second vote. Files: `src/api/vote.ts`, `test/idempotent-vote.test.ts` 2. Rate limit per identifier and per source Either one alone is trivially defeated by the other. Files: `src/abuse/limits.ts` 3. Say the count is approximate, on the page Honesty here costs one sentence and prevents one bad roadmap. Files: `src/board/VoteCount.tsx` ### Done when - [ ] A repeated vote is idempotent - [ ] A missing identifier is rejected clearly - [ ] Limits apply per identifier and per source - [ ] The count is described as approximate in the interface ### Watch out - Treating a device identifier as a person produces a vote count people make roadmap decisions with, and it is wrong in both directions. Apple documents that the vendor identifier changes when a user deletes all of that vendor's apps and reinstalls, and that it can be nil after a restart before the device is unlocked, so an honest user can vote twice; meanwhile anyone can send whatever identifier they like to a public endpoint. Deduplicate on it, rate limit around it, and describe the number as interest rather than as a count of people. ## M4 · Merging duplicates without losing votes Two wishes for one idea become one, and the arithmetic is recorded. ### Steps 1. Move rows, let the constraint deduplicate The unique pair is already the right answer; adding totals is not. Files: `src/admin/merge.ts`, `test/merge.test.ts` 2. Record the arithmetic Somebody will ask why the number went down. Files: `src/admin/merge.ts` 3. Redirect the old wish Links live in chat logs and in shipped app builds. Files: `src/board/redirect.ts` ### Done when - [ ] A merge totals distinct voters, not summed votes - [ ] Moved and dropped counts are recorded and shown - [ ] The merged wish redirects rather than 404s - [ ] Merging into an already merged wish is refused ### Watch out - Adding the two vote totals together after a merge double counts everybody who voted for both wishes, and since duplicates are exactly the wishes the same people vote for, the merged total is inflated precisely where it matters most. Move rows and let the unique constraint drop the overlap, then show both numbers. ## M5 · Running it for as long as the app is installed (production only) The service stays available for old builds, the data leaves cleanly, and removal requests work. ### Steps 1. Test the restore, not the backup The backup script is easy; the restore is the one you will run under pressure. Files: `scripts/backup.sh`, `test/restore.test.ts` 2. Write a deprecation window and keep to it Your users cannot be asked to update, so you wait instead. Files: `docs/deprecation.md` 3. Delete by identifier, then verify A deletion you have not queried for afterwards is a deletion you hope happened. Files: `src/admin/delete-device.ts` ### Done when - [ ] A restore is exercised in the suite - [ ] Export counts match the database - [ ] Deletion by identifier is verified by query - [ ] A deprecation window is published and honoured ### Watch out - Turning off an old API version because nothing in your current app uses it breaks the copies of the app still installed on phones that have not updated in two years, and the failure looks to those users like your product is broken. Publish a deprecation window, serve the old version until it expires, and make the client show a quiet message rather than an error. ===== OPERATIONS.md ===== # Operations · WishKit ## Backup The database file on a schedule, off the host. It is small, it is the whole product, and the restore is what you are actually testing when you write the backup script. ## Restore Restore, start the service, and call the API with an old fixture before announcing anything. A restored board that fails the compatibility test would break shipped apps rather than just inconvenience you. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Health endpoint availability, write rate limit hits, and the pending moderation queue depth. A queue nobody empties is abuse sitting one approval away from your customers. ## Incident checklist A spam flood is handled by hiding rather than deleting, so the decisions stay reviewable, and by tightening the proxy limit rather than the application one. An API outage is the serious incident here, because the affected clients are binaries you cannot patch. 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 - [ ] New wishes are confirmed hidden from the public list - [ ] An old response fixture is confirmed to still parse - [ ] A repeated vote is confirmed idempotent - [ ] A merge is confirmed to total distinct voters - [ ] A restore is confirmed exercised in the suite ## Launch constraint Do not market omitted WishKit 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. One file. Backed up on a schedule with a tested restore. DATABASE_PATH=./data/wishes.db # Required · secret. The only credential in the system. Rotate it by changing this and restarting. ADMIN_PASSWORD=a long random string # Required · secret. Signs the admin session cookie. Changing it signs everyone out, which is a feature. COOKIE_SECRET=redacted-32-byte-random # Required. What your shipped apps call. Changing it strands old builds, so choose once. PUBLIC_BASE_URL=https://wishes.example.com # Required. Per device identifier. Stated in the error so a blocked caller knows why. WRITE_LIMIT_PER_HOUR=10 # Required. Titles, descriptions and comments. Enforced before storage, not after. MAX_BODY_CHARS=2000 # Required. How long a superseded API version keeps answering. Published in the README. API_DEPRECATION_DAYS=365
# WishKit · indie build A feature request board whose hard problems are an API that must keep working for binaries you shipped last year, a device identifier that is a hint rather than an identity, and abuse handling on endpoints anybody on the internet can post to. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with the App Router and TypeScript | one deployment serving a board, an admin view and an API | | Storage | SQLite through Drizzle, one file | a board is small and a single file is the easiest thing to back up | | Identity | A client-generated identifier treated as a hint, never as proof | anything a client sends about itself is a claim | | API | A versioned path with fields that are only ever added | an old build of your app cannot be asked to update | | Admin | One password from the environment behind a signed cookie | the smallest thing that is not nothing | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the board, the admin view and the API. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A small host with persistent disk and a stable name** · a few pounds a month - Why: The endpoint has to answer for as long as an old build of your app is installed somewhere, which is longer than you think and not something you can shorten. - Get it: Any small virtual machine or container platform with a volume and a domain you control. - Verify: The health endpoint answers over TLS at a name you own - [ ] **A proxy or platform that can rate limit** · free with most hosts - Why: Application rate limiting protects the database and does nothing about the bandwidth, so the layer in front needs a limit too before a public write endpoint meets its first script. - Get it: Any reverse proxy or platform feature that limits requests per source. - Verify: A burst from one source is refused before it reaches the application - [ ] **Xcode, if you want the native widget** (optional) · free, on a Mac - Why: The small SwiftUI package is the part that makes this worth self-hosting, and it cannot be compiled or tested anywhere else. - Get it: Xcode from the Mac App Store. - Verify: The package builds and the board view lists wishes from your API - [ ] **Your API deprecation window** · free - Why: It is a promise to people running software you can no longer change, and picking the number after the first breaking change means picking it too late. - Get it: Decide a minimum period an API version is served after it is superseded, and write it in the README. - Verify: The figure is in the README and the old version still answers ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Email and push notifications. - Multi-tenant signup and billing. - User accounts and sign-in. - Public roadmap columns. - A maintained SDK per platform, so every new client is your code to write and version - Anti-abuse that someone else already tuned: duplicate votes, spam floods, garbage submissions - Uptime you did not have to think about for users running an old build of your app - Email or push notifications when a request they voted for ships - Duplicate merging, tagging and the other board hygiene features that only matter once you have hundreds of wishes If one of those is essential to you, that is the reason to keep paying for WishKit, and the README should say so rather than pretend.
# Build brief · WishKit 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 self-hosted feature request board with a small API your shipped apps can call to replace WishKit. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with the App Router and TypeScript · one deployment serving a board, an admin view and an API - Storage: SQLite through Drizzle, one file · a board is small and a single file is the easiest thing to back up - Identity: A client-generated identifier treated as a hint, never as proof · anything a client sends about itself is a claim - API: A versioned path with fields that are only ever added · an old build of your app cannot be asked to update - Admin: One password from the environment behind a signed cookie · the smallest thing that is not nothing ### Data model (create this before Phase 1) - projects(id, name, api_key, created_at) · the key identifies an app, not a person, and it will end up public - wishes(id, project_id, title, description, status, created_at, author_device_id, hidden_at, hidden_reason) - votes(wish_id, device_id, created_at) · unique on the pair, which is the only deduplication available to you - comments(id, wish_id, device_id, body, created_at, hidden_at) · hidden rather than deleted, so a moderation decision is reviewable - merges(from_wish_id, into_wish_id, merged_at, votes_moved, votes_dropped) · a merge is a recorded event, not an update - abuse(bucket, window_start, count, blocked_until) · per device and per source, because a public write endpoint attracts the internet - The vote count is the sum of rows in votes, and every row came from a client that told you who it was. The board should present it as a signal, not a measurement. ### Phase 1 · A board where new things are not visible yet Build: Build the public board with a sorted list, a detail page, a submit form and comments. New wishes start pending and are not returned by the public list. Statuses cover pending, approved, in progress, completed and rejected, and a rejected wish says so rather than vanishing. Trap: Publishing submissions immediately means the first time somebody posts abuse to your public board it is live on a page linked from inside your app, visible to every customer, until you happen to look. Default new wishes to pending, show the submitter their own pending wish so the form does not feel broken, and make approval a deliberate act. Done when: A newly submitted wish is absent from the public list and visible to its submitter as pending, tested from two device identifiers. A rejected wish shows a status rather than disappearing, so nobody submits it three more times. Sorting by votes and by recency both produce a stable order with ties broken deterministically. Comments on a hidden wish are unreachable from the public board. Do not build yet: No API, rate limiting, merging or admin tooling. ### Phase 2 · An API you cannot take back Build: Expose a versioned path with a project key header and a device identifier header. Only ever add fields to a response, never remove or retype one. Return a clear error shape. Document every endpoint with a working command line example. Trap: Renaming a response field or tightening a validation rule breaks every copy of your app that people already installed, and unlike a website you cannot fix them, you can only wait for updates that some users will never install. Version the path, add fields rather than changing them, and treat a shipped client as a contract you signed. Done when: A recorded response from an earlier version still parses against the current endpoint, asserted by a test holding a stored fixture. Every documented endpoint has a working command line example in the README that is exercised by the test suite. An unknown project key returns the same error shape as a malformed body, with no detail about which projects exist. The API key is treated as public, so every endpoint it reaches is one a stranger may reach. Do not build yet: No abuse handling, merging or admin tooling yet. ### Phase 3 · A device identifier is a hint Build: Accept a client-generated identifier, store it, and make the vote table unique per wish and identifier. Rate limit writes per identifier and per source. State on the board that counts are approximate, and never present a vote total as a headcount. Trap: Treating a device identifier as a person produces a vote count people make roadmap decisions with, and it is wrong in both directions. Apple documents that the vendor identifier changes when a user deletes all of that vendor's apps and reinstalls, and that it can be nil after a restart before the device is unlocked, so an honest user can vote twice; meanwhile anyone can send whatever identifier they like to a public endpoint. Deduplicate on it, rate limit around it, and describe the number as interest rather than as a count of people. Done when: Voting twice from one identifier changes nothing and returns the same result, so the client can be naive about retries. A missing or malformed identifier is rejected with a clear error rather than being generated server side. Rate limits apply per identifier and per source and a blocked caller is told the limit rather than being silently dropped. The board and the README both describe vote counts as approximate and say why. Do not build yet: No merging, admin tooling or export yet. ### Phase 4 · Merging duplicates without losing votes Build: Merge a wish into another, moving votes and deduplicating identifiers that voted for both. Record how many votes moved and how many were dropped as duplicates. Leave a redirect from the merged wish so an old link still lands somewhere sensible. Trap: Adding the two vote totals together after a merge double counts everybody who voted for both wishes, and since duplicates are exactly the wishes the same people vote for, the merged total is inflated precisely where it matters most. Move rows and let the unique constraint drop the overlap, then show both numbers. Done when: Merging two wishes with overlapping voters produces a total equal to the distinct voters, asserted with a crafted overlap. The merge record stores votes moved and votes dropped and both are shown in the admin view. A link to the merged wish redirects to the surviving one rather than returning not found. A merge cannot be performed into a wish that has itself been merged away. Do not build yet: No operations, backup or export yet. ### Phase 5 · Running it for as long as the app is installed Build: Add a health endpoint, structured request logs without bodies, an automated backup with a tested restore, and a full JSON export. Write a deprecation policy for the API and a procedure for a deletion request that covers wishes, votes and comments by identifier. Trap: Turning off an old API version because nothing in your current app uses it breaks the copies of the app still installed on phones that have not updated in two years, and the failure looks to those users like your product is broken. Publish a deprecation window, serve the old version until it expires, and make the client show a quiet message rather than an error. Done when: A restore from an automated backup is exercised in the suite and the restored board serves the API. Export writes projects, wishes, votes, comments and merges to JSON and the counts match the database. A deletion request removes every row carrying a given identifier and the removal is verified by querying for it afterwards. The deprecation policy is in the README with a stated minimum window and the health endpoint reports version and database reachability. Do not build yet: Nothing after this. ### Out of scope (and why) - Email and push notifications, which turn a board into a message sender with deliverability, unsubscribes and a reputation to protect. - Multi-tenant signup and billing, which is the moment you stop running a board and start running a service for other people. - Accounts and sign-in, since the whole appeal here is a widget that works without asking a user to make yet another account. - Roadmap columns and public planning, which is a promise to strangers about what you will build and harder to retract than it looks. ### README must contain - That the project key ships inside your binary and is therefore public, and what that means for every endpoint - That vote counts are approximate, and the specific reasons a device identifier is not a person - A working command line example for every endpoint, matching the tested fixtures - The API deprecation window, because users cannot be made to update a shipped app - Where the database file lives, how backups run, and that the restore is tested
# Agent instructions · WishKit indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with the App Router and TypeScript, SQLite through Drizzle, one file, A client-generated identifier treated as a hint, never as proof, A versioned path with fields that are only ever added, One password from the environment behind a signed cookie. 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 - Publishing submissions immediately means the first time somebody posts abuse to your public board it is live on a page linked from inside your app, visible to every customer, until you happen to look. Default new wishes to pending, show the submitter their own pending wish so the form does not feel broken, and make approval a deliberate act. - Renaming a response field or tightening a validation rule breaks every copy of your app that people already installed, and unlike a website you cannot fix them, you can only wait for updates that some users will never install. Version the path, add fields rather than changing them, and treat a shipped client as a contract you signed. - Treating a device identifier as a person produces a vote count people make roadmap decisions with, and it is wrong in both directions. Apple documents that the vendor identifier changes when a user deletes all of that vendor's apps and reinstalls, and that it can be nil after a restart before the device is unlocked, so an honest user can vote twice; meanwhile anyone can send whatever identifier they like to a public endpoint. Deduplicate on it, rate limit around it, and describe the number as interest rather than as a count of people. - Adding the two vote totals together after a merge double counts everybody who voted for both wishes, and since duplicates are exactly the wishes the same people vote for, the merged total is inflated precisely where it matters most. Move rows and let the unique constraint drop the overlap, then show both numbers.
# Build plan · WishKit A feature request board whose hard problems are an API that must keep working for binaries you shipped last year, a device identifier that is a hint rather than an identity, and abuse handling on endpoints anybody on the internet can post to. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A board where new things are not visible yet Wishes, votes and comments work and nothing a stranger submits appears without a decision. ### Steps 1. Default to pending, show the submitter Moderation by default, without a form that looks broken. Files: `src/board/submit.ts` 2. Break sort ties deterministically Two wishes with five votes should not swap places on every refresh. Files: `src/board/sort.ts` 3. Make rejection visible A wish that vanishes gets submitted again by the same person next week. Files: `src/board/statuses.ts` ### Done when - [ ] New wishes are pending and hidden from the list - [ ] A submitter sees their own pending wish - [ ] Sort order is stable and tie-broken - [ ] Hidden wishes take their comments with them ### Watch out - Publishing submissions immediately means the first time somebody posts abuse to your public board it is live on a page linked from inside your app, visible to every customer, until you happen to look. Default new wishes to pending, show the submitter their own pending wish so the form does not feel broken, and make approval a deliberate act. ## Phase 2 · An API you cannot take back The endpoints are versioned and shaped so a build shipped today keeps working next year. ### Steps 1. Version the path from the first release The cheapest thing you will ever do for the version of yourself who ships a change. Files: `src/app/api/v1/route.ts` 2. Keep old response fixtures and test against them The fixture is the shipped client you cannot run. Files: `test/fixtures/v1`, `test/compat.test.ts` 3. Assume the key is public It is inside a binary anybody can download and unpack. Files: `docs/api.md` ### Done when - [ ] An old response fixture still parses - [ ] Every endpoint has a tested example - [ ] Errors leak nothing about other projects - [ ] No endpoint trusts the key as a secret ### Watch out - Renaming a response field or tightening a validation rule breaks every copy of your app that people already installed, and unlike a website you cannot fix them, you can only wait for updates that some users will never install. Version the path, add fields rather than changing them, and treat a shipped client as a contract you signed. ## Phase 3 · A device identifier is a hint Duplicate votes are discouraged honestly and the board never claims the count is exact. ### Steps 1. Make voting idempotent per identifier A flaky mobile network retries, and a retry must not be a second vote. Files: `src/api/vote.ts`, `test/idempotent-vote.test.ts` 2. Rate limit per identifier and per source Either one alone is trivially defeated by the other. Files: `src/abuse/limits.ts` 3. Say the count is approximate, on the page Honesty here costs one sentence and prevents one bad roadmap. Files: `src/board/VoteCount.tsx` ### Done when - [ ] A repeated vote is idempotent - [ ] A missing identifier is rejected clearly - [ ] Limits apply per identifier and per source - [ ] The count is described as approximate in the interface ### Watch out - Treating a device identifier as a person produces a vote count people make roadmap decisions with, and it is wrong in both directions. Apple documents that the vendor identifier changes when a user deletes all of that vendor's apps and reinstalls, and that it can be nil after a restart before the device is unlocked, so an honest user can vote twice; meanwhile anyone can send whatever identifier they like to a public endpoint. Deduplicate on it, rate limit around it, and describe the number as interest rather than as a count of people. ## Phase 4 · Merging duplicates without losing votes Two wishes for one idea become one, and the arithmetic is recorded. ### Steps 1. Move rows, let the constraint deduplicate The unique pair is already the right answer; adding totals is not. Files: `src/admin/merge.ts`, `test/merge.test.ts` 2. Record the arithmetic Somebody will ask why the number went down. Files: `src/admin/merge.ts` 3. Redirect the old wish Links live in chat logs and in shipped app builds. Files: `src/board/redirect.ts` ### Done when - [ ] A merge totals distinct voters, not summed votes - [ ] Moved and dropped counts are recorded and shown - [ ] The merged wish redirects rather than 404s - [ ] Merging into an already merged wish is refused ### Watch out - Adding the two vote totals together after a merge double counts everybody who voted for both wishes, and since duplicates are exactly the wishes the same people vote for, the merged total is inflated precisely where it matters most. Move rows and let the unique constraint drop the overlap, then show both numbers. ## Not in this build - Email and push notifications. - Multi-tenant signup and billing. - User accounts and sign-in. - Public roadmap columns. ## After v1, if you want it - A second platform widget, once the API has been stable for a release cycle - Tagging and a duplicate suggestion at submission time, which reduces merges rather than improving them - A digest the admin reads rather than a notification every user receives
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. One file. Backed up on a schedule with a tested restore. DATABASE_PATH=./data/wishes.db # Required · secret. The only credential in the system. Rotate it by changing this and restarting. ADMIN_PASSWORD=a long random string # Required · secret. Signs the admin session cookie. Changing it signs everyone out, which is a feature. COOKIE_SECRET=redacted-32-byte-random # Required. What your shipped apps call. Changing it strands old builds, so choose once. PUBLIC_BASE_URL=https://wishes.example.com # Required. Per device identifier. Stated in the error so a blocked caller knows why. WRITE_LIMIT_PER_HOUR=10 # Required. Titles, descriptions and comments. Enforced before storage, not after. MAX_BODY_CHARS=2000 # Required. How long a superseded API version keeps answering. Published in the README. API_DEPRECATION_DAYS=365
# WishKit · product brief ## Problem The data model here is four tables and a sort function: wishes, votes, comments, statuses. An agent can produce the whole web board plus a REST API in a single sitting, and it will work fine for a side project. The part that is actually work is everything after that: a native in-app widget for each platform you ship on, anonymous device identity so the same user cannot vote fifty times, spam and profanity handling, and an endpoint that stays up for people who already installed your binary. Nothing there is hard, it is just a real backend you now own forever. Build it if you like owning it, pay if your roadmap board is not the thing you want to be on call for. ## Product outcome A board you own, an API that will still answer a two year old build of your app, a vote count presented as the approximation it is, and moderation that happens before a stranger's words reach your customers. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - A place to host a small Node service with persistent disk - A domain or subdomain your shipped app can call - Xcode if you want the SwiftUI widget compiled and tested - Basic rate limiting or a proxy in front of the write endpoints ## Explicit non-goals for v1 - Email and push notifications. - Multi-tenant signup and billing. - User accounts and sign-in. - Public roadmap columns. - A maintained SDK per platform, so every new client is your code to write and version - Anti-abuse that someone else already tuned: duplicate votes, spam floods, garbage submissions - Uptime you did not have to think about for users running an old build of your app - Email or push notifications when a request they voted for ships - Duplicate merging, tagging and the other board hygiene features that only matter once you have hundreds of wishes ## Success criteria - New wishes are confirmed hidden from the public list - An old response fixture is confirmed to still parse - A repeated vote is confirmed idempotent - A merge is confirmed to total distinct voters - A restore is confirmed exercised in the suite
# Build brief · WishKit 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 self-hosted feature request board with a small API your shipped apps can call to replace WishKit. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with the App Router and TypeScript · one deployment serving a board, an admin view and an API - Storage: SQLite through Drizzle, one file · a board is small and a single file is the easiest thing to back up - Identity: A client-generated identifier treated as a hint, never as proof · anything a client sends about itself is a claim - API: A versioned path with fields that are only ever added · an old build of your app cannot be asked to update - Admin: One password from the environment behind a signed cookie · the smallest thing that is not nothing ### Data model (create this before Phase 1) - projects(id, name, api_key, created_at) · the key identifies an app, not a person, and it will end up public - wishes(id, project_id, title, description, status, created_at, author_device_id, hidden_at, hidden_reason) - votes(wish_id, device_id, created_at) · unique on the pair, which is the only deduplication available to you - comments(id, wish_id, device_id, body, created_at, hidden_at) · hidden rather than deleted, so a moderation decision is reviewable - merges(from_wish_id, into_wish_id, merged_at, votes_moved, votes_dropped) · a merge is a recorded event, not an update - abuse(bucket, window_start, count, blocked_until) · per device and per source, because a public write endpoint attracts the internet - The vote count is the sum of rows in votes, and every row came from a client that told you who it was. The board should present it as a signal, not a measurement. ### Phase 1 · A board where new things are not visible yet Build: Build the public board with a sorted list, a detail page, a submit form and comments. New wishes start pending and are not returned by the public list. Statuses cover pending, approved, in progress, completed and rejected, and a rejected wish says so rather than vanishing. Trap: Publishing submissions immediately means the first time somebody posts abuse to your public board it is live on a page linked from inside your app, visible to every customer, until you happen to look. Default new wishes to pending, show the submitter their own pending wish so the form does not feel broken, and make approval a deliberate act. Done when: A newly submitted wish is absent from the public list and visible to its submitter as pending, tested from two device identifiers. A rejected wish shows a status rather than disappearing, so nobody submits it three more times. Sorting by votes and by recency both produce a stable order with ties broken deterministically. Comments on a hidden wish are unreachable from the public board. Do not build yet: No API, rate limiting, merging or admin tooling. ### Phase 2 · An API you cannot take back Build: Expose a versioned path with a project key header and a device identifier header. Only ever add fields to a response, never remove or retype one. Return a clear error shape. Document every endpoint with a working command line example. Trap: Renaming a response field or tightening a validation rule breaks every copy of your app that people already installed, and unlike a website you cannot fix them, you can only wait for updates that some users will never install. Version the path, add fields rather than changing them, and treat a shipped client as a contract you signed. Done when: A recorded response from an earlier version still parses against the current endpoint, asserted by a test holding a stored fixture. Every documented endpoint has a working command line example in the README that is exercised by the test suite. An unknown project key returns the same error shape as a malformed body, with no detail about which projects exist. The API key is treated as public, so every endpoint it reaches is one a stranger may reach. Do not build yet: No abuse handling, merging or admin tooling yet. ### Phase 3 · A device identifier is a hint Build: Accept a client-generated identifier, store it, and make the vote table unique per wish and identifier. Rate limit writes per identifier and per source. State on the board that counts are approximate, and never present a vote total as a headcount. Trap: Treating a device identifier as a person produces a vote count people make roadmap decisions with, and it is wrong in both directions. Apple documents that the vendor identifier changes when a user deletes all of that vendor's apps and reinstalls, and that it can be nil after a restart before the device is unlocked, so an honest user can vote twice; meanwhile anyone can send whatever identifier they like to a public endpoint. Deduplicate on it, rate limit around it, and describe the number as interest rather than as a count of people. Done when: Voting twice from one identifier changes nothing and returns the same result, so the client can be naive about retries. A missing or malformed identifier is rejected with a clear error rather than being generated server side. Rate limits apply per identifier and per source and a blocked caller is told the limit rather than being silently dropped. The board and the README both describe vote counts as approximate and say why. Do not build yet: No merging, admin tooling or export yet. ### Phase 4 · Merging duplicates without losing votes Build: Merge a wish into another, moving votes and deduplicating identifiers that voted for both. Record how many votes moved and how many were dropped as duplicates. Leave a redirect from the merged wish so an old link still lands somewhere sensible. Trap: Adding the two vote totals together after a merge double counts everybody who voted for both wishes, and since duplicates are exactly the wishes the same people vote for, the merged total is inflated precisely where it matters most. Move rows and let the unique constraint drop the overlap, then show both numbers. Done when: Merging two wishes with overlapping voters produces a total equal to the distinct voters, asserted with a crafted overlap. The merge record stores votes moved and votes dropped and both are shown in the admin view. A link to the merged wish redirects to the surviving one rather than returning not found. A merge cannot be performed into a wish that has itself been merged away. Do not build yet: No operations, backup or export yet. ### Phase 5 · Running it for as long as the app is installed Build: Add a health endpoint, structured request logs without bodies, an automated backup with a tested restore, and a full JSON export. Write a deprecation policy for the API and a procedure for a deletion request that covers wishes, votes and comments by identifier. Trap: Turning off an old API version because nothing in your current app uses it breaks the copies of the app still installed on phones that have not updated in two years, and the failure looks to those users like your product is broken. Publish a deprecation window, serve the old version until it expires, and make the client show a quiet message rather than an error. Done when: A restore from an automated backup is exercised in the suite and the restored board serves the API. Export writes projects, wishes, votes, comments and merges to JSON and the counts match the database. A deletion request removes every row carrying a given identifier and the removal is verified by querying for it afterwards. The deprecation policy is in the README with a stated minimum window and the health endpoint reports version and database reachability. Do not build yet: Nothing after this. ### Out of scope (and why) - Email and push notifications, which turn a board into a message sender with deliverability, unsubscribes and a reputation to protect. - Multi-tenant signup and billing, which is the moment you stop running a board and start running a service for other people. - Accounts and sign-in, since the whole appeal here is a widget that works without asking a user to make yet another account. - Roadmap columns and public planning, which is a promise to strangers about what you will build and harder to retract than it looks. ### README must contain - That the project key ships inside your binary and is therefore public, and what that means for every endpoint - That vote counts are approximate, and the specific reasons a device identifier is not a person - A working command line example for every endpoint, matching the tested fixtures - The API deprecation window, because users cannot be made to update a shipped app - Where the database file lives, how backups run, and that the restore is tested
# Architecture · WishKit ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with the App Router and TypeScript | one deployment serving a board, an admin view and an API | | Storage | SQLite through Drizzle, one file | a board is small and a single file is the easiest thing to back up | | Identity | A client-generated identifier treated as a hint, never as proof | anything a client sends about itself is a claim | | API | A versioned path with fields that are only ever added | an old build of your app cannot be asked to update | | Admin | One password from the environment behind a signed cookie | the smallest thing that is not nothing | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Board | Public list, detail and submission with pending by default. | Any presentation, provided new submissions stay hidden. | | API | Versioned endpoints with additive changes only. | Any framework, provided old fixtures still parse. | | Identity | Idempotent votes and honest wording about counts. | Any identifier scheme, provided it is never described as a person. | | Moderation | Approval, hiding and merging with recorded arithmetic. | Any workflow, provided hiding is reversible and recorded. | | Operations | Health, backup, export and deletion by identifier. | Any tooling, provided the restore is exercised. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · One file. Backed up on a schedule with a tested restore. - `ADMIN_PASSWORD` · required, secret · The only credential in the system. Rotate it by changing this and restarting. - `COOKIE_SECRET` · required, secret · Signs the admin session cookie. Changing it signs everyone out, which is a feature. - `PUBLIC_BASE_URL` · required · What your shipped apps call. Changing it strands old builds, so choose once. - `WRITE_LIMIT_PER_HOUR` · required · Per device identifier. Stated in the error so a blocked caller knows why. - `MAX_BODY_CHARS` · required · Titles, descriptions and comments. Enforced before storage, not after. - `API_DEPRECATION_DAYS` · required · How long a superseded API version keeps answering. Published in the README. ## 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 · WishKit product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with the App Router and TypeScript, SQLite through Drizzle, one file, A client-generated identifier treated as a hint, never as proof, A versioned path with fields that are only ever added, One password from the environment behind a signed cookie. - 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 - Publishing submissions immediately means the first time somebody posts abuse to your public board it is live on a page linked from inside your app, visible to every customer, until you happen to look. Default new wishes to pending, show the submitter their own pending wish so the form does not feel broken, and make approval a deliberate act. - Renaming a response field or tightening a validation rule breaks every copy of your app that people already installed, and unlike a website you cannot fix them, you can only wait for updates that some users will never install. Version the path, add fields rather than changing them, and treat a shipped client as a contract you signed. - Treating a device identifier as a person produces a vote count people make roadmap decisions with, and it is wrong in both directions. Apple documents that the vendor identifier changes when a user deletes all of that vendor's apps and reinstalls, and that it can be nil after a restart before the device is unlocked, so an honest user can vote twice; meanwhile anyone can send whatever identifier they like to a public endpoint. Deduplicate on it, rate limit around it, and describe the number as interest rather than as a count of people. - Adding the two vote totals together after a merge double counts everybody who voted for both wishes, and since duplicates are exactly the wishes the same people vote for, the merged total is inflated precisely where it matters most. Move rows and let the unique constraint drop the overlap, then show both numbers. - Turning off an old API version because nothing in your current app uses it breaks the copies of the app still installed on phones that have not updated in two years, and the failure looks to those users like your product is broken. Publish a deprecation window, serve the old version until it expires, and make the client show a quiet message rather than an error.
# Delivery milestones · WishKit Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A board where new things are not visible yet Wishes, votes and comments work and nothing a stranger submits appears without a decision. ### Steps 1. Default to pending, show the submitter Moderation by default, without a form that looks broken. Files: `src/board/submit.ts` 2. Break sort ties deterministically Two wishes with five votes should not swap places on every refresh. Files: `src/board/sort.ts` 3. Make rejection visible A wish that vanishes gets submitted again by the same person next week. Files: `src/board/statuses.ts` ### Done when - [ ] New wishes are pending and hidden from the list - [ ] A submitter sees their own pending wish - [ ] Sort order is stable and tie-broken - [ ] Hidden wishes take their comments with them ### Watch out - Publishing submissions immediately means the first time somebody posts abuse to your public board it is live on a page linked from inside your app, visible to every customer, until you happen to look. Default new wishes to pending, show the submitter their own pending wish so the form does not feel broken, and make approval a deliberate act. ## M2 · An API you cannot take back The endpoints are versioned and shaped so a build shipped today keeps working next year. ### Steps 1. Version the path from the first release The cheapest thing you will ever do for the version of yourself who ships a change. Files: `src/app/api/v1/route.ts` 2. Keep old response fixtures and test against them The fixture is the shipped client you cannot run. Files: `test/fixtures/v1`, `test/compat.test.ts` 3. Assume the key is public It is inside a binary anybody can download and unpack. Files: `docs/api.md` ### Done when - [ ] An old response fixture still parses - [ ] Every endpoint has a tested example - [ ] Errors leak nothing about other projects - [ ] No endpoint trusts the key as a secret ### Watch out - Renaming a response field or tightening a validation rule breaks every copy of your app that people already installed, and unlike a website you cannot fix them, you can only wait for updates that some users will never install. Version the path, add fields rather than changing them, and treat a shipped client as a contract you signed. ## M3 · A device identifier is a hint Duplicate votes are discouraged honestly and the board never claims the count is exact. ### Steps 1. Make voting idempotent per identifier A flaky mobile network retries, and a retry must not be a second vote. Files: `src/api/vote.ts`, `test/idempotent-vote.test.ts` 2. Rate limit per identifier and per source Either one alone is trivially defeated by the other. Files: `src/abuse/limits.ts` 3. Say the count is approximate, on the page Honesty here costs one sentence and prevents one bad roadmap. Files: `src/board/VoteCount.tsx` ### Done when - [ ] A repeated vote is idempotent - [ ] A missing identifier is rejected clearly - [ ] Limits apply per identifier and per source - [ ] The count is described as approximate in the interface ### Watch out - Treating a device identifier as a person produces a vote count people make roadmap decisions with, and it is wrong in both directions. Apple documents that the vendor identifier changes when a user deletes all of that vendor's apps and reinstalls, and that it can be nil after a restart before the device is unlocked, so an honest user can vote twice; meanwhile anyone can send whatever identifier they like to a public endpoint. Deduplicate on it, rate limit around it, and describe the number as interest rather than as a count of people. ## M4 · Merging duplicates without losing votes Two wishes for one idea become one, and the arithmetic is recorded. ### Steps 1. Move rows, let the constraint deduplicate The unique pair is already the right answer; adding totals is not. Files: `src/admin/merge.ts`, `test/merge.test.ts` 2. Record the arithmetic Somebody will ask why the number went down. Files: `src/admin/merge.ts` 3. Redirect the old wish Links live in chat logs and in shipped app builds. Files: `src/board/redirect.ts` ### Done when - [ ] A merge totals distinct voters, not summed votes - [ ] Moved and dropped counts are recorded and shown - [ ] The merged wish redirects rather than 404s - [ ] Merging into an already merged wish is refused ### Watch out - Adding the two vote totals together after a merge double counts everybody who voted for both wishes, and since duplicates are exactly the wishes the same people vote for, the merged total is inflated precisely where it matters most. Move rows and let the unique constraint drop the overlap, then show both numbers. ## M5 · Running it for as long as the app is installed (production only) The service stays available for old builds, the data leaves cleanly, and removal requests work. ### Steps 1. Test the restore, not the backup The backup script is easy; the restore is the one you will run under pressure. Files: `scripts/backup.sh`, `test/restore.test.ts` 2. Write a deprecation window and keep to it Your users cannot be asked to update, so you wait instead. Files: `docs/deprecation.md` 3. Delete by identifier, then verify A deletion you have not queried for afterwards is a deletion you hope happened. Files: `src/admin/delete-device.ts` ### Done when - [ ] A restore is exercised in the suite - [ ] Export counts match the database - [ ] Deletion by identifier is verified by query - [ ] A deprecation window is published and honoured ### Watch out - Turning off an old API version because nothing in your current app uses it breaks the copies of the app still installed on phones that have not updated in two years, and the failure looks to those users like your product is broken. Publish a deprecation window, serve the old version until it expires, and make the client show a quiet message rather than an error.
# Operations · WishKit ## Backup The database file on a schedule, off the host. It is small, it is the whole product, and the restore is what you are actually testing when you write the backup script. ## Restore Restore, start the service, and call the API with an old fixture before announcing anything. A restored board that fails the compatibility test would break shipped apps rather than just inconvenience you. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Health endpoint availability, write rate limit hits, and the pending moderation queue depth. A queue nobody empties is abuse sitting one approval away from your customers. ## Incident checklist A spam flood is handled by hiding rather than deleting, so the decisions stay reviewable, and by tightening the proxy limit rather than the application one. An API outage is the serious incident here, because the affected clients are binaries you cannot patch. 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 - [ ] New wishes are confirmed hidden from the public list - [ ] An old response fixture is confirmed to still parse - [ ] A repeated vote is confirmed idempotent - [ ] A merge is confirmed to total distinct voters - [ ] A restore is confirmed exercised in the suite ## Launch constraint Do not market omitted WishKit 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. One file. Backed up on a schedule with a tested restore. DATABASE_PATH=./data/wishes.db # Required · secret. The only credential in the system. Rotate it by changing this and restarting. ADMIN_PASSWORD=a long random string # Required · secret. Signs the admin session cookie. Changing it signs everyone out, which is a feature. COOKIE_SECRET=redacted-32-byte-random # Required. What your shipped apps call. Changing it strands old builds, so choose once. PUBLIC_BASE_URL=https://wishes.example.com # Required. Per device identifier. Stated in the error so a blocked caller knows why. WRITE_LIMIT_PER_HOUR=10 # Required. Titles, descriptions and comments. Enforced before storage, not after. MAX_BODY_CHARS=2000 # Required. How long a superseded API version keeps answering. Published in the README. API_DEPRECATION_DAYS=365
$ choose a build depth, inspect the files, then open the complete pack in your agent
Because a feature request board is not the product, it is a chore attached to the product. Paying converts an endpoint you have to keep alive, patch, and defend from spam into a line item, and it gets you a native widget that matches the platform without you writing UI you do not care about. Indie developers in particular are buying the SDK and the uptime, not the CRUD.
xA maintained SDK per platform, so every new client is your code to write and version
xAnti-abuse that someone else already tuned: duplicate votes, spam floods, garbage submissions
xUptime you did not have to think about for users running an old build of your app
xEmail or push notifications when a request they voted for ships
xDuplicate merging, tagging and the other board hygiene features that only matter once you have hundreds of wishes
Nothing worth pointing at. That's why the prompt exists.
Vibecode WishKit
Kinda. The core of WishKit is buildable in a weekend with the prompt on this page, but there are real gaps: A maintained SDK per platform, so every new client is your code to write and version, Anti-abuse that someone else already tuned: duplicate votes, spam floods, garbage submissions. Read the honest list above before committing.
How much does WishKit cost?
WishKit costs about $15/month (Premium, checked 2026-08-18), which is $180 per year.
What do I lose by replacing WishKit?
Honestly: A maintained SDK per platform, so every new client is your code to write and version; Anti-abuse that someone else already tuned: duplicate votes, spam floods, garbage submissions; Uptime you did not have to think about for users running an old build of your app; Email or push notifications when a request they voted for ships; Duplicate merging, tagging and the other board hygiene features that only matter once you have hundreds of wishes. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to WishKit?
No mature open-source alternative worth pointing at, which is exactly why the one-shot prompt on this page exists.