OverviewFilesCommitsIssuesDocsReleasesPeers

favela-bridge commits / d891c44

the DID names its favela feed back: net.favela.feed/self (I2a, hyper-social #82 D4a)
The identity's reach already names its DID (credential); the DID repo, signed by the DID key, now names the feed. A gateway binds a DID to an identity only when both agree. Published content: needs bsky consent AND a room type that reaches bsky (the claim exposes the feed key, which replicates in the clear); reconciled against the repo, never written after a failed read. Test-first; 55/55. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d891c44c4db977d29e9a6fea2f956d45fbfcb1a9
by plankatron <fleeky@gmail.com> · 2026-10-14T23:18:20.000Z

Changed files (3) +80 -8

lib/project.js +32 -0
……@@ -22,6 +22,7 @@ import { samePost } from './plan.js'
2222import { tidFor } from './tid.js'
2323import { POST_COLLECTION } from './bsky.js'
2424import { planEdge } from './plan-edge.js'
25import { maxReach } from 'favela-markdown/lib/reach.js'
2526
2627// oid -> blob ref. Content-addressed, so this can never go stale.
2728const blobCache = new Map()
……@@ -133,6 +134,35 @@ export async function reconcileProfile (peer, bsky, reach, send, log) {
133134 return { done: !!r.sent, error: r.error || null }
134135}
135136
137/**
138 * The DID names its favela feed back: `net.favela.feed/self = { feed }` (hyper-social #82, I2a / D4a).
139 *
140 * ⛔ TWO-SIDED, OR NOT AT ALL. The identity's reach already names its DID (`credential`); this is the other half,
141 * signed by the DID key. A gateway binds a DID to an identity only when both agree, so nobody can attach somebody
142 * else's DID to their feed — or their feed to somebody else's DID.
143 *
144 * ⛔ PUBLISHED CONTENT: waits for bsky consent like the profile, and RECONCILES against the repo — written only when
145 * absent or different, and never after a failed read ("could not ask" is not "absent").
146 */
147export const FEED_CLAIM = 'net.favela.feed'
148
149export async function reconcileFeedClaim (feed, bsky, reach, send, log, roomType = 'public') {
150 if (!bsky.configured) return { done: false, reason: 'no sink' }
151 if (!reach.networks.includes('bsky')) return { done: false, reason: 'not enabled' }
152 // ⛔ THE REACH LATTICE TOO, not only the switch. The claim names the feed key, and a favela feed replicates in the
153 // clear to anyone holding its key — so it may leave only from where a post may (a members room reaches nothing).
154 if (!maxReach({ roomType, verb: 'post' }).has('bsky')) return { done: false, reason: 'room type does not reach bsky' }
155 if (!/^[0-9a-f]{64}$/i.test(String(feed || ''))) return { done: false, reason: 'no feed key' }
156 const actual = await bsky.getRecord(FEED_CLAIM, 'self')
157 if (actual && actual.unknown) { log(' feed link could not read what bsky holds — not writing'); return { done: false, reason: 'unknown' } }
158 const want = String(feed).toLowerCase()
159 if (actual && actual.feed === want) return { done: false, reason: 'already matches' }
160 if (!send) { log(' feed link bsky: would write net.favela.feed/self'); return { done: false, reason: 'dry run' } }
161 const r = await bsky.putRecord(FEED_CLAIM, 'self', { $type: FEED_CLAIM, feed: want, createdAt: new Date().toISOString() })
162 log(' feed link bsky: ' + (r.sent ? 'WRITTEN (this DID names feed ' + want.slice(0, 12) + ')' : 'FAILED — ' + r.error))
163 return { done: !!r.sent, error: r.error || null }
164}
165
136166/**
137167 * One full pass: read consent, reconcile the profile, then project the posts that reach bsky.
138168 *
……@@ -172,6 +202,8 @@ export async function projectCycle ({
172202 if (found === null) log(' ⚠ could not read the sink — "already there" is unknown below')
173203
174204 const profile = await reconcileProfile(peer, bsky, reach, send, log)
205 // ⛔ AND THE OTHER HALF OF THE IDENTITY LINK (I2a): the DID names this feed. Never fatal to the pass.
206 try { await reconcileFeedClaim(feed, bsky, reach, send, log, roomType) } catch (err) { log(' feed link failed: ' + ((err && err.message) || err)) }
175207
176208 let sent = 0; let skipped = 0; let withheld = 0; let held = 0
177209 for (const row of rows) {
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-bridge",
3 "version": "0.0.12",
3 "version": "0.0.13",
44 "description": "Projects favela records onto the networks a room may reach. One reader, one reach decision, one projection per network.",
55 "type": "module",
66 "main": "index.js",
test/project.test.js +47 -7
……@@ -26,7 +26,7 @@ function fakePeer ({ networks = ['bsky'], rows = [], profile = null, unknown = f
2626 }
2727}
2828
29function fakeSink ({ has = [], haveNull = false } = {}) {
29function fakeSink ({ has = [], haveNull = false, getFails = false } = {}) {
3030 const written = []
3131 const records = new Map()
3232 const client = {
……@@ -36,7 +36,7 @@ function fakeSink ({ has = [], haveNull = false } = {}) {
3636 return { records: has.map((rkey) => ({ collection: 'app.bsky.feed.post', rkey })) }
3737 }
3838 if (cmd === 'apply-write') { written.push(args); records.set(args.collection + '/' + args.rkey, args.record); return { ok: true } }
39 if (cmd === 'get-record') return records.get(args.collection + '/' + args.rkey) || null
39 if (cmd === 'get-record') { if (getFails) throw new Error('cannot read'); return records.get(args.collection + '/' + args.rkey) || null }
4040 throw new Error('unexpected ' + cmd)
4141 }
4242 }
……@@ -72,7 +72,7 @@ test('★★ a post already in the repo is not sent twice', async (t) => {
7272 const r = await projectCycle({ peer: fakePeer({ rows: [row(name, 'hello')] }), bsky: sink, send: true })
7373 t.is(r.skipped, 1, 'recognised as already there')
7474 t.is(r.sent, 0)
75 t.is(written.length, 0)
75 t.is(written.filter((w) => w.collection === 'app.bsky.feed.post').length, 0, 'no post re-sent (the feed link may be written once)')
7676})
7777
7878test('★★ a public post is sent, under a TID rkey', async (t) => {
……@@ -80,10 +80,12 @@ test('★★ a public post is sent, under a TID rkey', async (t) => {
8080 const { sink, written } = fakeSink()
8181 const r = await projectCycle({ peer: fakePeer({ rows: [row(name, 'hello')] }), bsky: sink, send: true })
8282 t.is(r.sent, 1)
83 t.is(written.length, 1)
84 t.is(written[0].collection, 'app.bsky.feed.post')
85 t.is(written[0].rkey, tidFor(name), 'the AppView silently drops posts whose rkey is not a TID')
86 t.is(written[0].record.text, 'hello')
83 // The DID's feed-link record (net.favela.feed/self, I2a) is also written on this first pass; count POSTS.
84 const posts = written.filter((w) => w.collection === 'app.bsky.feed.post')
85 t.is(posts.length, 1)
86 t.is(posts[0].collection, 'app.bsky.feed.post')
87 t.is(posts[0].rkey, tidFor(name), 'the AppView silently drops posts whose rkey is not a TID')
88 t.is(posts[0].record.text, 'hello')
8789})
8890
8991test('★★ a dry run writes nothing but still reports what it would do', async (t) => {
……@@ -174,3 +176,41 @@ test('★★★ with bsky switched off, no edge is projected either', async (t)
174176 t.absent(r.edges.sent, 'consent gates edges exactly as it gates posts')
175177 t.is(written.length, 0)
176178})
179
180// ── the DID names its favela feed back (hyper-social #82, I2a / D4a) ───────────────────────────────────────────
181//
182// ⛔ TWO-SIDED, OR NOT AT ALL. The identity's reach already names its DID (credential). This is the other half: the
183// DID's own repo, signed by the DID key, names the favela feed. A gateway binds a DID to an identity only when both
184// agree, so nobody can attach someone else's DID to their feed. Published content, so it waits for bsky consent
185// exactly like the profile; reconciled against what the repo holds, never written on a failed read.
186const FEED_CLAIM = 'net.favela.feed'
187const claimOf = (records) => records.get(FEED_CLAIM + '/self') || null
188
189test('★★ with bsky on, the DID repo gets net.favela.feed/self naming this feed', async (t) => {
190 const { sink, written, records } = fakeSink()
191 await projectCycle({ peer: fakePeer(), bsky: sink, send: true })
192 const c = claimOf(records)
193 t.ok(c, 'written')
194 t.alike({ type: c.$type, feed: c.feed }, { type: FEED_CLAIM, feed: 'f'.repeat(64) })
195 t.ok(written.some((w) => w.collection === FEED_CLAIM && w.rkey === 'self'))
196})
197
198test('the same claim is not rewritten', async (t) => {
199 const { sink, written } = fakeSink()
200 await projectCycle({ peer: fakePeer(), bsky: sink, send: true })
201 const n = written.filter((w) => w.collection === FEED_CLAIM).length
202 await projectCycle({ peer: fakePeer(), bsky: sink, send: true })
203 t.is(written.filter((w) => w.collection === FEED_CLAIM).length, n, 'a second pass writes nothing')
204})
205
206test('⛔ no claim without bsky consent, on a dry run, or when the repo cannot be read', async (t) => {
207 const off = fakeSink()
208 await projectCycle({ peer: fakePeer({ networks: ['fediverse'] }), bsky: off.sink, send: true })
209 t.absent(claimOf(off.records), 'bsky off')
210 const dry = fakeSink()
211 await projectCycle({ peer: fakePeer(), bsky: dry.sink, send: false })
212 t.absent(claimOf(dry.records), 'dry run')
213 const blind = fakeSink({ getFails: true })
214 await projectCycle({ peer: fakePeer(), bsky: blind.sink, send: true })
215 t.absent(blind.written.some((w) => w.collection === FEED_CLAIM), 'could not read is not absent')
216})