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://pr-710.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.

@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: 07ecb7b4-9426-48db-bbe8-d7da1d9129b9

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
@dahlia dahlia added this to the Fedify 2.2 milestone Apr 22, 2026
@dahlia
Copy link
Copy Markdown
Member Author

dahlia commented Apr 22, 2026

@codex 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 layer to improve interoperability with threadiverse software like Lemmy by normalizing the public audience CURIE (as:Public) to its full URI in outgoing activities. This change includes a new utility normalizePublicAudience that uses JSON-LD canonicalization to safely rewrite addressing fields before signing and transmission. Additionally, a new tutorial for building threadiverse community platforms has been added to the documentation, and several proper nouns were added to the project configuration. I have no feedback to provide.

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

This PR adds a new “Threadiverse” tutorial (paired with a Next.js community-style example) and introduces a Fedify interoperability workaround to serialize the public audience as the full ActivityStreams Public URI in outbound JSON-LD, ensuring Lemmy-like implementations accept public activities and that Object Integrity Proof signing matches on-wire bytes.

Changes:

  • Added normalizePublicAudience() helper to expand as:Public / Public to https://www.w3.org/ns/activitystreams#Public (with a URDNA2015 equivalence gate), and applied it in outbound send + OIP signing.
  • Added unit/integration tests to lock in normalization + proof verification round-trip behavior.
  • Added the new “Building a threadiverse community…” tutorial to docs navigation, changelog entries, and supporting tutorial assets (screenshots).

Reviewed changes

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

