// The community protocol — the op contract for a co-owned community room, as a hyper-rooms schema.
// Shared by the READ layer (favela-web / favela-native open the room keylessly and derive the roster) and
// the WRITE layer (mods' hpp / full-node / CLI append ops). Plaintext by design (keyless-read ⊥ encryption;
// the room holds only PUBLIC metadata — member DIDs + moderation — never posts or private messages).
//
// Domain ops (writer ops add-writer/remove-writer are handled by hyper-rooms Room itself):
// add-member {did} → member:<did> (the roster)
// remove-member {did} → del member:<did>
// ban {did} → banned:<did> + del member:<did> (moderation; excluded from the roster)
// unban {did} → del banned:<did>
// set-meta {key,value} → meta:<key> (name / description / …)
// set-curation {curator,params} → curation (the curation CONTRACT — HOW the feed selects posts)
//
// apply is DETERMINISTIC — it reads only fields carried in the op (op.at/op.by, set at append time), never
// Date.now()/randomness (Autobase replays apply on every boot + reorg).
import { jsonSchema } from 'hyper-rooms/lib/json-schema.js'
export const isDid = (d) => typeof d === 'string' && /^did:[a-z]+:/.test(d)
// The built-in curator names favela-web resolves NATIVELY. The schema stays curator-AGNOSTIC (any non-empty
// string is a valid curator) so a community can name a custom/sandboxed curator with NO schema change
// (architecture.md "Feeds are curation functions"). These are just the ones with a native implementation.
// The curation contract is bsky-NEUTRAL: it only decides WHICH posts a feed returns; bsky sees the resulting
// app.bsky.feed.post at-uris, never the curator.
export const BUILTIN_CURATORS = ['roster', 'subject', 'rules']
// Default for a room that has never set a curation contract = pure roster curation (the historical implicit
// behavior). So pre-curation rooms keep working with NO migration — never break persistent state.
export const DEFAULT_CURATION = { curator: 'roster', params: {} }
const isPlainObject = (o) => o != null && typeof o === 'object' && !Array.isArray(o)
export const communitySchema = jsonSchema({
validate (op) {
if (['add-member', 'remove-member', 'ban', 'unban'].includes(op.type) && !isDid(op.did)) {
throw new Error(`${op.type} requires a valid did`)
}
if (op.type === 'set-meta' && typeof op.key !== 'string') throw new Error('set-meta requires a key')
if (op.type === 'set-curation') {
if (typeof op.curator !== 'string' || !op.curator) throw new Error('set-curation requires a curator name')
if (op.params != null && !isPlainObject(op.params)) throw new Error('set-curation params must be an object')
}
},
async apply (batch, op, host) {
switch (op.type) {
case 'add-member':
await batch.put(`member:${op.did}`, { did: op.did, addedBy: op.by || null, at: op.at || null })
break
case 'remove-member':
await batch.del(`member:${op.did}`)
break
case 'ban':
await batch.del(`member:${op.did}`)
await batch.put(`banned:${op.did}`, { by: op.by || null, at: op.at || null })
break
case 'unban':
await batch.del(`banned:${op.did}`)
break
case 'set-meta':
await batch.put(`meta:${op.key}`, { value: op.value })
break
case 'set-curation':
// single record (no prefix) — sorts before every prefix scan (member:/banned:/meta:), so it is
// never swept into a roster/meta read. Stored as the payload directly; curation() reads node.value.
await batch.put('curation', { curator: op.curator, params: op.params || {} })
break
}
}
})
export default communitySchema