OverviewFilesCommitsIssuesDocsReleasesPeers

favela-community commits / cbeeaac

favela-community 0.1.5: {curator,params} curation contract
The community room now carries a mutable curation CONTRACT — the rule for HOW its feed selects posts — alongside the roster (WHO) and the co-owned DID (the signer). New set-curation op + curation()/setCuration() + create({curator,params}) + CommunityRooms.curation() passthrough. - Schema stays curator-AGNOSTIC (any non-empty string) so a custom/sandboxed curator needs NO schema change (architecture.md "Feeds are curation functions"). - Defaults to roster curation = the historical implicit behavior, so pre-curation rooms keep working with NO migration (never break state). - P2P-first: the contract linearizes through the owner (sole indexer) and replicates to keyless readers (proven). bsky-neutral: only affects which app.bsky.feed.post URIs a feed returns; bsky never sees the curator. Tests: 7 new (default/set/validation/keyless-read/write-guard/custom), 25/25 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cbeeaaced5d33d5967e282a63e3bc8c1fa8b06b2
by plankatron <fleeky@gmail.com> · 2026-07-15T22:02:57.000Z

Changed files (4) +79 -4

lib/community-room.js +27 -2
……@@ -12,7 +12,7 @@
1212// hyper-rooms `Room` is imported by SUBPATH (not the barrel) to skip its room-pairing → blind-pairing dep,
1313// which the read/write paths here don't use (mod admission is key-exchange, below).
1414import { Room } from 'hyper-rooms/lib/room.js'
15import { communitySchema, isDid } from './schema.js'
15import { communitySchema, isDid, DEFAULT_CURATION } from './schema.js'
1616
1717const MEMBER = 'member:'
1818const BANNED = 'banned:'
……@@ -25,12 +25,13 @@ export class CommunityRoom {
2525 // OWNER — create a fresh community room. This peer is the genesis writer AND the sole indexer (N=1, the
2626 // safe default per the autobase quorum rule — never auto-grow to N=2). Optionally set the name and the
2727 // community's atproto DID (see communityDid below).
28 static async create (corestore, { namespace = 'community', name = null, did = null } = {}) {
28 static async create (corestore, { namespace = 'community', name = null, did = null, curator = null, params = {} } = {}) {
2929 const room = new Room(corestore, { schema: communitySchema, bootstrapKey: null, namespace })
3030 await room.ready()
3131 const cr = new CommunityRoom(room)
3232 if (name) await cr.setMeta('name', name)
3333 if (did) await cr.setMeta('did', did)
34 if (curator) await cr.setCuration(curator, params)
3435 return cr
3536 }
3637
……@@ -102,6 +103,29 @@ export class CommunityRoom {
102103 // replicates to keyless readers. Set once at create normally; this is for late-binding / migration.
103104 async setDid (did) { this._assertWritable('setDid'); this._assertDid(did); await this.setMeta('did', did) }
104105
106 // The curation CONTRACT — { curator, params } — the mutable rule for HOW this community's feed selects
107 // posts (roster / subject / rules / custom), which favela-web's curator registry runs to drive
108 // getFeedSkeleton (architecture.md "Feeds are curation functions"). THREE distinct substrates, don't
109 // conflate: the ROSTER is WHO (members()), the DID is the signing identity (communityDid()), the CURATION
110 // is HOW (here). Defaults to roster curation (the historical implicit behavior) until set, so pre-curation
111 // rooms keep working with NO migration. Keyless-safe (a plain view read) and bsky-neutral (it only affects
112 // which app.bsky.feed.post URIs the feed returns; bsky never sees the curator).
113 async curation () {
114 await this._room.base.update()
115 const node = await this._room.view.get('curation')
116 return (node && node.value) || { ...DEFAULT_CURATION }
117 }
118
119 // Owner/mod: set (or change) the curation contract. Mutable — an autobase op, so it linearizes + replicates
120 // to keyless readers like any other. curator = any non-empty string (a built-in name or a custom curator
121 // id — the schema is curator-agnostic); params = that curator's config object.
122 async setCuration (curator, params = {}) {
123 this._assertWritable('setCuration')
124 if (typeof curator !== 'string' || !curator) throw new Error('setCuration requires a curator name')
125 if (params != null && (typeof params !== 'object' || Array.isArray(params))) throw new Error('setCuration params must be an object')
126 await this._room.append({ type: 'set-curation', curator, params: params || {} })
127 }
128
105129 async meta () {
106130 await this._room.base.update()
107131 const m = {}
……@@ -175,6 +199,7 @@ export class CommunityRooms {
175199 async members (roomKeyHex) { return (await this.room(roomKeyHex)).members() }
176200 async meta (roomKeyHex) { return (await this.room(roomKeyHex)).meta() }
177201 async communityDid (roomKeyHex) { return (await this.room(roomKeyHex)).communityDid() }
202 async curation (roomKeyHex) { return (await this.room(roomKeyHex)).curation() }
178203
179204 // Watch one room's roster for changes. onChange(memberDids[]) fires (debounced) on every linearized-view
180205 // advance — i.e. whenever a member add/remove/ban replicates in — PLUS once immediately as a baseline.
lib/schema.js +21 -0
……@@ -9,6 +9,7 @@
99// ban {did} → banned:<did> + del member:<did> (moderation; excluded from the roster)
1010// unban {did} → del banned:<did>
1111// set-meta {key,value} → meta:<key> (name / description / …)
12// set-curation {curator,params} → curation (the curation CONTRACT — HOW the feed selects posts)
1213//
1314// apply is DETERMINISTIC — it reads only fields carried in the op (op.at/op.by, set at append time), never
1415// Date.now()/randomness (Autobase replays apply on every boot + reorg).
……@@ -16,12 +17,27 @@ import { jsonSchema } from 'hyper-rooms/lib/json-schema.js'
1617
1718export const isDid = (d) => typeof d === 'string' && /^did:[a-z]+:/.test(d)
1819
20// The built-in curator names favela-web resolves NATIVELY. The schema stays curator-AGNOSTIC (any non-empty
21// string is a valid curator) so a community can name a custom/sandboxed curator with NO schema change
22// (architecture.md "Feeds are curation functions"). These are just the ones with a native implementation.
23// The curation contract is bsky-NEUTRAL: it only decides WHICH posts a feed returns; bsky sees the resulting
24// app.bsky.feed.post at-uris, never the curator.
25export const BUILTIN_CURATORS = ['roster', 'subject', 'rules']
26// Default for a room that has never set a curation contract = pure roster curation (the historical implicit
27// behavior). So pre-curation rooms keep working with NO migration — never break persistent state.
28export const DEFAULT_CURATION = { curator: 'roster', params: {} }
29const isPlainObject = (o) => o != null && typeof o === 'object' && !Array.isArray(o)
30
1931export const communitySchema = jsonSchema({
2032 validate (op) {
2133 if (['add-member', 'remove-member', 'ban', 'unban'].includes(op.type) && !isDid(op.did)) {
2234 throw new Error(`${op.type} requires a valid did`)
2335 }
2436 if (op.type === 'set-meta' && typeof op.key !== 'string') throw new Error('set-meta requires a key')
37 if (op.type === 'set-curation') {
38 if (typeof op.curator !== 'string' || !op.curator) throw new Error('set-curation requires a curator name')
39 if (op.params != null && !isPlainObject(op.params)) throw new Error('set-curation params must be an object')
40 }
2541 },
2642 async apply (batch, op, host) {
2743 switch (op.type) {
……@@ -41,6 +57,11 @@ export const communitySchema = jsonSchema({
4157 case 'set-meta':
4258 await batch.put(`meta:${op.key}`, { value: op.value })
4359 break
60 case 'set-curation':
61 // single record (no prefix) — sorts before every prefix scan (member:/banned:/meta:), so it is
62 // never swept into a roster/meta read. Stored as the payload directly; curation() reads node.value.
63 await batch.put('curation', { curator: op.curator, params: op.params || {} })
64 break
4465 }
4566 }
4667})
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-community",
3 "version": "0.1.4",
3 "version": "0.1.5",
44 "description": "Co-owned, plaintext community rooms for Favela — membership + moderation on a hyper-rooms Autobase. Read KEYLESSLY by the gateway (favela-web); WRITTEN by mods (hpp / full-node / CLI). One community protocol, shared across surfaces.",
55 "type": "module",
66 "main": "index.js",
test/community-room.test.mjs +30 -1
……@@ -99,7 +99,36 @@ assert(await until(() => seen[seen.length - 1].includes(E)), 'watch DELTA: a mem
9999await unwatch()
100100unwireW()
101101
102console.log('\nFAVELA-COMMUNITY OK (Bare-native): keyless read + mod admission + co-writer membership/moderation linearize through the owner + roster watcher (baseline + delta)')
102// ── CURATION CONTRACT: {curator,params} — HOW the feed selects posts (distinct from WHO=roster) ──
103// bsky-neutral (only affects which post URIs a feed returns) + keyless-readable + backward-compatible.
104const curStore = new Corestore('data/cc-cur-' + stamp)
105const curOwner = await CommunityRoom.create(curStore, { name: 'Cur Community' })
106const cur0 = await curOwner.curation()
107assert(cur0.curator === 'roster' && typeof cur0.params === 'object', 'DEFAULT curation = roster (pre-curation rooms keep working, no migration)')
108await curOwner.setCuration('subject', { tag: 'p2p' })
109const cur1 = await curOwner.curation()
110assert(cur1.curator === 'subject' && cur1.params.tag === 'p2p', 'setCuration binds {curator:subject, params:{tag}} — mutable contract')
111let curThrew = false
112try { await curOwner.setCuration('', {}) } catch { curThrew = true }
113assert(curThrew, 'setCuration rejects an empty curator name')
114let curParamThrew = false
115try { await curOwner.setCuration('rules', [1, 2]) } catch { curParamThrew = true }
116assert(curParamThrew, 'setCuration rejects non-object params')
117// a KEYLESS reader replicates + reads the curation contract (no key)
118const curReaderStore = new Corestore('data/cc-curr-' + stamp)
119const curReader = await CommunityRoom.openReadonly(curReaderStore, curOwner.key)
120const unwireC = wire(curOwner, curReader)
121assert(await until(async () => (await curReader.curation()).curator === 'subject'), 'KEYLESS READ: reader replicates the curation contract {curator:subject}')
122let curRThrew = false
123try { await curReader.setCuration('roster', {}) } catch { curRThrew = true }
124assert(curRThrew, 'keyless reader cannot setCuration (write-guarded)')
125// custom curator name accepted with NO schema change (architecture.md escape-hatch)
126await curOwner.setCuration('my-custom-algo', { weight: 3 })
127assert((await curOwner.curation()).curator === 'my-custom-algo', 'custom curator id accepted (schema is curator-agnostic)')
128unwireC(); await curOwner.close(); await curReader.close()
129try { await curStore.close(); await curReaderStore.close() } catch {}
130
131console.log('\nFAVELA-COMMUNITY OK (Bare-native): keyless read + mod admission + co-writer membership/moderation linearize through the owner + roster watcher (baseline + delta) + curation contract (default/set/keyless-read/custom)')
103132unwireR(); unwireM()
104133await owner.close(); await reader.close(); await mod.close()
105134try { await crooms.close() } catch {}