// 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