A standalone AT Protocol labeler service that hands out badges to Colibri users. Badges are ordinary com.atproto.label labels, signed with the labeler's #atproto_label key and served over the standard query and firehose endpoints.
Badges like team and play-store-tester are granted by hand with pnpm label. Supporter badges are granted automatically once a user links their Open Collective account, see Supporter badges from Open Collective.
- Create a fresh atproto account dedicated to this labeler. Do not use an existing personal or Colibri account.
- Add the
#atproto_labelverification method and the#atproto_labelerservice endpoint to its DID document. This is a PLC operation and only needs doing once. - Copy
.env.exampleto.envand fill it in.LABELER_LABEL_PUBLIC_KEYis worth setting: the server then refuses to start ifLABELER_SIGNING_KEYdoes not derive to the key in the DID document, which otherwise fails silently by writing labels nobody can verify. - Publish the declaration record:
pnpm install pnpm declare
pnpm dev # tsx watch, for local development
pnpm build && pnpm start # production
pnpm migrate # apply pending database migrations on their own
pnpm test # vitestLABELER_DB_URL accepts a remote libSQL URL (LABELER_DB_TOKEN then required) or a local file: path, which is what the tests use.
The service must be reachable at a stable public HTTPS URL matching the #atproto_labeler service endpoint, reverse-proxied with TLS.
pnpm labelThe TUI resolves a handle or DID, picks a badge (or a custom value), grants or revokes it, and confirms before applying. It writes to the database directly. The running server picks those writes up by polling and forwards them to subscribeLabels subscribers, so a badge granted this way still reaches the firehose.
Revoking appends a negation row rather than deleting anything. Consumers resolve the current state by taking the newest cts per label value and dropping negated and expired ones.
Supporter badges (sponsor-twenty-five, supporter-ten, backer-five, donator) are handed out without anyone running pnpm label, once the LABELER_OC_* block in .env is filled in. Set all of LABELER_PUBLIC_URL, LABELER_OC_CLIENT_ID, LABELER_OC_CLIENT_SECRET, LABELER_OC_STATE_SECRET, LABELER_HANDLE and LABELER_APP_PASSWORD, or none of them. A partial configuration is refused at boot rather than half-enabled.
Register the OAuth app at https://opencollective.com/<collective>/admin/for-developers with <LABELER_PUBLIC_URL>/oc/callback as the redirect.
How a link happens:
- The client calls
social.colibri.labeler.linkExternalAccountwith service auth. The JWT's issuer is the subject, so an account can only ever link itself. This is deliberately not gated onLABELER_ADMIN_DIDS. - The labeler answers with an Open Collective authorization URL whose
stateis an HMAC-signed, ten-minute token carrying the subject DID. Nothing is stored at this point. - Open Collective redirects to
/oc/callback. The labeler verifies the state, exchanges the code, readsme, and throws the access token away. No token is persisted and no refresh token is kept. - It publishes a
social.colibri.labeler.attestationrecord keyed by the subject DID, then evaluates that subject's entitlements immediately so the badge appears at once.
The attestation record is what makes this work without a mapping table. It lives in the labeler's own repo, so it is public, auditable, and replicated like any other record.
Reading contributions needs no authentication at all: the collective's orders, including each contributor's account id, are public. The access token from step 3 is only ever used to prove which Open Collective account the user controls.
Entitlements are recomputed every LABELER_OC_SYNC_SECS (default 900):
| Contribution | Badge |
|---|---|
| active recurring, 2500 cents/month or more | sponsor-twenty-five |
| active recurring, 1000 cents/month or more | supporter-ten |
| active recurring, 500 cents/month or more | backer-five |
| any paid one-time contribution | donator |
Amounts drive this, not Open Collective tiers. The collective has only a $5 and a $25 tier, both flexible, so a $10 contributor arrives on the $5 tier at a custom amount or with no tier at all. Matching on tier would quietly underpay them. Yearly contributions are divided by 12, several active contributions are summed, and anything not in LABELER_OC_CURRENCY is skipped because there is no conversion source.
A lapse is handled twice over. Each pass reissues the badge with exp set to the next charge date plus LABELER_OC_GRACE_DAYS, so badges expire on their own if the loop ever stops running, and a contribution that goes inactive also gets an immediate negation row. donator is permanent: it never expires and reconciliation never revokes it, though unlinking does.
Guest profiles cannot link, and need no manual auditing. A guest profile has no credentials, so it cannot complete OAuth in the first place. Claiming one means logging in with the email used for the contribution, which converts the profile in place and keeps the contribution attached, so anyone who does reach the callback is already a claimed account. The callback rejects isGuest accounts anyway, and each pass logs how many contributions came from unclaimed guest profiles. A guest who never claims theirs is the one case still needing pnpm label.
com.atproto.label.queryLabels: HTTP query.uriPatternsis a repeated parameter, so one request can ask about many subjects at once, which is how the Colibri client avoids a request per member. Seecolibri.social/packages/client/src/atproto/labeler-lookup.ts.com.atproto.label.subscribeLabels: WebSocket firehose, with cursor backfill.tools.ozone.moderation.emitEvent: authenticated write path, kept for compatibility. Requires a service JWT whose issuer is the labeler account or a DID listed inLABELER_ADMIN_DIDS.social.colibri.labeler.attestation: read the labeler's repo to see which subjects have a verified external account.