Voting service on AT Protocol.
README.md

voted #

anonymous polls for bluesky, built on atproto records.

how it works #

  • polls belong to the user. publishing a poll writes records to the author's own repo via oauth: one blog.brookie.poll.option record per answer, then a blog.brookie.poll.question record holding strong refs (com.atproto.repo.strongRef) to those options.
  • polls are posted to bluesky. publishing also creates an app.bsky.feed.post in the author's repo: the question, then each option as a rich-text link facet pointing at the vote server's /vote endpoint. voting happens straight from the timeline.
  • votes are anonymous. voters never write to their own repo. instead a voting authority account (did + app password, held as a cloudflare worker secret — see worker/index.js) creates blog.brookie.poll.vote records in its repo, each strong-linking the chosen option. the public record shows that a vote happened, not who cast it. the vote link opens a confirm page first — GET never mutates, because unfurlers/scrapers prefetch every url in a post — then the form POST casts the vote and shows live results.
  • counting is backlinks. microcosm's constellation indexes links across the network; each option's tally is the count of blog.brookie.poll.vote records whose .subject.uri points at it.
author repo                    authority repo            constellation
┌──────────────────────┐       ┌───────────────┐
│ poll.question        │       │ poll.vote ────┼──┐
│   ├─ ref ─ option A ◄┼───────┼── subject     │  │  count links
│   └─ ref ─ option B ◄┼───┐   │ poll.vote     │  ├─ per option
└──────────────────────┘   └───┼── subject     │  │
                               └───────────────┘

lexicon drafts live in lexicons/.

dev #

the app and the voting authority are one cloudflare worker: the assets binding serves the vite build, and /vote + /poll are worker routes.

bun install
bun run build
bun run worker:dev   # whole thing on http://127.0.0.1:8787

bun run dev          # OR: vite with hot reload on :3000
                     # (.env.development sets VITE_VOTE_ENDPOINT=127.0.0.1:8787
                     # so votes still go to the worker)

the two local config files are gitignored — copy them from their examples first:

cp .env.development.example .env.development   # dev-server vote endpoint
cp .dev.vars.example .dev.vars                 # authority app password

wrangler dev reads AUTHORITY_APP_PASSWORD from .dev.vars; AUTHORITY_PDS / AUTHORITY_IDENTIFIER are plain vars in wrangler.jsonc.

note: vote/results links are baked into published posts as absolute urls (VITE_VOTE_ENDPOINT, or the page origin in production) — posts made during local dev will contain 127.0.0.1 links only you can open.

deploy #

bunx wrangler secret put AUTHORITY_APP_PASSWORD  # once
bun run deploy                                   # vite build && wrangler deploy

ci deploys on push to main via .tangled/workflows/build.yml — it needs a CLOUDFLARE_API_TOKEN secret (Workers Scripts:Edit scope) configured in tangled.

running your own #

wrangler.jsonc and public/oauth-client-metadata.json are committed — they hold no secrets, only public identifiers, and ci needs them on disk to build and deploy. but every value in them is mine, so a fork has to change them. annotated copies live in wrangler.jsonc.example and oauth-client-metadata.json.example.

  1. a voting authority account. make a dedicated atproto account — not your personal one; its app password is a server secret and whoever holds it controls the account. put its did in AUTHORITY_IDENTIFIER and its pds in AUTHORITY_PDS (wrangler.jsonc), then bunx wrangler secret put AUTHORITY_APP_PASSWORD.

  2. your origin, in three places that must agree — oauth breaks silently if they drift:

    • routes[0].pattern and name in wrangler.jsonc
    • PROD_ORIGIN in src/app.js
    • client_id, client_uri, redirect_uris in public/oauth-client-metadata.json (the client_id is the url this file is served from, so it has to resolve publicly)
  3. USER_AGENT in worker/index.js, and the credit line in src/footer.js.

  4. the lexicons are blog.brookie.poll.*. you can leave them — sharing them means your polls are counted the same way and stay legible to other instances — or fork them under your own nsid prefix, in which case update the collection constants in src/api.js / worker/index.js, the scope in the oauth metadata, and run scripts/publish-lexicons.js to register them (it also prints the _lexicon dns TXT record you'll need).

note that a fork does not inherit vote counts: tallies are backlinks to option records, filtered to your authority did.

counting #

only votes from the authority's did are counted (constellation's did filter on blue.microcosm.links.getBacklinks), so vote records forged in other repos don't move the tallies.

non-goals #

  • hard double-vote prevention: votes are anonymous and the vote server is stateless (no identity gate, no database) by design. a localStorage courtesy check remembers voted:<poll uri> per browser — on the app origin and on the vote server's confirm page — but clearing storage or switching browsers gets around it. rate limiting in front of the server is the only other mitigation.

todo #

  • view/vote on other people's polls (currently only your own are listed)

license #

apache 2.0 — see NOTICE for attribution terms.

the brand assets are mine personally — the logos, og.png, the heart, and public/fonts/BrookeHand-Regular.ttf, which is my actual handwriting. the license covers them too, so you may redistribute them, but please swap them out if you run a public fork: a copy wearing my handwriting reads as me. (section 6 grants no trademark rights, so the name stays mine regardless.)

credits #

style + login flow lifted from evil (followtunnel).