| … | … | @@ -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 | ||
| 11 | export const FEED_CLAIM = 'net.favela.feed' | |
| 12 | ||
| 13 | export 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 | ||
| 28 | export default { bindDid, FEED_CLAIM } |
| … | … | @@ -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 | ||
| 15 | export 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). | |
| 26 | export const WANTED = [SOURCES.LIKE, SOURCES.REPOST, SOURCES.FOLLOW, SOURCES.REPLY_PARENT] | |
| 27 | ||
| 28 | const 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 | */ | |
| 42 | export 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 | ||
| 65 | export default { linkToObservation, SOURCES, WANTED } |
| … | … | @@ -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 | ||
| 9 | import test from 'brittle' | |
| 10 | import { bindDid, FEED_CLAIM } from '../lib/bsky-bind.js' | |
| 11 | ||
| 12 | const DID = 'did:plc:fleekyfleekyfleeky' | |
| 13 | const FEED = 'd2'.repeat(32) | |
| 14 | ||
| 15 | const front = (value) => ({ getRecord: async (c, r) => (c === FEED_CLAIM && r === 'self' && value ? { uri: `at://${DID}/${c}/${r}`, value } : null) }) | |
| 16 | const held = (credential) => ({ reach: async () => ({ networks: ['bsky'], credential }) }) | |
| 17 | ||
| 18 | test('★ 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 | ||
| 23 | test('⛔ 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 | ||
| 34 | test('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 | }) |
| … | … | @@ -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 | ||
| 10 | import test from 'brittle' | |
| 11 | import { linkToObservation, SOURCES } from '../lib/bsky-inbound.js' | |
| 12 | ||
| 13 | const DID = 'did:plc:fleekyfleekyfleeky' | |
| 14 | const FEED = 'd2'.repeat(32) | |
| 15 | const ALICE = 'did:plc:alicealicealice' | |
| 16 | const POST = `at://${DID}/app.bsky.feed.post/3mrhxnkbhv622` | |
| 17 | const postName = (rkey) => (rkey === '3mrhxnkbhv622' ? '01k4qspvquliq9js01m6c5658b' : null) | |
| 18 | const link = (source, over = {}) => ({ operation: 'create', source, subject: POST, did: ALICE, collection: source.split(':')[0], rkey: '3abc', ...over }) | |
| 19 | ||
| 20 | test('★ 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 | ||
| 33 | test('★ 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 | ||
| 38 | test('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 | ||
| 43 | test('⛔ 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 | }) |