// Likes and reposts, projected.
//
// ⛔ THE GAP THIS CLOSES, AND ITS HARD LIMIT. hyper-social #41 §3 found ap-bridge emits
// Create/Update/Follow/Accept/Undo/Note/Person/Image/Document and NO Announce, NO Like — so every
// like and repost authored on favela is invisible to the fediverse. Confirmed in source before
// building: as2-project.js had only projectNote + wrapCreate.
//
// ⛔ AN INTERACTION CAN ONLY NAME AN OBJECT THE RECEIVER CAN RESOLVE. AS2 `object` is a URI, and a
// Mastodon server can do nothing with `at://did:plc:…/app.bsky.feed.post/…`. Two facts, both read
// from source, bound what is projectable today:
// - the inbox accepts ONLY Follow and Undo (router.js), so no fediverse post ever arrives here
// and none has a local AP identity;
// - objectIdFor mints `https://<host>/ap/objects/<rkey>` for OUR repo's records only.
// So a subject we did not publish CANNOT be named, and these functions return null for it rather
// than guessing a foreign server's URI scheme — the same refusal blobs.js makes about publishing
// somebody else's endpoint, and the same one projectNote already makes for an unresolvable reply
// parent.
//
// Returning null is the feature. The alternative — inventing a plausible bridge URI — publishes a
// permanent reference to an endpoint we do not control and cannot fix.
import test from 'brittle'
import { projectLike, projectAnnounce, projectUndo, PUBLIC_AUDIENCE, AS2_CONTEXT } from '../lib/as2-project.js'
const ACTOR = 'https://ap.7rnx.net/ap/actor'
const FOLLOWERS = 'https://ap.7rnx.net/ap/followers'
const OURS = 'at://did:plc:wj62/app.bsky.feed.post/3mtecpdhk5c22'
const OURS_AP = 'https://ap.7rnx.net/ap/objects/3mtecpdhk5c22'
const THEIRS = 'at://did:plc:someoneelse/app.bsky.feed.post/3aaaaaaaaaaaa'
const resolve = (atUri) => (atUri === OURS ? OURS_AP : null)
const likeRecord = (subject = OURS) => ({ $type: 'app.bsky.feed.like', subject: { uri: subject, cid: 'bafyrei...' }, createdAt: '2026-08-18T15:00:00.000Z' })
const repostRecord = (subject = OURS) => ({ $type: 'app.bsky.feed.repost', subject: { uri: subject, cid: 'bafyrei...' }, createdAt: '2026-08-18T15:00:00.000Z' })
test('a Like names the AP object, not the at-uri', (t) => {
const like = projectLike({
record: likeRecord(),
activityId: 'https://ap.7rnx.net/ap/activities/like-1',
actorId: ACTOR,
resolveObjectUri: resolve
})
t.is(like.type, 'Like')
t.is(like.actor, ACTOR)
t.is(like.object, OURS_AP, 'the object is a URI a Mastodon server can fetch')
t.absent(JSON.stringify(like).includes('at://'), 'no at-uri leaks into the projection')
})
test('a Like of something we did not publish is NOT projected', (t) => {
// The common case in practice, and the one that must not be faked: liking a bsky post. No AP
// object exists for it anywhere we control, so there is nothing truthful to point at.
t.is(projectLike({ record: likeRecord(THEIRS), activityId: 'x', actorId: ACTOR, resolveObjectUri: resolve }), null)
})
test('a malformed subject is not projected', (t) => {
t.is(projectLike({ record: { $type: 'app.bsky.feed.like' }, activityId: 'x', actorId: ACTOR, resolveObjectUri: resolve }), null, 'no subject')
t.is(projectLike({ record: likeRecord(''), activityId: 'x', actorId: ACTOR, resolveObjectUri: resolve }), null, 'empty subject uri')
t.is(projectLike({ record: null, activityId: 'x', actorId: ACTOR, resolveObjectUri: resolve }), null, 'no record at all')
})
test('an Announce is addressed PUBLICLY — a boost nobody sees is not a boost', (t) => {
// A Like is a private signal to the author; a boost is a republication to your followers and to
// the public timeline. Getting the addressing wrong makes it silently invisible, which is the
// same class of failure as an Update with no `updated`.
const boost = projectAnnounce({
record: repostRecord(),
activityId: 'https://ap.7rnx.net/ap/activities/announce-1',
actorId: ACTOR,
followersId: FOLLOWERS,
resolveObjectUri: resolve
})
t.is(boost.type, 'Announce')
t.is(boost.object, OURS_AP)
t.alike(boost.to, [PUBLIC_AUDIENCE], 'to: Public')
t.alike(boost.cc, [FOLLOWERS], 'cc: our followers')
t.is(boost.published, '2026-08-18T15:00:00.000Z', 'published carries the record time, not now')
})
test('an Announce of an unresolvable subject is NOT projected either', (t) => {
t.is(projectAnnounce({ record: repostRecord(THEIRS), activityId: 'x', actorId: ACTOR, followersId: FOLLOWERS, resolveObjectUri: resolve }), null)
})
test('both carry the standalone @context, since each is fetched on its own', (t) => {
const like = projectLike({ record: likeRecord(), activityId: 'x', actorId: ACTOR, resolveObjectUri: resolve })
const boost = projectAnnounce({ record: repostRecord(), activityId: 'y', actorId: ACTOR, followersId: FOLLOWERS, resolveObjectUri: resolve })
t.ok(JSON.stringify(like['@context']).includes(AS2_CONTEXT))
t.ok(JSON.stringify(boost['@context']).includes(AS2_CONTEXT))
})
test('Undo wraps the activity it reverses, keeping its id', (t) => {
// Unliking and un-boosting are deletions of the like/repost RECORD. AS2 models that as Undo of
// the original activity, so the receiver needs that activity's id — which is why activity ids are
// derived from the rkey and not from a counter.
const like = projectLike({ record: likeRecord(), activityId: 'https://ap.7rnx.net/ap/activities/like-1', actorId: ACTOR, resolveObjectUri: resolve })
const undo = projectUndo({ activity: like, actorId: ACTOR, activityId: 'https://ap.7rnx.net/ap/activities/like-1/undo' })
t.is(undo.type, 'Undo')
t.is(undo.actor, ACTOR)
t.is(undo.object.id, 'https://ap.7rnx.net/ap/activities/like-1', 'names the exact activity being undone')
t.is(undo.object.type, 'Like')
})
test('Undo of nothing is nothing', (t) => {
t.is(projectUndo({ activity: null, actorId: ACTOR, activityId: 'x' }), null)
})