Skip to content

Public audience IRI rewrite from #710 doesn't cover Follow.object, so relay subscriptions get rejected #998

Description

@icco

Summary

#710 rewrites the public audience to the full https://www.w3.org/ns/activitystreams#Public IRI in to, cc, bto, bcc, and audience, because consumers that compare literally reject the compacted as:Public CURIE. The same hazard applies to object on a Follow, which is how Mastodon-style relay subscription is expressed — but that field isn't covered, so subscribing to a relay from Fedify fails.

Environment

  • Fedify version: 2.3.4 (@fedify/fedify and @fedify/vocab)
  • Runtime: Node.js v26.7.0
  • OS: Debian GNU/Linux 13 (trixie), linux/amd64

Steps to reproduce

import { Follow, PUBLIC_COLLECTION } from "@fedify/vocab";

const follow = new Follow({
  id: new URL("https://example.com/users/alice/follows/1"),
  actor: new URL("https://example.com/users/alice"),
  object: PUBLIC_COLLECTION,
});

console.log((await follow.toJsonLd()).object);

Expected behavior

https://www.w3.org/ns/activitystreams#Public, matching what #710 already does for the addressing fields.

Actual behavior

as:Public

Sending that Follow to a relay running YUKIMOCHI Activity-Relay gets a Reject. Its executeFollowing in api/resolver.go validates with exact string equality:

case contains(activity.Object, "https://www.w3.org/ns/activitystreams#Public"):
    // contains(string) → entry == key
...
default:
    err := errors.New("only https://www.w3.org/ns/activitystreams#Public is allowed to follow")
    return err

"as:Public" doesn't match, so it falls through to default and Rejects.

I hit this against relay.toot.io and relay.intahnet.co.uk. Both run that software, both advertise open registration and auto-accept (toot.io lists ~1,300 connected instances), and both Rejected within about 3.5 minutes of receiving the Follow. Re-fetching the Follow object by URL returns the same as:Public, so a relay that dereferences to verify hits it too.

Why this belongs in Fedify

It's the same interop problem #710 already solved, one field short of the relay case, and every app that wants to subscribe to a relay has to reimplement the workaround. The workaround is also easy to get subtly wrong: it has to happen inside toJsonLd rather than after serialization, or the Object Integrity Proof no longer covers the bytes on the wire — the same ordering constraint #710 mentions.

For reference, what I'm using locally:

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 = "https://www.w3.org/ns/activitystreams#Public";
      }
    }
    return json;
  }
}

I appreciate that as:Public is correct JSON-LD and the relay is at fault for not expanding before comparing — but given #710 already accepted that tradeoff for Lemmy, extending it to object seems consistent.

Metadata

Metadata

Assignees

Type

Fields

Priority

None yet

Effort

None yet

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions