// The projection pass, with no transport under it.
//
// ⛔ WHY IT IS EXTRACTED. This loop has to run in two places: on a desktop against unix sockets, and
// inside the handset worklet, because bsky records are authored and SIGNED next to the DID key and
// that key is on the phone. It lived inside index.js next to argv parsing, a socket path and
// process.exit, so the only way to run it on a phone was to write it a second time.
//
// These pin the behaviours that must survive the move — the ones where getting it wrong is silent.
import test from 'brittle'
import { projectCycle } from '../lib/project.js'
import { BskySink } from '../lib/bsky.js'
import { tidFor } from '../lib/tid.js'
const at = '2026-09-07T23:12:04.600Z'
const row = (name, body) => ({ name, body, createdAt: at })
function fakePeer ({ networks = ['bsky'], rows = [], profile = null, unknown = false, deleted = null } = {}) {
return {
// Only when given: a peer adapter without it (the desktop CLI's socket client) must still work.
...(deleted ? { ownDeleted: async () => deleted } : {}),
reach: async () => (unknown ? { networks: [], credential: null, unknown: true } : { networks, credential: null }),
ownPosts: async () => ({ feed: 'f'.repeat(64), rows }),
call: async (cmd) => {
if (cmd === 'profile') return profile
return null
}
}
}
function fakeSink ({ has = [], haveNull = false, getFails = false } = {}) {
const written = []
const records = new Map()
const client = {
call: async (cmd, args = {}) => {
if (cmd === 'get-state') {
if (haveNull) throw new Error('cannot read')
return { records: has.map((rkey) => ({ collection: 'app.bsky.feed.post', rkey })) }
}
if (cmd === 'apply-write') { written.push(args); records.set(args.collection + '/' + args.rkey, args.record); return { ok: true } }
if (cmd === 'get-record') { if (getFails) throw new Error('cannot read'); return records.get(args.collection + '/' + args.rkey) || null }
throw new Error('unexpected ' + cmd)
}
}
return { sink: new BskySink(null, { connect: async () => client }), written, records }
}
test('★★★ consent gates every post — an identity with bsky off publishes nothing', async (t) => {
const { sink, written } = fakeSink()
const r = await projectCycle({
peer: fakePeer({ networks: ['fediverse'], rows: [row('1788822724600', 'hello')] }),
bsky: sink, send: true
})
t.is(r.withheld, 1, 'the post is withheld')
t.is(r.sent, 0)
t.is(written.length, 0, 'and nothing whatsoever was written')
})
test('★★★ an unreadable repo REFUSES a send rather than duplicating everything', async (t) => {
// ⛔ `have()` answering null means "could not find out", which is NOT an empty repo. Treating the
// two alike re-sends every post the repo already holds.
const { sink, written } = fakeSink({ haveNull: true })
const r = await projectCycle({
peer: fakePeer({ rows: [row('1788822724600', 'hello')] }),
bsky: sink, send: true
})
t.ok(r.refused, 'the run refuses: ' + r.refused)
t.is(written.length, 0, 'and writes nothing')
})
test('★★ a post already in the repo is not sent twice', async (t) => {
const name = '1788822724600'
const { sink, written } = fakeSink({ has: [tidFor(name)] })
const r = await projectCycle({ peer: fakePeer({ rows: [row(name, 'hello')] }), bsky: sink, send: true })
t.is(r.skipped, 1, 'recognised as already there')
t.is(r.sent, 0)
t.is(written.filter((w) => w.collection === 'app.bsky.feed.post').length, 0, 'no post re-sent (the feed link may be written once)')
})
test('★★ a public post is sent, under a TID rkey', async (t) => {
const name = '1788822724600'
const { sink, written } = fakeSink()
const r = await projectCycle({ peer: fakePeer({ rows: [row(name, 'hello')] }), bsky: sink, send: true })
t.is(r.sent, 1)
// The DID's feed-link record (net.favela.feed/self, I2a) is also written on this first pass; count POSTS.
const posts = written.filter((w) => w.collection === 'app.bsky.feed.post')
t.is(posts.length, 1)
t.is(posts[0].collection, 'app.bsky.feed.post')
t.is(posts[0].rkey, tidFor(name), 'the AppView silently drops posts whose rkey is not a TID')
t.is(posts[0].record.text, 'hello')
})
test('★★ a dry run writes nothing but still reports what it would do', async (t) => {
const { sink, written } = fakeSink()
const r = await projectCycle({ peer: fakePeer({ rows: [row('1788822724600', 'hello')] }), bsky: sink, send: false })
t.is(written.length, 0, 'nothing published')
t.is(r.sent, 0)
})
test('★★★ a members room reaches nothing, even with bsky switched on', async (t) => {
// The reach lattice, not the switch, is what makes this safe: consent says where the owner WANTS to
// publish; the room says what may leave at all. A private room must not escape a switched-on network.
const { sink, written } = fakeSink()
const r = await projectCycle({
peer: fakePeer({ networks: ['bsky'], rows: [row('1788822724600', 'secret')] }),
bsky: sink, send: true, roomType: 'members'
})
t.is(r.withheld, 1, 'withheld by the room, not by the switch')
t.is(written.length, 0, 'and nothing left the device')
})
test('★★ the profile is projected, and only when it differs', async (t) => {
const { sink, written } = fakeSink()
const peer = fakePeer({ rows: [], profile: { name: 'Fleeky', bio: 'hello' } })
const first = await projectCycle({ peer, bsky: sink, send: true })
t.ok(first.profile.done, 'written on the first pass')
const profileWrites = written.filter((w) => w.collection === 'app.bsky.actor.profile')
t.is(profileWrites.length, 1)
t.is(profileWrites[0].record.displayName, 'Fleeky')
const second = await projectCycle({ peer, bsky: sink, send: true })
t.absent(second.profile.done, 'and NOT rewritten when it already matches — each write is a repo version')
})
test('★★★ edges are projected after the posts, and only with a resolver', async (t) => {
// ⛔ AFTER, ALWAYS. A subject resolves only once the post it points at is in the repo, so edges
// first would withhold every one on the first pass and quietly fix itself on the second.
const name = '1788822724600'
const { sink, written } = fakeSink()
const peer = fakePeer({ rows: [row(name, 'hello')] })
peer.call = async (cmd, args = {}) => {
if (cmd === 'edges-by' && args.collection === 'reaction') {
return [{ name: 'e1', collection: 'reaction', value: 'like', createdAt: '2026-09-07T00:00:00.000Z', target: { feed: 'f'.repeat(64), collection: 'post', name } }]
}
return cmd === 'profile' ? null : []
}
// Without a resolver nothing edge-shaped happens — the desktop CLI cannot compute a subject CID.
const plain = await projectCycle({ peer, bsky: sink, send: true })
t.absent(plain.edges && plain.edges.sent, 'no resolver, no edges')
const SUBJ = { uri: 'at://did:plc:me/app.bsky.feed.post/3mv', cid: 'bafy1' }
const r = await projectCycle({ peer, bsky: sink, send: true, resolveSubject: () => SUBJ })
t.is(r.edges.sent, 1, 'the like was projected')
const like = written.find((w) => w.collection === 'app.bsky.feed.like')
t.ok(like, 'as an app.bsky.feed.like')
t.alike(like.record.subject, SUBJ, 'with uri and cid')
const post = written.find((w) => w.collection === 'app.bsky.feed.post')
t.ok(post, 'and the post it points at went too')
})
test('★★★ an edge already in the repo is not written again', async (t) => {
// ⛔ Each write is a repo version. A like nobody changed must not append one every pass.
const name = '1788822724600'
const { sink, written, records } = fakeSink()
records.set('app.bsky.feed.like/' + tidFor('e1'), { $type: 'app.bsky.feed.like' })
const peer = fakePeer({ rows: [] })
peer.call = async (cmd, args = {}) => (cmd === 'edges-by' && args.collection === 'reaction')
? [{ name: 'e1', collection: 'reaction', value: 'like', createdAt: '2026-09-07T00:00:00.000Z', target: { feed: 'f'.repeat(64), collection: 'post', name } }]
: (cmd === 'profile' ? null : [])
const r = await projectCycle({ peer, bsky: sink, send: true, resolveSubject: () => ({ uri: 'at://x/y/z', cid: 'c' }) })
t.is(r.edges.skipped, 1, 'recognised as already there')
t.is(written.filter((w) => w.collection === 'app.bsky.feed.like').length, 0, 'and nothing was written')
})
test('★★★ with bsky switched off, no edge is projected either', async (t) => {
const { sink, written } = fakeSink()
const peer = fakePeer({ networks: ['fediverse'], rows: [] })
peer.call = async (cmd, args = {}) => (cmd === 'edges-by' && args.collection === 'reaction')
? [{ name: 'e1', collection: 'reaction', value: 'like', createdAt: '2026-09-07T00:00:00.000Z', target: { feed: 'f'.repeat(64), collection: 'post', name: 'p' } }]
: (cmd === 'profile' ? null : [])
const r = await projectCycle({ peer, bsky: sink, send: true, resolveSubject: () => ({ uri: 'at://x/y/z', cid: 'c' }) })
t.absent(r.edges.sent, 'consent gates edges exactly as it gates posts')
t.is(written.length, 0)
})
// ── the DID names its favela feed back (hyper-social #82, I2a / D4a) ───────────────────────────────────────────
//
// ⛔ TWO-SIDED, OR NOT AT ALL. The identity's reach already names its DID (credential). This is the other half: the
// DID's own repo, signed by the DID key, names the favela feed. A gateway binds a DID to an identity only when both
// agree, so nobody can attach someone else's DID to their feed. Published content, so it waits for bsky consent
// exactly like the profile; reconciled against what the repo holds, never written on a failed read.
const FEED_CLAIM = 'net.favela.feed'
const claimOf = (records) => records.get(FEED_CLAIM + '/self') || null
test('★★ with bsky on, the DID repo gets net.favela.feed/self naming this feed', async (t) => {
const { sink, written, records } = fakeSink()
await projectCycle({ peer: fakePeer(), bsky: sink, send: true })
const c = claimOf(records)
t.ok(c, 'written')
t.alike({ type: c.$type, feed: c.feed }, { type: FEED_CLAIM, feed: 'f'.repeat(64) })
t.ok(written.some((w) => w.collection === FEED_CLAIM && w.rkey === 'self'))
})
test('the same claim is not rewritten', async (t) => {
const { sink, written } = fakeSink()
await projectCycle({ peer: fakePeer(), bsky: sink, send: true })
const n = written.filter((w) => w.collection === FEED_CLAIM).length
await projectCycle({ peer: fakePeer(), bsky: sink, send: true })
t.is(written.filter((w) => w.collection === FEED_CLAIM).length, n, 'a second pass writes nothing')
})
test('⛔ no claim without bsky consent, on a dry run, or when the repo cannot be read', async (t) => {
const off = fakeSink()
await projectCycle({ peer: fakePeer({ networks: ['fediverse'] }), bsky: off.sink, send: true })
t.absent(claimOf(off.records), 'bsky off')
const dry = fakeSink()
await projectCycle({ peer: fakePeer(), bsky: dry.sink, send: false })
t.absent(claimOf(dry.records), 'dry run')
const blind = fakeSink({ getFails: true })
await projectCycle({ peer: fakePeer(), bsky: blind.sink, send: true })
t.absent(blind.written.some((w) => w.collection === FEED_CLAIM), 'could not read is not absent')
})
// ── deletes ──────────────────────────────────────────────────────────────────────────────────────
//
// ⛔ A deleted favela post is retracted from bsky ONLY on positive evidence — a tombstone the peer
// reports — and only where the repo still holds it. A name merely missing from ownPosts() is never
// grounds to delete anything: "not replicated yet" looks identical.
const deletes = (written) => written.filter((w) => w.action === 'delete')
test('★★★ a deleted post that bsky holds is deleted there, at its TID rkey', async (t) => {
const name = '1788822724600'
const { sink, written } = fakeSink({ has: [tidFor(name)] })
const r = await projectCycle({ peer: fakePeer({ deleted: { post: [name], reply: [] } }), bsky: sink, send: true })
t.alike(deletes(written), [{ action: 'delete', collection: 'app.bsky.feed.post', rkey: tidFor(name) }], 'exactly that record')
t.is(r.deleted, 1)
})
test('★★ a deleted reply is retracted the same way — replies are app.bsky.feed.post too', async (t) => {
const name = '1788822724601'
const { sink, written } = fakeSink({ has: [tidFor(name)] })
await projectCycle({ peer: fakePeer({ deleted: { post: [], reply: [name] } }), bsky: sink, send: true })
t.is(deletes(written).length, 1)
t.is(deletes(written)[0].rkey, tidFor(name))
})
test('★★ a deleted post bsky never had is not touched', async (t) => {
const { sink, written } = fakeSink({ has: [] })
const r = await projectCycle({ peer: fakePeer({ deleted: { post: ['1788822724600'], reply: [] } }), bsky: sink, send: true })
t.is(deletes(written).length, 0, 'nothing to retract')
t.is(r.deleted, 0)
})
test('★★ a dry run deletes nothing', async (t) => {
const name = '1788822724600'
const { sink, written } = fakeSink({ has: [tidFor(name)] })
const said = []
await projectCycle({ peer: fakePeer({ deleted: { post: [name], reply: [] } }), bsky: sink, send: false, log: (l) => said.push(l) })
t.is(deletes(written).length, 0)
t.ok(said.some((l) => /would DELETE/.test(l)), 'but says it would')
})
test('★★ retraction is not gated on consent — switching bsky off must not strand a deleted post there', async (t) => {
const name = '1788822724600'
const { sink, written } = fakeSink({ has: [tidFor(name)] })
await projectCycle({ peer: fakePeer({ networks: [], deleted: { post: [name], reply: [] } }), bsky: sink, send: true })
t.is(deletes(written).length, 1, 'deleting publishes nothing, so it does not need consent to publish')
})
test('★★★ an unreadable repo deletes nothing either', async (t) => {
const { sink, written } = fakeSink({ haveNull: true })
await projectCycle({ peer: fakePeer({ deleted: { post: ['1788822724600'], reply: [] } }), bsky: sink, send: true })
t.is(deletes(written).length, 0)
})
test('a peer with no ownDeleted (the desktop socket client) projects as before', async (t) => {
const { sink, written } = fakeSink({ has: [] })
const r = await projectCycle({ peer: fakePeer({ rows: [row('1788822724600', 'hello')] }), bsky: sink, send: true })
t.is(r.sent, 1)
t.is(deletes(written).length, 0)
t.is(r.deleted, 0)
})