Skip to content

About

An open board for startup sentiment. Add any private company, call it bullish or bearish, and write down why. No accounts, no build step.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

bullishorbearish.ai

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.

The board

Why this exists

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.

Quick start

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:3000

npm test runs the suite. npm start runs it without the file watcher.

How it's built

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

Decisions worth knowing about

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.

Moderation

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.

Posting to X

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.

The honest bit about vote integrity

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.

API

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.

Deploying

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 deploy

Then point the domain at it:

fly certs add bullishorbearish.ai

Fly prints the A/AAAA records to add at your registrar and issues the TLS certificate once they resolve.

One machine, on purpose

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.

Anywhere else

Any host that runs Node and gives you a persistent disk works. The only requirements:

  • Set VOTE_SALT to a long random string and never change it — rotating it lets everyone vote again.
  • Put DB_PATH on 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.

Contributing

Pull requests welcome — see CONTRIBUTING.md. Good first issues live in the "Ideas" section there.

Licence

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.

About

An open board for startup sentiment. Add any private company, call it bullish or bearish, and write down why. No accounts, no build step.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages