OverviewFilesCommitsIssuesDocsReleasesPeers

favela-community / lib / schema.js

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