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
Add docs/tutorial/threadiverse.md with the Introduction, Target audience, Goals, and Setting up the development environment sections, and register it in the tutorial nav. This first chapter walks the reader through `fedify init -w next -p npm -k in-memory -m in-process`, the Next.js 15.5.x pin workaround, and verifying the dev server and federation middleware with `fedify lookup`. Subsequent commits will add chapters in step with commits in the paired fedify-dev/threadiverse example repository. Assisted-By: Claude Code:claude-opus-4-7
Add the *Swapping ESLint for Biome* subsection to the Setup chapter. It walks the reader through deleting the two ESLint configs that `fedify init` leaves behind for Next.js, turning on Biome's linter, rewriting the `lint` and `format` scripts to call Biome, and cleaning up the unused `getLogger` call that `noUnusedVariables` flags in *federation/index.ts*. Matches commit 839def1 in fedify-dev/threadiverse. Assisted-By: Claude Code:claude-opus-4-7
Add the *Layout and navigation* chapter. It walks the reader through replacing `create-next-app`'s demo landing page with a minimal root layout (top nav with brand + Home/New community links), a one-paragraph home page, and an ~80-line plain stylesheet for typography, forms, cards, buttons, and the nested reply tree. The reader copies the stylesheet once and never touches CSS again, which keeps later chapters focused on federation rather than presentation. Matches commit 4d31dab in fedify-dev/threadiverse. Assisted-By: Claude Code:claude-opus-4-7
Add the opening of the *User accounts* chapter. It introduces Drizzle ORM and SQLite, walks through installing `drizzle-orm`, `better-sqlite3`, and the dev-time `drizzle-kit` + `@types/better-sqlite3`, and declares the first table (`users`) in *db/schema.ts* with `id`, `username`, `password_hash`, and `created_at` columns. Also covers opening the database with WAL + foreign-key pragmas, wiring up *drizzle.config.ts*, adding `db:push` and `db:studio` scripts, and gitignoring the SQLite files. Matches commit d3aa008 in fedify-dev/threadiverse. Assisted-By: Claude Code:claude-opus-4-7
Add the *Signup form and password hashing* subsection. It introduces Node's scrypt as the password hashing primitive, shows how to build a Next.js App Router *server action* (a server function called directly from an HTML form via the `action` attribute, with no client fetch or API route), and walks the reader through the full validation chain: client-side HTML pattern, server-side regex re-validation, Drizzle duplicate-username lookup, scrypt hash, and a redirect back to the login page with a success message. Includes a screenshot of the rendered form for visual confirmation. Matches commit b170aa5 in fedify-dev/threadiverse. Assisted-By: Claude Code:claude-opus-4-7
Add the *Login and sessions* subsection. It introduces the server-side `sessions` table (indexed by an opaque random token, with `onDelete: "cascade"` on the user reference), walks through writing `createSession`, `getCurrentUser`, and `destroySession` helpers in *lib/session.ts*, and discusses each cookie flag (httpOnly, sameSite, secure in production only, 30-day maxAge). It then builds the login page + login/logout server actions and turns the root layout into an async server component so every page can branch on whether a user is signed in, showing `@username / Log out` or `Log in / Sign up` in the nav accordingly. Includes a screenshot of the logged-in home page. Matches commit b5e8ba7 in fedify-dev/threadiverse. Assisted-By: Claude Code:claude-opus-4-7
Add the *Profile page* subsection. It introduces Next.js App Router dynamic segments (`[username]`), server-side `params` as a Promise, and `notFound()` for 404 handling, then builds a simple profile page that renders the user's handle, join date, and a placeholder for future threads. Also updates the nav-bar `@username` label into a link pointing at that page. Ends with a teaser: the same URL currently serves HTML to browsers and a placeholder `Person` JSON to ActivityPub clients, and the next commit will swap the placeholder for a dispatcher backed by the `users` table. Matches commit a0ed553 in fedify-dev/threadiverse. Assisted-By: Claude Code:claude-opus-4-7
Add the *Federating your user: the Person actor* chapter. It walks
the reader through:
- The `keys` table, keyed by actor identifier so the same table
serves both user and community keys when we add `Group` actors.
- A real `setActorDispatcher` that looks the identifier up in
`users` and returns a `Person` with `inbox`, shared-inbox
endpoints, `publicKey`, and `assertionMethods` populated from
`ctx.getActorKeyPairs`.
- `setKeyPairsDispatcher` that lazily generates and persists both
RSA and Ed25519 key pairs as JWK JSON.
- A minimal `setInboxListeners` call (URL templates only; handlers
come later) so `ctx.getInboxUri` resolves.
- Local verification with `fedify lookup` and a direct WebFinger
`curl`.
- Running `fedify tunnel`, why `x-forwarded-fetch` is needed when
the app is behind a tunnel, and how to rewrite *middleware.ts*
with `getXForwardedRequest`.
- A screenshot of ActivityPub.Academy finding the local actor via
WebFinger through the tunnel URL.
Matches commits ad9ae55 ("Actor dispatcher and key pairs") and
4d462c1 ("Use x-forwarded-fetch in middleware") in
fedify-dev/threadiverse.
Assisted-By: Claude Code:claude-opus-4-7
Add the *Communities as Group actors* chapter. It covers four
example-repo commits' worth of content in one coherent narrative:
- Declaring the `communities` table and lifting the identifier
uniqueness check into a shared `lib/identifiers.ts` so signup and
community creation both go through `isIdentifierTaken`.
- The community creation UI and the server action that inserts a
new row under the logged-in user.
- Teaching the existing profile page to fall through from `users`
to `communities`, with screenshots of the filled form and the
rendered community page.
- Extending the actor dispatcher (and key pairs dispatcher) so the
same `/users/{identifier}` URL returns a `Group` for community
slugs and a `Person` for usernames, plus a screenshot of
ActivityPub.Academy resolving `@<slug>@<host>` via WebFinger.
Matches commits 8200730 ("communities table"), 46c628b ("Community
creation form"), 32e75bf ("Community page"), and 6de227a ("Group
actor dispatcher") in fedify-dev/threadiverse.
Assisted-By: Claude Code:claude-opus-4-7
Add the *Subscribing to communities* chapter. It walks through:
- Declaring a polymorphic `follows` table that carries the
follower's inbox and optional shared inbox inline, plus a
unique `(follower_uri, followed_uri)` index.
- Wiring `followers` into the `Group` actor and implementing
`setFollowersDispatcher` so remote threadiverse servers can
paginate subscribers.
- Inbound `Follow` handling: validate, fetch the follower actor,
upsert an accepted row, and ship `Accept(Follow)` back.
- Inbound `Accept(Follow)` handling: unwrap the enclosed Follow
and flip the matching outbound row to accepted.
- Outbound follow UI: `/follow` form, `followCommunity` server
action using `ctx.lookupObject`, and a `currentOrigin` helper
that builds the correct origin from `x-forwarded-host`.
- Verification with a screenshot of ActivityPub.Academy listing
the local community with an "Unfollow" button and a follower
count of 1 after the full round trip.
Matches commits 8a8cb7d ("follows table and followers dispatcher")
and f4162ad ("Follow and Accept(Follow)") in
fedify-dev/threadiverse.
Assisted-By: Claude Code:claude-opus-4-7
Add the *Threads* chapter covering five example-repo commits:
- `threads` table keyed by ActivityPub URI with a UNIQUE constraint
so `onConflictDoNothing()` on inserts is idempotent.
- Per-community creation form scoped via `createThread.bind(null,
slug)`, plus community-page thread list + *Start a thread* CTA.
- Server action that inserts the thread row optimistically, then
federates a `Create(Page)` with the threadiverse-standard
audience addressing (`audience: community`, `to: community`,
`cc: [PUBLIC, community/followers]`).
- Community inbox `Create` handler: stores the thread idempotently,
then `sendActivity(..., "followers", Announce(Create))` with
`preferSharedInbox` so local community posts fan out to
every subscriber.
- Follower-side `Announce` handler that `routeActivity`s the
enclosed Create back through the same Create handler, verifying
the enclosed activity's origin in the process.
A TIP at the end explains that Mastodon (Academy) doesn't render
`Page` objects in its timeline even though Fedify's Announce lands;
Lemmy/Mbin/NodeBB, the intended consumers, do.
Matches commits e1feb72 ("threads table"), 191379e ("Thread
creation form"), 415bdce ("Publish Create(Page) to the community"),
1d623cb ("Community inbox Create handler + Announce"), and 9b5f149
("Announce handler routes to Create") in fedify-dev/threadiverse.
Assisted-By: Claude Code:claude-opus-4-7
Add the *Replies* chapter walking through three example-repo commits:
- Thread detail page at `/users/<slug>/threads/<id>` that
re-derives the canonical thread URI from `currentOrigin()` and
the route params.
- `replies` table parallel to `threads` (URI-keyed, carries
`thread_uri`, `parent_uri`, and `community_uri`) plus the
reply tree UI (recursive `<ul class="reply-tree">` with
per-node inline reply forms wrapped in `<details>`).
- Extension of the community inbox `Create` handler for `Note`
objects: resolve the parent via both `threads` and `replies`,
derive the top-level `thread_uri`, insert with
`onConflictDoNothing`, then Announce to followers through the
same local-community branch that handles threads.
Matches commits 8c46ff9 ("Thread page"), f7a5b9d ("replies table +
thread-page reply UI"), and a93ed2b ("Community Announce of
Create(Note)") in fedify-dev/threadiverse.
Assisted-By: Claude Code:claude-opus-4-7
Add the *Votes* chapter pairing with the two vote example-repo
commits: Like/Dislike UI plus outbound action, and the shared
`handleVote` inbox handler that upserts the vote row and
re-Announces to the community's followers.
The chapter walks through the `(voter_uri, target_uri)` unique
index and why an upsert on that pair replaces the previous vote
instead of stacking rows, the `Map<targetUri, VoteTally>` approach
that bundles all the tallies for a thread page into one `inArray`
query, and the common `VoteClass = kind === "Like" ? Like : Dislike`
construction pattern for activity types with identical field shape.
Ends with a TIP showing the single SQL expression
(`SUM(CASE kind WHEN 'Like' THEN 1 ELSE -1 END)`) that threadiverse
platforms use for ranking, and the rationale for keeping per-vote
rows instead of pre-aggregating counters.
Matches commits 7136e54 ("votes table + Like/Dislike UI") and
aa00671 ("Community Announce of Like / Dislike") in
fedify-dev/threadiverse.
Assisted-By: Claude Code:claude-opus-4-7
Close the local-user loop with the two remaining user-facing
features.
- *app/page.tsx* becomes the subscribed feed: anonymous visitors
see the welcome blurb, logged-in users see every accepted
subscription listed above a chronological thread list pulled
via `inArray(threads.communityUri, subscribedUris)`.
- Unsubscribing is Ch. 14 played backwards. The outbound
`unfollowCommunity` action wraps the original Follow's actor +
object in an `Undo(Follow)`, sends it to the followee's inbox,
and deletes the follow row. The community inbox handler flips
on `Undo`, unwraps the enclosed Follow, verifies
`undo.actorId === follow.actorId` so nobody can Undo someone
else's subscription, and deletes the corresponding row.
Also teach *.hongdown.toml* to preserve the product and tool names
that show up in this tutorial (Biome, Drizzle, Drizzle Kit, Drizzle
ORM, Next.js, Node.js, SQLite, TypeScript, Mbin, Piefed, NodeBB,
cloudflared, ngrok, Serveo, Cloudflare Tunnel, x-forwarded-fetch,
create-next-app, scrypt, JSX) and fix the subsection headings that
hongdown previously lower-cased for ActivityPub activity type
names; those (`Follow`, `Accept(Follow)`, `Create(Page)`,
`Create(Note)`, `Like`, `Dislike`, `Announce`, `Undo`, `Group`)
are now wrapped in backticks so hongdown leaves them alone.
Matches commits 717cad6 ("Subscribed front page") and f83fecd
("Unsubscribe with Undo(Follow)") in fedify-dev/threadiverse.
Assisted-By: Claude Code:claude-opus-4-7
Close out *docs/tutorial/threadiverse.md* with the standard two
sections the microblog tutorial uses, adapted for threadiverse
scope:
- *Areas to improve* enumerates extensions the reader can
reasonably add next: link threads, Update/Delete/Tombstone,
local communities index, ranking via the vote-sum expression
already computable from the `votes` table, persistent KV/MQ
via `@fedify/postgres` or `@fedify/sqlite`, PostgreSQL for
application data, and Lemmy-specific Group actor fields
(`attributedTo`, `moderators`, `featured`) for full Lemmy
interop.
- *Next steps* links into *docs/manual/deploy.md* (specifically
its canonical-origin, reverse-proxy, persistent-KV/MQ,
actor-key-lifecycle, and running-the-process sections), the
`@fedify/next` README for deployment caveats, and the Fedify
community channels.
Also add a *Documentation* entry to the 2.2.0 changelog section
summarising the new tutorial and cross-linking the example repo.
Matches commits 0ed0741 ("Advertise outbox URL on actors") and
2cad236 ("Front matter") in fedify-dev/threadiverse.
Assisted-By: Claude Code:claude-opus-4-7
Add a new chapter between *Unsubscribing with `Undo(Follow)`* and
*Areas to improve* that covers the three field additions needed for
Lemmy to accept the community as a first-class community: a
`moderators` collection (registered through `setCollectionDispatcher`
+ returning the community creator as the sole `Person`),
a `featured` URL (via `setFeaturedDispatcher` + an empty
`OrderedCollection`), and an `attributedTo` pointing at the
moderators collection URL (Lemmy's convention for locating mods).
A NOTE callout explains Lemmy's exponential-backoff federation
retries so the reader isn't surprised by a multi-minute Subscribe
Pending state, and a TIP covers the remaining Lemmy-specific
`postingRestrictedToMods` boolean that Fedify's `Group` vocab
doesn't expose directly but that Lemmy tolerates being absent.
The entry in *Areas to improve* that previously pointed at these
fields is removed, since they're now covered in-chapter.
Matches commit df0e2a5 ("Lemmy-compatible Group actor fields") in
fedify-dev/threadiverse.
Assisted-By: Claude Code:claude-opus-4-7
Extend the *Making the community actor Lemmy-compatible* chapter with the two more fixes needed for Lemmy's actual `Subscribe` button to reach the *Joined* state, not just *Subscribe Pending*: - Bundle Lemmy's JSON-LD context (`https://join-lemmy.org/context.json`) locally and register a `documentLoaderFactory` / `contextLoaderFactory` pair that short-circuits the URL to the bundled copy. Without this, Lemmy's activities fail to parse because Lemmy serves the context as `application/json` without a JSON-LD `Link` header, and Fedify's default loader rejects those responses. - Rewrite the `Accept(Follow)` we ship to satisfy Lemmy's strict `PersonInboxActivities` enum: a UUID-based path for the `id` instead of a URL-encoded fragment, and a brand-new minimal `Follow` (`id` + `actor` + `object`) as the nested `object` instead of echoing the entire incoming Follow. Ends with a verification SQL snippet, a screenshot of Lemmy showing "Joined" after the round-trip, and a WARNING callout about a known `Announce(Create(Page))` digest-verification failure specific to Cloudflare quick tunnels re-framing the HTTP/2 body in transit — `Follow` delivery works through that tunnel but the larger Announce body can trip Lemmy's SHA-256 digest check; named Cloudflare tunnels, `serveo`, `ngrok`, and normal reverse proxies don't hit this. Matches commit 4c01faa ("Preload Lemmy's JSON-LD context and use minimal Accept(Follow)") in fedify-dev/threadiverse. Assisted-By: Claude Code:claude-opus-4-7
Revise the WARNING callout in *Making the community actor Lemmy-
compatible* to reflect what actually works against lemmy.ml today:
- Inbound `Follow`, outbound `Follow`, `Accept(Follow)`, and
`Undo(Follow)` all round-trip cleanly over both ngrok and
named Cloudflare tunnels in both directions. The earlier
wording blamed Cloudflare quick tunnels for the digest
mismatch, which turned out to be a red herring once we had
proper logging.
- The remaining blocker is Lemmy returning
`{"error":"object_is_not_public"}` on our community's fan-out
`Announce(Create(Page))`. It's a nested-object audience shape
issue, not a tunnel issue — Mastodon, Mbin, and Peertube accept
the same activity — and the fix belongs in the example repo's
open items rather than in the tutorial.
Also refresh *docs/tutorial/threadiverse/lemmy-subscribed.png* to
the ngrok-hosted community screenshot, which is cleaner and matches
the reader's expected host format.
Assisted-By: Claude Code:claude-opus-4-7
JSON-LD compaction rewrites `https://www.w3.org/ns/activitystreams#Public` to the CURIE `as:Public` (or, once `@vocab` applies, the bare term `Public`) whenever the activity's `@context` defines the `as` prefix. Strictly speaking the compact form is equivalent, but several threadiverse implementations — Lemmy among them, see [LemmyNet/lemmy#6465] — match the `to`, `cc`, `bto`, `bcc`, and `audience` fields against the full URI by string equality without running JSON-LD expansion. Outgoing `Announce(Create(Page))` from a community with Lemmy subscribers therefore gets silently rejected with `{"error":"object_is_not_public"}`. Add a post-compaction normalization pass in `FederationImpl.sendActivity()` that walks the serialized JSON-LD and replaces `as:Public` / `Public` occurrences inside those five addressing fields with the expanded URI. Other fields (and the full URI elsewhere, e.g. inside tags) are left untouched. The extra pass only runs on the direct-send path; the fan-out queue path re-deserializes through `Activity.fromJsonLd()` before reaching the same code, so it benefits from the same normalization without double-processing. Also add a regression test that sends an activity addressed to `vocab.PUBLIC_COLLECTION`, captures the posted body, and asserts the `to` field is the full URI rather than a CURIE. [LemmyNet/lemmy#6465]: LemmyNet/lemmy#6465 Assisted-By: Claude Code:claude-opus-4-7
Replace the WARNING callout in *Making the community actor Lemmy- compatible* that previously labelled outbound `Announce(Create(Page))` as blocked by Lemmy's `object_is_not_public` check. Now that Fedify 2.2 serializes the public audience as the full `https://www.w3.org/ns/activitystreams#Public` URI (and Lemmy's upstream fix in [LemmyNet/lemmy#6466] is on the way), the community's fan-out lands in subscribers' feeds on Lemmy, and replies and votes ride the same path back. Rewritten as a NOTE with the historical context preserved so readers encountering older Fedify or Lemmy versions can still diagnose the original symptom. Also bump the `fedify --version` prerequisite from 2.1.0 to 2.2.0, since that's the release that ships the sender-side workaround this tutorial now relies on for the Lemmy walkthrough. [LemmyNet/lemmy#6466]: LemmyNet/lemmy#6466 Assisted-By: Claude Code:claude-opus-4-7
Two follow-ups on the workaround added in the previous commit, both
prompted by a closer read of how the normalization interacts with
application-defined JSON-LD contexts and with Object Integrity Proof
signing:
- The unconditional CURIE rewrite was unsafe in the presence of a
custom `@context` that redefines the `as:` prefix or the bare
`Public` term. Add a URDNA2015 canonicalization pass on both
the original and the rewritten document and only emit the
rewritten form when the two produce identical N-Quads. When
the canonical forms diverge, or when canonicalization itself
errors out, the original document is returned unchanged so the
application's semantics are preserved.
- The Ed25519 `eddsa-jcs-2022` Object Integrity Proof path in
`createProof` canonicalizes the compact JSON-LD byte-for-byte
with JCS, not URDNA2015. Applying the CURIE rewrite only after
signing would therefore have invalidated the proof for every
receiver, since `verifyProof` JCS-hashes the on-wire form. Move
the normalization into `createProof` itself, before the JCS pass,
so the proof is signed over the same bytes we emit.
Extract the helper into a dedicated *packages/fedify/src/compat/public-
audience.ts* module so both `FederationImpl.sendActivity` and
`createProof` can share it without `sig/` importing from `federation/`.
Add unit tests covering the CURIE rewrite, the no-op path, the
non-addressing fields, the semantic-equivalence bailout for a
redefined `as:` context, and a full `signObject`/`verifyProof`
round-trip with `tos: [PUBLIC_COLLECTION]` to pin down the
before-JCS ordering.
Assisted-By: Claude Code:claude-opus-4-7
Polish pass on the threadiverse tutorial based on review feedback:
- Tighten intra-sentence em dashes to have no surrounding
whitespace; keep the spaced form only when separating a list-item
label from its body. Many of these read better as comma,
semicolon, or parentheses and were converted that way.
- Switch bold emphasis in list-item labels and step titles to
italics, matching the project's house style.
- Use the full names *Drizzle ORM* (never bare *Drizzle*) and
*Linked Data Signatures* (never *LD Signatures*); drop the now
unused *Drizzle* entry from the hongdown proper-nouns list.
- Add markdown-it-abbr definitions for *CURIE* and *FEP* so the
acronyms get tooltip expansions alongside the existing *JSX*,
*TSX*, and *ORM* ones.
- Link every FEP-xxxx reference to its canonical `w3id.org/fep/…`
URL.
- Spell HTTP status codes as `<code> <name>` (`404 Not Found`
instead of a bare 404), and keep header names in backticks
wherever they appear.
- Replace every *Ch. N* cross-reference with an italicised
chapter title linked to its VitePress anchor, so readers
actually have somewhere to click; chapter numbers don't show up
in the rendered TOC.
- Rework the highlight ranges on every `typescript{…}` / `tsx{…}`
code fence so the highlighted lines match what the surrounding
prose calls attention to. Several were silently off-by-one or
outright referenced lines that existed in an earlier draft of
the listing.
Assisted-By: Claude Code:claude-opus-4-7
|
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 |
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.
|
@codex review |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a compatibility utility to normalize public audience CURIEs (such as as:Public) to full URIs in outgoing ActivityPub activities, which improves interoperability with platforms like Lemmy. This normalization is integrated into the federation middleware and signature creation/verification processes to ensure that Object Integrity Proofs remain valid. The PR also adds a new tutorial titled "Building a threadiverse community platform" and updates the documentation configuration. Review feedback identifies an opportunity to improve TypeScript strictness by casting unknown types before spreading and suggests optimizing the proof verification loop by moving allocations outside the loop and removing redundant array slicing.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b1639cd9a6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
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).
|
@codex review |
|
/gemini review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 527afb16dd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Code Review
This pull request adds a compatibility layer to normalize public audience CURIEs into full URIs in outgoing activities, enhancing interoperability with software like Lemmy. It introduces a normalizePublicAudience utility with context optimizations and SSRF protection, integrated into the federation middleware and Object Integrity Proof signing. The PR also adds a threadiverse community platform tutorial and updates the proper nouns configuration. I have no feedback to provide.
|
Pre-release has been published for this pull request: Packages
DocumentationThe docs for this pull request have been published: |
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.
Closes #704.
Read it at https://4813e09a.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.