// favela-web feed generator (lens export §3.6), MULTI-TENANT (phase 4) — each community's OWN did:plc is
// its feed-generator service. Proves: feed-uri validation keyed by (communityDid, rkey), community-scoped
// skeleton (bsky-only posts), and slugRkey. Pure, injected stubs. Run: node test/feedgen.test.mjs
import { FeedGenerator, parseFeedUri, isBskyPost, isValidPostRkey, postUri, slugRkey } from '../lib/feedgen.js'
const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
assert(slugRkey('Cat Lovers') === 'cat-lovers', 'slugRkey lowercases + hyphenates spaces')
assert(slugRkey('pets') === 'pets' && /^[a-z0-9._~-]+$/.test(slugRkey('a b/c!')), 'slugRkey yields a valid rkey')
assert(slugRkey('') === 'feed', 'slugRkey falls back to "feed" on empty')
// ── parse + helpers ──
assert(parseFeedUri('at://did:plc:x/app.bsky.feed.generator/cats').rkey === 'cats', 'parseFeedUri extracts rkey')
assert(parseFeedUri('not-a-uri') === null, 'parseFeedUri rejects malformed')
assert(postUri('did:plc:a', 'r1') === 'at://did:plc:a/app.bsky.feed.post/r1', 'postUri builds the bsky post at-uri')
assert(isBskyPost({ record: { $type: 'app.bsky.feed.post' } }) === true, 'isBskyPost accepts app.bsky.feed.post')
assert(isBskyPost({ record: { $type: 'net.favela.local.post' } }) === false, 'isBskyPost rejects favela-local')
// isValidPostRkey: bsky post rkeys MUST be TIDs (13-char base32-sortable). A bootstrap placeholder like
// 'self.hello' is a valid record-key but NOT a TID — bsky rejects a whole feed skeleton containing one.
assert(isValidPostRkey('3kaaaaaaaaaaa') === true, 'isValidPostRkey accepts a 13-char TID')
assert(isValidPostRkey('self.hello') === false, 'isValidPostRkey rejects a non-TID rkey (self.hello)')
assert(isValidPostRkey('') === false && isValidPostRkey('short') === false, 'isValidPostRkey rejects empty/short rkeys')
assert(isValidPostRkey('3kaaaaaaaaaa1') === false, 'isValidPostRkey rejects a 13-char rkey with an out-of-alphabet char (1)')
// ── register TWO communities, each with its OWN did:plc ──
const CATS = 'did:plc:cats2222222222222222222'
const DOGS = 'did:plc:dogs2222222222222222222'
const fg = new FeedGenerator({ hostname: 'favela.example.net' })
fg.addFeed({ communityDid: CATS, rkey: 'cats', roomKey: 'a'.repeat(64), displayName: 'Cat Lovers' })
fg.addFeed({ communityDid: DOGS, rkey: 'dogs', roomKey: 'b'.repeat(64), displayName: 'Dog Fans' })
assert(fg.feedUri(CATS, 'cats') === `at://${CATS}/app.bsky.feed.generator/cats`, 'feedUri is under the COMMUNITY DID')
assert(fg.has(CATS, 'cats') && !fg.has(CATS, 'dogs'), 'has() is keyed by (communityDid, rkey)')
// ── removeFeed (a remove-feed reaction from the FeedRegistry): in-memory deregister, idempotent ──
assert(fg.removeFeed(CATS, 'cats') === true && !fg.has(CATS, 'cats'), 'removeFeed deregisters the feed (stops serving it)')
assert(fg.removeFeed(CATS, 'cats') === false, 'removeFeed is idempotent (removing an unknown feed → false, no-op)')
fg.addFeed({ communityDid: CATS, rkey: 'cats', roomKey: 'a'.repeat(64), displayName: 'Cat Lovers' }) // restore for the rest
// ── describe (per-community + superset) ──
assert(fg.describe(CATS).did === CATS && fg.describe(CATS).feeds[0].uri === fg.feedUri(CATS, 'cats'), 'describe(communityDid) lists that community\'s feeds under its DID')
assert(fg.describe().feeds.length === 2, 'describe() with no DID lists all hosted feeds (superset)')
// ── resolve validation (the atproto UnsupportedAlgorithm contract), per community DID ──
assert(fg.resolve(fg.feedUri(CATS, 'cats')).communityDid === CATS, 'resolve accepts a valid hosted community feed uri')
for (const [bad, why] of [
[`at://${CATS}/app.bsky.feed.generator/unknown`, 'unknown rkey for this community'],
[`at://did:plc:stranger222222222222222/app.bsky.feed.generator/cats`, 'rkey under a non-hosted DID'],
[`at://${CATS}/app.bsky.feed.post/cats`, 'wrong collection'],
[`at://${DOGS}/app.bsky.feed.generator/cats`, 'rkey belongs to a DIFFERENT community']
]) {
let threw = false
try { fg.resolve(bad) } catch (e) { threw = e.error === 'UnsupportedAlgorithm' && e.status === 400 }
assert(threw, `resolve rejects (${why}) with UnsupportedAlgorithm`)
}
// ── skeleton: community roster → bsky-only post URIs, reverse-chron, respects limit ──
const ALICE = 'did:plc:alice', BOB = 'did:plc:bob', EVE = 'did:plc:eve'
const roster = [ALICE, BOB] // EVE is NOT a member
// realistic rkeys: bsky posts are TID-keyed (13-char base32-sortable). tid(c) = a distinct valid TID.
const tid = (c) => '3k' + String(c).repeat(11)
const P5 = tid('a'), P4 = tid('b'), P3 = tid('c'), P2 = tid('d'), P1 = tid('e')
const ALL = [
{ did: ALICE, rkey: P5, record: { $type: 'app.bsky.feed.post' } },
{ did: EVE, rkey: P4, record: { $type: 'app.bsky.feed.post' } }, // non-member → excluded by scope
{ did: BOB, rkey: P3, record: { $type: 'net.favela.local.post' } }, // local → excluded from bsky feed
{ did: ALICE, rkey: P2, record: { $type: 'app.bsky.feed.post' } },
{ did: BOB, rkey: P1, record: { $type: 'app.bsky.feed.post' } },
{ did: ALICE, rkey: 'self.hello', record: { $type: 'app.bsky.feed.post' } } // BOOTSTRAP placeholder: member bsky post but NON-TID rkey → must be DROPPED (else bsky rejects the whole feed)
]
const getTimeline = async ({ authors, limit, cursor }) => {
const start = cursor ? ALL.findIndex((p) => `${p.did}/${p.rkey}` === cursor) + 1 : 0
const posts = []
let i = start
for (; i < ALL.length && posts.length < limit; i++) { if (authors.has(ALL[i].did)) posts.push(ALL[i]) }
const last = posts[posts.length - 1]
return { posts, cursor: (i < ALL.length && last) ? `${last.did}/${last.rkey}` : null }
}
const rosterOf = async () => roster
const skel = await fg.skeleton(fg.feed(CATS, 'cats'), { rosterOf, getTimeline, limit: 10 })
const uris = skel.feed.map((f) => f.post)
assert(uris.includes(postUri(ALICE, P5)) && uris.includes(postUri(ALICE, P2)) && uris.includes(postUri(BOB, P1)), 'skeleton includes members\' bsky posts')
assert(!uris.some((u) => u.includes('eve')), 'skeleton EXCLUDES a non-member (author scope)')
assert(!uris.some((u) => u.includes('/net.favela.local.post/') || u.includes(P3)), 'skeleton EXCLUDES favela-local posts (bsky can\'t hydrate them)')
assert(!uris.some((u) => u.includes('self.hello')), 'skeleton DROPS a non-TID rkey (self.hello) — one bad rkey would else poison the whole bsky feed (InvalidFeedResponse)')
assert(skel.feed.length === 3, 'skeleton has exactly the 3 member bsky posts (poison self.hello excluded)')
// CURSOR must be a string or ABSENT — never JSON null (null fails bsky's XRPC response-schema validation →
// InvalidFeedResponse, a "broken" feed). The limit=10 pager above EXHAUSTS the roster, so cursor is omitted.
assert(!('cursor' in skel), 'skeleton OMITS cursor when the pager is exhausted (never emits cursor:null)')
const paged = await fg.skeleton(fg.feed(CATS, 'cats'), { rosterOf, getTimeline, limit: 2 })
assert(typeof paged.cursor === 'string' && paged.cursor.length > 0, 'skeleton INCLUDES a string cursor when more pages remain')
assert(paged.feed.length === 2, 'skeleton respects the limit while more pages remain')
const empty = await fg.skeleton(fg.feed(CATS, 'cats'), { rosterOf: async () => [], getTimeline, limit: 10 })
assert(empty.feed.length === 0 && !('cursor' in empty), 'empty roster → empty skeleton (cursor omitted, not null)')
// ── CURATION DISPATCH: the {curator,params} contract selects HOW the feed is built ──
// backward-compat: no curationOf given → roster curation (the assertions above already prove this).
// A tag-scoped appview query for the `subject` curator.
const T3 = tid('f'), T2 = tid('g'), T1 = tid('h')
const TAGGED = [
{ did: EVE, rkey: T3, record: { $type: 'app.bsky.feed.post', tags: ['p2p'] } }, // EVE is NOT a roster member
{ did: BOB, rkey: T2, record: { $type: 'net.favela.local.post', tags: ['p2p'] } }, // local → excluded
{ did: ALICE, rkey: T1, record: { $type: 'app.bsky.feed.post', tags: ['p2p'] } }
]
const getTagFeed = async (tag, { limit, cursor }) => {
if (tag !== 'p2p') return { posts: [], cursor: null }
const start = cursor ? TAGGED.findIndex((p) => `${p.did}/${p.rkey}` === cursor) + 1 : 0
const posts = TAGGED.slice(start, start + limit)
const last = posts[posts.length - 1]
return { posts, cursor: (start + limit < TAGGED.length && last) ? `${last.did}/${last.rkey}` : null }
}
// subject curator → TOPICAL (tag) feed across ALL authors (EVE included — NOT roster-scoped), bsky-only.
const subj = await fg.skeleton(fg.feed(CATS, 'cats'), {
rosterOf, curationOf: async () => ({ curator: 'subject', params: { tag: 'p2p' } }), getTimeline, getTagFeed, limit: 10
})
const subjUris = subj.feed.map((f) => f.post)
assert(subjUris.includes(postUri(EVE, T3)) && subjUris.includes(postUri(ALICE, T1)), 'subject curator: tag feed spans ALL authors (topical, not roster-scoped)')
assert(!subjUris.some((u) => u.includes(T2)), 'subject curator: still excludes favela-local posts')
assert(subj.feed.length === 2, 'subject curator: exactly the 2 bsky #p2p posts')
// explicit roster curator == the default behavior
const rost = await fg.skeleton(fg.feed(CATS, 'cats'), {
rosterOf, curationOf: async () => ({ curator: 'roster', params: {} }), getTimeline, getTagFeed, limit: 10
})
assert(rost.feed.length === 3 && !rost.feed.some((f) => f.post.includes('eve')), 'roster curator == default (members only, EVE excluded)')
// unknown / not-yet-built curator (rules, custom) → SAFE roster fallback (always a valid feed)
const fallback = await fg.skeleton(fg.feed(CATS, 'cats'), {
rosterOf, curationOf: async () => ({ curator: 'rules', params: { authors: ['did:plc:whoever'] } }), getTimeline, getTagFeed, limit: 10
})
assert(fallback.feed.length === 3 && !fallback.feed.some((f) => f.post.includes('eve')), 'unknown curator falls back to roster (safe default, never errors the feed)')
// subject with a missing tag → empty (misconfigured feed degrades, never throws)
const badSubj = await fg.skeleton(fg.feed(CATS, 'cats'), {
rosterOf, curationOf: async () => ({ curator: 'subject', params: {} }), getTimeline, getTagFeed, limit: 10
})
assert(badSubj.feed.length === 0, 'subject curator with no tag → empty (graceful)')
console.log('\nFEEDGEN OK (multi-tenant): per-community-DID getFeedSkeleton contract + community bsky-only skeleton + curation dispatch (roster/subject/fallback)')