// labeler-export.js — favela's LENS EXPORT to bsky, moderation half (DESIGN §3.6): expose favela's own
// signed labels over the atproto labeler surfaces so bsky users can SUBSCRIBE to favela moderation.
//
// This is the EMITTER side (the inverse of lib/labeler.js's reader). favela produces com.atproto.label
// records (byte-exact: DAG-CBOR + secp256k1 low-S over the sig-less bytes, atproto-identity/lib/labels.js);
// this module projects them into the three bsky-legible surfaces:
// 1. did:web identity — a #atproto_label verificationMethod (the signing key, so bsky verifies our sigs)
// + a #atproto_labeler service entry (where to reach queryLabels/subscribeLabels).
// 2. com.atproto.label.queryLabels — the HTTP pull surface: labels for a set of uriPatterns/sources.
// 3. app.bsky.labeler.service record — declares the labeler + its label values (the publish ceremony).
//
// The live-push surface (com.atproto.label.subscribeLabels, a WS firehose) is what bsky's AppView ingests
// for real-time labels; it needs a WS server (bare-ws) and is the remaining outward piece — see DESIGN.
// queryLabels + identity are enough to stand up + verify the labeler identity and pull labels.
import b4a from 'b4a'
// A signed label → its atproto JSON-XRPC representation. `sig` is CBOR bytes on the wire; in JSON it's the
// canonical { $bytes: base64 } form. undefined optional fields are omitted.
export function toWireLabel (label) {
const w = { src: label.src, uri: label.uri, val: label.val, cts: label.cts }
if (label.cid !== undefined) w.cid = label.cid
if (label.neg !== undefined) w.neg = label.neg
if (label.exp !== undefined) w.exp = label.exp
if (label.ver !== undefined) w.ver = label.ver
if (label.sig) w.sig = { $bytes: b4a.toString(b4a.isBuffer(label.sig) ? label.sig : b4a.from(label.sig), 'base64') }
return w
}
// Match an AT-URI/DID subject against a queryLabels uriPattern. `*` is a suffix wildcard; a bare pattern
// matches exactly. `*` alone matches everything (atproto convention).
export function uriMatches (uri, pattern) {
if (pattern === '*') return true
if (pattern.endsWith('*')) return String(uri).startsWith(pattern.slice(0, -1))
return uri === pattern
}
export class LabelerExport {
// labelerDid: the DID stamped as `src` in labels AND the labeler's service DID (did:web:<host>). Bsky
// verifies label sigs against this DID's #atproto_label key, so it MUST be the did:web we serve.
// signingDidKey: the label signing key's did:key (its multibase becomes the VM publicKeyMultibase).
// hostname: the public HTTPS host. source: { labels() => Promise<signedLabel[]> } — favela's produced
// labels (e.g. adapted from a Labeler feed). labelValues: the vals this labeler emits (declared surface).
constructor ({ labelerDid, signingDidKey, hostname, source, labelValues = [] } = {}) {
if (!labelerDid || !signingDidKey || !hostname || !source) throw new Error('LabelerExport requires { labelerDid, signingDidKey, hostname, source }')
this.labelerDid = labelerDid
this.signingDidKey = signingDidKey
this.hostname = hostname
this._source = source
this.labelValues = labelValues
}
// The #atproto_label verificationMethod for the did:web doc — bsky reads the signing key from here to
// verify our label signatures. publicKeyMultibase = the did:key multibase (drop the `did:key:` prefix).
verificationMethod () {
return {
id: this.labelerDid + '#atproto_label',
type: 'Multikey',
controller: this.labelerDid,
publicKeyMultibase: this.signingDidKey.replace(/^did:key:/, '')
}
}
// The #atproto_labeler service entry — where a consumer reaches queryLabels / subscribeLabels.
service () {
return { id: '#atproto_labeler', type: 'AtprotoLabeler', serviceEndpoint: `https://${this.hostname}` }
}
// com.atproto.label.queryLabels — labels for the given uriPatterns (required) filtered by sources
// (optional; defaults to us). Returns { cursor, labels:[wireLabel] }. Cursor pagination is opaque and
// index-based over the current source snapshot (a simple offset — fine for a pull surface).
async queryLabels ({ uriPatterns = [], sources = null, limit = 50, cursor = null } = {}) {
const patterns = (Array.isArray(uriPatterns) && uriPatterns.length) ? uriPatterns : ['*']
// Default to OUR own labels (this is favela's labeler endpoint); an explicit `sources` overrides.
const srcAllow = (Array.isArray(sources) && sources.length) ? new Set(sources) : new Set([this.labelerDid])
const all = (await this._source.labels()) || []
const matched = all.filter((l) =>
srcAllow.has(l.src) && patterns.some((p) => uriMatches(l.uri, p))
)
const start = cursor ? Math.max(0, Number(cursor) || 0) : 0
const page = matched.slice(start, start + limit)
const next = (start + limit) < matched.length ? String(start + limit) : undefined
return { ...(next !== undefined ? { cursor: next } : {}), labels: page.map(toWireLabel) }
}
// The app.bsky.labeler.service record body (rkey 'self') to publish into the labeler account's repo —
// declares the labeler and the label values it emits. policies.labelValueDefinitions may add severity/
// blur/locale definitions; omitted here → consumers use their own defaults for our vals.
serviceRecord ({ createdAt, labelValueDefinitions = [] } = {}) {
if (!createdAt) throw new Error('serviceRecord requires createdAt (ISO datetime, caller-supplied)')
return {
$type: 'app.bsky.labeler.service',
policies: { labelValues: this.labelValues, ...(labelValueDefinitions.length ? { labelValueDefinitions } : {}) },
createdAt
}
}
}
export default LabelerExport