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;