side-quest: cross-user XP leaderboard with public-key-verified score submissions #1

Open
opened 2026-08-27 15:17:06 +00:00 by trtmn · 0 comments
Owner

Proposal for a public leaderboard on top of the side-quest XP plugin. Today the ledger (~/.claude/side-quest/xp.json) is one person across many machines, synced over MQTT. A leaderboard means multiple people, which needs a trust model — nothing stops someone editing their xp.json to 999999999.

Verification: sign submissions, verify against the user's public key

Each player has a keypair. A score submission is a canonical JSON payload — {player, total_xp, level, quests_completed, epoch, ts} — with a detached signature. The leaderboard verifies the signature against that player's registered public key before accepting the row.

Key trust root — reuse Forgejo. Every player already has SSH keys on git.trtmn.io, and Forgejo publishes them at https://git.trtmn.io/<user>.keys. No separate key registry to build: the leaderboard fetches <user>.keys (cached) and verifies against it. Standardize on raw ed25519 detached signatures over the canonical payload rather than the ssh-keygen -Y sign armored envelope — ed25519 verify is in WebCrypto, and parsing the OpenSSH pubkey wire format for the ssh-ed25519 line is ~20 lines, versus implementing the full SSHSIG format.

What this does and does not stop: a signature proves the submission came from the key owner — nobody can forge your score. It does not stop the key owner inflating their own score. Closing that needs the ledger to be an append-only, independently-replayable event log (it partly is already — the MQTT event stream), which is a larger scope. Recommend shipping signature verification first and treating self-inflation as out of scope for v1 (social trust among a small group).

Where to host — options considered

Audience decision: fully public.

  1. Cloudflare Worker + D1 (recommended). POST /submit with the signed payload -> Worker fetches/caches <user>.keys, verifies ed25519, upserts into D1. GET / renders the board (HTML) or serves JSON for a static Pages frontend. Public URL, free tier, no server to run, rate-limitable, single trusted verifier, fits the existing Cloudflare-heavy setup. Cost: a new Worker + D1 binding, ~150 lines.
  2. Forgejo repo as datastore + Forgejo Actions to render. xp.sh leaderboard-submit opens/updates a PR adding scores/<player>.json + .sig; an Action verifies every sig against <user>.keys on merge and regenerates LEADERBOARD.md. No new infra, git history is the audit trail. But: needs push/PR access per player (or a bot token), git.trtmn.io sits behind Cloudflare Access so the rendered page is not truly public without a bypass, and a PR per session is heavyweight for a number that changes constantly.
  3. Extend the MQTT sync + a renderer. New retained topic sidequest/leaderboard/<player> with the signed payload; a clancy-side subscriber verifies and writes a static board to R2/Pages. Reuses the transport that already exists, near-real-time. But the broker is Tailscale-only and unauthenticated — fine for a private board, but a public one still needs the separate public renderer, so this is option 1's verifier plus an extra hop.
  4. Static site + client-side verification. The board is a static page; the viewer's browser verifies every signature and computes the ranking (trustless display). Cleanest trust model, cacheable, cheap — but still needs a write path (one of the above) and a spam story, plus more frontend code.

Recommendation: option 1 (Worker + D1), raw ed25519 signatures, pubkeys from Forgejo <user>.keys. Submission via a new xp.sh leaderboard-submit that signs with ~/.ssh/id_ed25519 and does one HTTPS POST — wired into the existing Stop hook (debounced) or run periodically by the sync daemon.

Open questions

  • Identity: Forgejo username as the canonical player id? One key per person, or accept any of their registered keys?
  • What is ranked: total XP / level / quests_completed / a composite? Per-person, aggregating a player's machines.
  • Submission cadence: every session (Stop hook), periodic from the daemon, or manual?
  • Anti-self-inflation: accept social trust for v1, or require the replayable event log up front?
  • Abuse: rate-limit per key; cap implausible deltas; keep history so a sudden jump is visible.

Scope

New Worker + D1 (or chosen host), an xp.sh leaderboard-submit subcommand + ed25519 signing, key-fetch/verify logic, board rendering, and docs. Cross-cutting (plugin + new infra).

Filed from a /side-quest working session; see side-quest 2.5.0 for the current ledger/sync design.

