An open board for startup sentiment. Anyone can add a private company, call it bullish or bearish, and — the part that actually matters — write down why.
No accounts. No gatekeeping. No algorithmic feed. Just a list of companies, a split bar showing where the crowd landed, and the arguments on both sides.
Startup sentiment already gets traded constantly — in group chats, on podcasts, in replies. It just never gets written down anywhere durable or public. This is the smallest thing that does that: a permanent, linkable page per company where the bull case and the bear case sit next to each other.
The vote is the hook. The reasoning is the product.
Needs Node 22.13 or newer (for the built-in node:sqlite — 22.5 introduced it but kept it behind a flag until 22.13). There are no
native dependencies and no build step.
git clone https://github.com/MaverickFraser39/bullishorbearish.ai.git
cd bullishorbearish.ai
npm install
npm run seed # optional: puts a sample board in place
npm run dev # http://localhost:3000npm test runs the suite. npm start runs it without the file watcher.
| Server | Express 5, one file (server.js) |
| Storage | SQLite through Node's built-in node:sqlite — no better-sqlite3, nothing to compile |
| Frontend | Vanilla JS modules and one stylesheet. No framework, no bundler, no build |
| Type | Geist, Geist Mono and Martian Mono — self-hosted latin subsets (~72KB), so there are no third-party requests |
| Tests | node:test |
server.js HTTP layer: routes, rate limiting, voter fingerprinting
lib/db.js schema and every query — no SQL lives anywhere else
lib/validate.js input parsing; nothing reaches the database unvalidated
public/ the whole client — index.html, app.js, styles.css, favicon.svg
scripts/seed.js sample board
tests/ node:test suite
docs/ images used by this README
The green isn't quite green. Bullish/bearish wants red and green, and that
is what ships — it is the convention this category reads instantly. But plain
green/red is the textbook colourblind failure: #0ca30c against #d03b3b
separates by only ΔE 4.1 under deuteranopia, where 8 is the passing bar. The
live pair is #00e08f against #ff4d5e, which reaches ΔE 11.2 — the
bullish hue is stepped toward teal to get there — with a normal-vision ΔE of
39.3, both over 3:1 on the surface and legible as text at 11.4:1 and 6.1:1.
Worth being straight about the trade: a warm/cool pair such as gold against indigo measures ΔE 30.3, close to three times the separation, and carries no implication that bearish is an error the way a danger-red does. Red/green was chosen anyway, for recognisability. That makes the secondary encoding load-bearing rather than belt-and-braces: every sentiment indicator ships with an arrow and a written label, and none of them may be dropped to "clean up" the design.
Restraint is the design. Colour appears on the figures, the meter, an active vote and the live dot — nowhere else. There are no glows on the numbers, no bloom on the bars, no gradient-filled headline and no shimmer. Those were all there in an earlier pass and every one of them made the page look like it was trying to convince you.
Motion is decoration, never information. Entrances, the drifting wash, the
cursor spotlight and the counting figures are ornament. Under
prefers-reduced-motion every one is cut and the final state renders
immediately — nothing is withheld.
Dark only, by choice. There is no light theme and no toggle. The palette, the ground and the hairline system are built for one surface, and a mechanical inverse of them would be worse than not offering one.
Duplicates are the real enemy. On a board anyone can post to, OpenAI,
Open AI and open-ai become three cards splitting one company's votes. Every
name is reduced to an alphanumeric key (openai) that decides identity, while a
separate readable slug (open-ai) is what appears in the URL.
Thin samples don't top the charts. Ranking uses a Laplace-smoothed ratio,
(bullish + 1) / (total + 2), so a single enthusiastic vote can't outrank a
company sitting at 98% across forty.
Changing your mind isn't a second vote. Votes upsert on
(startup, voter). Re-voting rewrites your call rather than stacking another
one. A quick vote from the board sends no reason field at all, which
deliberately means "leave my writing alone" — a blank one sent explicitly is
what clears it.
Anyone can post without an account, so removal has to be possible. Every listing carries a Report control, and reports queue up for review.
Moderation endpoints are gated on ADMIN_TOKEN sent as an x-admin-token
header. With that unset every admin route refuses, so an unconfigured deploy is
closed rather than open. A wrong token returns 404, not 401 — an attacker
learns nothing about whether the route exists.
TOKEN=... # the ADMIN_TOKEN you set
BASE=https://bullishorbearish.fly.dev
curl -H "x-admin-token: $TOKEN" $BASE/api/admin/reports
curl -X DELETE -H "x-admin-token: $TOKEN" $BASE/api/admin/startups/<slug>
curl -X DELETE -H "x-admin-token: $TOKEN" $BASE/api/admin/takes/<id>Deleting a listing removes its votes and reports with it. Redacting a take strips the text and the name while leaving the vote counted, so moderating one abusive comment does not silently rewrite the tally.
The board can post its own activity. Set the four X_* values from
.env.example and it posts as that account; leave them unset and it logs what
it would have posted and carries on.
It is deliberately not one post per vote. X's free tier allows roughly 500
posts a month, and a busy company would burn that in a day and read as a bot.
Instead, votes for the same company coalesce into a single pending update and
the queue drains at most one post per ANNOUNCE_INTERVAL_MS. Five votes
landing in a minute produce one accurate post, not five stale ones. A new
listing gets its own post, and a lean crossing the midpoint is announced as a
flip.
Requests are signed with OAuth 1.0a using node:crypto — no SDK, no
dependency. The signing is tested against X's published reference vector rather
than assumed to work.
Votes are anonymous, and identity is a salted SHA-256 of IP address plus user agent. This is a speed bump, not a wall. Anyone with a VPN, a second browser, or ten minutes can vote more than once. That is a deliberate trade — accounts would kill the thing that makes this work, which is that voting costs nothing.
Treat every number here as directional, never authoritative. If you deploy this somewhere that needs stronger guarantees, that's the layer to replace.
Set VOTE_SALT to a long random string in production and never change it —
rotating it lets everyone vote again.
Everything the frontend does is a public JSON endpoint.
| Method | Path | |
|---|---|---|
GET |
/api/startups?sort=&q=&limit=&offset= |
the board. sort: hot, contested, bullish, bearish, votes, new |
POST |
/api/startups |
{ name, website?, pitch? } → 201 created, 200 if it already existed |
GET |
/api/startups/:slug |
one company, your current vote, and its takes |
POST |
/api/startups/:slug/votes |
{ direction, reason?, author? } |
GET |
/api/stats |
board totals |
Writes are rate limited per IP: 8 new companies and 60 votes an hour.
Runs on Fly.io out of the box — Dockerfile and fly.toml
are in the repo.
fly auth login
fly launch --no-deploy --copy-config --name bullishorbearish
fly volumes create board_data --size 1 --region iad
fly secrets set VOTE_SALT="$(node -e 'console.log(crypto.randomUUID())')"
fly deployThen point the domain at it:
fly certs add bullishorbearish.aiFly prints the A/AAAA records to add at your registrar and issues the TLS certificate once they resolve.
The SQLite file is the site, so this runs as a single instance with a single
volume. Do not fly scale count past 1. A second machine gets its own empty
volume and the board silently forks in two, with each half serving different
votes. If it ever outgrows one machine, that's the point to move to
LiteFS or Postgres — not a scale command.
For the same reason auto_stop_machines is off: a suspended machine is a
down site, and the saving on a shared-cpu-1x isn't worth it.
Any host that runs Node and gives you a persistent disk works. The only requirements:
- Set
VOTE_SALTto a long random string and never change it — rotating it lets everyone vote again. - Put
DB_PATHon a volume that survives restarts. - Run behind a proxy that sets
X-Forwarded-For; the app trusts it for rate limiting.
Back up by copying the database file: fly ssh console -C "cat /data/board.db" > backup.db.
Pull requests welcome — see CONTRIBUTING.md. Good first issues live in the "Ideas" section there.
MIT. See LICENSE.
This is not investment advice. It is the opinion of anonymous strangers on the internet, presented as such. Nothing here is a recommendation to buy, sell, or hold anything.
