OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / labeler-export.js

99 lines · javascript ref:
// 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