Skip to content

Commit 987a251

Browse files
release atproto badges
1 parent 44df50d commit 987a251

13 files changed

Lines changed: 1375 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@fujocoded/atproto-badges": minor
3+
---
4+
5+
Initial release of `@fujocoded/atproto-badges` — ATProto badge attestation utilities for creating, signing, and verifying badges per the badge.blue specification.
6+
7+
Also ships a `/react` subpath export with drop-in `<BadgeSection>`, `<BadgePill>`, `<BadgeClaim>`, and `<BadgeCertificate>` components, plus a `/styles.css` import. Components take async action handlers (`onClaim`/`onVerify`/`onUnclaim`) and theming props (`issuerName`, `getBadgeShortName`, `isRemoteBadge`, custom icon renderers), so consumers wire them to their own backend without forking. Requires Tailwind v4 in the consuming project.

atproto-badges/README.md

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
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>

atproto-badges/package.json

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{
2+
"name": "@fujocoded/atproto-badges",
3+
"version": "0.1.0",
4+
"description": "ATProto badge attestation utilities",
5+
"keywords": [
6+
"atproto",
7+
"badge"
8+
],
9+
"homepage": "https://github.com/FujoWebDev/fujocoded-plugins#readme",
10+
"bugs": {
11+
"url": "https://github.com/FujoWebDev/fujocoded-plugins/issues"
12+
},
13+
"license": "MIT",
14+
"author": "FujoCoded LLC",
15+
"repository": {
16+
"type": "git",
17+
"url": "git+https://github.com/FujoWebDev/fujocoded-plugins.git"
18+
},
19+
"files": [
20+
"dist",
21+
"LICENSE",
22+
"README.md",
23+
"package.json"
24+
],
25+
"type": "module",
26+
"main": "dist/index.js",
27+
"exports": {
28+
".": {
29+
"import": {
30+
"types": "./dist/index.d.ts",
31+
"default": "./dist/index.js"
32+
}
33+
}
34+
},
35+
"publishConfig": {
36+
"access": "public"
37+
},
38+
"scripts": {
39+
"build": "tsdown",
40+
"test": "vitest run",
41+
"validate": "npx publint"
42+
},
43+
"dependencies": {
44+
"@atproto/api": "^0.17.3",
45+
"@atproto/crypto": "^0.4.3",
46+
"@atproto/identity": "^0.4.3",
47+
"@atproto/xrpc": "^0.7.7",
48+
"@ipld/dag-cbor": "^9.2.2",
49+
"multiformats": "^13.3.1",
50+
"uint8arrays": "^5.1.0"
51+
},
52+
"devDependencies": {
53+
"tsdown": "^0.17.2",
54+
"vitest": "^3.1.1"
55+
}
56+
}

0 commit comments

Comments
 (0)