Skip to content

Threadiverse tutorial and public audience interop fix#710

Draft
dahlia wants to merge 35 commits intofedify-dev:mainfrom
dahlia:docs/tutorial/threadiverse
Draft

Threadiverse tutorial and public audience interop fix#710
dahlia wants to merge 35 commits intofedify-dev:mainfrom
dahlia:docs/tutorial/threadiverse

Conversation

@dahlia
Copy link
Copy Markdown
Member

@dahlia dahlia commented Apr 22, 2026

Closes #704.

Read it at https://4813e09a.fedify.pages.dev/tutorial/threadiverse.

Summary

  • Adds Building a threadiverse community platform, a new tutorial that walks readers through building a Lemmy-style community server with Fedify + Next.js. Where Creating your own federated microblog is actor- and timeline-centric, this one is community-centric: Group actors, Page threads, Note replies, and the community-side Announce redistribution that every threadiverse implementation uses to fan activity out to subscribers. It pairs commit-by-commit with the example repository at fedify-dev/threadiverse, so each chapter corresponds to a reviewable commit on the example side.
  • Ships a sender-side interop workaround in @fedify/fedify: activities now serialize the public audience as the full https://www.w3.org/ns/activitystreams#Public URI in to, cc, bto, bcc, and audience fields, instead of the as:Public / Public CURIE that JSON-LD compaction produces. Surfaced while testing the tutorial's community fan-out against lemmy.ml: Lemmy's inbox parser does literal URL comparison on those fields without running JSON-LD expansion, silently rejects Announce(Create(Page)) with {"error":"object_is_not_public"}, and won't accept threads. Reported upstream in LemmyNet/lemmy#6465 with a patch at LemmyNet/lemmy#6466, but several other fediverse implementations exhibit the same gap, so the workaround is valuable on its own.

The rewrite is gated by a URDNA2015 canonical-form equivalence check (both compact and normalized forms are canonicalised via jsonld.canonize and their N-Quads are compared); if an application-defined @context redefines the as: prefix or the bare Public term, the rewrite would change semantics and is skipped. The normalization is also applied inside createProof before the eddsa-jcs-2022 Object Integrity Proof JCS pass, so the bytes that get signed match the bytes that ship on the wire. Otherwise the proof would fail verification for every receiver, since verifyProof JCS-hashes the on-wire form byte-for-byte.

Why both changes in one PR

The tutorial's Lemmy-interop chapter ends with a screenshot of a thread landing in a Lemmy user's feed; that demonstration only works on Fedify 2.2 because of the CURIE workaround. The tutorial's prereq note already advertises "Fedify CLI 2.2.0 or higher", and the CHANGES.md entries for both land in the same 2.2.0 section. Splitting them would mean holding the tutorial back until a separate interop PR merges. Happy to split on request.

Files of interest

  • docs/tutorial/threadiverse.md (+4120): the tutorial itself, seventeen chapters.
  • docs/tutorial/threadiverse/: the 11 PNG screenshots the tutorial references (ActivityPub.Academy handshakes, community pages, Lemmy subscribe round-trip, etc.).
  • docs/.vitepress/config.mts: sidebar entry for the new tutorial.
  • CHANGES.md: two entries, one in @fedify/fedify for the interop workaround, one in the Documentation section citing Build threadiverse group federation example and tutorial (Next.js + Node.js) #704.
  • packages/fedify/src/compat/public-audience.ts (+112): the new shared helper.
  • packages/fedify/src/compat/public-audience.test.ts: unit tests covering the canonical-equivalence bailout, the nested-@context guard, the prototype-pollution guard, the known-safe fast path, and the depth-limit guard.
  • packages/fedify/src/sig/proof.ts / proof.test.ts: applies the helper before JCS in createProof, plus a full signObject to normalize to verifyProof round-trip test pinning down the ordering invariant.
  • packages/fedify/src/federation/middleware.ts / middleware.test.ts: applies the helper on the outbound path and asserts the posted body carries the full URI.

Test plan

  • mise run check (fmt + lint + types + md + manifest) passes.
  • mise run docs:build (VitePress) passes; the tutorial renders, all in-page anchors resolve to real headings (spot-checked in dist/tutorial/threadiverse.html), markdown-it-abbr expands CURIE/FEP/JSX/ORM/TSX.
  • deno test packages/fedify/src/compat/public-audience.test.ts, 11 tests pass.
  • deno test packages/fedify/src/sig/proof.test.ts, 5 tests pass, including the new public-audience round-trip under signObject() and the expanded-proof-key + top-level-array regression cases added during review.
  • deno test packages/fedify/src/federation/middleware.test.ts --filter "FederationImpl.sendActivity", passes, including the new assertion that the posted to field is the full URI.
  • End-to-end validation against lemmy.ml: posted a thread in a local community that @hongminhee@lemmy.ml was subscribed to, verified the Announce(Create(Page)) was accepted and the post rendered on Lemmy with working reply/vote buttons.
  • CI to re-run across Deno, Node.js, and Bun.

