From 1bfcb611ac75602129dd5007d696c2c1cee13266 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 1 Aug 2026 16:08:16 +0900 Subject: [PATCH 1/4] Respect origin boundaries in Follow examples Use dispatcher-backed Follow IDs and local pending-request state so cross-origin Accept activities can be correlated without trusting embedded objects. Cover shared inbox delivery and peers that remint nested IDs, and keep framework examples and the generator template aligned. Assisted-by: Codex:gpt-5.6-sol Assisted-by: Claude Code:claude-opus-5 --- .../example/src/federation.ts | 2 +- docs/manual/context-advanced.md | 4 +- docs/manual/context.md | 4 +- docs/manual/inbox.md | 4 +- docs/manual/migrate.md | 23 +- docs/tutorial/astro-blog.md | 8 +- docs/tutorial/basics.md | 9 +- docs/tutorial/content-sharing.md | 36 ++- docs/tutorial/microblog.md | 235 ++++++++++++++---- docs/tutorial/threadiverse.md | 100 ++++++-- examples/astro/src/lib/federation.ts | 2 +- examples/cloudflare-workers/src/index.ts | 2 +- examples/express/app.ts | 2 +- examples/next-integration/federation/index.ts | 2 +- .../app/[fedify]/[[...catchAll]]/route.ts | 2 +- .../shared/integrate-fedify.ts | 2 +- examples/nuxt/server/federation.ts | 2 +- examples/rfc-9421-test/federation.ts | 2 +- examples/solidstart/src/lib/federation.ts | 2 +- .../sveltekit-sample/src/lib/federation.ts | 2 +- 20 files changed, 332 insertions(+), 113 deletions(-) diff --git a/.agents/skills/create-example-app-with-integration/example/src/federation.ts b/.agents/skills/create-example-app-with-integration/example/src/federation.ts index b9b2d32af..0fbf50832 100644 --- a/.agents/skills/create-example-app-with-integration/example/src/federation.ts +++ b/.agents/skills/create-example-app-with-integration/example/src/federation.ts @@ -89,7 +89,7 @@ federation context.getActorUri(IDENTIFIER), ), actor: follow.objectId, - object: follow, + object: follow.id, }), ); relationStore.set(follower.id.href, follower); diff --git a/docs/manual/context-advanced.md b/docs/manual/context-advanced.md index c8dd0c992..dea6fa4da 100644 --- a/docs/manual/context-advanced.md +++ b/docs/manual/context-advanced.md @@ -91,7 +91,7 @@ import { Accept, Follow } from "@fedify/vocab"; federation .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { - if (follow.objectId == null) return; + if (follow.id == null || follow.objectId == null) return; const parsed = ctx.parseUri(follow.objectId); // [!code highlight] if (parsed?.type !== "actor") return; // [!code highlight] const recipient = await follow.getActor(ctx); @@ -99,7 +99,7 @@ federation await ctx.sendActivity( { identifier: parsed.identifier }, recipient, - new Accept({ actor: follow.objectId, object: follow }), + new Accept({ actor: follow.objectId, object: follow.id }), ); }); ~~~~ diff --git a/docs/manual/context.md b/docs/manual/context.md index eeee6c361..a6185f74f 100644 --- a/docs/manual/context.md +++ b/docs/manual/context.md @@ -153,7 +153,7 @@ federation .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { // In order to send an activity, we need the identifier of the sender actor: - if (follow.objectId == null) return; + if (follow.id == null || follow.objectId == null) return; const parsed = ctx.parseUri(follow.objectId); if (parsed?.type !== "actor") return; const recipient = await follow.getActor(ctx); @@ -161,7 +161,7 @@ federation await ctx.sendActivity( { identifier: parsed.identifier }, // sender recipient, - new Accept({ actor: follow.objectId, object: follow }), + new Accept({ actor: follow.objectId, object: follow.id }), ); }); ~~~~ diff --git a/docs/manual/inbox.md b/docs/manual/inbox.md index 3c436b44d..1348a1710 100644 --- a/docs/manual/inbox.md +++ b/docs/manual/inbox.md @@ -158,7 +158,7 @@ const federation = createFederation({ federation .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { - if (follow.objectId == null) return; + if (follow.id == null || follow.objectId == null) return; const parsed = ctx.parseUri(follow.objectId); if (parsed?.type !== "actor") return; const recipient = await follow.getActor(ctx); @@ -166,7 +166,7 @@ federation await ctx.sendActivity( { identifier: parsed.identifier }, recipient, - new Accept({ actor: follow.objectId, object: follow }), + new Accept({ actor: follow.objectId, object: follow.id }), ); }); ~~~~ diff --git a/docs/manual/migrate.md b/docs/manual/migrate.md index f212bf7d0..0271b124b 100644 --- a/docs/manual/migrate.md +++ b/docs/manual/migrate.md @@ -303,7 +303,7 @@ const federation = null as unknown as Federation; federation .setInboxListeners("/u/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { - if (follow.objectId == null) return; + if (follow.id == null || follow.objectId == null) return; const parsed = ctx.parseUri(follow.objectId); if (parsed?.type !== "actor") return; const follower = await follow.getActor(ctx); @@ -311,7 +311,7 @@ federation await ctx.sendActivity( { identifier: parsed.identifier }, follower, - new Accept({ actor: follow.objectId, object: follow }), + new Accept({ actor: follow.objectId, object: follow.id }), ); }); ~~~~ @@ -600,6 +600,7 @@ federation federation .setInboxListeners("/u/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { + if (follow.id == null) return; const parsed = follow.objectId == null ? null : ctx.parseUri(follow.objectId); @@ -609,7 +610,7 @@ federation await ctx.sendActivity( { identifier: parsed.identifier }, follower, - new Accept({ actor: follow.objectId!, object: follow }), + new Accept({ actor: follow.objectId!, object: follow.id }), ); }); @@ -859,7 +860,7 @@ const federation = null as unknown as Federation; federation .setInboxListeners("/u/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { - if (follow.objectId == null) return; + if (follow.id == null || follow.objectId == null) return; const parsed = ctx.parseUri(follow.objectId); if (parsed?.type !== "actor") return; const follower = await follow.getActor(ctx); @@ -867,7 +868,7 @@ federation await ctx.sendActivity( { identifier: parsed.identifier }, follower, - new Accept({ actor: follow.objectId, object: follow }), + new Accept({ actor: follow.objectId, object: follow.id }), ); }); ~~~~ @@ -1113,6 +1114,7 @@ federation federation .setInboxListeners("/u/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { + if (follow.id == null) return; const parsed = follow.objectId == null ? null : ctx.parseUri(follow.objectId); @@ -1122,7 +1124,7 @@ federation await ctx.sendActivity( { identifier: parsed.identifier }, follower, - new Accept({ actor: follow.objectId!, object: follow }), + new Accept({ actor: follow.objectId!, object: follow.id }), ); }); @@ -1325,7 +1327,7 @@ async function removeFollower(_: { federation .setInboxListeners("/u/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { - if (follow.objectId == null) return; + if (follow.id == null || follow.objectId == null) return; const parsed = ctx.parseUri(follow.objectId); if (parsed?.type !== "actor") return; const follower = await follow.getActor(ctx); @@ -1333,7 +1335,7 @@ federation await ctx.sendActivity( { identifier: parsed.identifier }, follower, - new Accept({ actor: follow.objectId, object: follow }), + new Accept({ actor: follow.objectId, object: follow.id }), ); }) .on(Undo, async (ctx, undo) => { @@ -1409,7 +1411,7 @@ await ctx.sendActivity( follower, new Accept({ actor: ctx.getActorUri(identifier), - object: follow, + object: follow.id, }), ); ~~~~ @@ -1660,6 +1662,7 @@ federation federation .setInboxListeners("/u/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { + if (follow.id == null) return; const parsed = follow.objectId == null ? null : ctx.parseUri(follow.objectId); @@ -1672,7 +1675,7 @@ federation await ctx.sendActivity( { identifier: parsed.identifier }, follower, - new Accept({ actor: follow.objectId!, object: follow }), + new Accept({ actor: follow.objectId!, object: follow.id }), ); }) .on(Undo, async (ctx, undo) => { diff --git a/docs/tutorial/astro-blog.md b/docs/tutorial/astro-blog.md index e99a1e88f..1adfbc952 100644 --- a/docs/tutorial/astro-blog.md +++ b/docs/tutorial/astro-blog.md @@ -1295,7 +1295,7 @@ federation ctx.getActorUri(BLOG_IDENTIFIER), ), actor: ctx.getActorUri(BLOG_IDENTIFIER), - object: follow, + object: follow.id, }), ); }) @@ -1740,7 +1740,7 @@ federation ctx.getActorUri(BLOG_IDENTIFIER), ), actor: ctx.getActorUri(BLOG_IDENTIFIER), - object: follow, + object: follow.id, }), ); }) @@ -2047,7 +2047,7 @@ federation ctx.getActorUri(BLOG_IDENTIFIER), ), actor: ctx.getActorUri(BLOG_IDENTIFIER), - object: follow, + object: follow.id, }), ); }) @@ -2603,7 +2603,7 @@ federation ctx.getActorUri(BLOG_IDENTIFIER), ), actor: ctx.getActorUri(BLOG_IDENTIFIER), - object: follow, + object: follow.id, }), ); }) diff --git a/docs/tutorial/basics.md b/docs/tutorial/basics.md index 34b3da8a9..8ba19c763 100644 --- a/docs/tutorial/basics.md +++ b/docs/tutorial/basics.md @@ -1125,7 +1125,7 @@ federation await ctx.sendActivity( { identifier: parsed.identifier }, follower, - new Accept({ actor: follow.objectId, object: follow }), + new Accept({ actor: follow.objectId, object: follow.id }), ); }); ~~~~ @@ -1134,6 +1134,11 @@ Restart the server, and make a follow request from your Mastodon account to the actor *me*. You should see the server immediately accept the follow request. +The `Accept` refers to the original `Follow` by its ID instead of embedding +the whole activity. The `Follow` belongs to the remote server's origin, so a +copy embedded in our `Accept` would not be authoritative under Fedify's +[origin-based security model](../manual/vocab.md#origin-based-security-model). + > [!TIP] > If you are curious about sending activities further, see the [*Sending > activities* section](../manual/send.md) in the manual. @@ -1165,7 +1170,7 @@ federation await ctx.sendActivity( { identifier: parsed.identifier }, follower, - new Accept({ actor: follow.objectId, object: follow }), + new Accept({ actor: follow.objectId, object: follow.id }), ); // Store the follower in the key–value store: await kv.set(["followers", follow.id.href], follow.actorId.href); diff --git a/docs/tutorial/content-sharing.md b/docs/tutorial/content-sharing.md index eef00b083..0c7b87899 100644 --- a/docs/tutorial/content-sharing.md +++ b/docs/tutorial/content-sharing.md @@ -4588,7 +4588,7 @@ import { InProcessMessageQueue, MemoryKvStore, } from "@fedify/fedify"; -import { Accept, Follow } from "@fedify/vocab"; +import { Accept } from "@fedify/vocab"; import { eq, or } from "drizzle-orm"; import { db } from "./db/client"; import { following } from "./db/schema"; @@ -4601,23 +4601,21 @@ export const federation = createFederation({ federation .setInboxListeners("/users/{identifier}/inbox", "/inbox") // ---cut--- -.on(Accept, async (_ctx, accept) => { +.on(Accept, async (ctx, accept) => { // The remote server has accepted alice's outbound Follow. // Match the Accept against our `following` row by either the // original Follow's `id` (when the peer echoes it) or the // remote actor URI (Pixelfed sometimes mints a fresh id on the // way back), and flip the row's status to "accepted". - if (accept.actorId == null) return; - const followObject = await accept.getObject(); - const followActivityId = - followObject instanceof Follow ? followObject.id?.href : null; + if (accept.actorId == null || accept.objectId == null) return; + const followActivityId = accept.objectId.href; const remoteActorUri = accept.actorId.href; - const matcher = followActivityId - ? or( - eq(following.followActivityId, followActivityId), - eq(following.actorUri, remoteActorUri), - ) - : eq(following.actorUri, remoteActorUri); + const matcher = accept.objectId.origin === ctx.canonicalOrigin + ? eq(following.followActivityId, followActivityId) + : or( + eq(following.followActivityId, followActivityId), + eq(following.actorUri, remoteActorUri), + ); await db .update(following) .set({ status: "accepted" }) @@ -4625,13 +4623,13 @@ federation }); ~~~~ -`accept.getObject()` resolves the embedded Follow. -Mastodon and GoToSocial both keep the original Follow's `id`, -which makes matching unambiguous. Pixelfed, in contrast, often -returns a freshly-minted Follow with a different `id`, so even -when an `id` is present it may not be the one we sent. Combining -the two predicates with `or` lets the row flip to *accepted* -whichever path the peer took. +`accept.objectId` reads the referenced activity's ID without trusting or +dereferencing an embedded `Follow` from another origin. Mastodon and +GoToSocial keep the original `Follow` ID, which makes matching unambiguous. +Pixelfed often returns a freshly minted ID instead, so the remote actor URI +remains the interoperability fallback in this single-user application. The +handler only uses that fallback for an ID minted on another origin; a mismatched +ID on our own origin is rejected instead of accepting an unrelated activity. > [!TIP] > If you ship multiple local users later (the closing chapter diff --git a/docs/tutorial/microblog.md b/docs/tutorial/microblog.md index 85e7b9be7..0275b35e8 100644 --- a/docs/tutorial/microblog.md +++ b/docs/tutorial/microblog.md @@ -1915,7 +1915,7 @@ interface Actor { id: number; } federation .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { - if (follow.objectId == null) { + if (follow.id == null || follow.objectId == null) { logger.debug("The Follow object does not have an object: {follow}", { follow, }); @@ -1928,7 +1928,7 @@ federation }); return; } - const follower = await follow.getActor(); + const follower = await follow.getActor(ctx); if (follower?.id == null || follower.inboxId == null) { logger.debug("The Follow object does not have an actor: {follow}", { follow, @@ -1981,7 +1981,7 @@ federation const accept = new Accept({ actor: follow.objectId, to: follow.actorId, - object: follow, + object: follow.id, }); await ctx.sendActivity(object, follower, accept); }); @@ -3343,8 +3343,10 @@ as the `Note` object. We set an arbitrary unique URI for the `id` of the activity. > [!TIP] -> The `id` property of the activity object doesn't necessarily need to be -> an accessible URI. It just needs to be unique. +> An activity ID does not always need its own dispatcher. If an activity from +> another origin will refer to it later, however, make the ID dereferenceable +> or keep enough local state to verify that reference without trusting an +> embedded copy. The outgoing `Follow` later in this tutorial does both. The second parameter of the `sendActivity()` method is where the recipients go, and here we've specified the special option `"followers"`. When this option is @@ -3481,6 +3483,91 @@ but it can't send follow requests to actors on other servers. Since we can't follow, we also can't see posts created by other actors. So, let's add the functionality to send follow requests to actors on other servers. +### Pending follow requests + +An `Accept(Follow)` comes from the remote actor's origin, while the original +`Follow` belongs to our origin. Fedify's +[origin-based security model](../manual/vocab.md#origin-based-security-model) +therefore does not trust a copy of our `Follow` embedded in the `Accept`. We +need to remember each outgoing request locally so that we can match the reply +without trusting that copy. + +Add a `follow_requests` table to *src/schema.sql*: + +~~~~ sql [src/schema.sql] +CREATE TABLE IF NOT EXISTS follow_requests ( + id TEXT NOT NULL PRIMARY KEY CHECK (id <> ''), + uri TEXT NOT NULL UNIQUE CHECK (uri <> ''), + follower_id INTEGER NOT NULL REFERENCES actors (id), + following_uri TEXT NOT NULL CHECK (following_uri <> ''), + accepted INTEGER NOT NULL DEFAULT 0 CHECK (accepted IN (0, 1)), + created TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) + CHECK (created <> ''), + UNIQUE (follower_id, following_uri) +); +~~~~ + +Run the schema again: + +~~~~ sh +sqlite3 microblog.sqlite3 < src/schema.sql +~~~~ + +Then add the corresponding type to *src/schema.ts*: + +~~~~ typescript twoslash [src/schema.ts] +export interface FollowRequest { + id: string; + uri: string; + follower_id: number; + following_uri: string; + accepted: number; + created: string; +} +~~~~ + +The `uri` column contains the ID of the outgoing `Follow`. Make that ID +dereferenceable by adding an object dispatcher to *src/federation.ts*: + +~~~~ typescript twoslash [src/federation.ts] +import { type Federation } from "@fedify/fedify"; +import { Follow } from "@fedify/vocab"; +const federation = null as unknown as Federation; +import Database from "better-sqlite3"; +const db = new Database(""); +interface FollowRequest { following_uri: string; } +// ---cut-before--- +federation.setObjectDispatcher( + Follow, + "/users/{identifier}/follows/{id}", + (ctx, values) => { + const request = db + .prepare( + ` + SELECT follow_requests.* + FROM follow_requests + JOIN actors ON actors.id = follow_requests.follower_id + JOIN users ON users.id = actors.user_id + WHERE users.username = ? AND follow_requests.id = ? + `, + ) + .get(values.identifier, values.id); + if (request == null) return null; + const following = new URL(request.following_uri); + return new Follow({ + id: ctx.getObjectUri(Follow, values), + actor: ctx.getActorUri(values.identifier), + object: following, + to: following, + }); + }, +); +~~~~ + +The dispatcher lets another server fetch the original activity from its ID. +Our own inbox listener will use the database row directly instead of making an +HTTP request back to this endpoint. + Let's start with the UI. Open the *src/views.tsx* file and modify the existing `` component as follows: @@ -3543,6 +3630,10 @@ import { Follow, isActor } from "@fedify/vocab"; const fedi = null as unknown as Federation; import { Hono } from "hono"; const app = new Hono(); +import Database from "better-sqlite3"; +const db = new Database(""); +interface LocalActor { id: number; } +interface FollowRequest { id: string; } // ---cut-before--- app.post("/users/:username/following", async (c) => { const username = c.req.param("username"); @@ -3553,17 +3644,55 @@ app.post("/users/:username/following", async (c) => { } const ctx = fedi.createContext(c.req.raw, undefined); const actor = await ctx.lookupObject(handle.trim()); - if (!isActor(actor)) { + if (!isActor(actor) || actor.id == null) { return c.text("Invalid actor handle or URL", 400); } + const localActor = db + .prepare( + ` + SELECT actors.id + FROM actors + JOIN users ON users.id = actors.user_id + WHERE users.username = ? + `, + ) + .get(username); + if (localActor == null) return c.text("User not found", 404); + const requestedId = crypto.randomUUID(); + const requestedArgs = { identifier: username, id: requestedId }; + const requestedUri = ctx.getObjectUri(Follow, requestedArgs); + db + .prepare( + ` + INSERT INTO follow_requests (id, uri, follower_id, following_uri) + VALUES (?, ?, ?, ?) + ON CONFLICT (follower_id, following_uri) DO NOTHING + `, + ) + .run(requestedId, requestedUri.href, localActor.id, actor.id.href); + const request = db + .prepare( + ` + SELECT id + FROM follow_requests + WHERE follower_id = ? AND following_uri = ? + `, + ) + .get(localActor.id, actor.id.href); + if (request == null) return c.text("Failed to store follow request", 500); + const followArgs = { identifier: username, id: request.id }; + const followUri = ctx.getObjectUri(Follow, followArgs); + db.prepare( + "UPDATE follow_requests SET uri = ? WHERE id = ?", + ).run(followUri.href, request.id); + const follow = await ctx.getObject(Follow, followArgs); + if (follow == null) { + return c.text("Failed to create a follow request", 500); + } await ctx.sendActivity( { identifier: username }, actor, - new Follow({ - actor: ctx.getActorUri(username), - object: actor.id, - to: actor.id, - }), + follow, ); return c.text("Successfully sent a follow request"); }); @@ -3575,10 +3704,12 @@ and returns the looked-up ActivityPub object. The `isActor()` function checks if the given ActivityPub object is an actor. -In this code, we're using the `sendActivity()` method to send a `Follow` -activity to the looked-up actor. However, we're not adding any records to -the `follows` table yet. This is because we should add the record after -receiving an `Accept(Follow)` activity from the other party. +Before sending the activity, we give it an explicit ID and insert a pending row +in `follow_requests`. If a request for the same pair already exists, we reuse +its opaque ID, update the stored URI for the current origin, and send it again. +The object dispatcher builds the `Follow` from that row. We do not add +anything to `follows` yet; that table only contains established relationships +after the other party sends an `Accept(Follow)`. ### Testing @@ -3744,13 +3875,13 @@ import { type Federation } from "@fedify/fedify"; import { Accept, type Actor as APActor, - Follow, isActor, } from "@fedify/vocab"; const federation = null as unknown as Federation; import Database from "better-sqlite3"; const db = new Database(""); interface Actor { id: number; } +interface FollowRequest { id: string; follower_id: number; } async function persistActor(actor: APActor): Promise { return null; } @@ -3758,37 +3889,57 @@ federation .setInboxListeners("/users/{identifier}/inbox", "/inbox") // ---cut-before--- .on(Accept, async (ctx, accept) => { - const follow = await accept.getObject(); - if (!(follow instanceof Follow)) return; - const following = await accept.getActor(); - if (!isActor(following)) return; - const follower = follow.actorId; - if (follower == null) return; - const parsed = ctx.parseUri(follower); - if (parsed == null || parsed.type !== "actor") return; + if (accept.actorId == null || accept.objectId == null) return; + const exactRequest = db + .prepare( + ` + SELECT follow_requests.* + FROM follow_requests + WHERE follow_requests.uri = ? + AND follow_requests.following_uri = ? + AND follow_requests.accepted = 0 + `, + ) + .get(accept.objectId.href, accept.actorId.href); + const referencesLocalOrigin = + accept.objectId.origin === ctx.canonicalOrigin; + const request = exactRequest ?? (referencesLocalOrigin ? null : db + .prepare( + ` + SELECT follow_requests.* + FROM follow_requests + WHERE follow_requests.following_uri = ? + AND follow_requests.accepted = 0 + `, + ) + .get(accept.actorId.href)); + if (request == null) return; + const following = await accept.getActor(ctx); + if (!isActor(following) || following.id?.href !== accept.actorId.href) { + return; + } const followingId = (await persistActor(following))?.id; if (followingId == null) return; - db.prepare( - ` - INSERT INTO follows (following_id, follower_id) - VALUES ( - ?, - ( - SELECT actors.id - FROM actors - JOIN users ON actors.user_id = users.id - WHERE users.username = ? - ) - ) - `, - ).run(followingId, parsed.identifier); + db.transaction(() => { + db.prepare( + "INSERT INTO follows (following_id, follower_id) VALUES (?, ?)", + ).run(followingId, request.follower_id); + db.prepare( + "UPDATE follow_requests SET accepted = 1 WHERE id = ?", + ).run(request.id); + })(); }); ~~~~ -Although there's a lot of validity checking code, in summary, it gets the actor -who sent the follow request (`follower`) and the actor who received the follow -request (`following`) from the contents of the `Accept(Follow)` activity and -adds a record to the `follows` table. +The handler first tries to match `accept.objectId` against the exact activity ID +we stored. Some implementations mint a different ID on their own origin for +the nested `Follow`, so it falls back to the pending request for the +authenticated remote actor. It never falls back for a mismatched ID on our own +origin, and it never reads fields from the cross-origin inline `Follow`. This +also works when the `Accept` is delivered through the shared inbox. After the +match succeeds, it inserts the relationship into `follows` and marks the stored +activity as accepted in one transaction. The stored activity remains +available through its object dispatcher. ### Testing diff --git a/docs/tutorial/threadiverse.md b/docs/tutorial/threadiverse.md index 012480974..09d8cbda1 100644 --- a/docs/tutorial/threadiverse.md +++ b/docs/tutorial/threadiverse.md @@ -2181,6 +2181,7 @@ export const follows = sqliteTable( followerInbox: text("follower_inbox").notNull(), followerSharedInbox: text("follower_shared_inbox"), followedUri: text("followed_uri").notNull(), + followActivityId: text("follow_activity_id").notNull(), accepted: integer("accepted", { mode: "boolean" }).notNull().default(false), createdAt: integer("created_at", { mode: "timestamp" }) .notNull() @@ -2194,12 +2195,13 @@ export const follows = sqliteTable( export type Follow = typeof follows.$inferSelect; ~~~~ -A row holds both sides of a subscription as plain ActivityPub URIs, -plus the follower's inbox and optional shared inbox (denormalised so -fan-out doesn't have to re-fetch the actor). `accepted` starts false -and flips to true once we've seen (or sent) the corresponding -`Accept(Follow)`. The unique index on `(followerUri, followedUri)` -keeps the table from sprouting duplicate subscriptions. +A row holds both sides of a subscription as plain ActivityPub URIs, the +original `Follow` ID, and the follower's inbox and optional shared inbox +(denormalised so fan-out doesn't have to re-fetch the actor). `accepted` +starts false and flips to true once we've seen (or sent) the corresponding +`Accept(Follow)`. The unique index on `(followerUri, followedUri)` keeps the +table from sprouting duplicate subscriptions, while the stored activity ID +lets an `Accept` identify one row even through the shared inbox. Re-run `npm run db:push`. @@ -2299,6 +2301,7 @@ federation followerInbox: actor.inboxId.href, followerSharedInbox: actor.endpoints?.sharedInbox?.href ?? null, followedUri: follow.objectId.href, + followActivityId: follow.id.href, accepted: true, }) .onConflictDoUpdate({ @@ -2306,6 +2309,7 @@ federation set: { followerInbox: actor.inboxId.href, followerSharedInbox: actor.endpoints?.sharedInbox?.href ?? null, + followActivityId: follow.id.href, accepted: true, }, }) @@ -2320,7 +2324,7 @@ federation follow.objectId, ), actor: follow.objectId, - object: follow, + object: follow.id, }), ); }); @@ -2349,24 +2353,64 @@ listener: ~~~~ typescript [federation/index.ts] .on(Accept, async (ctx, accept) => { - const enclosed = await accept.getObject(ctx); - if (!(enclosed instanceof Follow)) return; - if (!enclosed.actorId || !enclosed.objectId) return; + if (accept.actorId == null || accept.objectId == null) { + return; + } + const followerUri = ctx.recipient == null + ? null + : ctx.getActorUri(ctx.recipient).href; + const exactResult = db.update(follows) + .set({ accepted: true }) + .where( + and( + followerUri == null + ? undefined + : eq(follows.followerUri, followerUri), + eq(follows.followedUri, accept.actorId.href), + eq(follows.followActivityId, accept.objectId.href), + eq(follows.accepted, false), + ), + ) + .run(); + if (exactResult.changes > 0) return; + if (accept.objectId.origin === ctx.canonicalOrigin) return; + + const candidates = db + .select({ followerUri: follows.followerUri }) + .from(follows) + .where( + and( + followerUri == null + ? undefined + : eq(follows.followerUri, followerUri), + eq(follows.followedUri, accept.actorId.href), + eq(follows.accepted, false), + ), + ) + .limit(2) + .all(); + if (candidates.length !== 1) return; db.update(follows) .set({ accepted: true }) .where( and( - eq(follows.followerUri, enclosed.actorId.href), - eq(follows.followedUri, enclosed.objectId.href), + eq(follows.followerUri, candidates[0].followerUri), + eq(follows.followedUri, accept.actorId.href), + eq(follows.accepted, false), ), ) .run(); }); ~~~~ -`accept.getObject` returns the object the `Accept` wraps. Only when -that object is a `Follow` do we flip the corresponding row's -`accepted` flag. +The `Accept` comes from a different origin than our original `Follow`, so we +do not dereference or trust an embedded copy of that activity. Its `objectId` +is enough to match the pending row together with the remote actor that sent the +`Accept`, including when it arrives through the shared inbox. If a peer mints +a different ID on its own origin, the handler falls back only when the receiving +local actor identifies one pending row—or, for the shared inbox, when there is +only one possible local actor. A mismatched ID on our own origin never falls +back. ### Sending an outbound `Follow` @@ -2468,6 +2512,10 @@ export async function followCommunity(formData: FormData): Promise { const followerUri = ctx.getActorUri(user.username); const followerInbox = ctx.getInboxUri(user.username); const sharedInbox = ctx.getInboxUri(); + const requestedFollowActivityId = new URL( + `#follow/${crypto.randomUUID()}`, + followerUri, + ); db.insert(follows) .values({ @@ -2475,16 +2523,29 @@ export async function followCommunity(formData: FormData): Promise { followerInbox: followerInbox.href, followerSharedInbox: sharedInbox.href, followedUri: actor.id.href, + followActivityId: requestedFollowActivityId.href, accepted: false, }) .onConflictDoNothing() .run(); + const row = db + .select({ followActivityId: follows.followActivityId }) + .from(follows) + .where( + and( + eq(follows.followerUri, followerUri.href), + eq(follows.followedUri, actor.id.href), + ), + ) + .get(); + if (row == null) throw new Error("Failed to store the follow request"); + const followActivityId = new URL(row.followActivityId); await ctx.sendActivity( { identifier: user.username }, actor, new Follow({ - id: new URL(`#follow/${Date.now()}`, followerUri), + id: followActivityId, actor: followerUri, object: actor.id, }), @@ -2502,9 +2563,10 @@ export async function followCommunity(formData: FormData): Promise { then fetches the actor JSON the handle points at. `isActor(actor)` and `actor instanceof Group` narrow the result to the type we want to follow. We record the follow row as `accepted: false` up front so -the UI could, if we wanted, show a “pending” badge; when the remote -server sends back `Accept(Follow)`, the inbox listener we wrote above -flips it to true. +the UI could, if we wanted, show a “pending” badge. If the row already +exists, we reuse its activity ID so that a later `Accept` still matches our +local state. When the remote server sends back `Accept(Follow)`, the inbox +listener we wrote above flips it to true. ### Verifying with ActivityPub.Academy diff --git a/examples/astro/src/lib/federation.ts b/examples/astro/src/lib/federation.ts index 52ab33d6b..fa2c8b13e 100644 --- a/examples/astro/src/lib/federation.ts +++ b/examples/astro/src/lib/federation.ts @@ -84,7 +84,7 @@ federation context.getActorUri(IDENTIFIER), ), actor: follow.objectId, - object: follow, + object: follow.id, }), ); relationStore.set(follower.id.href, follower); diff --git a/examples/cloudflare-workers/src/index.ts b/examples/cloudflare-workers/src/index.ts index 1660a77c7..c474c9748 100644 --- a/examples/cloudflare-workers/src/index.ts +++ b/examples/cloudflare-workers/src/index.ts @@ -92,7 +92,7 @@ builder ctx.getActorUri(result.identifier), ), actor: follow.objectId, - object: follow, + object: follow.id, }), ); await ctx.data.kv.put( diff --git a/examples/express/app.ts b/examples/express/app.ts index 23c84d82f..58d2bf70e 100644 --- a/examples/express/app.ts +++ b/examples/express/app.ts @@ -96,7 +96,7 @@ federation context.getActorUri("demo"), ), actor: follow.objectId, - object: follow, + object: follow.id, }), ); relationStore.set(follower.id.href, follow.objectId.href); diff --git a/examples/next-integration/federation/index.ts b/examples/next-integration/federation/index.ts index c9aefa1c5..a0f962f95 100644 --- a/examples/next-integration/federation/index.ts +++ b/examples/next-integration/federation/index.ts @@ -74,7 +74,7 @@ federation context.getActorUri("demo"), ), actor: follow.objectId, - object: follow, + object: follow.id, }), ); relationStore.set(follower.id.href, follow.objectId.href); diff --git a/examples/next14-app-router/app/[fedify]/[[...catchAll]]/route.ts b/examples/next14-app-router/app/[fedify]/[[...catchAll]]/route.ts index 34122c227..8ab129e4c 100644 --- a/examples/next14-app-router/app/[fedify]/[[...catchAll]]/route.ts +++ b/examples/next14-app-router/app/[fedify]/[[...catchAll]]/route.ts @@ -81,7 +81,7 @@ federation context.getActorUri("demo"), ), actor: follow.objectId, - object: follow, + object: follow.id, }), ); relationStore.set(follower.id.href, follow.objectId.href); diff --git a/examples/next15-app-router/shared/integrate-fedify.ts b/examples/next15-app-router/shared/integrate-fedify.ts index 59038f38e..b170f8163 100644 --- a/examples/next15-app-router/shared/integrate-fedify.ts +++ b/examples/next15-app-router/shared/integrate-fedify.ts @@ -79,7 +79,7 @@ federation context.getActorUri("demo"), ), actor: follow.objectId, - object: follow, + object: follow.id, }), ); relationStore.set(follower.id.href, follow.objectId.href); diff --git a/examples/nuxt/server/federation.ts b/examples/nuxt/server/federation.ts index 375a7e080..1976338ae 100644 --- a/examples/nuxt/server/federation.ts +++ b/examples/nuxt/server/federation.ts @@ -101,7 +101,7 @@ federation context.getActorUri(IDENTIFIER), ), actor: follow.objectId, - object: follow, + object: follow.id, }), ); relationStore.set(follower.id.href, follower); diff --git a/examples/rfc-9421-test/federation.ts b/examples/rfc-9421-test/federation.ts index 16cc01c37..2bfd406b1 100644 --- a/examples/rfc-9421-test/federation.ts +++ b/examples/rfc-9421-test/federation.ts @@ -130,7 +130,7 @@ export default function createFedify( ctx.getActorUri(ACTOR_ID), ), actor: follow.objectId, - object: follow, + object: follow.id, }), ); followersStore.set(follower.id.href, { diff --git a/examples/solidstart/src/lib/federation.ts b/examples/solidstart/src/lib/federation.ts index 361b93604..c04bfb710 100644 --- a/examples/solidstart/src/lib/federation.ts +++ b/examples/solidstart/src/lib/federation.ts @@ -93,7 +93,7 @@ federation context.getActorUri(IDENTIFIER), ), actor: follow.objectId, - object: follow, + object: follow.id, }), ); relationStore.set(follower.id.href, follower); diff --git a/examples/sveltekit-sample/src/lib/federation.ts b/examples/sveltekit-sample/src/lib/federation.ts index d1dc07952..accab04d3 100644 --- a/examples/sveltekit-sample/src/lib/federation.ts +++ b/examples/sveltekit-sample/src/lib/federation.ts @@ -81,7 +81,7 @@ federation context.getActorUri(IDENTIFIER), ), actor: follow.objectId, - object: follow, + object: follow.id, }), ); relationStore.set(follower.id.href, follower); From 7057839a2970299c723c2f71351f84b29d687b6b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 2 Aug 2026 15:07:37 +0900 Subject: [PATCH 2/4] Clarify the single-user Follow fallback Explain why a reminted Follow ID still identifies at most one pending request in the microblog tutorial, while calling out the recipient checks needed when adapting the example for multiple local actors. https://github.com/fedify-dev/fedify/pull/979#discussion_r3697988991 Assisted-by: Codex:gpt-5.6-sol --- docs/tutorial/microblog.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/tutorial/microblog.md b/docs/tutorial/microblog.md index 0275b35e8..72b177c21 100644 --- a/docs/tutorial/microblog.md +++ b/docs/tutorial/microblog.md @@ -3935,11 +3935,14 @@ The handler first tries to match `accept.objectId` against the exact activity ID we stored. Some implementations mint a different ID on their own origin for the nested `Follow`, so it falls back to the pending request for the authenticated remote actor. It never falls back for a mismatched ID on our own -origin, and it never reads fields from the cross-origin inline `Follow`. This -also works when the `Accept` is delivered through the shared inbox. After the -match succeeds, it inserts the relationship into `follows` and marks the stored -activity as accepted in one transaction. The stored activity remains -available through its object dispatcher. +origin, and it never reads fields from the cross-origin inline `Follow`. +Because this tutorial permits only one local account, the fallback can match at +most one pending request, even when the `Accept` arrives through the shared +inbox. A multi-user application would need to narrow the match by the local +recipient and reject ambiguous shared-inbox deliveries. After the match +succeeds, it inserts the relationship into `follows` and marks the stored +activity as accepted in one transaction. The stored activity remains available +through its object dispatcher. ### Testing From eff68d03972b9037ed1ecc5b04ee879c9407ed2b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 2 Aug 2026 15:20:22 +0900 Subject: [PATCH 3/4] Update the JSR reference plugin Use markdown-it-jsr-ref 0.5.0 so documentation builds can consume the current JSR symbol index instead of failing while loading the VitePress configuration. https://github.com/fedify-dev/fedify/actions/runs/30689337969/job/91458298163 Assisted-by: Codex:gpt-5.6-sol --- docs/package.json | 2 +- pnpm-lock.yaml | 37 +++++++++++++++++++++++++++++++------ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/docs/package.json b/docs/package.json index 6af010382..9fdd43087 100644 --- a/docs/package.json +++ b/docs/package.json @@ -62,7 +62,7 @@ "markdown-it-abbr": "^2.0.0", "markdown-it-deflist": "^3.0.0", "markdown-it-footnote": "^4.0.0", - "markdown-it-jsr-ref": "0.4.4", + "markdown-it-jsr-ref": "0.5.0", "mermaid": "^11.4.1", "postgres": "catalog:", "srvx": "^0.11.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1aed6d4db..4b843bdac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -377,8 +377,8 @@ importers: specifier: ^4.0.0 version: 4.0.0 markdown-it-jsr-ref: - specifier: 0.4.4 - version: 0.4.4 + specifier: 0.5.0 + version: 0.5.0 mermaid: specifier: ^11.4.1 version: 11.7.0 @@ -9638,6 +9638,9 @@ packages: linkify-it@5.0.1: resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + listhen@1.9.1: resolution: {integrity: sha512-4EhoyVcXEpNlY5HJRSQpH7Rba94M8N2JmI62ePjl0lrJKXSfG0F1FAgHGxBoz/T3pe41sUEwkIRRIcaUL0/Ofw==} hasBin: true @@ -9751,8 +9754,8 @@ packages: markdown-it-footnote@4.0.0: resolution: {integrity: sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==} - markdown-it-jsr-ref@0.4.4: - resolution: {integrity: sha512-z6abJJXW0vNHaLC9fxVvT9iaHw2pGjiQDn+rFlQKV3+JgfmNH6bwF8uzWGh1d5Wo7UUAx1w7drmyQFG0OWwh8g==} + markdown-it-jsr-ref@0.5.0: + resolution: {integrity: sha512-lPo1of7KhZ3wrSenZG7W1Ddoal2sfpVPNuPLlF24BxO8xwwfOA/CxdnZ8HwJ+6YZFkUR7y1uCJKRqF1NUym3LQ==} markdown-it@14.1.0: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} @@ -9762,6 +9765,10 @@ packages: resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==} hasBin: true + markdown-it@14.3.0: + resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} + hasBin: true + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -9835,6 +9842,9 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -21639,6 +21649,10 @@ snapshots: dependencies: uc.micro: 2.1.0 + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + listhen@1.9.1: dependencies: '@parcel/watcher': 2.5.6 @@ -21763,10 +21777,10 @@ snapshots: markdown-it-footnote@4.0.0: {} - markdown-it-jsr-ref@0.4.4: + markdown-it-jsr-ref@0.5.0: dependencies: '@deno/shim-deno': 0.18.2 - markdown-it: 14.1.0 + markdown-it: 14.3.0 markdown-it@14.1.0: dependencies: @@ -21786,6 +21800,15 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + markdown-it@14.3.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + markdown-table@3.0.4: {} markdown-title@1.0.2: {} @@ -21960,6 +21983,8 @@ snapshots: mdurl@2.0.0: {} + mdurl@2.1.0: {} + media-typer@0.3.0: {} media-typer@1.1.0: {} From 36211315f6a3d3b63da4b95784c46a1ed3345e60 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 2 Aug 2026 15:56:34 +0900 Subject: [PATCH 4/4] Import the Drizzle query helpers The follow action now uses and() and eq() to recover the persisted activity ID. Import them in the standalone tutorial example so that readers can use the code without unresolved names. https://github.com/fedify-dev/fedify/pull/979#discussion_r3698133952 Assisted-by: Codex:gpt-5.6-sol --- docs/tutorial/threadiverse.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/tutorial/threadiverse.md b/docs/tutorial/threadiverse.md index 09d8cbda1..434e44007 100644 --- a/docs/tutorial/threadiverse.md +++ b/docs/tutorial/threadiverse.md @@ -2479,6 +2479,7 @@ Now write the follow action in *app/follow/actions.ts*: "use server"; import { Follow, Group, isActor } from "@fedify/vocab"; +import { and, eq } from "drizzle-orm"; import { redirect } from "next/navigation"; import { db, follows } from "@/db"; import federation from "@/federation";