Threadiverse tutorial and public audience interop fix#710
Threadiverse tutorial and public audience interop fix#710dahlia wants to merge 35 commits intofedify-dev:mainfrom
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
@codex review |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 expandas:Public/Publictohttps://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. |
There was a problem hiding this comment.
💡 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".
Codecov Report❌ Patch coverage is
🚀 New features to boost your workflow:
|
- 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
|
@codex review |
|
/gemini review |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 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".
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
|
@codex review |
|
/gemini review |
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.
5bb1419 to
7ce4f97
Compare
|
Pre-release has been published for this pull request: Packages
DocumentationThe docs for this pull request have been published: |
Closes #704.
Read it at https://pr-710.fedify.pages.dev/tutorial/threadiverse.
Summary
Groupactors,Pagethreads,Notereplies, and the community-sideAnnounceredistribution 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.@fedify/fedify: activities now serialize the public audience as the fullhttps://www.w3.org/ns/activitystreams#PublicURI into,cc,bto,bcc, andaudiencefields, instead of theas:Public/PublicCURIE 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 rejectsAnnounce(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.canonizeand their N-Quads are compared); if an application-defined@contextredefines theas:prefix or the barePublicterm, the rewrite would change semantics and is skipped. The normalization is also applied insidecreateProofbefore theeddsa-jcs-2022Object 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, sinceverifyProofJCS-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
@fedify/fedifyfor the interop workaround, one in the Documentation section citing Build threadiverse group federation example and tutorial (Next.js + Node.js) #704.@contextguard, the prototype-pollution guard, the known-safe fast path, and the depth-limit guard.createProof, plus a fullsignObjectto normalize toverifyProofround-trip test pinning down the ordering invariant.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-abbrexpandsCURIE/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 undersignObject()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 postedtofield is the full URI.@hongminhee@lemmy.mlwas subscribed to, verified theAnnounce(Create(Page))was accepted and the post rendered on Lemmy with working reply/vote buttons.