dahlia added 22 commits April 21, 2026 21:43
Add docs/tutorial/threadiverse.md with the Introduction, Target
audience, Goals, and Setting up the development environment sections,
and register it in the tutorial nav.  This first chapter walks the
reader through `fedify init -w next -p npm -k in-memory -m in-process`,
the Next.js 15.5.x pin workaround, and verifying the dev server and
federation middleware with `fedify lookup`.

Subsequent commits will add chapters in step with commits in the
paired fedify-dev/threadiverse example repository.

Assisted-By: Claude Code:claude-opus-4-7
Add the *Swapping ESLint for Biome* subsection to the Setup chapter.
It walks the reader through deleting the two ESLint configs that
`fedify init` leaves behind for Next.js, turning on Biome's linter,
rewriting the `lint` and `format` scripts to call Biome, and cleaning
up the unused `getLogger` call that `noUnusedVariables` flags in
*federation/index.ts*.

Matches commit 839def1 in fedify-dev/threadiverse.

Assisted-By: Claude Code:claude-opus-4-7
Add the *Layout and navigation* chapter.  It walks the reader through
replacing `create-next-app`'s demo landing page with a minimal root
layout (top nav with brand + Home/New community links), a one-paragraph
home page, and an ~80-line plain stylesheet for typography, forms,
cards, buttons, and the nested reply tree.  The reader copies the
stylesheet once and never touches CSS again, which keeps later chapters
focused on federation rather than presentation.

Matches commit 4d31dab in fedify-dev/threadiverse.

Assisted-By: Claude Code:claude-opus-4-7
Add the opening of the *User accounts* chapter.  It introduces Drizzle
ORM and SQLite, walks through installing `drizzle-orm`, `better-sqlite3`,
and the dev-time `drizzle-kit` + `@types/better-sqlite3`, and declares
the first table (`users`) in *db/schema.ts* with `id`, `username`,
`password_hash`, and `created_at` columns.  Also covers opening the
database with WAL + foreign-key pragmas, wiring up *drizzle.config.ts*,
adding `db:push` and `db:studio` scripts, and gitignoring the SQLite
files.

Matches commit d3aa008 in fedify-dev/threadiverse.

Assisted-By: Claude Code:claude-opus-4-7
Add the *Signup form and password hashing* subsection.  It introduces
Node's scrypt as the password hashing primitive, shows how to build a
Next.js App Router *server action* (a server function called directly
from an HTML form via the `action` attribute, with no client fetch or
API route), and walks the reader through the full validation chain:
client-side HTML pattern, server-side regex re-validation, Drizzle
duplicate-username lookup, scrypt hash, and a redirect back to the
login page with a success message.

Includes a screenshot of the rendered form for visual confirmation.

Matches commit b170aa5 in fedify-dev/threadiverse.

Assisted-By: Claude Code:claude-opus-4-7
Add the *Login and sessions* subsection.  It introduces the server-side
`sessions` table (indexed by an opaque random token, with
`onDelete: "cascade"` on the user reference), walks through writing
`createSession`, `getCurrentUser`, and `destroySession` helpers in
*lib/session.ts*, and discusses each cookie flag (httpOnly, sameSite,
secure in production only, 30-day maxAge).  It then builds the login
page + login/logout server actions and turns the root layout into an
async server component so every page can branch on whether a user is
signed in, showing `@username / Log out` or `Log in / Sign up` in the
nav accordingly.

Includes a screenshot of the logged-in home page.

Matches commit b5e8ba7 in fedify-dev/threadiverse.

Assisted-By: Claude Code:claude-opus-4-7
Add the *Profile page* subsection.  It introduces Next.js App Router
dynamic segments (`[username]`), server-side `params` as a Promise,
and `notFound()` for 404 handling, then builds a simple profile page
that renders the user's handle, join date, and a placeholder for
future threads.  Also updates the nav-bar `@username` label into a
link pointing at that page.

Ends with a teaser: the same URL currently serves HTML to browsers
and a placeholder `Person` JSON to ActivityPub clients, and the next
commit will swap the placeholder for a dispatcher backed by the
`users` table.

Matches commit a0ed553 in fedify-dev/threadiverse.

