OverviewFilesCommitsIssuesDocsReleasesPeers

favela-gateway / lib / member-labels.js

72 lines · javascript ref:
// The member projector — a community's roster → `<feed id>-member` labels (hyper-social #80, member labels).
//
// The gateway is keyless: it STATES intents on the co-located labeler DID's hpp control socket and hpp
// signs them (hyper-proto-peer 0.3.128 label-intent, idempotent). On each roster change: assert every
// member, negate every labelled DID that is no longer one. Negated, never deleted — a retraction is a
// signed negation in atproto, so a subscriber that saw the label also sees it withdrawn.
//
// ⛔ An EMPTY roster negates nothing: at boot the room reads empty until it replicates, and "everyone
// left" would strip every badge. ⛔ The socket must serve the community's DID (get-state), or a socket
// configured for the wrong DID would write another account's labels.
//
// `connect` is injected: () => Promise<{ call(method, params), close() }> — the gateway passes a hyper-svc
// UDS client to LENS_LABELER_SOCK. Runs are serialized per process; one connection, reopened on failure.

export function createMemberLabels ({ connect, log = () => {} } = {}) {
  let client = null
  let queue = Promise.resolve()
  const feeds = new Map() // id -> { at, added, negated, members, error, skipped }

  const getClient = async () => {
    if (!client) client = await connect()
    return client
  }
  const drop = async () => { const c = client; client = null; if (c) { try { await c.close() } catch {} } }

  async function run (f) {
    const val = f.id + '-member'
    const out = { feed: f.id, val, added: 0, negated: 0 }
    if (!f.communityDid) return { ...out, skipped: 'no community DID yet' }
    if (!f.members || !f.members.length) return { ...out, skipped: 'empty roster' }
    try {
      const c = await getClient()
      const st = await c.call('get-state', {})
      if (!st || st.did !== f.communityDid) return { ...out, skipped: `the labeler socket serves ${st && st.did}, not ${f.communityDid}` }
      for (const did of f.members) {
        const r = await c.call('label-intent', { uri: did, val })
        if (r && r.changed) out.added++
      }
      const members = new Set(f.members)
      const { intents = [] } = (await c.call('label-list', {})) || {}
      for (const i of intents) {
        if (i.val !== val || i.neg || members.has(i.uri)) continue
        const r = await c.call('label-intent', { uri: i.uri, val, neg: true })
        if (r && r.changed) out.negated++
      }
      return out
    } catch (err) {
      await drop()
      return { ...out, error: (err && err.message) || String(err) }
    }
  }

  // → the run's outcome; never throws (a down socket is a metric, not a crash).
  function apply (f) {
    const p = queue.then(() => run(f))
    queue = p.catch(() => {})
    return p.then((r) => {
      feeds.set(f.id, { at: Date.now(), members: (f.members || []).length, added: r.added, negated: r.negated, skipped: r.skipped || null, error: r.error || (r.skipped && r.skipped.startsWith('the labeler socket') ? r.skipped : null) })
      if (r.error || r.added || r.negated || r.skipped) log({ msg: 'member labels', ...r })
      return r
    })
  }

  return {
    apply,
    metrics: () => ({ feeds: Object.fromEntries(feeds) }),
    close: async () => { await queue; await drop() }
  }
}

export default createMemberLabels