Proposal for a **public** leaderboard on top of the side-quest XP plugin. Today the ledger (`~/.claude/side-quest/xp.json`) is one person across many machines, synced over MQTT. A leaderboard means multiple *people*, which needs a trust model — nothing stops someone editing their `xp.json` to `999999999`. ## Verification: sign submissions, verify against the user's public key Each player has a keypair. A score submission is a canonical JSON payload — `{player, total_xp, level, quests_completed, epoch, ts}` — with a detached signature. The leaderboard verifies the signature against that player's registered public key before accepting the row. **Key trust root — reuse Forgejo.** Every player already has SSH keys on git.trtmn.io, and Forgejo publishes them at `https://git.trtmn.io/<user>.keys`. No separate key registry to build: the leaderboard fetches `<user>.keys` (cached) and verifies against it. Standardize on **raw ed25519 detached signatures** over the canonical payload rather than the `ssh-keygen -Y sign` armored envelope — ed25519 verify is in WebCrypto, and parsing the OpenSSH pubkey wire format for the `ssh-ed25519` line is ~20 lines, versus implementing the full SSHSIG format. **What this does and does not stop:** a signature proves the submission came from the key owner — nobody can forge *your* score. It does **not** stop the key owner inflating *their own* score. Closing that needs the ledger to be an append-only, independently-replayable event log (it partly is already — the MQTT event stream), which is a larger scope. Recommend shipping signature verification first and treating self-inflation as out of scope for v1 (social trust among a small group). ## Where to host — options considered Audience decision: **fully public**. 1. **Cloudflare Worker + D1 (recommended).** `POST /submit` with the signed payload -> Worker fetches/caches `<user>.keys`, verifies ed25519, upserts into D1. `GET /` renders the board (HTML) or serves JSON for a static Pages frontend. Public URL, free tier, no server to run, rate-limitable, single trusted verifier, fits the existing Cloudflare-heavy setup. Cost: a new Worker + D1 binding, ~150 lines. 2. **Forgejo repo as datastore + Forgejo Actions to render.** `xp.sh leaderboard-submit` opens/updates a PR adding `scores/<player>.json` + `.sig`; an Action verifies every sig against `<user>.keys` on merge and regenerates `LEADERBOARD.md`. No new infra, git history is the audit trail. But: needs push/PR access per player (or a bot token), git.trtmn.io sits behind Cloudflare Access so the rendered page is not truly public without a bypass, and a PR per session is heavyweight for a number that changes constantly. 3. **Extend the MQTT sync + a renderer.** New retained topic `sidequest/leaderboard/<player>` with the signed payload; a clancy-side subscriber verifies and writes a static board to R2/Pages. Reuses the transport that already exists, near-real-time. But the broker is Tailscale-only and unauthenticated — fine for a private board, but a *public* one still needs the separate public renderer, so this is option 1's verifier plus an extra hop. 4. **Static site + client-side verification.** The board is a static page; the viewer's browser verifies every signature and computes the ranking (trustless display). Cleanest trust model, cacheable, cheap — but still needs a write path (one of the above) and a spam story, plus more frontend code. **Recommendation:** option 1 (Worker + D1), raw ed25519 signatures, pubkeys from Forgejo `<user>.keys`. Submission via a new `xp.sh leaderboard-submit` that signs with `~/.ssh/id_ed25519` and does one HTTPS POST — wired into the existing Stop hook (debounced) or run periodically by the sync daemon. ## Open questions - **Identity:** Forgejo username as the canonical player id? One key per person, or accept any of their registered keys? - **What is ranked:** total XP / level / quests_completed / a composite? Per-person, aggregating a player's machines. - **Submission cadence:** every session (Stop hook), periodic from the daemon, or manual? - **Anti-self-inflation:** accept social trust for v1, or require the replayable event log up front? - **Abuse:** rate-limit per key; cap implausible deltas; keep history so a sudden jump is visible. ## Scope New Worker + D1 (or chosen host), an `xp.sh leaderboard-submit` subcommand + ed25519 signing, key-fetch/verify logic, board rendering, and docs. Cross-cutting (plugin + new infra). Filed from a `/side-quest` working session; see side-quest 2.5.0 for the current ledger/sync design.
Sign in to join this conversation.
No labels
CR1
CR2
CR3
CR4
CR5
P0
P1
P2
P3
P4
P5
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
trtmn/agent-plugins#1
No description provided.