|
| 1 | +# `@fujocoded/atproto-badges` |
| 2 | + |
| 3 | +Signed badges on ATproto. Officially Certify™ whatever your heart desires: |
| 4 | +events, communities, inside-jokes, friends, and anything in-between! |
| 5 | + |
| 6 | +<!-- badges --> |
| 7 | + |
| 8 | +<div align="center"> |
| 9 | + |
| 10 | +<a href="https://choosealicense.com/licenses/mit/"> <img alt="NPM license" |
| 11 | + src="https://img.shields.io/npm/l/%40fujocoded%2Fastro-atproto-loader" |
| 12 | + /> </a> <a href="https://fujocoded.com/"> <img |
| 13 | + src="https://img.shields.io/badge/fujo-coded-555555?labelColor=9c89fa" |
| 14 | + alt="FujoCoded badge"/> </a> <a |
| 15 | + href="https://npmjs.com/package/@fujocoded/atproto-badges"> <img |
| 16 | + src="https://badge.fury.io/js/%40fujocoded%2Fatproto-badges.svg" |
| 17 | + alt="NPM version badge"/> </a> <a |
| 18 | + href="https://codespaces.new/FujoWebDev/fujocoded-plugins"> <img |
| 19 | + src="https://github.com/codespaces/badge.svg" alt="Open in GitHub Codespaces" |
| 20 | + style="height: 20px"/> </a> |
| 21 | + |
| 22 | +</div> |
| 23 | + |
| 24 | +## What is `@fujocoded/atproto-badges`? |
| 25 | + |
| 26 | +`@fujocoded/atproto-badges` lets you create and sign badges on ATProto. You |
| 27 | +define a badge (like "Yuletide 2026 Writer" or "ATmosphereConf 2026 attendee"), |
| 28 | +sign it with your secret key, and write it to the recipient's PDS, where anyone can |
| 29 | +verify it came from you. |
| 30 | + |
| 31 | +Under the hood, it handles the cryptographic attestation (DAG-CBOR hashing, |
| 32 | +P-256 signing, PLC document updates) so you can focus on when and why to award |
| 33 | +badges, not how the signatures work. |
| 34 | + |
| 35 | +## What's included in `@fujocoded/atproto-badges`? |
| 36 | + |
| 37 | +In this package, you'll find utilities to manage: |
| 38 | + |
| 39 | +- **Key management** |
| 40 | + - `generateSigningKeys` creates a new key pair for signing badges |
| 41 | + - `loadSigningKey` loads a previously saved key so you can sign with it again |
| 42 | +- **Badge definitions** |
| 43 | + - `createBadgeDefinition` creates a new badge type on your PDS |
| 44 | + - `findExistingBadgeDefinition` checks if a badge type already exists, so you |
| 45 | + don't create duplicates |
| 46 | +- **Badge awards** |
| 47 | + - `createBadgeAwardRecord` builds a signed badge award, ready to write to a |
| 48 | + recipient's PDS |
| 49 | + - `getExistingBadgeAward` checks if someone already has a particular badge |
| 50 | + - `getBadgeRkey` gives you a deterministic record key, so concurrent requests |
| 51 | + don't create duplicate awards |
| 52 | +- **PLC updates** |
| 53 | + - `addAttestationVerificationMethod` publishes your public key to your DID |
| 54 | + document, so others can verify your signatures |
| 55 | +- **Verification** |
| 56 | + - `verifyBadgeAward` checks whether a badge award's signature is legit — looks |
| 57 | + up the issuer's DID document and verifies the cryptographic signature |
| 58 | +- **Lower-level signing** (if you're building something custom) |
| 59 | + - `createRecordSignature` signs any ATProto record, not just badges |
| 60 | + - `getRecordHash` computes the hash that gets signed — useful for verification |
| 61 | + or multi-signer workflows |
| 62 | + |
| 63 | +## What can you do with `@fujocoded/atproto-badges`? |
| 64 | + |
| 65 | +- **Award participation badges for events & exchanges:** give artists, writers, |
| 66 | + and other participants a verifiable badge that lives in their ATProto account— |
| 67 | + whether you're hosting Yuletide or a smaller shipping week |
| 68 | +- **Recognize contributors:** zine participants, community moderators, event |
| 69 | + volunteers, code contributors...whatever you want to celebrate, put a badge on |
| 70 | + it! |
| 71 | +- **Verify badges:** — use `verifyBadgeAward` to confirm a badge is legit by |
| 72 | + checking the issuer's signature against their published key |
| 73 | +- **Build tools to mint and manage badges:** these bad ~~boy~~badges can fit so |
| 74 | + many use cases within them! |
| 75 | + |
| 76 | +## Installation |
| 77 | + |
| 78 | +```bash |
| 79 | +npm add @fujocoded/atproto-badges |
| 80 | +``` |
| 81 | + |
| 82 | +## Getting started |
| 83 | + |
| 84 | +Here's the typical flow, from setup to awarding your first badge. |
| 85 | + |
| 86 | +At high level: |
| 87 | + |
| 88 | +1. Generate your secret key to sign badges with <u>and store them safely!</u> |
| 89 | +2. Publish your public key on your Identity Document™ |
| 90 | +3. Create a badge definition in your PDS... |
| 91 | +4. ...then award it someone, with a signed copy in their PDS! |
| 92 | + |
| 93 | +### 1. Generate your signing key |
| 94 | + |
| 95 | +This generates your super ultra mega secret credentials that allow you to sign |
| 96 | +badges, pinkie-promising it is indeed you. <u>You only need to do this once!</u> |
| 97 | + |
| 98 | +```ts |
| 99 | +import fs from "node:fs"; |
| 100 | +import { generateSigningKeys } from "@fujocoded/atproto-badges"; |
| 101 | + |
| 102 | +const keys = await generateSigningKeys(); |
| 103 | + |
| 104 | +// If you want, you can save them to files |
| 105 | + |
| 106 | +// This will be BADGE_PRIVATE_KEY in your secrets |
| 107 | +fs.writeFileSync("./private.key", keys.privateKeyBase64url); |
| 108 | +// This is your public key, for step 2 |
| 109 | +fs.writeFileSync("./public.key.txt", keys.publicDidKey); |
| 110 | +``` |
| 111 | + |
| 112 | +> [!IMPORTANT] |
| 113 | +> |
| 114 | +> You must keep the private key somewhere safe and never show it to anyone. If |
| 115 | +> you lose it, you won't be able to sign with that key anymore; if someone steals |
| 116 | +> it, they'll be able to sign as you. |
| 117 | +> |
| 118 | +> When using private keys in your programs, make sure to use environment variables |
| 119 | +> rather than hardcoding them. <u>Never commit them to Git.</u> |
| 120 | +
|
| 121 | +### 2. Publish your public key |
| 122 | + |
| 123 | +To let everyone know you're the one whose secret key has been going around |
| 124 | +signing badges left and right, you must first upload the corresponding public |
| 125 | +key to your DID document—that is, to your ATproto "id card". |
| 126 | + |
| 127 | +This command will send a verification email, and update your PLC |
| 128 | +document with the content of the key: |
| 129 | + |
| 130 | +```ts |
| 131 | +import { addAttestationVerificationMethod } from "@fujocoded/atproto-badges"; |
| 132 | + |
| 133 | +// Your DID, in this case the "yuletide exchange" |
| 134 | +const exchangeDid = "did:plc:yuletide"; |
| 135 | + |
| 136 | +// First, trigger the verification email: |
| 137 | +await agent.com.atproto.identity.requestPlcOperationSignature(); |
| 138 | + |
| 139 | +// Then, once you have the token from the email: |
| 140 | +await addAttestationVerificationMethod({ |
| 141 | + // Check out @fujocoded/authproto if you have an |
| 142 | + // Astro site! |
| 143 | + agent, |
| 144 | + // Your identity |
| 145 | + did: exchangeDid, |
| 146 | + // Your public key |
| 147 | + publicDidKey: keys.publicDidKey, |
| 148 | + token, |
| 149 | +}); |
| 150 | +``` |
| 151 | + |
| 152 | +### 3. Create a badge definition |
| 153 | + |
| 154 | +A badge definition describes what the badge is. You create it once, put it on |
| 155 | +your PDS, then reference it every time you award it: |
| 156 | + |
| 157 | +```ts |
| 158 | +import { |
| 159 | + createBadgeDefinition, |
| 160 | + findExistingBadgeDefinition, |
| 161 | +} from "@fujocoded/atproto-badges"; |
| 162 | + |
| 163 | +// Check if it already exists first! |
| 164 | +const existing = await findExistingBadgeDefinition({ |
| 165 | + agent, |
| 166 | + did: exchangeDid, |
| 167 | + name: "Yuletide 2026 Writer", |
| 168 | +}); |
| 169 | + |
| 170 | +if (existing) { |
| 171 | + return "don't be greedy!"; |
| 172 | +} |
| 173 | + |
| 174 | +const badgeDefinition = await createBadgeDefinition({ |
| 175 | + agent, |
| 176 | + // Badge owner |
| 177 | + did: exchangeDid, |
| 178 | + // Badge name |
| 179 | + name: "Yuletide 2026 Writer", |
| 180 | + // Badge description |
| 181 | + description: "Completed a gift fic for Yuletide 2026", |
| 182 | +}); |
| 183 | +``` |
| 184 | + |
| 185 | +> [!NOTE] |
| 186 | +> |
| 187 | +> The `agent` used for `putRecord` must be authenticated as the **issuer** of the badge. This establishes the legitimacy of the badge. |
| 188 | +
|
| 189 | +### 4. Award the badge |
| 190 | + |
| 191 | +```ts |
| 192 | +import { |
| 193 | + createBadgeAwardRecord, |
| 194 | + getExistingBadgeAward, |
| 195 | + getBadgeRkey, |
| 196 | + loadSigningKey, |
| 197 | +} from "@fujocoded/atproto-badges"; |
| 198 | + |
| 199 | +const participantDid = "did:plc:participant"; |
| 200 | + |
| 201 | +// Don't award it twice! |
| 202 | +const currentAward = await getExistingBadgeAward({ |
| 203 | + agent, |
| 204 | + did: participantDid, |
| 205 | + badgeDefinitionUri: badgeRef.uri, |
| 206 | +}); |
| 207 | + |
| 208 | +if (currentAward) { |
| 209 | + return "don't be greedy!"; |
| 210 | +} |
| 211 | + |
| 212 | +// Get your key ready to sign! |
| 213 | +const signingKey = await loadSigningKey({ |
| 214 | + privateKeyBase64url: process.env.BADGE_PRIVATE_KEY!, |
| 215 | +}); |
| 216 | + |
| 217 | +// "I hereby award you the badge—" |
| 218 | +const award = await createBadgeAwardRecord({ |
| 219 | + // The badge recipient |
| 220 | + recipientDid: participantDid, |
| 221 | + // Reference returned by createBadgeDefinition |
| 222 | + badgeRef: badgeDefinition, |
| 223 | + // Your DID |
| 224 | + organizerDid: exchangeDid, |
| 225 | + signingKey, |
| 226 | +}); |
| 227 | + |
| 228 | +// Save the badge to the recipients PDS |
| 229 | +await agent.com.atproto.repo.putRecord({ |
| 230 | + repo: participantDid, |
| 231 | + collection: "community.lexicon.badge.award", |
| 232 | + rkey: getBadgeRkey({ badgeDefinitionUri: badgeRef.uri }), |
| 233 | + record: award, |
| 234 | +}); |
| 235 | +``` |
| 236 | + |
| 237 | +> [!Note] |
| 238 | +> |
| 239 | +> The `agent` used for `putRecord` must be authenticated as the **recipient of |
| 240 | +> the badge**, not the issuer. |
| 241 | +> |
| 242 | +> The recipient claims their badge by writing the issuer-signed badge to their |
| 243 | +> own PDS. The issuer never needs write access to the recipient's repo—the |
| 244 | +> signature itself proves legitimacy. |
| 245 | +
|
| 246 | +## Good to know |
| 247 | + |
| 248 | +- Badge **definitions** live in the issuing organization's repo. Badge |
| 249 | + **awards** go in the recipient's repo. |
| 250 | +- `getExistingBadgeAward` looks up a badge award by definition URI and returns |
| 251 | + the full record value. You can check the CID yourself if you need to |
| 252 | + distinguish between versions of a badge definition. |
| 253 | +- `getBadgeRkey` derives the record key from the badge definition URI. This |
| 254 | + means awarding the same badge definition to the same person always targets the |
| 255 | + same record (easier to avoid duplicates!). |
| 256 | +- This package handles signing and data — you bring your own `AtpAgent`, |
| 257 | + authentication, and app logic around it. |
| 258 | + |
| 259 | +> [!WARNING] |
| 260 | +> |
| 261 | +> All parameters to `createBadgeAwardRecord` must be defined — passing |
| 262 | +> `undefined` for any field (e.g. an unset env var for `organizerDid`) will |
| 263 | +> throw with a message like `Cannot CBOR-encode record: field "organizerDid" is |
| 264 | +undefined`. ATProto records are CBOR-encoded, and CBOR has no concept of |
| 265 | +> `undefined`. |
| 266 | +
|
| 267 | +## Based on |
| 268 | + |
| 269 | +The attestation signing in this package is based on the |
| 270 | +[`atproto-attestation`](https://tangled.org/@smokesignal.events/atproto-identity-rs) |
| 271 | +Rust crate by [smokesignal.events](https://tangled.org/@smokesignal.events/). If you're looking for a full Rust |
| 272 | +implementation (including CLI tools for signing and verifying attestations), |
| 273 | +check that out! |
| 274 | + |
| 275 | +# Support Us |
| 276 | + |
| 277 | +You can check out more of our plugins here: |
| 278 | + |
| 279 | +- [Authproto](https://github.com/FujoWebDev/fujocoded-plugins/tree/main/astro-authproto) |
| 280 | +- [Socials plugin](https://github.com/FujoWebDev/fujocoded-plugins/tree/main/zod-transform-socials) |
| 281 | +- [Alt text files plugin](https://github.com/FujoWebDev/fujocoded-plugins/tree/main/remark-alt-text-files) |
| 282 | + |
| 283 | +You can also become a patron or buy some merch: |
| 284 | + |
| 285 | +- [Monthly Support](https://fujocoded.com/support) |
| 286 | +- [Merch Shop](https://store.fujocoded.com/) |
| 287 | +- [RobinBoob](https://www.robinboob.com/) |
| 288 | + |
| 289 | +# Follow Us |
| 290 | + |
| 291 | +<p align="center"><a href="https://twitter.com/fujoc0ded"><img width="35" src="https://raw.githubusercontent.com/FujoWebDev/.github/main/profile/images/twitter.svg" /></a><a href="https://www.tumblr.com/fujocoded"><img width="35" src="https://raw.githubusercontent.com/FujoWebDev/.github/main/profile/images/tumblr.svg" /></a><a href="https://bsky.app/profile/fujocoded.bsky.social"><img width="35" src="https://raw.githubusercontent.com/FujoWebDev/.github/main/profile/images/bluesky.svg" /></a><a href="https://blorbo.social/@fujocoded"><img width="35" src="https://raw.githubusercontent.com/FujoWebDev/.github/main/profile/images/mastodon.svg" /></a><a href="https://fujocoded.dreamwidth.org/"><img width="17" src="https://raw.githubusercontent.com/FujoWebDev/.github/main/profile/images/dreamwidth.svg" /></a></p> |
0 commit comments