Assisted-By: Claude Code:claude-opus-4-7
Add the *Federating your user: the Person actor* chapter.  It walks
the reader through:

 -  The `keys` table, keyed by actor identifier so the same table
    serves both user and community keys when we add `Group` actors.
 -  A real `setActorDispatcher` that looks the identifier up in
    `users` and returns a `Person` with `inbox`, shared-inbox
    endpoints, `publicKey`, and `assertionMethods` populated from
    `ctx.getActorKeyPairs`.
 -  `setKeyPairsDispatcher` that lazily generates and persists both
    RSA and Ed25519 key pairs as JWK JSON.
 -  A minimal `setInboxListeners` call (URL templates only; handlers
    come later) so `ctx.getInboxUri` resolves.
 -  Local verification with `fedify lookup` and a direct WebFinger
    `curl`.
 -  Running `fedify tunnel`, why `x-forwarded-fetch` is needed when
    the app is behind a tunnel, and how to rewrite *middleware.ts*
    with `getXForwardedRequest`.
 -  A screenshot of ActivityPub.Academy finding the local actor via
    WebFinger through the tunnel URL.

Matches commits ad9ae55 ("Actor dispatcher and key pairs") and
4d462c1 ("Use x-forwarded-fetch in middleware") in
fedify-dev/threadiverse.

Assisted-By: Claude Code:claude-opus-4-7
Add the *Communities as Group actors* chapter.  It covers four
example-repo commits' worth of content in one coherent narrative:

 -  Declaring the `communities` table and lifting the identifier
    uniqueness check into a shared `lib/identifiers.ts` so signup and
    community creation both go through `isIdentifierTaken`.
 -  The community creation UI and the server action that inserts a
    new row under the logged-in user.
 -  Teaching the existing profile page to fall through from `users`
    to `communities`, with screenshots of the filled form and the
    rendered community page.
 -  Extending the actor dispatcher (and key pairs dispatcher) so the
    same `/users/{identifier}` URL returns a `Group` for community
    slugs and a `Person` for usernames, plus a screenshot of
    ActivityPub.Academy resolving `@<slug>@<host>` via WebFinger.

