From c8ee340e19ba75df8e8351d40efbff3a33cd500c Mon Sep 17 00:00:00 2001 From: Nat Welch Date: Mon, 17 Aug 2026 02:52:41 +0000 Subject: [PATCH] fix: send relay Follow object as the full Public IRI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit relay.toot.io and relay.intahnet.co.uk both run YUKIMOCHI Activity-Relay, both auto-accept, and both Rejected us within 3.5 minutes of a fresh Follow. Its executeFollowing validates with a literal string comparison: case contains(activity.Object, "https://www.w3.org/ns/activitystreams#Public") We serialize PUBLIC_COLLECTION as the compacted CURIE "as:Public", so the comparison fails and it falls through to "only ...#Public is allowed to follow" and Rejects. Semantically as:Public is correct — the relay is string-matching a compacted term — but we're the ones being refused. Fedify applies this same rewrite to to/cc/bto/bcc/audience as of 2.2.0 (fedify-dev/fedify#710) but not to object. RelayFollow overrides toJsonLd so the rewrite lands before signing and the signed bytes match the wire bytes. Used on both the sent activity and the Follow object dispatcher, since the relay re-fetches the Follow URL to verify it. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/__tests__/subscriptions.test.ts | 39 +++++++++++++++++++++++++ src/lib/federation.ts | 9 ++++-- src/lib/subscriptions.ts | 31 +++++++++++++++++++- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/lib/__tests__/subscriptions.test.ts b/src/lib/__tests__/subscriptions.test.ts index 7d60304..0792da9 100644 --- a/src/lib/__tests__/subscriptions.test.ts +++ b/src/lib/__tests__/subscriptions.test.ts @@ -1,10 +1,49 @@ import { describe, it, expect } from "vitest"; +import { Follow, PUBLIC_COLLECTION } from "@fedify/vocab"; import { RELAY_REJECT_RETRY_MS, isRelayTerminal, findLostAccepts, + RelayFollow, + AS_PUBLIC, } from "../subscriptions"; +const FOLLOW_ARGS = { + id: new URL("https://robot.villas/users/nyt_homepage/follows/x"), + actor: new URL("https://robot.villas/users/nyt_homepage"), + object: PUBLIC_COLLECTION, +}; + +describe("RelayFollow", () => { + it("serializes object as the full Public IRI", async () => { + const json = (await new RelayFollow(FOLLOW_ARGS).toJsonLd()) as Record; + expect(json.object).toBe(AS_PUBLIC); + expect(json.type).toBe("Follow"); + }); + + it("documents the upstream behaviour it works around", async () => { + // Plain Follow compacts to the CURIE, which YUKIMOCHI Activity-Relay + // rejects because it string-compares against the full IRI. + const json = (await new Follow(FOLLOW_ARGS).toJsonLd()) as Record; + expect(json.object).toBe("as:Public"); + }); + + it("survives clone(), which Fedify uses internally", async () => { + const cloned = new RelayFollow(FOLLOW_ARGS).clone({}); + expect(cloned).toBeInstanceOf(RelayFollow); + const json = (await cloned.toJsonLd()) as Record; + expect(json.object).toBe(AS_PUBLIC); + }); + + it("leaves a non-Public object untouched", async () => { + const json = (await new RelayFollow({ + ...FOLLOW_ARGS, + object: new URL("https://tags.pub/user/_followback"), + }).toJsonLd()) as Record; + expect(json.object).toBe("https://tags.pub/user/_followback"); + }); +}); + const NOW = new Date("2026-08-16T00:00:00Z"); const daysAgo = (n: number) => new Date(NOW.getTime() - n * 24 * 60 * 60 * 1000); diff --git a/src/lib/federation.ts b/src/lib/federation.ts index 3d727ea..de92920 100644 --- a/src/lib/federation.ts +++ b/src/lib/federation.ts @@ -37,7 +37,7 @@ import { } from "@fedify/vocab"; import escapeHtml from "escape-html"; import { getRelaySubscriptionBot, type BotConfig, type FeedsConfig } from "./config"; -import { findLostAccepts, isRelayTerminal } from "./subscriptions"; +import { findLostAccepts, isRelayTerminal, RelayFollow } from "./subscriptions"; import { addFollower, countEntries, @@ -509,7 +509,9 @@ export function setupFederation(deps: FederationDeps): Federation { // Also check the relays table (relay subscriptions) const relayRow = await getRelayByActivityId(db, followUri.href); if (relayRow?.actorId) { - return new Follow({ + // RelayFollow, not Follow: the relay re-fetches this URL to verify the + // subscription and applies the same full-IRI string check. + return new RelayFollow({ id: followUri, actor: ctx.getActorUri(identifier), object: PUBLIC_COLLECTION, @@ -1037,12 +1039,13 @@ export async function subscribeToRelays( id: crypto.randomUUID(), }); - const follow = new Follow({ + const follow = new RelayFollow({ id: followId, actor: ctx.getActorUri(designated), // ActivityRelay expects object=PUBLIC_COLLECTION (Mastodon-style subscription), // not the relay actor's own URL (which triggers the LitePub peer-relay path // and gets rejected because our actor URLs don't end in /relay). + // RelayFollow serializes it as the full IRI; see its docstring. object: PUBLIC_COLLECTION, }); diff --git a/src/lib/subscriptions.ts b/src/lib/subscriptions.ts index 1c0b55c..de44824 100644 --- a/src/lib/subscriptions.ts +++ b/src/lib/subscriptions.ts @@ -1,4 +1,33 @@ -/** Escape hatches from terminal subscription state that would otherwise stick forever. */ +/** Relay and follow subscription helpers: retry policy, reconciliation, wire format. */ + +import { Follow } from "@fedify/vocab"; + +export const AS_PUBLIC = "https://www.w3.org/ns/activitystreams#Public"; + +/** + * A Follow that serializes `object` as the full Public IRI instead of the + * `as:Public` CURIE that JSON-LD compaction produces. + * + * YUKIMOCHI Activity-Relay — which relay.toot.io and relay.intahnet.co.uk both + * run — validates subscriptions with a literal string comparison against the + * full IRI, so the compacted form falls through to its "only + * https://www.w3.org/ns/activitystreams#Public is allowed to follow" Reject. + * Fedify applies the same rewrite to to/cc/bto/bcc/audience as of 2.2.0 but not + * to `object`. Rewriting inside toJsonLd keeps the signed bytes and the wire + * bytes identical. + */ +export class RelayFollow extends Follow { + override async toJsonLd(options?: Parameters[0]): Promise { + const json = await super.toJsonLd(options); + if (json && typeof json === "object") { + const doc = json as Record; + if (doc.object === "as:Public" || doc.object === "Public") { + doc.object = AS_PUBLIC; + } + } + return json; + } +} /** How long a relay Reject is honored before we re-attempt. */ export const RELAY_REJECT_RETRY_MS = 30 * 24 * 60 * 60 * 1000;