Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/lib/__tests__/subscriptions.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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);

Expand Down
9 changes: 6 additions & 3 deletions src/lib/federation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -509,7 +509,9 @@ export function setupFederation(deps: FederationDeps): Federation<void> {
// 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,
Expand Down Expand Up @@ -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,
});

Expand Down
31 changes: 30 additions & 1 deletion src/lib/subscriptions.ts
Original file line number Diff line number Diff line change
@@ -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<Follow["toJsonLd"]>[0]): Promise<unknown> {
const json = await super.toJsonLd(options);
if (json && typeof json === "object") {
const doc = json as Record<string, unknown>;
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;
Expand Down