Matches commits 8200730 ("communities table"), 46c628b ("Community
creation form"), 32e75bf ("Community page"), and 6de227a ("Group
actor dispatcher") in fedify-dev/threadiverse.

Assisted-By: Claude Code:claude-opus-4-7
Add the *Subscribing to communities* chapter.  It walks through:

 -  Declaring a polymorphic `follows` table that carries the
    follower's inbox and optional shared inbox inline, plus a
    unique `(follower_uri, followed_uri)` index.
 -  Wiring `followers` into the `Group` actor and implementing
    `setFollowersDispatcher` so remote threadiverse servers can
    paginate subscribers.
 -  Inbound `Follow` handling: validate, fetch the follower actor,
    upsert an accepted row, and ship `Accept(Follow)` back.
 -  Inbound `Accept(Follow)` handling: unwrap the enclosed Follow
    and flip the matching outbound row to accepted.
 -  Outbound follow UI: `/follow` form, `followCommunity` server
    action using `ctx.lookupObject`, and a `currentOrigin` helper
    that builds the correct origin from `x-forwarded-host`.
 -  Verification with a screenshot of ActivityPub.Academy listing
    the local community with an "Unfollow" button and a follower
    count of 1 after the full round trip.

Matches commits 8a8cb7d ("follows table and followers dispatcher")
and f4162ad ("Follow and Accept(Follow)") in
fedify-dev/threadiverse.

Assisted-By: Claude Code:claude-opus-4-7
Add the *Threads* chapter covering five example-repo commits:

 -  `threads` table keyed by ActivityPub URI with a UNIQUE constraint
    so `onConflictDoNothing()` on inserts is idempotent.
 -  Per-community creation form scoped via `createThread.bind(null,
    slug)`, plus community-page thread list + *Start a thread* CTA.
 -  Server action that inserts the thread row optimistically, then
    federates a `Create(Page)` with the threadiverse-standard
    audience addressing (`audience: community`, `to: community`,
    `cc: [PUBLIC, community/followers]`).
 -  Community inbox `Create` handler: stores the thread idempotently,
    then `sendActivity(..., "followers", Announce(Create))` with
    `preferSharedInbox` so local community posts fan out to
    every subscriber.
 -  Follower-side `Announce` handler that `routeActivity`s the
    enclosed Create back through the same Create handler, verifying
    the enclosed activity's origin in the process.

A TIP at the end explains that Mastodon (Academy) doesn't render
`Page` objects in its timeline even though Fedify's Announce lands;
Lemmy/Mbin/NodeBB, the intended consumers, do.

Matches commits e1feb72 ("threads table"), 191379e ("Thread
creation form"), 415bdce ("Publish Create(Page) to the community"),
1d623cb ("Community inbox Create handler + Announce"), and 9b5f149
("Announce handler routes to Create") in fedify-dev/threadiverse.

Assisted-By: Claude Code:claude-opus-4-7
Add the *Replies* chapter walking through three example-repo commits:

 -  Thread detail page at `/users/<slug>/threads/<id>` that
    re-derives the canonical thread URI from `currentOrigin()` and
    the route params.
 -  `replies` table parallel to `threads` (URI-keyed, carries
    `thread_uri`, `parent_uri`, and `community_uri`) plus the
    reply tree UI (recursive `<ul class="reply-tree">` with
    per-node inline reply forms wrapped in `<details>`).
 -  Extension of the community inbox `Create` handler for `Note`
    objects: resolve the parent via both `threads` and `replies`,
    derive the top-level `thread_uri`, insert with
    `onConflictDoNothing`, then Announce to followers through the
    same local-community branch that handles threads.

Matches commits 8c46ff9 ("Thread page"), f7a5b9d ("replies table +
thread-page reply UI"), and a93ed2b ("Community Announce of
Create(Note)") in fedify-dev/threadiverse.

Assisted-By: Claude Code:claude-opus-4-7
Add the *Votes* chapter pairing with the two vote example-repo
commits: Like/Dislike UI plus outbound action, and the shared
`handleVote` inbox handler that upserts the vote row and
re-Announces to the community's followers.

The chapter walks through the `(voter_uri, target_uri)` unique
index and why an upsert on that pair replaces the previous vote
instead of stacking rows, the `Map<targetUri, VoteTally>` approach
that bundles all the tallies for a thread page into one `inArray`
query, and the common `VoteClass = kind === "Like" ? Like : Dislike`
construction pattern for activity types with identical field shape.

Ends with a TIP showing the single SQL expression
(`SUM(CASE kind WHEN 'Like' THEN 1 ELSE -1 END)`) that threadiverse
platforms use for ranking, and the rationale for keeping per-vote
rows instead of pre-aggregating counters.

Matches commits 7136e54 ("votes table + Like/Dislike UI") and
aa00671 ("Community Announce of Like / Dislike") in
fedify-dev/threadiverse.

Assisted-By: Claude Code:claude-opus-4-7
Close the local-user loop with the two remaining user-facing
features.

 -  *app/page.tsx* becomes the subscribed feed: anonymous visitors
    see the welcome blurb, logged-in users see every accepted
    subscription listed above a chronological thread list pulled
    via `inArray(threads.communityUri, subscribedUris)`.
 -  Unsubscribing is Ch. 14 played backwards.  The outbound
    `unfollowCommunity` action wraps the original Follow's actor +
    object in an `Undo(Follow)`, sends it to the followee's inbox,
    and deletes the follow row.  The community inbox handler flips
    on `Undo`, unwraps the enclosed Follow, verifies
    `undo.actorId === follow.actorId` so nobody can Undo someone
    else's subscription, and deletes the corresponding row.

Also teach *.hongdown.toml* to preserve the product and tool names
that show up in this tutorial (Biome, Drizzle, Drizzle Kit, Drizzle
ORM, Next.js, Node.js, SQLite, TypeScript, Mbin, Piefed, NodeBB,
cloudflared, ngrok, Serveo, Cloudflare Tunnel, x-forwarded-fetch,
create-next-app, scrypt, JSX) and fix the subsection headings that
hongdown previously lower-cased for ActivityPub activity type
names; those (`Follow`, `Accept(Follow)`, `Create(Page)`,
`Create(Note)`, `Like`, `Dislike`, `Announce`, `Undo`, `Group`)
are now wrapped in backticks so hongdown leaves them alone.

Matches commits 717cad6 ("Subscribed front page") and f83fecd
("Unsubscribe with Undo(Follow)") in fedify-dev/threadiverse.

Assisted-By: Claude Code:claude-opus-4-7
Close out *docs/tutorial/threadiverse.md* with the standard two
sections the microblog tutorial uses, adapted for threadiverse
scope:

 -  *Areas to improve* enumerates extensions the reader can
    reasonably add next: link threads, Update/Delete/Tombstone,
    local communities index, ranking via the vote-sum expression
    already computable from the `votes` table, persistent KV/MQ
    via `@fedify/postgres` or `@fedify/sqlite`, PostgreSQL for
    application data, and Lemmy-specific Group actor fields
    (`attributedTo`, `moderators`, `featured`) for full Lemmy
    interop.
 -  *Next steps* links into *docs/manual/deploy.md* (specifically
    its canonical-origin, reverse-proxy, persistent-KV/MQ,
    actor-key-lifecycle, and running-the-process sections), the
    `@fedify/next` README for deployment caveats, and the Fedify
    community channels.

Also add a *Documentation* entry to the 2.2.0 changelog section
summarising the new tutorial and cross-linking the example repo.

Matches commits 0ed0741 ("Advertise outbox URL on actors") and
2cad236 ("Front matter") in fedify-dev/threadiverse.

Assisted-By: Claude Code:claude-opus-4-7
Add a new chapter between *Unsubscribing with `Undo(Follow)`* and
*Areas to improve* that covers the three field additions needed for
Lemmy to accept the community as a first-class community: a
`moderators` collection (registered through `setCollectionDispatcher`
+ returning the community creator as the sole `Person`),
a `featured` URL (via `setFeaturedDispatcher` + an empty
`OrderedCollection`), and an `attributedTo` pointing at the
moderators collection URL (Lemmy's convention for locating mods).

A NOTE callout explains Lemmy's exponential-backoff federation
retries so the reader isn't surprised by a multi-minute Subscribe
Pending state, and a TIP covers the remaining Lemmy-specific
`postingRestrictedToMods` boolean that Fedify's `Group` vocab
doesn't expose directly but that Lemmy tolerates being absent.

The entry in *Areas to improve* that previously pointed at these
fields is removed, since they're now covered in-chapter.

Matches commit df0e2a5 ("Lemmy-compatible Group actor fields") in
fedify-dev/threadiverse.

Assisted-By: Claude Code:claude-opus-4-7
Extend the *Making the community actor Lemmy-compatible* chapter
with the two more fixes needed for Lemmy's actual `Subscribe`
button to reach the *Joined* state, not just *Subscribe Pending*:

 -  Bundle Lemmy's JSON-LD context (`https://join-lemmy.org/context.json`)
    locally and register a `documentLoaderFactory` /
    `contextLoaderFactory` pair that short-circuits the URL to the
    bundled copy.  Without this, Lemmy's activities fail to parse
    because Lemmy serves the context as `application/json` without
    a JSON-LD `Link` header, and Fedify's default loader rejects
    those responses.
 -  Rewrite the `Accept(Follow)` we ship to satisfy Lemmy's strict
    `PersonInboxActivities` enum: a UUID-based path for the `id`
    instead of a URL-encoded fragment, and a brand-new minimal
    `Follow` (`id` + `actor` + `object`) as the nested `object`
    instead of echoing the entire incoming Follow.

Ends with a verification SQL snippet, a screenshot of Lemmy showing
"Joined" after the round-trip, and a WARNING callout about a known
`Announce(Create(Page))` digest-verification failure specific to
Cloudflare quick tunnels re-framing the HTTP/2 body in transit —
`Follow` delivery works through that tunnel but the larger Announce
body can trip Lemmy's SHA-256 digest check; named Cloudflare
tunnels, `serveo`, `ngrok`, and normal reverse proxies don't hit
this.

Matches commit 4c01faa ("Preload Lemmy's JSON-LD context and use
minimal Accept(Follow)") in fedify-dev/threadiverse.

Assisted-By: Claude Code:claude-opus-4-7
Revise the WARNING callout in *Making the community actor Lemmy-
compatible* to reflect what actually works against lemmy.ml today:

 -  Inbound `Follow`, outbound `Follow`, `Accept(Follow)`, and
    `Undo(Follow)` all round-trip cleanly over both ngrok and
    named Cloudflare tunnels in both directions.  The earlier
    wording blamed Cloudflare quick tunnels for the digest
    mismatch, which turned out to be a red herring once we had
    proper logging.
 -  The remaining blocker is Lemmy returning
    `{"error":"object_is_not_public"}` on our community's fan-out
    `Announce(Create(Page))`.  It's a nested-object audience shape
    issue, not a tunnel issue — Mastodon, Mbin, and Peertube accept
    the same activity — and the fix belongs in the example repo's
    open items rather than in the tutorial.

Also refresh *docs/tutorial/threadiverse/lemmy-subscribed.png* to
the ngrok-hosted community screenshot, which is cleaner and matches
the reader's expected host format.

Assisted-By: Claude Code:claude-opus-4-7
JSON-LD compaction rewrites `https://www.w3.org/ns/activitystreams#Public`
to the CURIE `as:Public` (or, once `@vocab` applies, the bare term
`Public`) whenever the activity's `@context` defines the `as` prefix.
Strictly speaking the compact form is equivalent, but several
threadiverse implementations — Lemmy among them, see
[LemmyNet/lemmy#6465] — match the `to`, `cc`, `bto`, `bcc`, and
`audience` fields against the full URI by string equality without
running JSON-LD expansion.  Outgoing `Announce(Create(Page))` from a
community with Lemmy subscribers therefore gets silently rejected
with `{"error":"object_is_not_public"}`.

Add a post-compaction normalization pass in
`FederationImpl.sendActivity()` that walks the serialized JSON-LD and
replaces `as:Public` / `Public` occurrences inside those five
addressing fields with the expanded URI.  Other fields (and the full
URI elsewhere, e.g. inside tags) are left untouched.  The extra pass
only runs on the direct-send path; the fan-out queue path
re-deserializes through `Activity.fromJsonLd()` before reaching the
same code, so it benefits from the same normalization without
double-processing.

Also add a regression test that sends an activity addressed to
`vocab.PUBLIC_COLLECTION`, captures the posted body, and asserts the
`to` field is the full URI rather than a CURIE.

[LemmyNet/lemmy#6465]: LemmyNet/lemmy#6465

Assisted-By: Claude Code:claude-opus-4-7
Replace the WARNING callout in *Making the community actor Lemmy-
compatible* that previously labelled outbound
`Announce(Create(Page))` as blocked by Lemmy's
`object_is_not_public` check.  Now that Fedify 2.2 serializes the
public audience as the full `https://www.w3.org/ns/activitystreams#Public`
URI (and Lemmy's upstream fix in [LemmyNet/lemmy#6466] is on the
way), the community's fan-out lands in subscribers' feeds on Lemmy,
and replies and votes ride the same path back.  Rewritten as a NOTE
with the historical context preserved so readers encountering older
Fedify or Lemmy versions can still diagnose the original symptom.

Also bump the `fedify --version` prerequisite from 2.1.0 to 2.2.0,
since that's the release that ships the sender-side workaround this
tutorial now relies on for the Lemmy walkthrough.

[LemmyNet/lemmy#6466]: LemmyNet/lemmy#6466

Assisted-By: Claude Code:claude-opus-4-7
Two follow-ups on the workaround added in the previous commit, both
prompted by a closer read of how the normalization interacts with
application-defined JSON-LD contexts and with Object Integrity Proof
signing:

 -  The unconditional CURIE rewrite was unsafe in the presence of a
    custom `@context` that redefines the `as:` prefix or the bare
    `Public` term.  Add a URDNA2015 canonicalization pass on both
    the original and the rewritten document and only emit the
    rewritten form when the two produce identical N-Quads.  When
    the canonical forms diverge, or when canonicalization itself
    errors out, the original document is returned unchanged so the
    application's semantics are preserved.
 -  The Ed25519 `eddsa-jcs-2022` Object Integrity Proof path in
    `createProof` canonicalizes the compact JSON-LD byte-for-byte
    with JCS, not URDNA2015.  Applying the CURIE rewrite only after
    signing would therefore have invalidated the proof for every
    receiver, since `verifyProof` JCS-hashes the on-wire form.  Move
    the normalization into `createProof` itself, before the JCS pass,
    so the proof is signed over the same bytes we emit.

Extract the helper into a dedicated *packages/fedify/src/compat/public-
audience.ts* module so both `FederationImpl.sendActivity` and
`createProof` can share it without `sig/` importing from `federation/`.
Add unit tests covering the CURIE rewrite, the no-op path, the
non-addressing fields, the semantic-equivalence bailout for a
redefined `as:` context, and a full `signObject`/`verifyProof`
round-trip with `tos: [PUBLIC_COLLECTION]` to pin down the
before-JCS ordering.

Assisted-By: Claude Code:claude-opus-4-7
Polish pass on the threadiverse tutorial based on review feedback:

- Tighten intra-sentence em dashes to have no surrounding
  whitespace; keep the spaced form only when separating a list-item
  label from its body.  Many of these read better as comma,
  semicolon, or parentheses and were converted that way.
- Switch bold emphasis in list-item labels and step titles to
  italics, matching the project's house style.
- Use the full names *Drizzle ORM* (never bare *Drizzle*) and
  *Linked Data Signatures* (never *LD Signatures*); drop the now
  unused *Drizzle* entry from the hongdown proper-nouns list.
- Add markdown-it-abbr definitions for *CURIE* and *FEP* so the
  acronyms get tooltip expansions alongside the existing *JSX*,
  *TSX*, and *ORM* ones.
- Link every FEP-xxxx reference to its canonical `w3id.org/fep/…`
  URL.
- Spell HTTP status codes as `<code> <name>` (`404 Not Found`
  instead of a bare 404), and keep header names in backticks
  wherever they appear.
- Replace every *Ch. N* cross-reference with an italicised
  chapter title linked to its VitePress anchor, so readers
  actually have somewhere to click; chapter numbers don't show up
  in the rendered TOC.
- Rework the highlight ranges on every `typescript{…}` / `tsx{…}`
  code fence so the highlighted lines match what the surrounding
  prose calls attention to.  Several were silently off-by-one or
  outright referenced lines that existed in an earlier draft of
  the listing.

Assisted-By: Claude Code:claude-opus-4-7
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 22, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5f8783b0-8f73-482a-af1a-d14b54a67abd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@issues-auto-labeler issues-auto-labeler Bot added activitypub/interop Interoperability issues activitypub/lemmy Lemmy compatibility component/federation Federation object related component/signatures OIP or HTTP/LD Signatures related labels Apr 22, 2026
@dahlia dahlia requested a review from Copilot April 22, 2026 06:50
@dahlia dahlia self-assigned this Apr 22, 2026
The previous hardening commit moved the SSRF gate into
`normalizePublicAudience()` itself: when no `contextLoader` was
supplied, non-standard `@context` shapes skipped the URDNA2015
equivalence check and the document was returned unchanged.  That was
safe for the inbound verification path, but it silently broke
`createProof()` for callers that did not pass an explicit loader.

The Fedify/vocab default compact form embeds an inline namespace-prefix
object at the end of `@context`, so most signed activities fail the
"known-safe context" shortcut and fall to the canonicalization path.
After the previous commit, a caller that invoked `signObject()` without
`contextLoader` would therefore sign the bytes of the `as:Public`
CURIE form (because normalization was suppressed), but `middleware.ts`
sends activities through `normalizePublicAudience()` again at wire
time with its own `contextLoader`, which does rewrite the CURIE.
The signed bytes and the on-wire bytes then diverge and every
`eddsa-jcs-2022` proof produced along that path fails verification.

Move the gate to the one place that actually handles adversarial
input.  `normalizePublicAudience()` again falls back to
`getDocumentLoader()` when `contextLoader` is omitted, which is fine
for signing paths that run on local, trusted JSON-LD (and restores
the consistent signer/wire bytes Fedify's fan-out relies on).
`verifyProofInternal()` now wraps its `normalizePublicAudience()`
call in an explicit `options.contextLoader != null` check; without a
loader the fallback candidate is simply not tried, so an attacker
cannot steer the default loader into fetching an arbitrary
`@context` URL during verification.  Callers who want the fallback
during verification already supply a `contextLoader` (all internal
call sites in `middleware.ts` do), so no functional change there.

Addresses
fedify-dev#710 (comment),
fedify-dev#710 (comment),
and
fedify-dev#710 (comment).
The earlier SSRF concern at
fedify-dev#710 (comment)
remains addressed by the new gate in `verifyProofInternal()`.

Regression test in proof.test.ts feeds verifyProof an activity whose
`@context` mentions an attacker-controlled URL and confirms the call
returns null without attempting a fetch.  The no-loader helper test
that asserted the now-removed skip behavior is gone; the unknown-URL
loader-counting test still exercises the slow canonicalization path
for callers that do pass a loader.
@dahlia dahlia requested a review from Copilot April 22, 2026 09:07
@dahlia
Copy link
Copy Markdown
Member Author

dahlia commented Apr 22, 2026

@codex review

@dahlia
Copy link
Copy Markdown
Member Author

dahlia commented Apr 22, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a compatibility utility to normalize public audience CURIEs (such as as:Public) to full URIs in outgoing ActivityPub activities, which improves interoperability with platforms like Lemmy. This normalization is integrated into the federation middleware and signature creation/verification processes to ensure that Object Integrity Proofs remain valid. The PR also adds a new tutorial titled "Building a threadiverse community platform" and updates the documentation configuration. Review feedback identifies an opportunity to improve TypeScript strictness by casting unknown types before spreading and suggests optimizing the proof verification loop by moving allocations outside the loop and removing redundant array slicing.

Comment thread packages/fedify/src/sig/proof.ts Outdated
Comment thread packages/fedify/src/sig/proof.ts
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 20 changed files in this pull request and generated 1 comment.

Comment thread packages/fedify/src/compat/public-audience.ts Outdated
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b1639cd9a6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/fedify/src/federation/middleware.ts
Comment thread packages/fedify/src/sig/proof.ts Outdated
dahlia added 2 commits April 22, 2026 18:23
The previous hardening commit described the default fallback loader
as "only resolves URLs in the preloaded-contexts set", but the loader
it actually used was `@fedify/vocab-runtime`'s `getDocumentLoader()`,
which happily issues network requests for any non-preloaded URL after
its `validatePublicUrl()` check.  The docstring was therefore wrong
and, worse, the verification-side gate that had been added to close
off the SSRF vector caused a new false-negative regression: verifying
a Fedify 2.2-signed proof against `signed.toJsonLd({ format: "compact" })`
output without passing `options.contextLoader` used to work (the
canonicalization fallback produced the normalized candidate) but now
returned null because normalization was suppressed entirely.

Fix both by giving `normalizePublicAudience()` its own narrow default
loader that resolves only URLs in the preloaded-contexts set and
rejects every other URL without issuing a network request.
Canonicalization errors against this restricted loader fall through
to the existing "return the document unchanged" branch, so
adversarial `@context` URLs cannot be weaponized into outbound HTTP
requests during verification.  For URLs Fedify already preloads
(ActivityStreams, security, data-integrity, multikey, and so on) the
canonicalization path still works, so the round-trip case from
discussion_r3122865191 verifies again even when the caller omits
`contextLoader`.

Remove the now-redundant `options.contextLoader != null` gate in
`verifyProofInternal()`: the helper is already safe by construction,
so the gate was only blocking the normalized candidate from being
tried at all.  Updated the adjacent test and comment accordingly:
the test still exercises a non-preloaded attacker `@context` and
still returns null, but the reasoning shifts from "normalization was
skipped" to "canonicalization rejected the fetch without touching
the network".

Addresses
fedify-dev#710 (comment)
and
fedify-dev#710 (comment).
Two small cleanups on the same function, both from the same
review:

 -  The `{ ...jsonLd } as Record<string, unknown>` spread triggers
    a strict-TypeScript warning because `jsonLd` is still typed as
    `unknown` at that point, even though the enclosing guard has
    already narrowed it to a non-null, non-array object.  Move the
    cast inside the spread so the spread operates on a typed record
    from the start.  Addresses
    fedify-dev#710 (comment).

 -  `proofDigest` is constant across candidate messages, so the
    combined digest buffer can be allocated once outside the loop
    and only the message-digest tail rewritten per iteration.
    SHA-256 always produces 32 bytes, pulled into a named constant.
    The `proof.proofValue.slice()` call stays put with a clarifying
    comment: removing it looks like a pure micro-optimization but
    actually matters for typing, since `proof.proofValue`'s buffer
    is `ArrayBufferLike` and `crypto.subtle.verify()` needs a
    non-shared `ArrayBuffer`, which `.slice()` provides.  Addresses
    fedify-dev#710 (comment).
@dahlia dahlia requested a review from Copilot April 22, 2026 09:30
@dahlia
Copy link
Copy Markdown
Member Author

dahlia commented Apr 22, 2026

@codex review

@dahlia
Copy link
Copy Markdown
Member Author

dahlia commented Apr 22, 2026

/gemini review

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 20 changed files in this pull request and generated no new comments.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 527afb16dd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/fedify/src/federation/middleware.ts
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds a compatibility layer to normalize public audience CURIEs into full URIs in outgoing activities, enhancing interoperability with software like Lemmy. It introduces a normalizePublicAudience utility with context optimizations and SSRF protection, integrated into the federation middleware and Object Integrity Proof signing. The PR also adds a threadiverse community platform tutorial and updates the proper nouns configuration. I have no feedback to provide.

@github-actions
Copy link
Copy Markdown
Contributor

Pre-release has been published for this pull request:

Packages

Package Version JSR npm
@fedify/fedify 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/cli 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/amqp 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/astro 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/cfworkers 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/create 2.2.0-pr.710.22+527afb16 npm
@fedify/debugger 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/denokv 2.2.0-pr.710.22+527afb16 JSR
@fedify/elysia 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/express 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/fastify 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/fixture 2.2.0-pr.710.22+527afb16 JSR
@fedify/fresh 2.2.0-pr.710.22+527afb16 JSR
@fedify/h3 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/hono 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/init 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/koa 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/lint 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/mysql 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/nestjs 2.2.0-pr.710.22+527afb16 npm
@fedify/next 2.2.0-pr.710.22+527afb16 npm
@fedify/nuxt 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/postgres 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/redis 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/relay 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/solidstart 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/sqlite 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/sveltekit 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/testing 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/vocab 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/vocab-runtime 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/vocab-tools 2.2.0-pr.710.22+527afb16 JSR npm
@fedify/webfinger 2.2.0-pr.710.22+527afb16 JSR npm

Documentation

The docs for this pull request have been published:

https://4813e09a.fedify.pages.dev

@dahlia dahlia mentioned this pull request Apr 22, 2026
4 tasks
@issues-auto-labeler issues-auto-labeler Bot added the component/outbox Outbox related label Apr 22, 2026
Fedify CLI 2.1.8 ships with `@fedify/next` 2.1.8, whose peer
dependency on `next` was widened to `>=15.4.6 <17`, so
`fedify init -w next` against the current `create-next-app` now
installs Next.js 16 cleanly without the manual `package.json` edit
the tutorial used to describe.

Remove the WARNING callout and the pinning block, and update the
prereq sentence in [*Installing the `fedify` command*] to require
CLI 2.1.8 or higher (the version that gained Next.js 16 support)
rather than the 2.2.0 it previously named.  Also bump the Next.js
version printed in the sample *npm run dev* console output from
15.5.15 to 16.2.4 so it matches what a reader actually sees.  The
`fedify-dev/threadiverse` example repository has been updated to
the same dependency versions in a paired commit.

[*Installing the `fedify` command*]: https://fedify.dev/tutorial/threadiverse#installing-the-fedify-command

Resolves fedify-dev#713.
@dahlia dahlia added type/documentation Improvements or additions to documentation component/integration Web framework integration examples Example code related integration/next Next.js integration (@fedify/next) and removed component/federation Federation object related component/outbox Outbox related component/signatures OIP or HTTP/LD Signatures related activitypub/interop Interoperability issues activitypub/lemmy Lemmy compatibility labels Apr 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component/integration Web framework integration examples Example code related integration/next Next.js integration (@fedify/next) type/documentation Improvements or additions to documentation

Development

Successfully merging this pull request may close these issues.

Build threadiverse group federation example and tutorial (Next.js + Node.js)

2 participants