Build WishKit
KINDAreplaces $15/mosaves $180/yrback to the verdict
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.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the board, the admin view and the API.
Get it nodejs.org, or nvm install 22. open ↗
Verify
node --version prints v22 or higher - have readya 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 - have readyfree 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 - installfree, 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 - decidefree
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
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- 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.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_PATH | required | ./data/wishes.db | One file. Backed up on a schedule with a tested restore. |
ADMIN_PASSWORDsecret | required | a long random string | The only credential in the system. Rotate it by changing this and restarting. |
COOKIE_SECRETsecret | required | redacted-32-byte-random | Signs the admin session cookie. Changing it signs everyone out, which is a feature. |
PUBLIC_BASE_URL | required | https://wishes.example.com | What your shipped apps call. Changing it strands old builds, so choose once. |
WRITE_LIMIT_PER_HOUR | required | 10 | Per device identifier. Stated in the error so a blocked caller knows why. |
MAX_BODY_CHARS | required | 2000 | Titles, descriptions and comments. Enforced before storage, not after. |
API_DEPRECATION_DAYS | required | 365 | How long a superseded API version keeps answering. Published in the README. |
The build, in order
A board where new things are not visible yet
Wishes, votes and comments work and nothing a stranger submits appears without a decision.
Moderation by default, without a form that looks broken.
Files
src/board/submit.tsTwo wishes with five votes should not swap places on every refresh.
Files
src/board/sort.tsA wish that vanishes gets submitted again by the same person next week.
Files
src/board/statuses.ts
done when · tick each as it passeswatch 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.
An API you cannot take back
The endpoints are versioned and shaped so a build shipped today keeps working next year.
The cheapest thing you will ever do for the version of yourself who ships a change.
Files
src/app/api/v1/route.tsThe fixture is the shipped client you cannot run.
Files
test/fixtures/v1test/compat.test.tsIt is inside a binary anybody can download and unpack.
Files
docs/api.md
done when · tick each as it passeswatch 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.
A device identifier is a hint
Duplicate votes are discouraged honestly and the board never claims the count is exact.
A flaky mobile network retries, and a retry must not be a second vote.
Files
src/api/vote.tstest/idempotent-vote.test.tsEither one alone is trivially defeated by the other.
Files
src/abuse/limits.tsHonesty here costs one sentence and prevents one bad roadmap.
Files
src/board/VoteCount.tsx
done when · tick each as it passeswatch 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.
Merging duplicates without losing votes
Two wishes for one idea become one, and the arithmetic is recorded.
The unique pair is already the right answer; adding totals is not.
Files
src/admin/merge.tstest/merge.test.tsSomebody will ask why the number went down.
Files
src/admin/merge.tsLinks live in chat logs and in shipped app builds.
Files
src/board/redirect.ts
done when · tick each as it passeswatch 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.
Running it for as long as the app is installedproduct builder
The service stays available for old builds, the data leaves cleanly, and removal requests work.
The backup script is easy; the restore is the one you will run under pressure.
Files
scripts/backup.shtest/restore.test.tsYour users cannot be asked to update, so you wait instead.
Files
docs/deprecation.mdA deletion you have not queried for afterwards is a deletion you hope happened.
Files
src/admin/delete-device.ts
done when · tick each as it passeswatch 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.
That is the whole plan for WishKit. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- 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
- 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
Need the files? The project pack on the verdict page hands your agent the whole brief · more user research.