shared tallies on AT Protocol

README.md

rally #

shared tallies on AT Protocol.

Every match, proposal, confirmation, and dispute is published by its author to their own ATProto repository. The Cloudflare service is a rebuildable AppView: it consumes the public Jetstream, indexes records in D1, derives the current score, and explains which proposals count.

stack #

  • mobile-first PWA: Vite + vanilla TypeScript
  • auth and record writes: ATProto OAuth (authorization code + PKCE, DPoP-bound tokens)
  • handle lookup: typeahead.waow.tech, with keyboard-accessible comboboxes
  • indexer/API: Cloudflare Worker
  • derived persistence: D1
  • event source: public Bluesky Jetstream, filtered to rally collections

The browser keeps one canonical in-memory Rally state. Successful PDS write receipts are reduced into that state immediately, so the UI does not wait for the AppView to observe its own write. Jetstream events then index and reconcile the same state from the authoritative repository records. The match list has its own live subscription, and same-origin navigation preserves state instead of forcing a document reload.

The web client sends OAuth credentials only to the user's authorization server and PDS. The Worker never receives or stores access or refresh tokens.

The UI keeps a minimum 44px interactive target, supports browser zoom, uses explicit form labels and live status announcements, and exposes Typeahead via the ARIA combobox/listbox pattern (Arrow keys, Enter, Escape, touch, and mouse).

develop #

npm install
npm run build
npm run cf:migrate:local
npm run cf:dev

wrangler dev serves the built frontend, API, local D1 database, and scheduled handler. Use wrangler dev --test-scheduled to invoke the indexer locally.

protocol #

Lexicons live under lexicons/tech/waow/rally/:

  • tech.waow.rally.defs
  • tech.waow.rally.matchV2
  • tech.waow.rally.proposalV2
  • tech.waow.rally.confirmation
  • tech.waow.rally.dispute
  • tech.waow.rally.getMatch
  • tech.waow.rally.listMatches

New records use the V2 Match and Proposal schemas. Existing tech.waow.rally.match and tech.waow.rally.proposal records remain readable: the Worker converts them at one boundary in worker/record-compat.ts, while the AppView keeps the original repository record in each hydrated view's record field. The rest of the application only handles the canonical model, so the legacy conversion can be retired independently when the old data no longer matters.

Every record-to-record edge is a com.atproto.repo.strongRef. The AppView evaluates a proposal against the precise Match CID it names; an update at the same AT URI does not retroactively change existing attestations.

There is intentionally no separate join or acceptance record. A Match may name participants, but that alone never changes a score. The proposal author attests for their side, and a point counts only after every other side supplies a valid Confirmation from one of its named members. A valid Dispute from another side blocks that proposal, so disputed points never enter the score. The proposal author can publish a new Proposal that strongly references the disputed version with supersedes; the replacement is a fresh claim that the other side must confirm. Removing a Dispute also reopens the original proposal for confirmation.

Cloudflare setup #

No paid service beyond the existing Workers plan is intended.

npx wrangler d1 create rally
# copy database_id into wrangler.jsonc
npm run cf:migrate:remote
npm run cf:deploy

Before production OAuth is usable, rally.waow.tech must resolve to this Worker and serve the checked-in oauth-client-metadata.json from that exact origin. Changing the production hostname requires updating both that metadata document and the OAuth metadata builder in src/data/oauth.ts.