Show a summary per file
File Description
packages/fedify/src/compat/public-audience.ts New helper that rewrites public audience CURIEs to full URI with canonical-equivalence guard.
packages/fedify/src/compat/public-audience.test.ts Unit tests for rewrite behavior + semantic-bailout case.
packages/fedify/src/sig/proof.ts Normalizes public audience before JCS canonicalization/signing for eddsa-jcs-2022 proofs.
packages/fedify/src/sig/proof.test.ts Adds regression test ensuring sign/normalize/verify proof round-trip works.
packages/fedify/src/federation/middleware.ts Normalizes public audience on outbound JSON-LD before sending/signing.
packages/fedify/src/federation/middleware.test.ts Asserts outbound POST body uses the full Public URI in to.
docs/.vitepress/config.mts Adds tutorial entry to sidebar/nav.
CHANGES.md Documents the interop workaround + new tutorial.
.hongdown.toml Adds new proper nouns used by the tutorial/docs.
docs/tutorial/threadiverse/*.png Adds tutorial screenshots referenced by the new tutorial.

Comment thread packages/fedify/src/compat/public-audience.ts Outdated
Comment thread packages/fedify/src/compat/public-audience.ts
Comment thread packages/fedify/src/compat/public-audience.ts
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: e77a972ce7

ℹ️ 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/sig/proof.ts
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 22, 2026

Codecov Report

❌ Patch coverage is 94.94949% with 10 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
packages/fedify/src/compat/public-audience.ts 94.15% 2 Missing and 7 partials ⚠️
packages/fedify/src/sig/proof.ts 97.61% 1 Missing ⚠️
Files with missing lines Coverage Δ
packages/fedify/src/federation/middleware.ts 95.65% <100.00%> (+<0.01%) ⬆️
packages/fedify/src/sig/proof.ts 75.07% <97.61%> (+4.13%) ⬆️
packages/fedify/src/compat/public-audience.ts 94.15% <94.15%> (ø)
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

dahlia added a commit to dahlia/fedify-fork that referenced this pull request Apr 22, 2026
 -  Iterate over own keys only (Object.keys) instead of for...in in
    hasPublicCurieInAddressing and rewritePublicAudience, so enumerable
    inherited properties on the incoming (potentially adversarial)
    JSON-LD record are not copied into the normalized output.  Addresses
    fedify-dev#710 (comment)
    and
    fedify-dev#710 (comment).

 -  Add a URDNA2015 fast-path for JSON-LD documents whose @context is
    composed entirely of string IRIs.  In that case no inline context
    entry can redefine the `as:` prefix or the bare `Public` term, so
    the rewrite is provably semantics-preserving and the
    canonicalization equivalence check can be skipped.  Only documents
    that embed an inline @context object continue to pay the
    canonicalization cost.  Addresses
    fedify-dev#710 (comment).

 -  Accept either the on-wire form or the normalized form in
    verifyProof().  createProof() signs the bytes produced *after*
    normalization, but signObject()'s return value still serializes
    back to the `as:Public` CURIE form by default, so a caller doing
    an in-memory sign -> reserialize -> verify round-trip would have
    seen a spurious signature mismatch.  verifyProof() now tries the
    input as-is first (preserving verification of signatures produced
    by other implementations that signed the CURIE form) and falls
    back to the normalized form when the original fails, restoring
    the signObject()/verifyProof() API contract.  Addresses
    fedify-dev#710 (comment).

Added regression tests: the string-only-context fast path uses a
throwing contextLoader to prove URDNA2015 is not invoked; a
prototype-pollution test confirms inherited keys are not rewritten;
and the signObject() test now also verifies a proof directly from
`signed.toJsonLd({ format: "compact" })` output (which still contains
`as:Public`), exercising the verifyProof() fallback.

Assisted-By: Claude Code:claude-opus-4-7
@dahlia dahlia requested a review from Copilot April 22, 2026 07:11
@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 1 comment.

Comment thread packages/fedify/src/sig/proof.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 introduces a normalization utility for the public audience URI in ActivityPub activities to enhance interoperability with threadiverse software. The utility is integrated into the federation middleware and signature generation processes. The PR also includes a new tutorial and documentation updates. Feedback focuses on improving the efficiency of the normalization logic by skipping the "@context" property and avoiding unnecessary object cloning.

Comment thread packages/fedify/src/compat/public-audience.ts
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: e4798bab37

ℹ️ 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/compat/public-audience.ts Outdated
dahlia added a commit to dahlia/fedify-fork that referenced this pull request Apr 22, 2026
Three follow-ups on the previous review round, all local to the
public-audience helper:

 -  Skip `@context` in the recursive addressing-field search and
    rewrite.  Addressing fields such as `to`, `cc`, `audience` never
    live inside a term definition, so traversing into the context
    object was wasted work on documents with large inline contexts
    and risked rewriting a context term's value that happened to
    equal the CURIE.  Addresses
    fedify-dev#710 (comment)
    and
    fedify-dev#710 (comment).

 -  Have `rewritePublicAudience()` return the same reference when a
    subtree has no changes, so unchanged arrays and records are no
    longer cloned on every outbound send.  Addresses
    fedify-dev#710 (comment).

 -  Tighten the URDNA2015 fast path: a string `@context` entry was
    previously trusted as semantics-preserving even when the URL
    pointed at a remote context that could in principle redefine
    `as:` or `Public`.  The fast path now requires every `@context`
    entry to be drawn from Fedify's preloaded-contexts set and the
    ActivityStreams URL to be present; other shapes (inline objects,
    unknown external URLs, empty arrays) fall back to the
    canonicalization equivalence check.  Since every preloaded
    context other than ActivityStreams leaves `as:` and `Public`
    alone, combining any subset of them with the ActivityStreams URL
    cannot change public-audience semantics.  Addresses
    fedify-dev#710 (comment).

Tests exercise the tightened fast path (a throwing contextLoader
proves `jsonld.canonize` is not invoked for known-safe contexts),
the slow-path fallback when an unknown URL sneaks in (a mock loader
records calls and we assert at least one), and the `@context`-skip
invariant (a `customTerm: "as:Public"` inside an inline context is
left untouched).

Assisted-By: Claude Code:claude-opus-4-7
dahlia added a commit to dahlia/fedify-fork that referenced this pull request Apr 22, 2026
When `verifyProof()` found a cached verification key whose algorithm
was not Ed25519, it retried `verifyProof()` with a substitute
`keyCache` whose `get()` returned `null`.  Per the `KeyCache`
contract in `packages/fedify/src/sig/key.ts`, `null` means "the key
was fetched previously and found to be unavailable" and is treated
as a cached-negative result; `undefined` means "no entry cached at
all" and forces `fetchKey()` to hit the network.  The retry here
wants the latter, so returning `null` silently prevented the
refetch and made the Ed25519-mismatch recovery path ineffective.

The sibling retry a few lines later (for the proofValue-mismatch
case) already uses `undefined`; this change brings the Ed25519 path
in line so both retries behave consistently.  Addresses
fedify-dev#710 (comment).

Assisted-By: Claude Code:claude-opus-4-7
@dahlia dahlia requested a review from Copilot April 22, 2026 07:35
@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

dahlia added 24 commits April 22, 2026 22:50
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
 -  Iterate over own keys only (Object.keys) instead of for...in in
    hasPublicCurieInAddressing and rewritePublicAudience, so enumerable
    inherited properties on the incoming (potentially adversarial)
    JSON-LD record are not copied into the normalized output.  Addresses
    fedify-dev#710 (comment)
    and
    fedify-dev#710 (comment).

 -  Add a URDNA2015 fast-path for JSON-LD documents whose @context is
    composed entirely of string IRIs.  In that case no inline context
    entry can redefine the `as:` prefix or the bare `Public` term, so
    the rewrite is provably semantics-preserving and the
    canonicalization equivalence check can be skipped.  Only documents
    that embed an inline @context object continue to pay the
    canonicalization cost.  Addresses
    fedify-dev#710 (comment).

 -  Accept either the on-wire form or the normalized form in
    verifyProof().  createProof() signs the bytes produced *after*
    normalization, but signObject()'s return value still serializes
    back to the `as:Public` CURIE form by default, so a caller doing
    an in-memory sign -> reserialize -> verify round-trip would have
    seen a spurious signature mismatch.  verifyProof() now tries the
    input as-is first (preserving verification of signatures produced
    by other implementations that signed the CURIE form) and falls
    back to the normalized form when the original fails, restoring
    the signObject()/verifyProof() API contract.  Addresses
    fedify-dev#710 (comment).

Added regression tests: the string-only-context fast path uses a
throwing contextLoader to prove URDNA2015 is not invoked; a
prototype-pollution test confirms inherited keys are not rewritten;
and the signObject() test now also verifies a proof directly from
`signed.toJsonLd({ format: "compact" })` output (which still contains
`as:Public`), exercising the verifyProof() fallback.

Assisted-By: Claude Code:claude-opus-4-7
Three follow-ups on the previous review round, all local to the
public-audience helper:

 -  Skip `@context` in the recursive addressing-field search and
    rewrite.  Addressing fields such as `to`, `cc`, `audience` never
    live inside a term definition, so traversing into the context
    object was wasted work on documents with large inline contexts
    and risked rewriting a context term's value that happened to
    equal the CURIE.  Addresses
    fedify-dev#710 (comment)
    and
    fedify-dev#710 (comment).

 -  Have `rewritePublicAudience()` return the same reference when a
    subtree has no changes, so unchanged arrays and records are no
    longer cloned on every outbound send.  Addresses
    fedify-dev#710 (comment).

 -  Tighten the URDNA2015 fast path: a string `@context` entry was
    previously trusted as semantics-preserving even when the URL
    pointed at a remote context that could in principle redefine
    `as:` or `Public`.  The fast path now requires every `@context`
    entry to be drawn from Fedify's preloaded-contexts set and the
    ActivityStreams URL to be present; other shapes (inline objects,
    unknown external URLs, empty arrays) fall back to the
    canonicalization equivalence check.  Since every preloaded
    context other than ActivityStreams leaves `as:` and `Public`
    alone, combining any subset of them with the ActivityStreams URL
    cannot change public-audience semantics.  Addresses
    fedify-dev#710 (comment).

Tests exercise the tightened fast path (a throwing contextLoader
proves `jsonld.canonize` is not invoked for known-safe contexts),
the slow-path fallback when an unknown URL sneaks in (a mock loader
records calls and we assert at least one), and the `@context`-skip
invariant (a `customTerm: "as:Public"` inside an inline context is
left untouched).

Assisted-By: Claude Code:claude-opus-4-7
When `verifyProof()` found a cached verification key whose algorithm
was not Ed25519, it retried `verifyProof()` with a substitute
`keyCache` whose `get()` returned `null`.  Per the `KeyCache`
contract in `packages/fedify/src/sig/key.ts`, `null` means "the key
was fetched previously and found to be unavailable" and is treated
as a cached-negative result; `undefined` means "no entry cached at
all" and forces `fetchKey()` to hit the network.  The retry here
wants the latter, so returning `null` silently prevented the
refetch and made the Ed25519-mismatch recovery path ineffective.

The sibling retry a few lines later (for the proofValue-mismatch
case) already uses `undefined`; this change brings the Ed25519 path
in line so both retries behave consistently.  Addresses
fedify-dev#710 (comment).

Assisted-By: Claude Code:claude-opus-4-7
The previous fast path only inspected the top-level @context.  A JSON-LD
document can carry a nested @context inside any subtree to redefine
term mappings in a local scope, which means even a safe top-level
@context (an allowlisted ActivityStreams document) could sit above an
`object` whose inline @context remaps the `as:` prefix to a different
namespace.  Since `rewritePublicAudience()` descends into those
subtrees and would happily rewrite `as:Public` there, the fast path
could silently change the target IRI of a nested addressing field.

Extend `hasKnownSafeContext()` to walk every non-@context child of the
top-level object and require that no descendant carries its own
@context.  Whenever a nested @context is detected the helper defers to
the URDNA2015 canonicalisation check, which catches an actual
semantics mismatch and returns the document unchanged.  Activities
without nested contexts continue to take the fast path.

Regression test feeds an activity whose top-level @context is the
standard ActivityStreams URL but whose embedded `object` inlines an
@context that remaps `as:` to a different namespace, and asserts that
both the top-level and the nested `to` keep their original CURIE
values (i.e., the rewrite bails out rather than corrupt the nested
addressing).  Addresses
fedify-dev#710 (comment).

Assisted-By: Claude Code:claude-opus-4-7
`verifyProof()` documents that it ignores any proof already present on
the input JSON-LD, but `verifyProofInternal()` only deleted the compact
`proof` key before JCS-hashing the message.  A caller handing the
function a JSON-LD document in expanded form, which keys the property
as `https://w3id.org/security#proof` instead, would leave those bytes
in the message digest and fail to verify what would otherwise be a
valid signature.

The sibling `hasProofLike()` already recognises both the compact and
the expanded shapes, so deleting both forms here brings
`verifyProofInternal()` in line with that convention.  Added a
regression test that feeds the FEP-8b32 test vector with a stale
expanded proof stapled under `https://w3id.org/security#proof` and
asserts verification still returns the expected key.  Addresses
fedify-dev#710 (comment).

Assisted-By: Claude Code:claude-opus-4-7
The unknown-URL fallback test used a dynamic `await import()` of a
relative path to `packages/vocab-runtime/src/contexts/activitystreams.json`
to hand a stand-in context back to the mock document loader.  Deno and
Node.js resolve that path just fine at runtime, but the Cloudflare
Workers test harness bundles every test module with esbuild up front
and could not resolve a relative specifier pointing outside the
`packages/fedify` tree, which broke the `test:cfworkers` CI job with
`Build failed with 1 error: Could not resolve
"../../../vocab-runtime/src/contexts/activitystreams.json"`.

Replace the dynamic import with a static reference to
`preloadedContexts[AS_CONTEXT]`, which is re-exported from
`@fedify/vocab-runtime` and already used elsewhere in the fedify
package.  The behaviour of the test is unchanged: the loader still
resolves every URL to the ActivityStreams context so the two
canonical forms match and the rewrite goes through, and
`loaderCalls` still proves the slow path was taken for an
unknown-URL @context.

Assisted-By: Claude Code:claude-opus-4-7
Two security follow-ups on `normalizePublicAudience()`, both now
relevant because `verifyProof()` started calling the helper on
untrusted inbound JSON-LD:

 -  The URDNA2015 equivalence check previously fell back to
    `getDocumentLoader()` whenever the caller did not pass an explicit
    `contextLoader`.  That default loader fetches remote `@context`
    URLs, so an attacker could craft an activity whose top-level
    `@context` points at an arbitrary domain and force outbound HTTP
    requests every time `verifyProof()` ran the normalization helper.
    Require an explicit `contextLoader` for the canonicalization
    fallback instead; documents whose `@context` is not drawn from
    Fedify's preloaded-contexts set are now returned unchanged when no
    loader was supplied.  Addresses
    fedify-dev#710 (comment).

 -  The recursive walkers (`hasPublicCurieInAddressing`,
    `rewritePublicAudience`, and the new `hasNestedContext` helper)
    had no depth limit, so a sufficiently deep JSON-LD payload could
    have exhausted the V8 call stack.  Cap the walk at a
    MAX_TRAVERSAL_DEPTH of 64 levels (effectively unlimited for
    legitimate ActivityPub activities, which are typically two or
    three deep) and return the conservative answer at the guard:
    `false` from the has-CURIE walker (skip the rewrite), the subtree
    itself from the rewriter (leave it alone), and `true` from
    `hasNestedContext` (force the canonicalization path, which with
    the first fix above also skips the rewrite when untrusted).
    Addresses
    fedify-dev#710 (comment).

Updated tests cover: the no-loader path leaves a non-standard @context
unchanged; a deeply nested 256-level document returns without
overflowing the stack; and the vocab round-trip now passes an explicit
`getDocumentLoader()` because Fedify's default compact form embeds an
inline namespace-prefix object and takes the canonicalization path.
`verifyProofInternal()` dropped the proof key before JCS-hashing by
shallow-copying the input via `{ ...jsonLd }`.  If the caller handed
the function an array instead of an object, that spread produced an
object with stringified numeric keys (`{ "0": elem, "1": elem, ... }`),
which JCS would then canonicalize into a shape that never matches what
the signer actually hashed.  An array is also not a valid top-level
FEP-8b32 signed document: proofs live on single objects.

Widen the up-front validation to reject arrays (as well as the
already-rejected non-objects and `null`) and return `null` so the
caller treats the input as unsigned.  A regression test hands
`verifyProof()` an array wrapping the same document the next-line test
verifies successfully and asserts it gets `null`.  Addresses
fedify-dev#710 (comment).
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.
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).
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 force-pushed the docs/tutorial/threadiverse branch from 5bb1419 to 7ce4f97 Compare April 22, 2026 13:51
@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.24+7ce4f97e JSR npm
@fedify/cli 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/amqp 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/astro 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/cfworkers 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/create 2.2.0-pr.710.24+7ce4f97e npm
@fedify/debugger 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/denokv 2.2.0-pr.710.24+7ce4f97e JSR
@fedify/elysia 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/express 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/fastify 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/fixture 2.2.0-pr.710.24+7ce4f97e JSR
@fedify/fresh 2.2.0-pr.710.24+7ce4f97e JSR
@fedify/h3 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/hono 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/init 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/koa 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/lint 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/mysql 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/nestjs 2.2.0-pr.710.24+7ce4f97e npm
@fedify/next 2.2.0-pr.710.24+7ce4f97e npm
@fedify/nuxt 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/postgres 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/redis 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/relay 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/solidstart 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/sqlite 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/sveltekit 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/testing 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/vocab 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/vocab-runtime 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/vocab-tools 2.2.0-pr.710.24+7ce4f97e JSR npm
@fedify/webfinger 2.2.0-pr.710.24+7ce4f97e JSR npm

Documentation

The docs for this pull request have been published:

https://0f3f2447.fedify.pages.dev

@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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

activitypub/interop Interoperability issues activitypub/lemmy Lemmy compatibility component/federation Federation object related component/integration Web framework integration component/signatures OIP or HTTP/LD Signatures related 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