| … | … | @@ -12,7 +12,7 @@ |
| 12 | 12 | // hyper-rooms `Room` is imported by SUBPATH (not the barrel) to skip its room-pairing → blind-pairing dep, |
| 13 | 13 | // which the read/write paths here don't use (mod admission is key-exchange, below). |
| 14 | 14 | import { Room } from 'hyper-rooms/lib/room.js' |
| 15 | import { communitySchema, isDid } from './schema.js' | |
| 15 | import { communitySchema, isDid, DEFAULT_CURATION } from './schema.js' | |
| 16 | 16 | |
| 17 | 17 | const MEMBER = 'member:' |
| 18 | 18 | const BANNED = 'banned:' |
| … | … | @@ -25,12 +25,13 @@ export class CommunityRoom { |
| 25 | 25 | // OWNER — create a fresh community room. This peer is the genesis writer AND the sole indexer (N=1, the |
| 26 | 26 | // safe default per the autobase quorum rule — never auto-grow to N=2). Optionally set the name and the |
| 27 | 27 | // 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 = {} } = {}) { | |
| 29 | 29 | const room = new Room(corestore, { schema: communitySchema, bootstrapKey: null, namespace }) |
| 30 | 30 | await room.ready() |
| 31 | 31 | const cr = new CommunityRoom(room) |
| 32 | 32 | if (name) await cr.setMeta('name', name) |
| 33 | 33 | if (did) await cr.setMeta('did', did) |
| 34 | if (curator) await cr.setCuration(curator, params) | |
| 34 | 35 | return cr |
| 35 | 36 | } |
| 36 | 37 | |
| … | … | @@ -102,6 +103,29 @@ export class CommunityRoom { |
| 102 | 103 | // replicates to keyless readers. Set once at create normally; this is for late-binding / migration. |
| 103 | 104 | async setDid (did) { this._assertWritable('setDid'); this._assertDid(did); await this.setMeta('did', did) } |
| 104 | 105 | |
| 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 | ||
| 105 | 129 | async meta () { |
| 106 | 130 | await this._room.base.update() |
| 107 | 131 | const m = {} |
| … | … | @@ -175,6 +199,7 @@ export class CommunityRooms { |
| 175 | 199 | async members (roomKeyHex) { return (await this.room(roomKeyHex)).members() } |
| 176 | 200 | async meta (roomKeyHex) { return (await this.room(roomKeyHex)).meta() } |
| 177 | 201 | async communityDid (roomKeyHex) { return (await this.room(roomKeyHex)).communityDid() } |
| 202 | async curation (roomKeyHex) { return (await this.room(roomKeyHex)).curation() } | |
| 178 | 203 | |
| 179 | 204 | // Watch one room's roster for changes. onChange(memberDids[]) fires (debounced) on every linearized-view |
| 180 | 205 | // advance — i.e. whenever a member add/remove/ban replicates in — PLUS once immediately as a baseline. |
| … | … | @@ -9,6 +9,7 @@ |
| 9 | 9 | // ban {did} → banned:<did> + del member:<did> (moderation; excluded from the roster) |
| 10 | 10 | // unban {did} → del banned:<did> |
| 11 | 11 | // set-meta {key,value} → meta:<key> (name / description / …) |
| 12 | // set-curation {curator,params} → curation (the curation CONTRACT — HOW the feed selects posts) | |
| 12 | 13 | // |
| 13 | 14 | // apply is DETERMINISTIC — it reads only fields carried in the op (op.at/op.by, set at append time), never |
| 14 | 15 | // Date.now()/randomness (Autobase replays apply on every boot + reorg). |
| … | … | @@ -16,12 +17,27 @@ import { jsonSchema } from 'hyper-rooms/lib/json-schema.js' |
| 16 | 17 | |
| 17 | 18 | export const isDid = (d) => typeof d === 'string' && /^did:[a-z]+:/.test(d) |
| 18 | 19 | |
| 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. | |
| 25 | export 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. | |
| 28 | export const DEFAULT_CURATION = { curator: 'roster', params: {} } | |
| 29 | const isPlainObject = (o) => o != null && typeof o === 'object' && !Array.isArray(o) | |
| 30 | ||
| 19 | 31 | export const communitySchema = jsonSchema({ |
| 20 | 32 | validate (op) { |
| 21 | 33 | if (['add-member', 'remove-member', 'ban', 'unban'].includes(op.type) && !isDid(op.did)) { |
| 22 | 34 | throw new Error(`${op.type} requires a valid did`) |
| 23 | 35 | } |
| 24 | 36 | 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 | } | |
| 25 | 41 | }, |
| 26 | 42 | async apply (batch, op, host) { |
| 27 | 43 | switch (op.type) { |
| … | … | @@ -41,6 +57,11 @@ export const communitySchema = jsonSchema({ |
| 41 | 57 | case 'set-meta': |
| 42 | 58 | await batch.put(`meta:${op.key}`, { value: op.value }) |
| 43 | 59 | 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 | |
| 44 | 65 | } |
| 45 | 66 | } |
| 46 | 67 | }) |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-community", |
| 3 | "version": "0.1.4", | |
| 3 | "version": "0.1.5", | |
| 4 | 4 | "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.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -99,7 +99,36 @@ assert(await until(() => seen[seen.length - 1].includes(E)), 'watch DELTA: a mem |
| 99 | 99 | await unwatch() |
| 100 | 100 | unwireW() |
| 101 | 101 | |
| 102 | console.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. | |
| 104 | const curStore = new Corestore('data/cc-cur-' + stamp) | |
| 105 | const curOwner = await CommunityRoom.create(curStore, { name: 'Cur Community' }) | |
| 106 | const cur0 = await curOwner.curation() | |
| 107 | assert(cur0.curator === 'roster' && typeof cur0.params === 'object', 'DEFAULT curation = roster (pre-curation rooms keep working, no migration)') | |
| 108 | await curOwner.setCuration('subject', { tag: 'p2p' }) | |
| 109 | const cur1 = await curOwner.curation() | |
| 110 | assert(cur1.curator === 'subject' && cur1.params.tag === 'p2p', 'setCuration binds {curator:subject, params:{tag}} — mutable contract') | |
| 111 | let curThrew = false | |
| 112 | try { await curOwner.setCuration('', {}) } catch { curThrew = true } | |
| 113 | assert(curThrew, 'setCuration rejects an empty curator name') | |
| 114 | let curParamThrew = false | |
| 115 | try { await curOwner.setCuration('rules', [1, 2]) } catch { curParamThrew = true } | |
| 116 | assert(curParamThrew, 'setCuration rejects non-object params') | |
| 117 | // a KEYLESS reader replicates + reads the curation contract (no key) | |
| 118 | const curReaderStore = new Corestore('data/cc-curr-' + stamp) | |
| 119 | const curReader = await CommunityRoom.openReadonly(curReaderStore, curOwner.key) | |
| 120 | const unwireC = wire(curOwner, curReader) | |
| 121 | assert(await until(async () => (await curReader.curation()).curator === 'subject'), 'KEYLESS READ: reader replicates the curation contract {curator:subject}') | |
| 122 | let curRThrew = false | |
| 123 | try { await curReader.setCuration('roster', {}) } catch { curRThrew = true } | |
| 124 | assert(curRThrew, 'keyless reader cannot setCuration (write-guarded)') | |
| 125 | // custom curator name accepted with NO schema change (architecture.md escape-hatch) | |
| 126 | await curOwner.setCuration('my-custom-algo', { weight: 3 }) | |
| 127 | assert((await curOwner.curation()).curator === 'my-custom-algo', 'custom curator id accepted (schema is curator-agnostic)') | |
| 128 | unwireC(); await curOwner.close(); await curReader.close() | |
| 129 | try { await curStore.close(); await curReaderStore.close() } catch {} | |
| 130 | ||
| 131 | console.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)') | |
| 103 | 132 | unwireR(); unwireM() |
| 104 | 133 | await owner.close(); await reader.close(); await mod.close() |
| 105 | 134 | try { await crooms.close() } catch {} |