Skip to content

Commit 7bd652d

Browse files
committed
Merge tag '0.7.4'
Hollo 0.7.4
2 parents 24703e6 + f06db49 commit 7bd652d

3 files changed

Lines changed: 237 additions & 35 deletions

File tree

CHANGES.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,27 @@ To be released.
7979
[Fedify debugger]: https://fedify.dev/manual/debug
8080

8181

82+
Version 0.7.4
83+
-------------
84+
85+
Released on February 24, 2026.
86+
87+
- Fixed a federation interoperability bug where follow requests to some
88+
Bonfire instances could remain pending even after receiving `Accept` or
89+
`Reject` activities. Inbox follow handlers now fall back to resolving the
90+
embedded `Follow` object (with `crossOrigin: "trust"`) and match by actor
91+
when the `object` ID does not match Hollo's stored follow IRI. [[#373]]
92+
93+
- Fixed a bug where the local account's `followingCount` was not updated
94+
when an `Accept` activity was processed via the fallback path that resolves
95+
the embedded `Follow` object (Path B). The handler was incorrectly passing
96+
the accepting actor's account ID to `updateAccountStats` instead of the
97+
local follower's account ID. [[#374]]
98+
99+
[#373]: https://github.com/fedify-dev/hollo/issues/373
100+
[#374]: https://github.com/fedify-dev/hollo/issues/374
101+
102+
82103
Version 0.7.3
83104
-------------
84105

src/federation/inbox.test.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import type { InboxContext } from "@fedify/fedify";
2+
import { Accept, Reject } from "@fedify/vocab";
3+
import { and, eq } from "drizzle-orm";
4+
import { beforeEach, describe, expect, it } from "vitest";
5+
import { cleanDatabase } from "../../tests/helpers";
6+
import { createAccount } from "../../tests/helpers/oauth";
7+
import db from "../db";
8+
import { accounts, follows } from "../schema";
9+
import type { Uuid } from "../uuid";
10+
import { onFollowAccepted, onFollowRejected } from "./inbox";
11+
12+
type SeededFollow = {
13+
followerId: Uuid;
14+
followingId: Uuid;
15+
followerIri: string;
16+
followingIri: string;
17+
};
18+
19+
async function seedFollow(): Promise<SeededFollow> {
20+
const followerOwner = await createAccount({ username: "follower" });
21+
const followingOwner = await createAccount({ username: "following" });
22+
const follower = await db.query.accounts.findFirst({
23+
where: eq(accounts.id, followerOwner.id as Uuid),
24+
});
25+
const following = await db.query.accounts.findFirst({
26+
where: eq(accounts.id, followingOwner.id as Uuid),
27+
});
28+
if (follower == null || following == null) {
29+
throw new Error("Failed to seed accounts");
30+
}
31+
const followIri = `${follower.iri}#follows/${crypto.randomUUID()}`;
32+
await db.insert(follows).values({
33+
iri: followIri,
34+
followerId: follower.id,
35+
followingId: following.id,
36+
approved: null,
37+
});
38+
return {
39+
followerId: follower.id,
40+
followingId: following.id,
41+
followerIri: follower.iri,
42+
followingIri: following.iri,
43+
};
44+
}
45+
46+
const ctx = {
47+
origin: "https://hollo.test",
48+
recipient: "follower",
49+
} as InboxContext<void>;
50+
51+
describe("onFollowAccepted", () => {
52+
beforeEach(async () => {
53+
await cleanDatabase();
54+
});
55+
56+
it("approves a pending follow from embedded Follow object", async () => {
57+
expect.assertions(2);
58+
59+
const seeded = await seedFollow();
60+
const accept = await Accept.fromJsonLd({
61+
"@context": ["https://www.w3.org/ns/activitystreams"],
62+
id: `${seeded.followingIri}#accepts/${crypto.randomUUID()}`,
63+
type: "Accept",
64+
actor: {
65+
id: seeded.followingIri,
66+
type: "Person",
67+
preferredUsername: "following",
68+
inbox: `${seeded.followingIri}/inbox`,
69+
},
70+
object: {
71+
id: `${seeded.followerIri}#follows/${crypto.randomUUID()}`,
72+
type: "Follow",
73+
actor: seeded.followerIri,
74+
object: seeded.followingIri,
75+
},
76+
});
77+
78+
await onFollowAccepted(ctx, accept);
79+
80+
const follow = await db.query.follows.findFirst({
81+
where: and(
82+
eq(follows.followerId, seeded.followerId),
83+
eq(follows.followingId, seeded.followingId),
84+
),
85+
});
86+
expect(follow).toBeDefined();
87+
expect(follow?.approved).not.toBeNull();
88+
});
89+
90+
it("updates the follower's followingCount when approved via embedded Follow object (Path B)", async () => {
91+
expect.assertions(2);
92+
93+
const seeded = await seedFollow();
94+
95+
const followerBefore = await db.query.accounts.findFirst({
96+
where: eq(accounts.id, seeded.followerId),
97+
});
98+
expect(followerBefore?.followingCount).toBe(0);
99+
100+
// Path B: Accept wraps a Follow object whose id does NOT match any stored
101+
// follow IRI, so the objectId-based lookup (Path A) finds nothing and falls
102+
// through to the embedded-object fallback.
103+
const accept = await Accept.fromJsonLd({
104+
"@context": ["https://www.w3.org/ns/activitystreams"],
105+
id: `${seeded.followingIri}#accepts/${crypto.randomUUID()}`,
106+
type: "Accept",
107+
actor: {
108+
id: seeded.followingIri,
109+
type: "Person",
110+
preferredUsername: "following",
111+
inbox: `${seeded.followingIri}/inbox`,
112+
},
113+
object: {
114+
id: `${seeded.followerIri}#follows/${crypto.randomUUID()}`,
115+
type: "Follow",
116+
actor: seeded.followerIri,
117+
object: seeded.followingIri,
118+
},
119+
});
120+
121+
await onFollowAccepted(ctx, accept);
122+
123+
const followerAfter = await db.query.accounts.findFirst({
124+
where: eq(accounts.id, seeded.followerId),
125+
});
126+
expect(followerAfter?.followingCount).toBe(1);
127+
});
128+
});
129+
130+
describe("onFollowRejected", () => {
131+
beforeEach(async () => {
132+
await cleanDatabase();
133+
});
134+
135+
it("deletes a pending follow from embedded Follow object", async () => {
136+
expect.assertions(1);
137+
138+
const seeded = await seedFollow();
139+
const reject = await Reject.fromJsonLd({
140+
"@context": ["https://www.w3.org/ns/activitystreams"],
141+
id: `${seeded.followingIri}#rejects/${crypto.randomUUID()}`,
142+
type: "Reject",
143+
actor: {
144+
id: seeded.followingIri,
145+
type: "Person",
146+
preferredUsername: "following",
147+
inbox: `${seeded.followingIri}/inbox`,
148+
},
149+
object: {
150+
id: `${seeded.followerIri}#follows/${crypto.randomUUID()}`,
151+
type: "Follow",
152+
actor: seeded.followerIri,
153+
object: seeded.followingIri,
154+
},
155+
});
156+
157+
await onFollowRejected(ctx, reject);
158+
159+
const follow = await db.query.follows.findFirst({
160+
where: and(
161+
eq(follows.followerId, seeded.followerId),
162+
eq(follows.followingId, seeded.followingId),
163+
),
164+
});
165+
expect(follow).toBeUndefined();
166+
});
167+
});

src/federation/inbox.ts

Lines changed: 49 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,29 @@ export async function onFollowAccepted(
193193
}
194194
const account = await persistAccount(db, actor, ctx.origin, ctx);
195195
if (account == null) return;
196+
const approveFollowByFollowerIri = async (
197+
followerIri: string,
198+
): Promise<boolean> => {
199+
const updated = await db
200+
.update(follows)
201+
.set({ approved: new Date() })
202+
.where(
203+
and(
204+
eq(
205+
follows.followerId,
206+
db
207+
.select({ id: accounts.id })
208+
.from(accounts)
209+
.where(eq(accounts.iri, followerIri)),
210+
),
211+
eq(follows.followingId, account.id),
212+
),
213+
)
214+
.returning({ followerId: follows.followerId });
215+
if (updated.length < 1) return false;
216+
await updateAccountStats(db, { id: updated[0].followerId });
217+
return true;
218+
};
196219
if (accept.objectId != null) {
197220
const updated = await db
198221
.update(follows)
@@ -210,24 +233,8 @@ export async function onFollowAccepted(
210233
}
211234
}
212235
const object = await accept.getObject({ crossOrigin: "trust" });
213-
if (object instanceof Follow) {
214-
if (object.actorId == null) return;
215-
await db
216-
.update(follows)
217-
.set({ approved: new Date() })
218-
.where(
219-
and(
220-
eq(
221-
follows.followerId,
222-
db
223-
.select({ id: accounts.id })
224-
.from(accounts)
225-
.where(eq(accounts.iri, object.actorId.href)),
226-
),
227-
eq(follows.followingId, account.id),
228-
),
229-
);
230-
await updateAccountStats(db, { iri: object.actorId.href });
236+
if (object instanceof Follow && object.actorId != null) {
237+
await approveFollowByFollowerIri(object.actorId.href);
231238
}
232239
}
233240

@@ -242,6 +249,28 @@ export async function onFollowRejected(
242249
}
243250
const account = await persistAccount(db, actor, ctx.origin, ctx);
244251
if (account == null) return;
252+
const deleteFollowByFollowerIri = async (
253+
followerIri: string,
254+
): Promise<boolean> => {
255+
const deleted = await db
256+
.delete(follows)
257+
.where(
258+
and(
259+
eq(
260+
follows.followerId,
261+
db
262+
.select({ id: accounts.id })
263+
.from(accounts)
264+
.where(eq(accounts.iri, followerIri)),
265+
),
266+
eq(follows.followingId, account.id),
267+
),
268+
)
269+
.returning({ followerId: follows.followerId });
270+
if (deleted.length < 1) return false;
271+
await updateAccountStats(db, { id: deleted[0].followerId });
272+
return true;
273+
};
245274
if (reject.objectId != null) {
246275
const deleted = await db
247276
.delete(follows)
@@ -258,23 +287,8 @@ export async function onFollowRejected(
258287
}
259288
}
260289
const object = await reject.getObject({ crossOrigin: "trust" });
261-
if (object instanceof Follow) {
262-
if (object.actorId == null) return;
263-
await db
264-
.delete(follows)
265-
.where(
266-
and(
267-
eq(
268-
follows.followerId,
269-
db
270-
.select({ id: accounts.id })
271-
.from(accounts)
272-
.where(eq(accounts.iri, object.actorId.href)),
273-
),
274-
eq(follows.followingId, account.id),
275-
),
276-
);
277-
await updateAccountStats(db, { iri: object.actorId.href });
290+
if (object instanceof Follow && object.actorId != null) {
291+
await deleteFollowByFollowerIri(object.actorId.href);
278292
}
279293
}
280294

0 commit comments

Comments
 (0)