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.optionrecord per answer, then ablog.brookie.poll.questionrecord holding strong refs (com.atproto.repo.strongRef) to those options. - polls are posted to bluesky. publishing also creates an
app.bsky.feed.postin the author's repo: the question, then each option as a rich-text link facet pointing at the vote server's/voteendpoint. 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) createsblog.brookie.poll.voterecords 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.voterecords whose.subject.uripoints 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.
-
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_IDENTIFIERand its pds inAUTHORITY_PDS(wrangler.jsonc), thenbunx wrangler secret put AUTHORITY_APP_PASSWORD. -
your origin, in three places that must agree — oauth breaks silently if they drift:
routes[0].patternandnameinwrangler.jsoncPROD_ORIGINinsrc/app.jsclient_id,client_uri,redirect_urisinpublic/oauth-client-metadata.json(theclient_idis the url this file is served from, so it has to resolve publicly)
-
USER_AGENTinworker/index.js, and the credit line insrc/footer.js. -
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 insrc/api.js/worker/index.js, thescopein the oauth metadata, and runscripts/publish-lexicons.jsto register them (it also prints the_lexicondns 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).