OverviewFilesCommitsIssuesDocsReleasesPeers

favela-gateway commits / 46edff1

bsky → observation feeds: two-sided DID binding + Spacedust link mapper (I2a/I2b, hyper-social #82)
bindDid: a fronted DID binds to a favela identity only when its repo names the feed (net.favela.feed/self) AND the held feed's reach.credential names the DID. linkToObservation: like / repost / follow / direct reply on the identity's own posts -> the I1 observation shape (target inverted via an injected rkey->name); a Spacedust delete removes by the linking record's at:// URI; root-only replies and quotes deferred. Pure; not wired yet. 87/87. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
46edff1693c74994e9acfece16a29fe79d601ceb
by plankatron <fleeky@gmail.com> · 2026-10-14T23:24:51.000Z

Changed files (4) +180 -0

lib/bsky-bind.js +28 -0
……@@ -0,0 +1,28 @@
1// bsky-bind.js — is this fronted DID a favela identity's, by BOTH sides' word? (hyper-social #82, I2a / D4a)
2//
3// ⛔ TWO-SIDED, OR NOT AT ALL. The DID's repo, signed by the DID key, names a favela feed (`net.favela.feed/self`,
4// written by favela-bridge's projection); AND that feed's reach/self, written by the identity's own devices, names
5// the DID as its credential. Either half alone is a claim anyone can make. Only the pair binds, and only a binding
6// lets bsky interactions on the DID reach that identity's observation feed.
7//
8// PURE + INJECTED: `front` is the membrane's front for the DID (getRecord), `feedOf(feedKey)` the held favela feed or
9// null. A failed read is a refusal, never a throw — the caller re-evaluates on the next change.
10
11export const FEED_CLAIM = 'net.favela.feed'
12
13export async function bindDid (did, { front, feedOf } = {}) {
14 let rec = null
15 try { rec = front && typeof front.getRecord === 'function' ? await front.getRecord(FEED_CLAIM, 'self') : null } catch { return { refused: 'could not read the DID repo' } }
16 const feed = rec && rec.value && typeof rec.value.feed === 'string' ? rec.value.feed.toLowerCase() : null
17 if (!feed) return { refused: 'the DID names no favela feed' }
18 if (!/^[0-9a-f]{64}$/.test(feed)) return { refused: 'the DID names a malformed feed key' }
19 let held = null
20 try { held = feedOf ? await feedOf(feed) : null } catch { held = null }
21 if (!held || typeof held.reach !== 'function') return { refused: 'that feed is not held here' }
22 let reach = null
23 try { reach = await held.reach() } catch { return { refused: 'could not read that feed\'s reach' } }
24 if (!reach || reach.credential !== did) return { refused: 'that feed does not name this DID' }
25 return { did, feed }
26}
27
28export default { bindDid, FEED_CLAIM }
lib/bsky-inbound.js +65 -0
……@@ -0,0 +1,65 @@
1// bsky-inbound.js — bsky interactions on a bound DID's posts → the identity's observation feed (hyper-social #82, I2b).
2//
3// ⛔ THE SAME OBSERVATION SHAPE AS THE FEDIVERSE (I1). The node already holds the identity's observation feed and
4// renders it; bsky arrivals are just more rows in it. Nothing on the node changes.
5//
6// Spacedust ships LOCATORS: the linking record's at:// address and the subject it points at. That is all an
7// observation needs — who, what kind, about which post, and the identity it is undone by (a Spacedust `delete` names
8// the same linking record). A reply's text is fetched separately, for display only, by the wiring.
9//
10// ⚠ A favela post behind an at:// rkey is found by recomputing tidFor over the identity's posts (tidFor is one-way),
11// so the caller injects `postName(rkey)`.
12//
13// PURE: no network, no clock.
14
15export const SOURCES = {
16 REPLY_PARENT: 'app.bsky.feed.post:reply.parent.uri',
17 REPLY_ROOT: 'app.bsky.feed.post:reply.root.uri',
18 QUOTE: 'app.bsky.feed.post:embed.record.uri',
19 QUOTE_MEDIA: 'app.bsky.feed.post:embed.record.record.uri',
20 LIKE: 'app.bsky.feed.like:subject.uri',
21 REPOST: 'app.bsky.feed.repost:subject.uri',
22 FOLLOW: 'app.bsky.graph.follow:subject'
23}
24
25// What this version subscribes to. Root-only replies and quotes are deliberately absent (see linkToObservation).
26export const WANTED = [SOURCES.LIKE, SOURCES.REPOST, SOURCES.FOLLOW, SOURCES.REPLY_PARENT]
27
28const KIND = {
29 [SOURCES.LIKE]: 'like',
30 [SOURCES.REPOST]: 'repost',
31 [SOURCES.FOLLOW]: 'follow',
32 [SOURCES.REPLY_PARENT]: 'reply'
33}
34
35/**
36 * One Spacedust link (spacedust.js parseLinkEvent shape) → { op: 'note', obs } | { op: 'remove', activity } | null.
37 *
38 * ⛔ NOTHING for what we have no words for yet: a root-only reply is a reply to someone else's post in a thread we
39 * started; a quote needs its own kind end to end. Nothing for a post with no favela original, and nothing for the
40 * identity's own actions.
41 */
42export function linkToObservation (link, { did, feed, postName } = {}) {
43 if (!link || !did || !feed) return null
44 const kind = KIND[link.source]
45 if (!kind) return null
46 if (link.did === did) return null
47 const activity = `at://${link.did}/${link.collection}/${link.rkey}`
48 if (link.operation === 'delete') return { op: 'remove', activity }
49 if (link.operation !== 'create') return null
50
51 let target = null
52 if (kind === 'follow') {
53 if (link.subject !== did) return null
54 } else {
55 const prefix = `at://${did}/app.bsky.feed.post/`
56 if (typeof link.subject !== 'string' || !link.subject.startsWith(prefix)) return null
57 const rkey = link.subject.slice(prefix.length)
58 const name = postName ? postName(rkey) : null
59 if (!name) return null
60 target = { feed, collection: 'post', name }
61 }
62 return { op: 'note', obs: { kind, actor: link.did, subject: link.subject, target, activity } }
63}
64
65export default { linkToObservation, SOURCES, WANTED }
test/bsky-bind.test.js +37 -0
……@@ -0,0 +1,37 @@
1// bsky-bind.test.js — a fronted DID is bound to a favela identity only when BOTH sides say so (hyper-social #82,
2// I2a / D4a).
3//
4// ⛔ TWO-SIDED, OR NOT AT ALL. The DID's repo (signed by the DID key) names a favela feed in net.favela.feed/self, AND
5// that feed's reach/self (written by the identity's own devices) names the DID as its credential. Either half alone
6// is a claim anyone could make: a feed can name any DID, and a DID's owner can name any feed. Only the pair binds —
7// and only a binding lets bsky interactions on that DID reach that identity's notifications.
8
9import test from 'brittle'
10import { bindDid, FEED_CLAIM } from '../lib/bsky-bind.js'
11
12const DID = 'did:plc:fleekyfleekyfleeky'
13const FEED = 'd2'.repeat(32)
14
15const front = (value) => ({ getRecord: async (c, r) => (c === FEED_CLAIM && r === 'self' && value ? { uri: `at://${DID}/${c}/${r}`, value } : null) })
16const held = (credential) => ({ reach: async () => ({ networks: ['bsky'], credential }) })
17
18test('★ bound when the DID names the feed AND the feed names the DID', async (t) => {
19 const r = await bindDid(DID, { front: front({ $type: FEED_CLAIM, feed: FEED }), feedOf: async (f) => (f === FEED ? held(DID) : null) })
20 t.alike(r, { did: DID, feed: FEED })
21})
22
23test('⛔ one-sided claims bind nothing', async (t) => {
24 const noClaim = await bindDid(DID, { front: front(null), feedOf: async () => held(DID) })
25 t.ok(noClaim.refused, 'the DID names no feed: ' + noClaim.refused)
26 const otherDid = await bindDid(DID, { front: front({ feed: FEED }), feedOf: async () => held('did:plc:someoneelse') })
27 t.ok(otherDid.refused, 'the feed names a different DID: ' + otherDid.refused)
28 const notHeld = await bindDid(DID, { front: front({ feed: FEED }), feedOf: async () => null })
29 t.ok(notHeld.refused, 'the gateway does not hold that feed: ' + notHeld.refused)
30 const junk = await bindDid(DID, { front: front({ feed: 'not-a-key' }), feedOf: async () => held(DID) })
31 t.ok(junk.refused, 'a malformed feed key: ' + junk.refused)
32})
33
34test('a read that fails is a refusal, never a throw', async (t) => {
35 const r = await bindDid(DID, { front: { getRecord: async () => { throw new Error('offline') } }, feedOf: async () => held(DID) })
36 t.ok(r.refused)
37})
test/bsky-inbound.test.js +50 -0
……@@ -0,0 +1,50 @@
1// bsky-inbound.test.js — a Spacedust link on a bound DID's post becomes an observation (hyper-social #82, I2b).
2//
3// ⛔ THE SAME OBSERVATION SHAPE AS THE FEDIVERSE (I1), so nothing on the node changes: kind, actor, subject (kept as
4// observed), target (inverted to the identity's own favela post), activity (the identity the observation is undone
5// by — here the linking record's at:// URI, which is what a Spacedust `delete` names).
6//
7// ⚠ The favela post behind an at:// rkey is found by recomputing tidFor over the identity's posts — tidFor is
8// one-way (favela-bridge tid.js), so the caller supplies `postName(rkey)`.
9
10import test from 'brittle'
11import { linkToObservation, SOURCES } from '../lib/bsky-inbound.js'
12
13const DID = 'did:plc:fleekyfleekyfleeky'
14const FEED = 'd2'.repeat(32)
15const ALICE = 'did:plc:alicealicealice'
16const POST = `at://${DID}/app.bsky.feed.post/3mrhxnkbhv622`
17const postName = (rkey) => (rkey === '3mrhxnkbhv622' ? '01k4qspvquliq9js01m6c5658b' : null)
18const link = (source, over = {}) => ({ operation: 'create', source, subject: POST, did: ALICE, collection: source.split(':')[0], rkey: '3abc', ...over })
19
20test('★ like / repost / reply on the identity\'s own post → observations aimed at the favela post', (t) => {
21 const ctx = { did: DID, feed: FEED, postName }
22 const like = linkToObservation(link(SOURCES.LIKE), ctx)
23 t.alike(like, {
24 op: 'note',
25 obs: { kind: 'like', actor: ALICE, subject: POST, target: { feed: FEED, collection: 'post', name: '01k4qspvquliq9js01m6c5658b' }, activity: `at://${ALICE}/app.bsky.feed.like/3abc` }
26 })
27 t.is(linkToObservation(link(SOURCES.REPOST), ctx).obs.kind, 'repost')
28 const reply = linkToObservation(link(SOURCES.REPLY_PARENT, { collection: 'app.bsky.feed.post' }), ctx)
29 t.is(reply.obs.kind, 'reply')
30 t.is(reply.obs.activity, `at://${ALICE}/app.bsky.feed.post/3abc`, 'the reply post itself — its text is fetched for display')
31})
32
33test('★ a follow of the DID is a follow, aimed at nobody\'s post', (t) => {
34 const r = linkToObservation(link(SOURCES.FOLLOW, { subject: DID, collection: 'app.bsky.graph.follow' }), { did: DID, feed: FEED, postName })
35 t.alike({ kind: r.obs.kind, subject: r.obs.subject, target: r.obs.target }, { kind: 'follow', subject: DID, target: null })
36})
37
38test('a delete removes what the create added', (t) => {
39 const r = linkToObservation(link(SOURCES.LIKE, { operation: 'delete' }), { did: DID, feed: FEED, postName })
40 t.alike(r, { op: 'remove', activity: `at://${ALICE}/app.bsky.feed.like/3abc` })
41})
42
43test('⛔ not ours, not mapped, or not a kind we have words for → nothing', (t) => {
44 const ctx = { did: DID, feed: FEED, postName }
45 t.is(linkToObservation(link(SOURCES.LIKE, { subject: 'at://did:plc:other/app.bsky.feed.post/3x' }), ctx), null, 'someone else\'s post')
46 t.is(linkToObservation(link(SOURCES.LIKE, { subject: `at://${DID}/app.bsky.feed.post/3unknown` }), ctx), null, 'a post with no favela original')
47 t.is(linkToObservation(link(SOURCES.REPLY_ROOT, { collection: 'app.bsky.feed.post' }), ctx), null, 'a root-only reply is not a reply to this post')
48 t.is(linkToObservation(link(SOURCES.QUOTE, { collection: 'app.bsky.feed.post' }), ctx), null, 'quotes come later, with their own kind')
49 t.is(linkToObservation(link(SOURCES.LIKE, { did: DID }), ctx), null, 'the identity liking its own post is not news')
50})