VYPR
High severity7.0GHSA Advisory· Published May 26, 2026· Updated May 26, 2026

Fedify has an LD-Signature Bypass via JSON-LD Named-Graph Restructuring

CVE-2026-42462

Description

Summary

An attacker can make use of JSON-LD features to restructure a JSON-LD document that would change how Fedify interprets it without changing its Linked Data Signature, allowing them to alter a third-party signed activity they have received.

Details

The vulnerability essentially boils down to the signature being on the canonical RDF graph representation of the JSON-LD document, and JSON-LD offering many ways to represent the same graph.

One of the issues is that by taking a signed Activity with an embedded object, an attacker can move the top-level Activity to a @graph property and move the activity's object to the top-level. Such a transformation preserves the signature and changes how the payload is interpreted by pretty much all ActivityPub implementations, making them process the object and ignore the formely-top-level activity. This can be used when the graph contains an embedded activity. In Mastodon, that is the case of { "type": "Undo", "object": { "type": "Announce" } }, but other implementations may sign other activities that can be exploited in the same way.

The @reverse keyword can also be used to change the shape of a JSON-LD document without changing the underlying graph, and could be used in a similar way to reverse an Activity and its object.

Another problematic feature is @included, which can be used to “move” properties outside of the normal tree, effectively making them invisible to most ActivityPub implementations, while, again, preserving the signature. This allows removing statuses or actor properties once a signed Create or Update activity is received.

Given that Fedify have seen no use of @graph, @included or @reverse in ActivityPub payloads and that they are very complex to handle correctly (the only JSON-LD API functions that “normalize” @included and @reverse are flattening and framing, which both lose the root node), a decision to reject them has been made, and it is recommended for users do so as well.

Detection of @graph, @included and @reverse should happen after compacting the incoming activity to an application's context, as aliases can be used for those keywords.

Additionally, after a quick scan of Fedify's source code, I could not verify that JSON-LD documents with a verified Linked Data Signature were compacted against an application's local JSON-LD context. Not doing that allows an attacker to rename aliases to non-standard names and use non-mapped aliases to replace existing values, while still leaving the signature intact. This allows an attacker to essentially replace arbitrary portions of any signed JSON-LD document and completely forge any activity while still passing verification. A similar issue was fixed in Mastodon a few years ago: https://github.com/mastodon/mastodon/pull/17426.

Impact

The impact is difficult to assess as this depends on the types of activities that are actually signed and processed in the wild.

The @included keyword allows “removing” arbitrary attributes, thus allowing replaying Create and Update activities while stripping away any attribute, such as content or metadata, which can lead to integrity and availability issues, although confidentiality issues are unlikely.

The @graph and @reverse keywords allow changing the root activity, which in the case of Mastodon allows sending an Announce from a Undo { Announce }, but might have wider consequences depending on what various servers sign.

The lack of compacting can allow rewriting any activity arbitrarily, thus leading to major integrity, availability, and possibly confidentiality issues (e.g. by replacing an actor's inbox).

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

JSON-LD features (@graph, @included, @reverse) let an attacker restructure a signed activity without invalidating its Linked Data Signature, bypassing Fedify's signature verification.

Vulnerability

Fedify versions prior to 2.2.3 are vulnerable to a signature bypass via JSON-LD named-graph restructuring. The Linked Data Signature is computed on the canonical RDF graph representation of a JSON-LD document. Because JSON-LD offers multiple ways (e.g., @graph, @included, and @reverse) to represent the same RDF graph, an attacker can re-shape a signed Activity payload without changing its signature, causing Fedify to interpret the activity differently than intended and thus bypassing the signature check [1], [2].

Exploitation

An attacker who has received a validly-signed third-party activity can craft a new JSON-LD document that is semantically equivalent to the signed original (i.e., the same canonical RDF graph) but structurally different. For example, they can take a signed Activity with an embedded object, move the top-level activity into a @graph property, and move the object to the top level. Such a transformation preserves the signature but changes how all ActivityPub implementations (including Fedify) interpret the payload: they process the object while ignoring the formerly top-level activity. Similarly, @reverse can reverse an Activity and its object, and @included can move properties outside of the normal tree, making them invisible while the signature remains valid [2], [3]. The attacker requires no additional privileges beyond possession of a signed activity to re-structure; no user interaction is needed.

Impact

Successful exploitation allows an attacker to alter the interpretation of a third-party-signed activity without invalidating its signature. For instance, when the original graph contains an embedded Undo activity (e.g., {"type": "Undo", "object": {"type": "Announce"}}), the attacker can make the recipient process the inner Announce object (as if it were the root activity) while ignoring the Undo wrapper. This can lead to unauthorized state changes, such as removing statuses or actor properties upon receiving a signed Create or Update activity [2], [3]. The compromise affects the integrity and availability of ActivityPub interactions that rely on Fedify's linked data signature verification.

Mitigation

The vulnerability is fixed in Fedify version 2.2.3, released on or before May 6, 2026 [1]. The fix rejects incoming JSON-LD payloads that use @graph, @included, or @reverse, as these features are not seen in normal ActivityPub traffic and are complex to handle correctly. Users should upgrade to 2.2.3 or later. No known workaround exists for versions prior to the patched release. The issue is not currently listed on the CISA KEV [1], [2].

AI Insight generated on May 26, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

2
f6c1e55cecc1

CVE-2026-42462: Harden JSON-LD signature handling

https://github.com/fedify-dev/fedifyHong MinheeApr 23, 2026Fixed in 2.2.3via github-commit-search
12 files changed · +6365 250
  • CHANGES.md+8 0 modified
    @@ -8,6 +8,14 @@ Version 1.9.11
     
     To be released.
     
    +### @fedify/fedify
    +
    + -  Fixed a security vulnerability in Linked Data Signature verification that
    +    could allow certain signed activities to be interpreted differently than
    +    intended.  [[CVE-2026-42462]]
    +
    +[CVE-2026-42462]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-9rfg-v8g9-9367
    +
     
     Version 1.9.10
     --------------
    
  • packages/fedify/src/federation/context.ts+12 6 modified
    @@ -585,9 +585,12 @@ export interface InboxContext<TContextData> extends Context<TContextData> {
        * Forwards a received activity to the recipients' inboxes.  The forwarded
        * activity will be signed in HTTP Signatures by the forwarder, but its
        * payload will not be modified, i.e., Linked Data Signatures and Object
    -   * Integrity Proofs will not be added.  Therefore, if the activity is not
    -   * signed (i.e., it has neither Linked Data Signatures nor Object Integrity
    -   * Proofs), the recipient probably will not trust the activity.
    +   * Integrity Proofs will not be added.  Even when Fedify internally
    +   * normalizes a Linked Data Signature activity for parsing, this method still
    +   * forwards the original received payload so the sender's signatures/proofs
    +   * are preserved as-is.  Therefore, if the activity is not signed (i.e., it
    +   * has neither Linked Data Signatures nor Object Integrity Proofs), the
    +   * recipient probably will not trust the activity.
        * @param forwarder The forwarder's identifier or the forwarder's username
        *                  or the forwarder's key pair(s).
        * @param recipients The recipients of the activity.
    @@ -609,9 +612,12 @@ export interface InboxContext<TContextData> extends Context<TContextData> {
        * Forwards a received activity to the recipients' inboxes.  The forwarded
        * activity will be signed in HTTP Signatures by the forwarder, but its
        * payload will not be modified, i.e., Linked Data Signatures and Object
    -   * Integrity Proofs will not be added.  Therefore, if the activity is not
    -   * signed (i.e., it has neither Linked Data Signatures nor Object Integrity
    -   * Proofs), the recipient probably will not trust the activity.
    +   * Integrity Proofs will not be added.  Even when Fedify internally
    +   * normalizes a Linked Data Signature activity for parsing, this method still
    +   * forwards the original received payload so the sender's signatures/proofs
    +   * are preserved as-is.  Therefore, if the activity is not signed (i.e., it
    +   * has neither Linked Data Signatures nor Object Integrity Proofs), the
    +   * recipient probably will not trust the activity.
        * @param forwarder The forwarder's identifier or the forwarder's username.
        * @param recipients In this case, it must be `"followers"`.
        * @param options Options for forwarding the activity.
    
  • packages/fedify/src/federation/handler.test.ts+1734 41 modified
    @@ -1,5 +1,6 @@
    -import { assert, assertEquals, assertFalse } from "@std/assert";
    +import { assert, assertEquals, assertFalse, assertRejects } from "@std/assert";
     import { signRequest } from "../sig/http.ts";
    +import { compactJsonLd, signJsonLd } from "../sig/ld.ts";
     import {
       createInboxContext,
       createRequestContext,
    @@ -44,6 +45,8 @@ import {
     import { InboxListenerSet } from "./inbox.ts";
     import { MemoryKvStore } from "./kv.ts";
     import { createFederation } from "./middleware.ts";
    +import type { MessageQueue } from "./mq.ts";
    +import type { InboxMessage } from "./queue.ts";
     
     test("acceptsJsonLd()", () => {
       assert(acceptsJsonLd(
    @@ -1178,6 +1181,16 @@ test("handleInbox()", async () => {
         if (identifier !== "someone") return null;
         return new Person({ name: "Someone" });
       };
    +  const restrictiveContextLoader = async (resource: string) => {
    +    const url = new URL(resource).href;
    +    if (
    +      url === "https://www.w3.org/ns/activitystreams" ||
    +      url === "https://w3id.org/identity/v1"
    +    ) {
    +      return await mockDocumentLoader(url);
    +    }
    +    throw new Error(`Unexpected context: ${url}`);
    +  };
       const inboxOptions = {
         kv: new MemoryKvStore(),
         kvPrefixes: {
    @@ -1244,6 +1257,57 @@ test("handleInbox()", async () => {
       assertEquals(onNotFoundCalled, null);
       assertEquals(response.status, 401);
     
    +  const malformedProofCreatedRequest = new Request("https://example.com/", {
    +    method: "POST",
    +    body: JSON.stringify({
    +      "@context": [
    +        "https://www.w3.org/ns/activitystreams",
    +        "https://w3id.org/security/data-integrity/v1",
    +      ],
    +      id: "https://example.com/activities/invalid-proof-created",
    +      type: "Create",
    +      actor: "https://example.com/person2",
    +      object: {
    +        id: "https://example.com/notes/invalid-proof-created",
    +        type: "Note",
    +        attributedTo: "https://example.com/person2",
    +        content: "Hello, world!",
    +      },
    +      proof: {
    +        type: "DataIntegrityProof",
    +        cryptosuite: "eddsa-jcs-2022",
    +        verificationMethod: "https://example.com/person2#main-key",
    +        proofPurpose: "assertionMethod",
    +        created: { "@value": "not-a-date" },
    +        proofValue:
    +          "zLaewdp4H9kqtwyrLatK4cjY5oRHwVcw4gibPSUDYDMhi4M49v8pcYk3ZB6D69dNpAPbUmY8ocuJ3m9KhKJEEg7z",
    +      },
    +    }),
    +  });
    +  const malformedProofCreatedContext = createRequestContext({
    +    federation,
    +    request: malformedProofCreatedRequest,
    +    url: new URL(malformedProofCreatedRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: mockDocumentLoader,
    +  });
    +  response = await handleInbox(malformedProofCreatedRequest, {
    +    recipient: null,
    +    context: malformedProofCreatedContext,
    +    inboxContextFactory(_activity) {
    +      return createInboxContext({
    +        ...malformedProofCreatedContext,
    +        clone: undefined,
    +      });
    +    },
    +    ...inboxOptions,
    +  });
    +  assertEquals([response.status, await response.text()], [
    +    400,
    +    "Invalid activity.",
    +  ]);
    +
       onNotFoundCalled = null;
       const signedRequest = await signRequest(
         unsignedRequest.clone() as Request,
    @@ -1268,85 +1332,1714 @@ test("handleInbox()", async () => {
       assertEquals(onNotFoundCalled, null);
       assertEquals([response.status, await response.text()], [202, ""]);
     
    -  response = await handleInbox(signedRequest, {
    -    recipient: "someone",
    -    context: signedContext,
    +  const ldSignedRequest = new Request("https://example.com/", {
    +    method: "POST",
    +    body: JSON.stringify(
    +      await signJsonLd(
    +        {
    +          "@context": [
    +            "https://www.w3.org/ns/activitystreams",
    +            "https://w3id.org/identity/v1",
    +            "https://w3id.org/security/v1",
    +            "https://w3id.org/security/data-integrity/v1",
    +          ],
    +          id: "https://example.com/activities/ld-signed",
    +          type: "Create",
    +          actor: "https://example.com/person2",
    +          object: {
    +            id: "https://example.com/notes/ld-signed",
    +            type: "Note",
    +            attributedTo: "https://example.com/person2",
    +            content: "Hello, world!",
    +          },
    +        },
    +        rsaPrivateKey3,
    +        rsaPublicKey3.id!,
    +        { contextLoader: mockDocumentLoader },
    +      ),
    +    ),
    +  });
    +  const ldSignedContext = createRequestContext({
    +    federation,
    +    request: ldSignedRequest,
    +    url: new URL(ldSignedRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: restrictiveContextLoader,
    +  });
    +  response = await handleInbox(ldSignedRequest, {
    +    recipient: null,
    +    context: ldSignedContext,
         inboxContextFactory(_activity) {
    -      return createInboxContext({
    -        ...unsignedContext,
    -        clone: undefined,
    -        recipient: "someone",
    -      });
    +      return createInboxContext({ ...ldSignedContext, clone: undefined });
         },
         ...inboxOptions,
       });
       assertEquals(onNotFoundCalled, null);
       assertEquals([response.status, await response.text()], [202, ""]);
     
    -  response = await handleInbox(unsignedRequest, {
    +  const remoteContextUrl = "https://remote.example/contexts/ext";
    +  let failRemoteContextOnce = true;
    +  const flakyContextLoader = async (resource: string) => {
    +    const url = new URL(resource).href;
    +    if (url === remoteContextUrl) {
    +      if (failRemoteContextOnce) {
    +        failRemoteContextOnce = false;
    +        throw new Error(`Unexpected context: ${url}`);
    +      }
    +      return {
    +        contextUrl: null,
    +        documentUrl: url,
    +        document: {
    +          "@context": {
    +            ext: "https://example.com/ext",
    +          },
    +        },
    +      };
    +    }
    +    return await mockDocumentLoader(url);
    +  };
    +  const httpSignedLdBody = {
    +    "@context": [
    +      remoteContextUrl,
    +      "https://www.w3.org/ns/activitystreams",
    +    ],
    +    id: "https://example.com/activities/http-signed-ld",
    +    type: "Create",
    +    actor: "https://example.com/person2",
    +    ext: "preserve-me",
    +    object: {
    +      id: "https://example.com/notes/http-signed-ld",
    +      type: "Note",
    +      attributedTo: "https://example.com/person2",
    +      content: "Hello, world!",
    +    },
    +    signature: {
    +      type: "RsaSignature2017",
    +      creator: rsaPublicKey3.id!.href,
    +      created: "2024-01-01T00:00:00Z",
    +      signatureValue: "bogus",
    +    },
    +  };
    +  const httpSignedLdRequest = await signRequest(
    +    new Request("https://example.com/", {
    +      method: "POST",
    +      body: JSON.stringify(httpSignedLdBody),
    +    }),
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +  );
    +  const httpSignedLdContext = createRequestContext({
    +    federation,
    +    request: httpSignedLdRequest,
    +    url: new URL(httpSignedLdRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: flakyContextLoader,
    +  });
    +  response = await handleInbox(httpSignedLdRequest, {
         recipient: null,
    -    context: unsignedContext,
    +    context: httpSignedLdContext,
         inboxContextFactory(_activity) {
    -      return createInboxContext({ ...unsignedContext, clone: undefined });
    +      return createInboxContext({ ...httpSignedLdContext, clone: undefined });
         },
         ...inboxOptions,
    -    skipSignatureVerification: true,
       });
       assertEquals(onNotFoundCalled, null);
    -  assertEquals(response.status, 202);
    +  assertEquals([response.status, await response.text()], [202, ""]);
     
    -  response = await handleInbox(unsignedRequest, {
    -    recipient: "someone",
    -    context: unsignedContext,
    +  const ldSignedOnlyBody = await signJsonLd(
    +    {
    +      "@context": [
    +        remoteContextUrl,
    +        "https://www.w3.org/ns/activitystreams",
    +      ],
    +      id: "https://example.com/activities/ld-only-transient",
    +      type: "Create",
    +      actor: "https://example.com/person2",
    +      ext: "preserve-me",
    +      object: {
    +        id: "https://example.com/notes/ld-only-transient",
    +        type: "Note",
    +        attributedTo: "https://example.com/person2",
    +        content: "Hello, world!",
    +      },
    +    },
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +    {
    +      contextLoader: async (resource: string) => {
    +        const url = new URL(resource).href;
    +        if (url === remoteContextUrl) {
    +          return {
    +            contextUrl: null,
    +            documentUrl: url,
    +            document: {
    +              "@context": {
    +                ext: "https://example.com/ext",
    +              },
    +            },
    +          };
    +        }
    +        return await mockDocumentLoader(url);
    +      },
    +    },
    +  );
    +  const malformedTemporalLdSignedBody = await signJsonLd(
    +    {
    +      "@context": "https://www.w3.org/ns/activitystreams",
    +      id: "https://example.com/activities/ld-only-invalid-published",
    +      type: "Create",
    +      actor: "https://example.com/person2",
    +      published: { "@value": "not-a-date" },
    +      object: {
    +        id: "https://example.com/notes/ld-only-invalid-published",
    +        type: "Note",
    +        attributedTo: "https://example.com/person2",
    +        content: "Hello, world!",
    +      },
    +    },
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +    {
    +      contextLoader: mockDocumentLoader,
    +    },
    +  );
    +  const malformedTemporalLdSignedRequest = new Request("https://example.com/", {
    +    method: "POST",
    +    body: JSON.stringify(malformedTemporalLdSignedBody),
    +  });
    +  const malformedTemporalLdSignedContext = createRequestContext({
    +    federation,
    +    request: malformedTemporalLdSignedRequest,
    +    url: new URL(malformedTemporalLdSignedRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: mockDocumentLoader,
    +  });
    +  response = await handleInbox(malformedTemporalLdSignedRequest, {
    +    recipient: null,
    +    context: malformedTemporalLdSignedContext,
         inboxContextFactory(_activity) {
           return createInboxContext({
    -        ...unsignedContext,
    +        ...malformedTemporalLdSignedContext,
             clone: undefined,
    -        recipient: "someone",
           });
         },
         ...inboxOptions,
    -    skipSignatureVerification: true,
       });
    -  assertEquals(onNotFoundCalled, null);
    -  assertEquals(response.status, 202);
    +  assertEquals([response.status, await response.text()], [
    +    400,
    +    "Invalid activity.",
    +  ]);
    +  const malformedClosedLdSignedBody = await signJsonLd(
    +    {
    +      "@context": "https://www.w3.org/ns/activitystreams",
    +      id: "https://example.com/questions/ld-only-invalid-closed",
    +      type: "Question",
    +      closed: "2024-02-31T00:00:00Z",
    +    },
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +    {
    +      contextLoader: mockDocumentLoader,
    +    },
    +  );
    +  const malformedClosedLdSignedRequest = new Request("https://example.com/", {
    +    method: "POST",
    +    body: JSON.stringify(malformedClosedLdSignedBody),
    +  });
    +  const malformedClosedLdSignedContext = createRequestContext({
    +    federation,
    +    request: malformedClosedLdSignedRequest,
    +    url: new URL(malformedClosedLdSignedRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: mockDocumentLoader,
    +  });
    +  response = await handleInbox(malformedClosedLdSignedRequest, {
    +    recipient: null,
    +    context: malformedClosedLdSignedContext,
    +    inboxContextFactory(_activity) {
    +      return createInboxContext({
    +        ...malformedClosedLdSignedContext,
    +        clone: undefined,
    +      });
    +    },
    +    ...inboxOptions,
    +  });
    +  assertEquals([response.status, await response.text()], [
    +    400,
    +    "Invalid activity.",
    +  ]);
     
    -  const invalidRequest = new Request("https://example.com/", {
    +  const malformedIriHttpSignedRequest = await signRequest(
    +    new Request("https://example.com/", {
    +      method: "POST",
    +      body: JSON.stringify({
    +        "@context": "https://www.w3.org/ns/activitystreams",
    +        id: "http://[",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        object: {
    +          id: "https://example.com/notes/http-signed-invalid-iri",
    +          type: "Note",
    +          attributedTo: "https://example.com/person2",
    +          content: "Hello, world!",
    +        },
    +      }),
    +    }),
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +  );
    +  const malformedIriHttpSignedContext = createRequestContext({
    +    federation,
    +    request: malformedIriHttpSignedRequest,
    +    url: new URL(malformedIriHttpSignedRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: mockDocumentLoader,
    +  });
    +  response = await handleInbox(malformedIriHttpSignedRequest, {
    +    recipient: null,
    +    context: malformedIriHttpSignedContext,
    +    inboxContextFactory(_activity) {
    +      return createInboxContext({
    +        ...malformedIriHttpSignedContext,
    +        clone: undefined,
    +      });
    +    },
    +    ...inboxOptions,
    +  });
    +  assertEquals([response.status, await response.text()], [
    +    400,
    +    "Invalid activity.",
    +  ]);
    +
    +  const ldSignedOnlyRequest = new Request("https://example.com/", {
         method: "POST",
    -    body: JSON.stringify({
    +    body: JSON.stringify(ldSignedOnlyBody),
    +  });
    +  const ldSignedOnlyContext = createRequestContext({
    +    federation,
    +    request: ldSignedOnlyRequest,
    +    url: new URL(ldSignedOnlyRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: async (resource: string) => {
    +      const url = new URL(resource).href;
    +      if (url === remoteContextUrl) {
    +        throw new Error(`Unexpected context: ${url}`);
    +      }
    +      return await mockDocumentLoader(url);
    +    },
    +  });
    +  await assertRejects(
    +    () =>
    +      handleInbox(ldSignedOnlyRequest, {
    +        recipient: null,
    +        context: ldSignedOnlyContext,
    +        inboxContextFactory(_activity) {
    +          return createInboxContext({
    +            ...ldSignedOnlyContext,
    +            clone: undefined,
    +          });
    +        },
    +        ...inboxOptions,
    +      }),
    +    Error,
    +  );
    +
    +  failRemoteContextOnce = true;
    +  const invalidHttpFallbackRequest = new Request("https://example.com/", {
    +    method: "POST",
    +    body: JSON.stringify(ldSignedOnlyBody),
    +    headers: { Signature: "bogus" },
    +  });
    +  const invalidHttpFallbackContext = createRequestContext({
    +    federation,
    +    request: invalidHttpFallbackRequest,
    +    url: new URL(invalidHttpFallbackRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: flakyContextLoader,
    +  });
    +  await assertRejects(
    +    () =>
    +      handleInbox(invalidHttpFallbackRequest, {
    +        recipient: null,
    +        context: invalidHttpFallbackContext,
    +        inboxContextFactory(_activity) {
    +          return createInboxContext({
    +            ...invalidHttpFallbackContext,
    +            clone: undefined,
    +          });
    +        },
    +        ...inboxOptions,
    +      }),
    +    Error,
    +  );
    +
    +  const transientKeyContextUrl = "https://remote.example/contexts/key";
    +  const transientCreatorUrl = "https://remote.example/keys/transient#main-key";
    +  const verificationFailureLdSignedBody = await signJsonLd(
    +    {
    +      "@context": "https://www.w3.org/ns/activitystreams",
    +      id: "https://example.com/activities/ld-key-fetch-transient",
    +      type: "Create",
    +      actor: "https://example.com/person2",
    +      object: {
    +        id: "https://example.com/notes/ld-key-fetch-transient",
    +        type: "Note",
    +        attributedTo: "https://example.com/person2",
    +        content: "Hello, world!",
    +      },
    +    },
    +    rsaPrivateKey3,
    +    new URL(transientCreatorUrl),
    +    {
    +      contextLoader: mockDocumentLoader,
    +    },
    +  );
    +  const verificationFailureLdSignedRequest = new Request(
    +    "https://example.com/",
    +    {
    +      method: "POST",
    +      body: JSON.stringify(verificationFailureLdSignedBody),
    +      headers: { Signature: "bogus" },
    +    },
    +  );
    +  const verificationFailureLdSignedContext = createRequestContext({
    +    federation,
    +    request: verificationFailureLdSignedRequest,
    +    url: new URL(verificationFailureLdSignedRequest.url),
    +    data: undefined,
    +    documentLoader: async (resource: string) => {
    +      if (resource === transientCreatorUrl) {
    +        return {
    +          contextUrl: null,
    +          documentUrl: resource,
    +          document: {
    +            "@context": [transientKeyContextUrl],
    +            id: resource,
    +          },
    +        };
    +      }
    +      return await mockDocumentLoader(new URL(resource).href);
    +    },
    +    contextLoader: async (resource: string) => {
    +      if (resource === transientKeyContextUrl) {
    +        throw new Error(`Transient key context failure: ${resource}`);
    +      }
    +      return await mockDocumentLoader(new URL(resource).href);
    +    },
    +  });
    +  response = await handleInbox(verificationFailureLdSignedRequest, {
    +    recipient: null,
    +    context: verificationFailureLdSignedContext,
    +    inboxContextFactory(_activity) {
    +      return createInboxContext({
    +        ...verificationFailureLdSignedContext,
    +        clone: undefined,
    +      });
    +    },
    +    ...inboxOptions,
    +  });
    +  assertEquals([response.status, await response.text()], [
    +    401,
    +    "Failed to verify the request signature.",
    +  ]);
    +
    +  failRemoteContextOnce = true;
    +  const deferredMalformedTemporalLdSignedBody = await signJsonLd(
    +    {
           "@context": [
    +        remoteContextUrl,
             "https://www.w3.org/ns/activitystreams",
    -        true,
    -        23,
           ],
    +      id: "https://example.com/activities/deferred-invalid-published",
           type: "Create",
    -      object: { type: "Note", content: "Hello, world!" },
    -      actor: "https://example.com/users/alice",
    -    }),
    -  });
    -  const signedInvalidRequest = await signRequest(
    -    invalidRequest,
    +      actor: "https://example.com/person2",
    +      ext: "preserve-me",
    +      published: { "@value": "not-a-date" },
    +      object: {
    +        id: "https://example.com/notes/deferred-invalid-published",
    +        type: "Note",
    +        attributedTo: "https://example.com/person2",
    +        content: "Hello, world!",
    +      },
    +    },
         rsaPrivateKey3,
         rsaPublicKey3.id!,
    +    {
    +      contextLoader: async (resource: string) => {
    +        const url = new URL(resource).href;
    +        if (url === remoteContextUrl) {
    +          return {
    +            contextUrl: null,
    +            documentUrl: url,
    +            document: {
    +              "@context": {
    +                ext: "https://example.com/ext",
    +              },
    +            },
    +          };
    +        }
    +        return await mockDocumentLoader(url);
    +      },
    +    },
       );
    -  const signedInvalidContext = createRequestContext({
    +  const deferredMalformedTemporalLdSignedRequest = new Request(
    +    "https://example.com/",
    +    {
    +      method: "POST",
    +      body: JSON.stringify(deferredMalformedTemporalLdSignedBody),
    +      headers: { Signature: "bogus" },
    +    },
    +  );
    +  const deferredMalformedTemporalLdSignedContext = createRequestContext({
         federation,
    -    request: signedInvalidRequest,
    -    url: new URL(signedInvalidRequest.url),
    +    request: deferredMalformedTemporalLdSignedRequest,
    +    url: new URL(deferredMalformedTemporalLdSignedRequest.url),
         data: undefined,
         documentLoader: mockDocumentLoader,
    +    contextLoader: flakyContextLoader,
       });
    -  response = await handleInbox(signedInvalidRequest, {
    +  response = await handleInbox(deferredMalformedTemporalLdSignedRequest, {
         recipient: null,
    -    context: signedContext,
    +    context: deferredMalformedTemporalLdSignedContext,
         inboxContextFactory(_activity) {
    -      return createInboxContext({ ...signedInvalidContext, clone: undefined });
    +      return createInboxContext({
    +        ...deferredMalformedTemporalLdSignedContext,
    +        clone: undefined,
    +      });
         },
         ...inboxOptions,
       });
    -  assertEquals(onNotFoundCalled, null);
    -  assertEquals(response.status, 400);
    -});
    +  assertEquals([response.status, await response.text()], [
    +    400,
    +    "Invalid activity.",
    +  ]);
    +
    +  const malformedLdSignedRequest = new Request("https://example.com/", {
    +    method: "POST",
    +    body: JSON.stringify({
    +      ...ldSignedOnlyBody,
    +      "@context": [
    +        "not a url",
    +        "https://www.w3.org/ns/activitystreams",
    +      ],
    +    }),
    +  });
    +  const malformedLdSignedContext = createRequestContext({
    +    federation,
    +    request: malformedLdSignedRequest,
    +    url: new URL(malformedLdSignedRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: mockDocumentLoader,
    +  });
    +  response = await handleInbox(malformedLdSignedRequest, {
    +    recipient: null,
    +    context: malformedLdSignedContext,
    +    inboxContextFactory(_activity) {
    +      return createInboxContext({
    +        ...malformedLdSignedContext,
    +        clone: undefined,
    +      });
    +    },
    +    ...inboxOptions,
    +  });
    +  assertEquals([response.status, await response.text()], [
    +    400,
    +    "Invalid JSON-LD.",
    +  ]);
    +
    +  const dualSignedInvalidCreatorRequest = await signRequest(
    +    new Request("https://example.com/", {
    +      method: "POST",
    +      body: JSON.stringify({
    +        ...httpSignedLdBody,
    +        signature: {
    +          ...httpSignedLdBody.signature,
    +          creator: "not a url",
    +        },
    +      }),
    +    }),
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +  );
    +  const dualSignedInvalidCreatorContext = createRequestContext({
    +    federation,
    +    request: dualSignedInvalidCreatorRequest,
    +    url: new URL(dualSignedInvalidCreatorRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: flakyContextLoader,
    +  });
    +  response = await handleInbox(dualSignedInvalidCreatorRequest, {
    +    recipient: null,
    +    context: dualSignedInvalidCreatorContext,
    +    inboxContextFactory(_activity) {
    +      return createInboxContext({
    +        ...dualSignedInvalidCreatorContext,
    +        clone: undefined,
    +      });
    +    },
    +    ...inboxOptions,
    +  });
    +  assertEquals(onNotFoundCalled, null);
    +  assertEquals([response.status, await response.text()], [202, ""]);
    +
    +  const invalidUrlHttpSignedRequest = await signRequest(
    +    new Request("https://example.com/", {
    +      method: "POST",
    +      body: JSON.stringify({
    +        "@context": [
    +          remoteContextUrl,
    +          "https://www.w3.org/ns/activitystreams",
    +        ],
    +        id: "https://example.com/activities/http-signed-invalid-context",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        ext: "preserve-me",
    +        object: {
    +          id: "https://example.com/notes/http-signed-invalid-context",
    +          type: "Note",
    +          attributedTo: "https://example.com/person2",
    +          content: "Hello, world!",
    +        },
    +      }),
    +    }),
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +  );
    +  const invalidUrlHttpSignedContext = createRequestContext({
    +    federation,
    +    request: invalidUrlHttpSignedRequest,
    +    url: new URL(invalidUrlHttpSignedRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: async (resource: string) => {
    +      const url = new URL(resource).href;
    +      if (url === remoteContextUrl) {
    +        const error = new Error(
    +          `Transient remote context failure: ${url}`,
    +        ) as Error & { details?: { code: string; url: string } };
    +        error.name = "jsonld.InvalidUrl";
    +        error.details = {
    +          code: "loading remote context failed",
    +          url,
    +        };
    +        throw error;
    +      }
    +      return await mockDocumentLoader(url);
    +    },
    +  });
    +  await assertRejects(
    +    () =>
    +      handleInbox(invalidUrlHttpSignedRequest, {
    +        recipient: null,
    +        context: invalidUrlHttpSignedContext,
    +        inboxContextFactory(_activity) {
    +          return createInboxContext({
    +            ...invalidUrlHttpSignedContext,
    +            clone: undefined,
    +          });
    +        },
    +        ...inboxOptions,
    +      }),
    +    Error,
    +  );
    +
    +  const opaqueContextIdHttpSignedRequest = await signRequest(
    +    new Request("https://example.com/", {
    +      method: "POST",
    +      body: JSON.stringify({
    +        "@context": [
    +          "app-context",
    +          "https://www.w3.org/ns/activitystreams",
    +        ],
    +        id: "https://example.com/activities/http-signed-opaque-context",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        object: {
    +          id: "https://example.com/notes/http-signed-opaque-context",
    +          type: "Note",
    +          attributedTo: "https://example.com/person2",
    +          content: "Hello, world!",
    +        },
    +      }),
    +    }),
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +  );
    +  const opaqueContextIdHttpSignedContext = createRequestContext({
    +    federation,
    +    request: opaqueContextIdHttpSignedRequest,
    +    url: new URL(opaqueContextIdHttpSignedRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: async (resource: string) => {
    +      if (resource === "app-context") {
    +        const error = new Error(
    +          `Opaque context backend is unavailable: ${resource}`,
    +        ) as Error & { details?: { code: string; url: string } };
    +        error.name = "jsonld.InvalidUrl";
    +        error.details = {
    +          code: "loading remote context failed",
    +          url: resource,
    +        };
    +        throw error;
    +      }
    +      return await mockDocumentLoader(new URL(resource).href);
    +    },
    +  });
    +  await assertRejects(
    +    () =>
    +      handleInbox(opaqueContextIdHttpSignedRequest, {
    +        recipient: null,
    +        context: opaqueContextIdHttpSignedContext,
    +        inboxContextFactory(_activity) {
    +          return createInboxContext({
    +            ...opaqueContextIdHttpSignedContext,
    +            clone: undefined,
    +          });
    +        },
    +        ...inboxOptions,
    +      }),
    +    Error,
    +  );
    +
    +  const opaqueContextTypeErrorHttpSignedRequest = await signRequest(
    +    new Request("https://example.com/", {
    +      method: "POST",
    +      body: JSON.stringify({
    +        "@context": [
    +          "app:context",
    +          "https://www.w3.org/ns/activitystreams",
    +        ],
    +        id: "https://example.com/activities/http-signed-opaque-typeerror",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        object: {
    +          id: "https://example.com/notes/http-signed-opaque-typeerror",
    +          type: "Note",
    +          attributedTo: "https://example.com/person2",
    +          content: "Hello, world!",
    +        },
    +      }),
    +    }),
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +  );
    +  const opaqueContextTypeErrorHttpSignedContext = createRequestContext({
    +    federation,
    +    request: opaqueContextTypeErrorHttpSignedRequest,
    +    url: new URL(opaqueContextTypeErrorHttpSignedRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: async (resource: string) => {
    +      if (resource === "app:context") {
    +        throw new TypeError(`Invalid URL: ${resource}`);
    +      }
    +      return await mockDocumentLoader(new URL(resource).href);
    +    },
    +  });
    +  await assertRejects(
    +    () =>
    +      handleInbox(opaqueContextTypeErrorHttpSignedRequest, {
    +        recipient: null,
    +        context: opaqueContextTypeErrorHttpSignedContext,
    +        inboxContextFactory(_activity) {
    +          return createInboxContext({
    +            ...opaqueContextTypeErrorHttpSignedContext,
    +            clone: undefined,
    +          });
    +        },
    +        ...inboxOptions,
    +      }),
    +    Error,
    +  );
    +
    +  const networkPathContextHttpSignedRequest = await signRequest(
    +    new Request("https://example.com/", {
    +      method: "POST",
    +      body: JSON.stringify({
    +        "@context": [
    +          "//cdn.example/ctx",
    +          "https://www.w3.org/ns/activitystreams",
    +        ],
    +        id: "https://example.com/activities/http-signed-network-path-context",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        object: {
    +          id: "https://example.com/notes/http-signed-network-path-context",
    +          type: "Note",
    +          attributedTo: "https://example.com/person2",
    +          content: "Hello, world!",
    +        },
    +      }),
    +    }),
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +  );
    +  const networkPathContextHttpSignedContext = createRequestContext({
    +    federation,
    +    request: networkPathContextHttpSignedRequest,
    +    url: new URL(networkPathContextHttpSignedRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: async (resource: string) => {
    +      if (resource === "//cdn.example/ctx") {
    +        const error = new Error(
    +          `Network-path context backend is unavailable: ${resource}`,
    +        ) as Error & { details?: { code: string; url: string } };
    +        error.name = "jsonld.InvalidUrl";
    +        error.details = {
    +          code: "loading remote context failed",
    +          url: resource,
    +        };
    +        throw error;
    +      }
    +      return await mockDocumentLoader(new URL(resource).href);
    +    },
    +  });
    +  await assertRejects(
    +    () =>
    +      handleInbox(networkPathContextHttpSignedRequest, {
    +        recipient: null,
    +        context: networkPathContextHttpSignedContext,
    +        inboxContextFactory(_activity) {
    +          return createInboxContext({
    +            ...networkPathContextHttpSignedContext,
    +            clone: undefined,
    +          });
    +        },
    +        ...inboxOptions,
    +      }),
    +    Error,
    +  );
    +
    +  const malformedNetworkPathContextHttpSignedRequest = await signRequest(
    +    new Request("https://example.com/", {
    +      method: "POST",
    +      body: JSON.stringify({
    +        "@context": [
    +          "//[",
    +          "https://www.w3.org/ns/activitystreams",
    +        ],
    +        id:
    +          "https://example.com/activities/http-signed-malformed-network-path-context",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        object: {
    +          id:
    +            "https://example.com/notes/http-signed-malformed-network-path-context",
    +          type: "Note",
    +          attributedTo: "https://example.com/person2",
    +          content: "Hello, world!",
    +        },
    +      }),
    +    }),
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +  );
    +  const malformedNetworkPathContextHttpSignedContext = createRequestContext({
    +    federation,
    +    request: malformedNetworkPathContextHttpSignedRequest,
    +    url: new URL(malformedNetworkPathContextHttpSignedRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: async (resource: string) => {
    +      if (resource === "//[") {
    +        const error = new Error(
    +          `Malformed network-path context: ${resource}`,
    +        ) as Error & { details?: { code: string; url: string } };
    +        error.name = "jsonld.InvalidUrl";
    +        error.details = {
    +          code: "loading remote context failed",
    +          url: resource,
    +        };
    +        throw error;
    +      }
    +      return await mockDocumentLoader(new URL(resource).href);
    +    },
    +  });
    +  response = await handleInbox(
    +    malformedNetworkPathContextHttpSignedRequest,
    +    {
    +      recipient: null,
    +      context: malformedNetworkPathContextHttpSignedContext,
    +      inboxContextFactory(_activity) {
    +        return createInboxContext({
    +          ...malformedNetworkPathContextHttpSignedContext,
    +          clone: undefined,
    +        });
    +      },
    +      ...inboxOptions,
    +    },
    +  );
    +  assertEquals(response.status, 400);
    +
    +  const malformedUrlLikeContextHttpSignedRequest = await signRequest(
    +    new Request("https://example.com/", {
    +      method: "POST",
    +      body: JSON.stringify({
    +        "@context": [
    +          "http://[",
    +          "https://www.w3.org/ns/activitystreams",
    +        ],
    +        id:
    +          "https://example.com/activities/http-signed-malformed-url-like-context",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        object: {
    +          id:
    +            "https://example.com/notes/http-signed-malformed-url-like-context",
    +          type: "Note",
    +          attributedTo: "https://example.com/person2",
    +          content: "Hello, world!",
    +        },
    +      }),
    +    }),
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +  );
    +  const malformedUrlLikeContextHttpSignedContext = createRequestContext({
    +    federation,
    +    request: malformedUrlLikeContextHttpSignedRequest,
    +    url: new URL(malformedUrlLikeContextHttpSignedRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: async (resource: string) => {
    +      if (resource === "http://[") {
    +        const error = new Error(
    +          `Invalid remote context URL: ${resource}`,
    +        ) as Error & { details?: { code: string; url: string } };
    +        error.name = "jsonld.InvalidUrl";
    +        error.details = {
    +          code: "loading remote context failed",
    +          url: resource,
    +        };
    +        throw error;
    +      }
    +      return await mockDocumentLoader(new URL(resource).href);
    +    },
    +  });
    +  response = await handleInbox(malformedUrlLikeContextHttpSignedRequest, {
    +    recipient: null,
    +    context: malformedUrlLikeContextHttpSignedContext,
    +    inboxContextFactory(_activity) {
    +      return createInboxContext({
    +        ...malformedUrlLikeContextHttpSignedContext,
    +        clone: undefined,
    +      });
    +    },
    +    ...inboxOptions,
    +  });
    +  assertEquals(response.status, 400);
    +
    +  const malformedContextUrlHttpSignedRequest = await signRequest(
    +    new Request("https://example.com/", {
    +      method: "POST",
    +      body: JSON.stringify({
    +        "@context": [
    +          "not a url",
    +          "https://www.w3.org/ns/activitystreams",
    +        ],
    +        id: "https://example.com/activities/http-signed-malformed-context",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        object: {
    +          id: "https://example.com/notes/http-signed-malformed-context",
    +          type: "Note",
    +          attributedTo: "https://example.com/person2",
    +          content: "Hello, world!",
    +        },
    +      }),
    +    }),
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +  );
    +  const malformedContextUrlHttpSignedContext = createRequestContext({
    +    federation,
    +    request: malformedContextUrlHttpSignedRequest,
    +    url: new URL(malformedContextUrlHttpSignedRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: async (resource: string) => {
    +      if (resource === "not a url") {
    +        const error = new Error(
    +          `Invalid remote context URL: ${resource}`,
    +        ) as Error & { details?: { code: string; url: string } };
    +        error.name = "jsonld.InvalidUrl";
    +        error.details = {
    +          code: "loading remote context failed",
    +          url: resource,
    +        };
    +        throw error;
    +      }
    +      return await mockDocumentLoader(new URL(resource).href);
    +    },
    +  });
    +  response = await handleInbox(malformedContextUrlHttpSignedRequest, {
    +    recipient: null,
    +    context: malformedContextUrlHttpSignedContext,
    +    inboxContextFactory(_activity) {
    +      return createInboxContext({
    +        ...malformedContextUrlHttpSignedContext,
    +        clone: undefined,
    +      });
    +    },
    +    ...inboxOptions,
    +  });
    +  assertEquals(response.status, 400);
    +
    +  const invalidRemoteContextHttpSignedRequest = await signRequest(
    +    new Request("https://example.com/", {
    +      method: "POST",
    +      body: JSON.stringify({
    +        "@context": [
    +          remoteContextUrl,
    +          "https://www.w3.org/ns/activitystreams",
    +        ],
    +        id: "https://example.com/activities/http-signed-invalid-remote-context",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        object: {
    +          id: "https://example.com/notes/http-signed-invalid-remote-context",
    +          type: "Note",
    +          attributedTo: "https://example.com/person2",
    +          content: "Hello, world!",
    +        },
    +      }),
    +    }),
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +  );
    +  const invalidRemoteContextHttpSignedContext = createRequestContext({
    +    federation,
    +    request: invalidRemoteContextHttpSignedRequest,
    +    url: new URL(invalidRemoteContextHttpSignedRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: async (resource: string) => {
    +      const url = new URL(resource).href;
    +      if (url === remoteContextUrl) {
    +        return {
    +          contextUrl: null,
    +          documentUrl: url,
    +          document: ["not", "an", "object"],
    +        };
    +      }
    +      return await mockDocumentLoader(url);
    +    },
    +  });
    +  response = await handleInbox(invalidRemoteContextHttpSignedRequest, {
    +    recipient: null,
    +    context: invalidRemoteContextHttpSignedContext,
    +    inboxContextFactory(_activity) {
    +      return createInboxContext({
    +        ...invalidRemoteContextHttpSignedContext,
    +        clone: undefined,
    +      });
    +    },
    +    ...inboxOptions,
    +  });
    +  assertEquals(response.status, 400);
    +
    +  const invalidUrlAbsoluteContextHttpSignedRequest = await signRequest(
    +    new Request("https://example.com/", {
    +      method: "POST",
    +      body: JSON.stringify({
    +        "@context": [
    +          remoteContextUrl,
    +          "https://www.w3.org/ns/activitystreams",
    +        ],
    +        id: "https://example.com/activities/http-signed-invalid-url-context",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        ext: "preserve-me",
    +        object: {
    +          id: "https://example.com/notes/http-signed-invalid-url-context",
    +          type: "Note",
    +          attributedTo: "https://example.com/person2",
    +          content: "Hello, world!",
    +        },
    +      }),
    +    }),
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +  );
    +  const invalidUrlAbsoluteContextHttpSignedContext = createRequestContext({
    +    federation,
    +    request: invalidUrlAbsoluteContextHttpSignedRequest,
    +    url: new URL(invalidUrlAbsoluteContextHttpSignedRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: async (resource: string) => {
    +      const url = new URL(resource).href;
    +      if (url === remoteContextUrl) {
    +        throw new TypeError(`Invalid URL: ${url}`);
    +      }
    +      return await mockDocumentLoader(url);
    +    },
    +  });
    +  await assertRejects(
    +    () =>
    +      handleInbox(invalidUrlAbsoluteContextHttpSignedRequest, {
    +        recipient: null,
    +        context: invalidUrlAbsoluteContextHttpSignedContext,
    +        inboxContextFactory(_activity) {
    +          return createInboxContext({
    +            ...invalidUrlAbsoluteContextHttpSignedContext,
    +            clone: undefined,
    +          });
    +        },
    +        ...inboxOptions,
    +      }),
    +    Error,
    +  );
    +
    +  const typeErrorHttpSignedRequest = await signRequest(
    +    new Request("https://example.com/", {
    +      method: "POST",
    +      body: JSON.stringify({
    +        "@context": [
    +          remoteContextUrl,
    +          "https://www.w3.org/ns/activitystreams",
    +        ],
    +        id: "https://example.com/activities/http-signed-typeerror-context",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        ext: "preserve-me",
    +        object: {
    +          id: "https://example.com/notes/http-signed-typeerror-context",
    +          type: "Note",
    +          attributedTo: "https://example.com/person2",
    +          content: "Hello, world!",
    +        },
    +      }),
    +    }),
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +  );
    +  const typeErrorHttpSignedContext = createRequestContext({
    +    federation,
    +    request: typeErrorHttpSignedRequest,
    +    url: new URL(typeErrorHttpSignedRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: async (resource: string) => {
    +      const url = new URL(resource).href;
    +      if (url === remoteContextUrl) {
    +        throw new TypeError(`The remote context host timed out: ${url}`);
    +      }
    +      return await mockDocumentLoader(url);
    +    },
    +  });
    +  await assertRejects(
    +    () =>
    +      handleInbox(typeErrorHttpSignedRequest, {
    +        recipient: null,
    +        context: typeErrorHttpSignedContext,
    +        inboxContextFactory(_activity) {
    +          return createInboxContext({
    +            ...typeErrorHttpSignedContext,
    +            clone: undefined,
    +          });
    +        },
    +        ...inboxOptions,
    +      }),
    +    Error,
    +  );
    +
    +  const rangeErrorHttpSignedRequest = await signRequest(
    +    new Request("https://example.com/", {
    +      method: "POST",
    +      body: JSON.stringify({
    +        "@context": [
    +          remoteContextUrl,
    +          "https://www.w3.org/ns/activitystreams",
    +        ],
    +        id: "https://example.com/activities/http-signed-rangeerror-context",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        ext: "preserve-me",
    +        object: {
    +          id: "https://example.com/notes/http-signed-rangeerror-context",
    +          type: "Note",
    +          attributedTo: "https://example.com/person2",
    +          content: "Hello, world!",
    +        },
    +      }),
    +    }),
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +  );
    +  const rangeErrorHttpSignedContext = createRequestContext({
    +    federation,
    +    request: rangeErrorHttpSignedRequest,
    +    url: new URL(rangeErrorHttpSignedRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: async (resource: string) => {
    +      const url = new URL(resource).href;
    +      if (url === remoteContextUrl) {
    +        throw new RangeError(
    +          `Temporary remote context cache window exceeded: ${url}`,
    +        );
    +      }
    +      return await mockDocumentLoader(url);
    +    },
    +  });
    +  await assertRejects(
    +    () =>
    +      handleInbox(rangeErrorHttpSignedRequest, {
    +        recipient: null,
    +        context: rangeErrorHttpSignedContext,
    +        inboxContextFactory(_activity) {
    +          return createInboxContext({
    +            ...rangeErrorHttpSignedContext,
    +            clone: undefined,
    +          });
    +        },
    +        ...inboxOptions,
    +      }),
    +    Error,
    +  );
    +
    +  const syntaxErrorHttpSignedRequest = await signRequest(
    +    new Request("https://example.com/", {
    +      method: "POST",
    +      body: JSON.stringify({
    +        "@context": [
    +          remoteContextUrl,
    +          "https://www.w3.org/ns/activitystreams",
    +        ],
    +        id: "https://example.com/activities/http-signed-syntax-context",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        ext: "preserve-me",
    +        object: {
    +          id: "https://example.com/notes/http-signed-syntax-context",
    +          type: "Note",
    +          attributedTo: "https://example.com/person2",
    +          content: "Hello, world!",
    +        },
    +      }),
    +    }),
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +  );
    +  const syntaxErrorHttpSignedContext = createRequestContext({
    +    federation,
    +    request: syntaxErrorHttpSignedRequest,
    +    url: new URL(syntaxErrorHttpSignedRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: async (resource: string) => {
    +      const url = new URL(resource).href;
    +      if (url === remoteContextUrl) {
    +        const error = new Error(
    +          `Transient syntax failure: ${url}`,
    +        ) as Error & { details?: { code: string } };
    +        error.name = "jsonld.SyntaxError";
    +        error.details = { code: "loading remote context failed" };
    +        throw error;
    +      }
    +      return await mockDocumentLoader(url);
    +    },
    +  });
    +  await assertRejects(
    +    () =>
    +      handleInbox(syntaxErrorHttpSignedRequest, {
    +        recipient: null,
    +        context: syntaxErrorHttpSignedContext,
    +        inboxContextFactory(_activity) {
    +          return createInboxContext({
    +            ...syntaxErrorHttpSignedContext,
    +            clone: undefined,
    +          });
    +        },
    +        ...inboxOptions,
    +      }),
    +    Error,
    +  );
    +
    +  response = await handleInbox(signedRequest, {
    +    recipient: "someone",
    +    context: signedContext,
    +    inboxContextFactory(_activity) {
    +      return createInboxContext({
    +        ...unsignedContext,
    +        clone: undefined,
    +        recipient: "someone",
    +      });
    +    },
    +    ...inboxOptions,
    +  });
    +  assertEquals(onNotFoundCalled, null);
    +  assertEquals([response.status, await response.text()], [202, ""]);
    +
    +  response = await handleInbox(unsignedRequest, {
    +    recipient: null,
    +    context: unsignedContext,
    +    inboxContextFactory(_activity) {
    +      return createInboxContext({ ...unsignedContext, clone: undefined });
    +    },
    +    ...inboxOptions,
    +    skipSignatureVerification: true,
    +  });
    +  assertEquals(onNotFoundCalled, null);
    +  assertEquals(response.status, 202);
    +
    +  response = await handleInbox(unsignedRequest, {
    +    recipient: "someone",
    +    context: unsignedContext,
    +    inboxContextFactory(_activity) {
    +      return createInboxContext({
    +        ...unsignedContext,
    +        clone: undefined,
    +        recipient: "someone",
    +      });
    +    },
    +    ...inboxOptions,
    +    skipSignatureVerification: true,
    +  });
    +  assertEquals(onNotFoundCalled, null);
    +  assertEquals(response.status, 202);
    +
    +  const unsafeJson = {
    +    "@context": [
    +      "https://www.w3.org/ns/activitystreams",
    +      { rev: "@reverse" },
    +    ],
    +    id: "https://example.com/activities/unsafe",
    +    type: "Announce",
    +    actor: "https://example.com/person2",
    +    object: "https://example.com/notes/1",
    +    rev: {
    +      object: {
    +        id: "https://example.com/activities/undo",
    +        type: "Undo",
    +        actor: "https://example.com/person2",
    +      },
    +    },
    +  };
    +  const unsafeRequest = await signRequest(
    +    new Request("https://example.com/", {
    +      method: "POST",
    +      body: JSON.stringify(unsafeJson),
    +    }),
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +  );
    +  const unsafeContext = createRequestContext({
    +    federation,
    +    request: unsafeRequest,
    +    url: new URL(unsafeRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +  });
    +  response = await handleInbox(unsafeRequest, {
    +    recipient: null,
    +    context: unsafeContext,
    +    inboxContextFactory(_activity) {
    +      return createInboxContext({ ...unsafeContext, clone: undefined });
    +    },
    +    ...inboxOptions,
    +  });
    +  assertEquals(response.status, 202);
    +
    +  const unsafeLdRequest = new Request("https://example.com/", {
    +    method: "POST",
    +    body: JSON.stringify(
    +      await signJsonLd(
    +        unsafeJson,
    +        rsaPrivateKey3,
    +        rsaPublicKey3.id!,
    +        { contextLoader: mockDocumentLoader },
    +      ),
    +    ),
    +  });
    +  const unsafeLdContext = createRequestContext({
    +    federation,
    +    request: unsafeLdRequest,
    +    url: new URL(unsafeLdRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +  });
    +  response = await handleInbox(unsafeLdRequest, {
    +    recipient: null,
    +    context: unsafeLdContext,
    +    inboxContextFactory(_activity) {
    +      return createInboxContext({ ...unsafeLdContext, clone: undefined });
    +    },
    +    ...inboxOptions,
    +  });
    +  assertEquals(response.status, 400);
    +
    +  const invalidRequest = new Request("https://example.com/", {
    +    method: "POST",
    +    body: JSON.stringify({
    +      "@context": [
    +        "https://www.w3.org/ns/activitystreams",
    +        true,
    +        23,
    +      ],
    +      type: "Create",
    +      object: { type: "Note", content: "Hello, world!" },
    +      actor: "https://example.com/users/alice",
    +    }),
    +  });
    +  const signedInvalidRequest = await signRequest(
    +    invalidRequest,
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +  );
    +  const signedInvalidContext = createRequestContext({
    +    federation,
    +    request: signedInvalidRequest,
    +    url: new URL(signedInvalidRequest.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +  });
    +  response = await handleInbox(signedInvalidRequest, {
    +    recipient: null,
    +    context: signedContext,
    +    inboxContextFactory(_activity) {
    +      return createInboxContext({ ...signedInvalidContext, clone: undefined });
    +    },
    +    ...inboxOptions,
    +  });
    +  assertEquals(onNotFoundCalled, null);
    +  assertEquals(response.status, 400);
    +});
    +
    +test("handleInbox() preserves the raw signed payload for inboxContextFactory", async () => {
    +  const federation = createFederation<void>({ kv: new MemoryKvStore() });
    +  const remoteContextUrl = "https://remote.example/contexts/ext";
    +  const sourceContextLoader = async (resource: string) => {
    +    const url = new URL(resource).href;
    +    if (url === remoteContextUrl) {
    +      return {
    +        contextUrl: null,
    +        documentUrl: url,
    +        document: {
    +          "@context": {
    +            ext: "https://example.com/ext",
    +          },
    +        },
    +      };
    +    }
    +    return await mockDocumentLoader(url);
    +  };
    +  const signed = await signJsonLd(
    +    {
    +      "@context": [
    +        remoteContextUrl,
    +        "https://www.w3.org/ns/activitystreams",
    +      ],
    +      id: "https://example.com/activities/preserve-raw",
    +      type: "Create",
    +      actor: "https://example.com/person2",
    +      ext: "preserve-me",
    +      object: {
    +        id: "https://example.com/notes/preserve-raw",
    +        type: "Note",
    +        attributedTo: "https://example.com/person2",
    +        content: "Hello, world!",
    +      },
    +    },
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +    { contextLoader: sourceContextLoader },
    +  );
    +  const request = new Request("https://example.com/", {
    +    method: "POST",
    +    body: JSON.stringify(signed),
    +  });
    +  const context = createRequestContext({
    +    federation,
    +    request,
    +    url: new URL(request.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: sourceContextLoader,
    +  });
    +  let receivedRaw: unknown = null;
    +  let receivedTyped: Create | null = null;
    +  const inboxListeners = new InboxListenerSet<void>();
    +  inboxListeners.add(Create, (ctx, activity) => {
    +    receivedRaw = (ctx as unknown as { activity: unknown }).activity;
    +    receivedTyped = activity;
    +  });
    +  const response = await handleInbox(request, {
    +    recipient: "someone",
    +    context,
    +    inboxContextFactory(recipient, activity, activityId, activityType) {
    +      return {
    +        ...createInboxContext({
    +          ...context,
    +          clone: undefined,
    +          recipient,
    +        }),
    +        activity,
    +        activityId,
    +        activityType,
    +      };
    +    },
    +    kv: new MemoryKvStore(),
    +    kvPrefixes: {
    +      activityIdempotence: ["_fedify", "activityIdempotence"],
    +      publicKey: ["_fedify", "publicKey"],
    +    },
    +    actorDispatcher: (_ctx, identifier) =>
    +      identifier === "someone" ? new Person({ name: "Someone" }) : null,
    +    inboxListeners,
    +    onNotFound: () => new Response("Not found", { status: 404 }),
    +    signatureTimeWindow: { minutes: 5 },
    +    skipSignatureVerification: false,
    +  });
    +  assertEquals([response.status, await response.text()], [202, ""]);
    +  assertEquals(receivedRaw, signed);
    +  const delivered = receivedTyped;
    +  assert(delivered != null);
    +  const deliveredCreate = delivered as Create;
    +  assertEquals(
    +    deliveredCreate.id?.href,
    +    "https://example.com/activities/preserve-raw",
    +  );
    +});
    +
    +test("handleInbox() enqueues normalizedActivity for LD-signed inbox work", async () => {
    +  const remoteContextUrl = "https://remote.example/contexts/ext";
    +  const sourceContextLoader = async (resource: string) => {
    +    const url = new URL(resource).href;
    +    if (url === remoteContextUrl) {
    +      return {
    +        contextUrl: null,
    +        documentUrl: url,
    +        document: {
    +          "@context": {
    +            ext: "https://example.com/ext",
    +          },
    +        },
    +      };
    +    }
    +    return await mockDocumentLoader(url);
    +  };
    +  let queuedMessage: InboxMessage | null = null;
    +  const queue: MessageQueue = {
    +    enqueue(message) {
    +      queuedMessage = message as InboxMessage;
    +      return Promise.resolve();
    +    },
    +    async listen() {
    +    },
    +  };
    +  const federation = createFederation<void>({
    +    kv: new MemoryKvStore(),
    +    queue,
    +  });
    +  const signed = await signJsonLd(
    +    {
    +      "@context": [
    +        remoteContextUrl,
    +        "https://www.w3.org/ns/activitystreams",
    +      ],
    +      id: "https://example.com/activities/enqueued-normalized",
    +      type: "Create",
    +      actor: "https://example.com/person2",
    +      ext: "preserve-me",
    +      object: {
    +        id: "https://example.com/notes/enqueued-normalized",
    +        type: "Note",
    +        attributedTo: "https://example.com/person2",
    +        content: "Hello, world!",
    +      },
    +    },
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +    { contextLoader: sourceContextLoader },
    +  );
    +  const request = new Request("https://example.com/", {
    +    method: "POST",
    +    body: JSON.stringify(signed),
    +  });
    +  const context = createRequestContext({
    +    federation,
    +    request,
    +    url: new URL(request.url),
    +    data: undefined,
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: sourceContextLoader,
    +  });
    +  const response = await handleInbox(request, {
    +    recipient: "someone",
    +    context,
    +    inboxContextFactory(recipient, activity, activityId, activityType) {
    +      return {
    +        ...createInboxContext({
    +          ...context,
    +          clone: undefined,
    +          recipient,
    +        }),
    +        activity,
    +        activityId,
    +        activityType,
    +      };
    +    },
    +    kv: new MemoryKvStore(),
    +    kvPrefixes: {
    +      activityIdempotence: ["_fedify", "activityIdempotence"],
    +      publicKey: ["_fedify", "publicKey"],
    +    },
    +    queue,
    +    actorDispatcher: (_ctx, identifier) =>
    +      identifier === "someone" ? new Person({ name: "Someone" }) : null,
    +    onNotFound: () => new Response("Not found", { status: 404 }),
    +    signatureTimeWindow: { minutes: 5 },
    +    skipSignatureVerification: false,
    +  });
    +  assertEquals([response.status, await response.text()], [
    +    202,
    +    "Activity is enqueued.",
    +  ]);
    +  const enqueued = queuedMessage;
    +  assert(enqueued != null);
    +  const inboxMessage = enqueued as InboxMessage;
    +  assertEquals(inboxMessage.activity, signed);
    +  assertEquals(
    +    inboxMessage.normalizedActivity,
    +    await compactJsonLd(signed, sourceContextLoader),
    +  );
    +  assertEquals(inboxMessage.ldSignatureVerified, true);
    +});
    +
    +test(
    +  "handleInbox() caches normalizedActivity for queued signature-bearing " +
    +    "fallback traffic",
    +  async () => {
    +    const remoteContextUrl = "https://remote.example/contexts/ext";
    +    let queuedMessage: InboxMessage | null = null;
    +    const queue: MessageQueue = {
    +      enqueue(message) {
    +        queuedMessage = message as InboxMessage;
    +        return Promise.resolve();
    +      },
    +      async listen() {
    +      },
    +    };
    +    const federation = createFederation<void>({
    +      kv: new MemoryKvStore(),
    +      queue,
    +    });
    +    const sourceContextLoader = async (resource: string) => {
    +      const url = new URL(resource).href;
    +      if (url === remoteContextUrl) {
    +        return {
    +          contextUrl: null,
    +          documentUrl: url,
    +          document: {
    +            "@context": {
    +              ext: "https://example.com/ext",
    +            },
    +          },
    +        };
    +      }
    +      return await mockDocumentLoader(url);
    +    };
    +    const unsignedBody = {
    +      "@context": [
    +        remoteContextUrl,
    +        "https://www.w3.org/ns/activitystreams",
    +      ],
    +      id: "https://example.com/activities/non-lds-queued-signature",
    +      type: "Create",
    +      actor: "https://example.com/person2",
    +      ext: "preserve-me",
    +      object: {
    +        id: "https://example.com/notes/non-lds-queued-signature",
    +        type: "Note",
    +        attributedTo: "https://example.com/person2",
    +        content: "Hello, world!",
    +      },
    +      signature: {
    +        type: "RsaSignature2017",
    +        creator: "not a url",
    +        created: "2024-09-12T16:50:46Z",
    +        signatureValue: "Zm9v",
    +      },
    +    };
    +    const request = await signRequest(
    +      new Request("https://example.com/", {
    +        method: "POST",
    +        body: JSON.stringify(unsignedBody),
    +      }),
    +      rsaPrivateKey3,
    +      rsaPublicKey3.id!,
    +    );
    +    const context = createRequestContext({
    +      federation,
    +      request,
    +      url: new URL(request.url),
    +      data: undefined,
    +      documentLoader: mockDocumentLoader,
    +      contextLoader: sourceContextLoader,
    +    });
    +    const response = await handleInbox(request, {
    +      recipient: "someone",
    +      context,
    +      inboxContextFactory(recipient, activity, activityId, activityType) {
    +        return {
    +          ...createInboxContext({
    +            ...context,
    +            clone: undefined,
    +            recipient,
    +          }),
    +          activity,
    +          activityId,
    +          activityType,
    +        };
    +      },
    +      kv: new MemoryKvStore(),
    +      kvPrefixes: {
    +        activityIdempotence: ["_fedify", "activityIdempotence"],
    +        publicKey: ["_fedify", "publicKey"],
    +      },
    +      queue,
    +      actorDispatcher: (_ctx, identifier) =>
    +        identifier === "someone" ? new Person({ name: "Someone" }) : null,
    +      onNotFound: () => new Response("Not found", { status: 404 }),
    +      signatureTimeWindow: { minutes: 5 },
    +      skipSignatureVerification: false,
    +    });
    +    assertEquals([response.status, await response.text()], [
    +      202,
    +      "Activity is enqueued.",
    +    ]);
    +    if (queuedMessage == null) throw new Error("Inbox message not queued.");
    +    const inboxMessage = queuedMessage as InboxMessage;
    +    assertEquals(inboxMessage, {
    +      type: "inbox",
    +      id: inboxMessage.id,
    +      baseUrl: "https://example.com",
    +      activity: unsignedBody,
    +      normalizedActivity: await compactJsonLd(
    +        unsignedBody,
    +        sourceContextLoader,
    +      ),
    +      ldSignatureVerified: false,
    +      started: inboxMessage.started,
    +      attempt: 0,
    +      identifier: "someone",
    +      traceContext: inboxMessage.traceContext,
    +    });
    +  },
    +);
     
     test("respondWithObject()", async () => {
       const response = await respondWithObject(
    
  • packages/fedify/src/federation/handler.ts+281 20 modified
    @@ -9,7 +9,16 @@ import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
     import metadata from "../../deno.json" with { type: "json" };
     import type { DocumentLoader } from "../runtime/docloader.ts";
     import { verifyRequest } from "../sig/http.ts";
    -import { detachSignature, verifyJsonLd } from "../sig/ld.ts";
    +import {
    +  compactJsonLd,
    +  detachSignature,
    +  getNormalizationContextLoader,
    +  hasSignature,
    +  InvalidContextReferenceError,
    +  isClearlyMalformedContextReference,
    +  verifyCompactJsonLd,
    +  wrapContextLoaderForJsonLd,
    +} from "../sig/ld.ts";
     import { doesActorOwnKey } from "../sig/owner.ts";
     import { verifyObject } from "../sig/proof.ts";
     import type { Recipient } from "../vocab/actor.ts";
    @@ -49,6 +58,76 @@ import { KvKeyCache } from "./keycache.ts";
     import type { KvKey, KvStore } from "./kv.ts";
     import type { MessageQueue } from "./mq.ts";
     import { preferredMediaTypes } from "./negotiation.ts";
    +import { hasMalformedKnownTemporalLiteral } from "./temporal.ts";
    +
    +export const rawInboxContextFactorySymbol = Symbol(
    +  "fedify.rawInboxContextFactory",
    +);
    +
    +function isRemoteContextLoadingFailure(error: unknown): boolean {
    +  return error instanceof Error &&
    +    typeof (error as Error & { details?: { code?: unknown } }).details ===
    +      "object" &&
    +    (error as Error & { details?: { code?: unknown } }).details != null &&
    +    (error as Error & { details: { code?: unknown } }).details.code ===
    +      "loading remote context failed";
    +}
    +
    +function isPermanentRemoteContextError(error: unknown): boolean {
    +  if (!(error instanceof Error) || error.name !== "jsonld.InvalidUrl") {
    +    return false;
    +  }
    +  const details = (error as Error & {
    +    details?: { code?: unknown; url?: unknown };
    +  }).details;
    +  if (details?.code === "invalid remote context") {
    +    return true;
    +  }
    +  return isRemoteContextLoadingFailure(error) &&
    +    typeof details?.url === "string" &&
    +    !URL.canParse(details.url) &&
    +    isClearlyMalformedContextReference(details.url);
    +}
    +
    +function isInvalidJsonLdError(error: unknown): error is Error {
    +  if (!(error instanceof Error)) return false;
    +  const name = error.name;
    +  return name === "UnsafeJsonLdError" ||
    +    error instanceof InvalidContextReferenceError ||
    +    isPermanentRemoteContextError(error) ||
    +    (name === "jsonld.SyntaxError" &&
    +      !isRemoteContextLoadingFailure(error));
    +}
    +
    +function isValidationTypeError(error: unknown): error is TypeError {
    +  return error instanceof TypeError &&
    +    /^(Invalid JSON-LD:|Invalid type:|Unexpected type:|Invalid URL)/
    +      .test(error.message);
    +}
    +
    +function isPermanentActivityParseError(error: unknown): error is Error {
    +  // jsonld.InvalidUrl is only treated as permanent for upstream
    +  // "invalid remote context" failures or for clearly malformed non-URL
    +  // context strings such as values containing whitespace/control characters.
    +  // Opaque or relative context ids may be valid for deployment-specific
    +  // loaders, so loading failures for other non-parseable ids stay
    +  // retryable/fallback-capable instead of being forced into the malformed
    +  // bucket.  jsonld.SyntaxError is similarly only permanent when it is local
    +  // to the payload rather than a remote-context loading failure.  Raw loader
    +  // TypeErrors for @context resolution are normalized earlier at the
    +  // context-loading layer, so any remaining "Invalid URL ..." here comes from
    +  // sender-controlled ActivityPub IRI fields and stays permanent.
    +  return isInvalidJsonLdError(error) || isValidationTypeError(error);
    +}
    +
    +function hasHttpSignatureHeaders(request: Request): boolean {
    +  return request.headers.has("Signature") ||
    +    request.headers.has("Signature-Input");
    +}
    +
    +function hasObjectIntegrityProof(json: unknown): boolean {
    +  return typeof json === "object" && json != null && "proof" in json;
    +}
     
     export function acceptsJsonLd(request: Request): boolean {
       const accept = request.headers.get("Accept");
    @@ -687,42 +766,189 @@ async function handleInboxInternal<TContextData>(
         });
       }
       const keyCache = new KvKeyCache(kv, kvPrefixes.publicKey, ctx);
    -  let ldSigVerified: boolean;
    -  try {
    -    ldSigVerified = await verifyJsonLd(json, {
    -      contextLoader: ctx.contextLoader,
    -      documentLoader: ctx.documentLoader,
    -      keyCache,
    -      tracerProvider,
    +  const jsonWithoutSig = detachSignature(json);
    +  const hasLdSignature = hasSignature(json);
    +  const canAttemptAlternateAuthAfterLdSignatureFailure =
    +    skipSignatureVerification ||
    +    hasHttpSignatureHeaders(request) ||
    +    hasObjectIntegrityProof(jsonWithoutSig);
    +  let deferredLdSignatureError: unknown = undefined;
    +  const respondInvalidActivity = async (error: unknown): Promise<Response> => {
    +    logger.error("Failed to parse activity:\n{error}", {
    +      recipient,
    +      activity: json,
    +      error,
         });
    -  } catch (error) {
    -    if (error instanceof Error && error.name === "jsonld.SyntaxError") {
    -      logger.error("Failed to parse JSON-LD:\n{error}", { recipient, error });
    -      return new Response("Invalid JSON-LD.", {
    -        status: 400,
    -        headers: { "Content-Type": "text/plain; charset=utf-8" },
    -      });
    +    try {
    +      await inboxErrorHandler?.(ctx, error as Error);
    +    } catch (error) {
    +      logger.error(
    +        "An unexpected error occurred in inbox error handler:\n{error}",
    +        { error, activity: json, recipient },
    +      );
    +    }
    +    span.setStatus({
    +      code: SpanStatusCode.ERROR,
    +      message: `Failed to parse activity:\n${error}`,
    +    });
    +    return new Response("Invalid activity.", {
    +      status: 400,
    +      headers: { "Content-Type": "text/plain; charset=utf-8" },
    +    });
    +  };
    +  let compactedJson = json;
    +  let compactedJsonWithoutSig = jsonWithoutSig;
    +  let ldSigVerified = false;
    +  if (hasLdSignature) {
    +    try {
    +      compactedJson = await compactJsonLd(json, ctx.contextLoader);
    +    } catch (error) {
    +      if (isInvalidJsonLdError(error)) {
    +        logger.error("Failed to parse JSON-LD:\n{error}", { recipient, error });
    +        return new Response("Invalid JSON-LD.", {
    +          status: 400,
    +          headers: { "Content-Type": "text/plain; charset=utf-8" },
    +        });
    +      }
    +      if (!canAttemptAlternateAuthAfterLdSignatureFailure) throw error;
    +      // The presence of a proof block or HTTP signature headers is not enough
    +      // to discard a transient LDS normalization failure.  Keep that error
    +      // alive until another authentication path actually verifies, otherwise a
    +      // stale proof or invalid HTTP signature could turn a retriable remote
    +      // context outage into a permanent 400/401 response.
    +      if (!skipSignatureVerification) deferredLdSignatureError = error;
    +      logger.debug(
    +        "Failed to normalize JSON-LD for Linked Data Signatures; " +
    +          "deferring to another authentication path only if it verifies:\n" +
    +          "{error}",
    +        { recipient, error },
    +      );
    +    }
    +    if (compactedJson !== json) {
    +      compactedJsonWithoutSig = detachSignature(compactedJson);
    +      try {
    +        ldSigVerified = await verifyCompactJsonLd(compactedJson, {
    +          contextLoader: ctx.contextLoader,
    +          documentLoader: ctx.documentLoader,
    +          keyCache,
    +          tracerProvider,
    +        });
    +      } catch (error) {
    +        if (
    +          error instanceof RangeError &&
    +          await hasMalformedKnownTemporalLiteral(
    +            compactedJsonWithoutSig,
    +            ctx.contextLoader,
    +          )
    +        ) {
    +          return await respondInvalidActivity(error);
    +        }
    +        if (isInvalidJsonLdError(error)) {
    +          logger.error("Failed to parse JSON-LD:\n{error}", {
    +            recipient,
    +            error,
    +          });
    +          return new Response("Invalid JSON-LD.", {
    +            status: 400,
    +            headers: { "Content-Type": "text/plain; charset=utf-8" },
    +          });
    +        }
    +        if (!canAttemptAlternateAuthAfterLdSignatureFailure) throw error;
    +        if (!skipSignatureVerification) {
    +          try {
    +            await Object.fromJsonLd(compactedJson, {
    +              contextLoader: getNormalizationContextLoader(ctx.contextLoader),
    +              documentLoader: ctx.documentLoader,
    +              tracerProvider,
    +            });
    +          } catch (parseError) {
    +            if (
    +              parseError instanceof RangeError &&
    +              await hasMalformedKnownTemporalLiteral(
    +                compactedJsonWithoutSig,
    +                ctx.contextLoader,
    +              )
    +            ) {
    +              return await respondInvalidActivity(parseError);
    +            }
    +            if (isInvalidJsonLdError(parseError)) {
    +              logger.error("Failed to parse JSON-LD:\n{error}", {
    +                recipient,
    +                error: parseError,
    +              });
    +              return new Response("Invalid JSON-LD.", {
    +                status: 400,
    +                headers: { "Content-Type": "text/plain; charset=utf-8" },
    +              });
    +            }
    +            // verifyCompactJsonLd() covers both payload parsing and signature
    +            // verification.  Only keep a deferred error when reparsing the
    +            // sender's compacted payload still fails for a retryable reason;
    +            // otherwise unauthenticated requests could turn transient LDS key
    +            // lookup / parsing failures into retryable 5xxs instead of
    +            // falling through to the established 401 path.
    +            deferredLdSignatureError = parseError;
    +          }
    +        }
    +        ldSigVerified = false;
    +      }
         }
    -    ldSigVerified = false;
       }
    -  const jsonWithoutSig = detachSignature(json);
       let activity: Activity | null = null;
       if (ldSigVerified) {
         logger.debug("Linked Data Signatures are verified.", { recipient, json });
    -    activity = await Activity.fromJsonLd(jsonWithoutSig, ctx);
    +    try {
    +      activity = await Activity.fromJsonLd(compactedJsonWithoutSig, {
    +        ...ctx,
    +        contextLoader: getNormalizationContextLoader(ctx.contextLoader),
    +      });
    +    } catch (error) {
    +      if (
    +        error instanceof RangeError &&
    +        await hasMalformedKnownTemporalLiteral(
    +          compactedJsonWithoutSig,
    +          ctx.contextLoader,
    +        )
    +      ) {
    +        return await respondInvalidActivity(error);
    +      }
    +      if (!isPermanentActivityParseError(error)) throw error;
    +      return await respondInvalidActivity(error);
    +    }
       } else {
         logger.debug(
           "Linked Data Signatures are not verified.",
           { recipient, json },
         );
         try {
           activity = await verifyObject(Activity, jsonWithoutSig, {
    -        contextLoader: ctx.contextLoader,
    +        contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader),
             documentLoader: ctx.documentLoader,
             keyCache,
             tracerProvider,
           });
         } catch (error) {
    +      if (
    +        error instanceof RangeError &&
    +        await hasMalformedKnownTemporalLiteral(
    +          jsonWithoutSig,
    +          ctx.contextLoader,
    +        )
    +      ) {
    +        // A deferred LDS loader failure is still retriable, but it must not
    +        // hide a payload that this boundary can already prove is permanently
    +        // malformed.  Preserve the established 400/drop behavior first.
    +        return await respondInvalidActivity(error);
    +      }
    +      if (deferredLdSignatureError != null) {
    +        logger.debug(
    +          "Object Integrity Proof fallback did not supersede a deferred " +
    +            "Linked Data Signature failure:\n{error}",
    +          { recipient, error },
    +        );
    +        activity = null;
    +      }
    +      if (!isPermanentActivityParseError(error)) throw error;
           logger.error("Failed to parse activity:\n{error}", {
             recipient,
             activity: json,
    @@ -768,6 +994,7 @@ async function handleInboxInternal<TContextData>(
             tracerProvider,
           });
           if (key == null) {
    +        if (deferredLdSignatureError != null) throw deferredLdSignatureError;
             logger.error(
               "Failed to verify the request's HTTP Signatures.",
               { recipient },
    @@ -789,7 +1016,15 @@ async function handleInboxInternal<TContextData>(
           }
           httpSigKey = key;
         }
    -    activity = await Activity.fromJsonLd(jsonWithoutSig, ctx);
    +    try {
    +      activity = await Activity.fromJsonLd(jsonWithoutSig, {
    +        ...ctx,
    +        contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader),
    +      });
    +    } catch (error) {
    +      if (!isPermanentActivityParseError(error)) throw error;
    +      return await respondInvalidActivity(error);
    +    }
       }
       if (activity.id != null) {
         span.setAttribute("activitypub.activity.id", activity.id.href);
    @@ -798,6 +1033,7 @@ async function handleInboxInternal<TContextData>(
       if (
         httpSigKey != null && !await doesActorOwnKey(activity, httpSigKey, ctx)
       ) {
    +    if (deferredLdSignatureError != null) throw deferredLdSignatureError;
         logger.error(
           "The signer ({keyId}) and the actor ({actorId}) do not match.",
           {
    @@ -819,11 +1055,36 @@ async function handleInboxInternal<TContextData>(
       }
       const routeResult = await routeActivity({
         context: ctx,
    +    // Direct handleInbox() consumers may later forward the payload from the
    +    // InboxContext returned by their public inboxContextFactory hook.  Favor
    +    // preserving the sender's exact signed body here; callers that need the
    +    // normalized representation can inspect the parsed Activity or compact the
    +    // payload explicitly.
         json,
    +    // Preserve the original payload for queue messages and for internal
    +    // InboxContextImpl instances that may forward the activity later.
    +    originalJson: json,
    +    // Queue workers may run later under stricter network or loader rules.
    +    // Keep any producer-side compaction result for signed payloads on the
    +    // queued message so workers can reuse the successful normalization without
    +    // re-fetching remote custom contexts.  This parse cache is intentionally
    +    // separate from ldSignatureVerified: fallback-authenticated traffic and
    +    // backlog messages from older producers can still depend on it, while the
    +    // raw payload stays preserved separately for forwarding and low-level
    +    // hooks.
    +    normalizedActivity: hasLdSignature && compactedJson !== json
    +      ? compactedJson
    +      : undefined,
    +    ldSignatureVerified: hasLdSignature ? ldSigVerified : undefined,
         activity,
         recipient,
         inboxListeners,
         inboxContextFactory,
    +    listenerInboxContextFactory: ldSigVerified
    +      ? (inboxContextFactory as typeof inboxContextFactory & {
    +        [rawInboxContextFactorySymbol]?: typeof inboxContextFactory;
    +      })[rawInboxContextFactorySymbol]
    +      : undefined,
         inboxErrorHandler,
         kv,
         kvPrefixes,
    
  • packages/fedify/src/federation/inbox.ts+55 3 modified
    @@ -85,6 +85,34 @@ export class InboxListenerSet<TContextData> {
     export interface RouteActivityParameters<TContextData> {
       context: Context<TContextData>;
       json: unknown;
    +  /**
    +   * The original activity payload to keep for queueing or for internal inbox
    +   * contexts that must preserve the sender's exact document.
    +   * @internal
    +   */
    +  originalJson?: unknown;
    +  /**
    +   * The normalized JSON-LD form of a signed inbox payload that Fedify already
    +   * compacted successfully while accepting it.  Queue workers can reuse this
    +   * producer-side parse cache later under stricter loader or network rules
    +   * without re-dereferencing remote custom contexts.
    +   *
    +   * When inbox work is queued, Fedify keeps this on the queued message itself
    +   * so workers can reuse the normalized representation without relying on
    +   * external sidecar lifecycles.  This cache is intentionally orthogonal to
    +   * {@link ldSignatureVerified}: fallback-authenticated signed traffic and
    +   * backlog messages from older producers may still need the normalized form
    +   * even though Linked Data Signatures were not the authentication path.
    +   */
    +  normalizedActivity?: unknown;
    +  /**
    +   * Whether the Linked Data Signature was actually verified before queueing.
    +   * This records authentication provenance separately from the optional
    +   * normalizedActivity parse cache so workers can distinguish verified LDS
    +   * replay from fallback-authenticated or legacy queued traffic.
    +   * @internal
    +   */
    +  ldSignatureVerified?: boolean;
       activity: Activity;
       recipient: string | null;
       inboxListeners?: InboxListenerSet<TContextData>;
    @@ -94,6 +122,17 @@ export interface RouteActivityParameters<TContextData> {
         activityId: string | undefined,
         activityType: string,
       ): InboxContext<TContextData>;
    +  /**
    +   * An internal context factory for dispatching inbox listeners when Fedify
    +   * needs a different payload than the public low-level hook should see.
    +   * @internal
    +   */
    +  listenerInboxContextFactory?: (
    +    recipient: string | null,
    +    activity: unknown,
    +    activityId: string | undefined,
    +    activityType: string,
    +  ) => InboxContext<TContextData>;
       inboxErrorHandler?: InboxErrorHandler<TContextData>;
       kv: KvStore;
       kvPrefixes: { activityIdempotence: KvKey };
    @@ -119,10 +158,14 @@ export async function routeActivity<TContextData>(
       {
         context: ctx,
         json,
    +    originalJson,
    +    normalizedActivity,
    +    ldSignatureVerified,
         activity,
         recipient,
         inboxListeners,
         inboxContextFactory,
    +    listenerInboxContextFactory,
         inboxErrorHandler,
         kv,
         kvPrefixes,
    @@ -222,7 +265,12 @@ export async function routeActivity<TContextData>(
               type: "inbox",
               id: crypto.randomUUID(),
               baseUrl: ctx.origin,
    -          activity: json,
    +          activity: originalJson ?? json,
    +          // Keep queued LDS inbox work self-contained.  This avoids depending
    +          // on external sidecar lifecycles across retries and redeliveries
    +          // while preserving the original payload for forwarding.
    +          ...(normalizedActivity == null ? {} : { normalizedActivity }),
    +          ...(ldSignatureVerified == null ? {} : { ldSignatureVerified }),
               identifier: recipient,
               attempt: 0,
               started: new Date().toISOString(),
    @@ -269,10 +317,14 @@ export async function routeActivity<TContextData>(
           const { class: cls, listener } = dispatched;
           span.updateName(`activitypub.dispatch_inbox_listener ${cls.name}`);
           try {
    +        const contextFactory = listenerInboxContextFactory ??
    +          inboxContextFactory;
             await listener(
    -          inboxContextFactory(
    +          contextFactory(
                 recipient,
    -            json,
    +            contextFactory === inboxContextFactory
    +              ? json
    +              : originalJson ?? json,
                 activity?.id?.href,
                 getTypeId(activity!).href,
               ),
    
  • packages/fedify/src/federation/middleware.test.ts+2212 117 modified
    @@ -13,7 +13,12 @@ import { getAuthenticatedDocumentLoader } from "../runtime/authdocloader.ts";
     import { fetchDocumentLoader, FetchError } from "../runtime/docloader.ts";
     import { signRequest, verifyRequest } from "../sig/http.ts";
     import type { KeyCache } from "../sig/key.ts";
    -import { detachSignature, signJsonLd, verifyJsonLd } from "../sig/ld.ts";
    +import {
    +  compactJsonLd,
    +  detachSignature,
    +  signJsonLd,
    +  verifyJsonLd,
    +} from "../sig/ld.ts";
     import { doesActorOwnKey } from "../sig/owner.ts";
     import { signObject, verifyObject } from "../sig/proof.ts";
     import { mockDocumentLoader } from "../testing/docloader.ts";
    @@ -1260,6 +1265,82 @@ test("Federation.setInboxListeners()", async (t) => {
       fetchMock.hardReset();
     });
     
    +test("Federation.fetch() preserves original LD-signed payload for InboxContextImpl.activity", async () => {
    +  const remoteContextUrl = "https://remote.example/contexts/ext";
    +  const sourceContextLoader = async (resource: string) => {
    +    const url = new URL(resource).href;
    +    if (url === remoteContextUrl) {
    +      return {
    +        contextUrl: null,
    +        documentUrl: url,
    +        document: {
    +          "@context": {
    +            ext: "https://example.com/ext",
    +          },
    +        },
    +      };
    +    }
    +    return await mockDocumentLoader(url);
    +  };
    +  const federation = createFederation<void>({
    +    kv: new MemoryKvStore(),
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: sourceContextLoader,
    +  });
    +  federation.setActorDispatcher(
    +    "/users/{identifier}",
    +    (_ctx, identifier) => identifier === "someone" ? new Person({}) : null,
    +  );
    +  let receivedRaw: unknown = null;
    +  let receivedTyped: Create | null = null;
    +  federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +    .on(Create, (ctx, activity) => {
    +      receivedRaw = (ctx as unknown as { activity: unknown }).activity;
    +      receivedTyped = activity;
    +    });
    +  const signed = await signJsonLd(
    +    {
    +      "@context": [
    +        remoteContextUrl,
    +        "https://www.w3.org/ns/activitystreams",
    +      ],
    +      id: "https://example.com/activities/preserve-raw",
    +      type: "Create",
    +      actor: "https://example.com/person2",
    +      ext: "preserve-me",
    +      object: {
    +        id: "https://example.com/notes/preserve-raw",
    +        type: "Note",
    +        attributedTo: "https://example.com/person2",
    +        content: "Hello, world!",
    +      },
    +    },
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +    { contextLoader: sourceContextLoader },
    +  );
    +  const response = await federation.fetch(
    +    new Request("https://example.com/inbox", {
    +      method: "POST",
    +      headers: { "Content-Type": "application/activity+json" },
    +      body: JSON.stringify(signed),
    +    }),
    +    { contextData: undefined },
    +  );
    +  assertEquals([response.status, await response.text()], [202, ""]);
    +  assertEquals(receivedRaw, signed);
    +  assertNotEquals(
    +    receivedRaw,
    +    await compactJsonLd(signed, sourceContextLoader),
    +  );
    +  const delivered = receivedTyped;
    +  assert(delivered != null);
    +  assertEquals(
    +    (delivered as Create).id?.href,
    +    "https://example.com/activities/preserve-raw",
    +  );
    +});
    +
     test("Federation.setInboxDispatcher()", async (t) => {
       const kv = new MemoryKvStore();
     
    @@ -1596,139 +1677,2111 @@ test("FederationImpl.processQueuedTask()", async (t) => {
         await federation.processQueuedTask(undefined, inboxMessage);
         assertEquals(queuedMessages, [{ ...inboxMessage, attempt: 1 }]);
       });
    -});
     
    -test("ContextImpl.lookupObject()", async (t) => {
    -  // Note that this test only checks if allowPrivateAddress option affects
    -  // the ContextImpl.lookupObject() method.  Other aspects of the method are
    -  // tested in the lookupObject() tests.
    -
    -  fetchMock.spyGlobal();
    -
    -  fetchMock.get("begin:https://localhost/.well-known/webfinger", {
    -    headers: { "Content-Type": "application/jrd+json" },
    -    body: {
    -      subject: "acct:test@localhost",
    -      links: [
    +  await t.step(
    +    "with restrictive context loader and normalized LD-signed inbox activity",
    +    async () => {
    +      const remoteContextUrl = "https://remote.example/contexts/ext";
    +      const sourceContextLoader = async (resource: string) => {
    +        const url = new URL(resource).href;
    +        if (url === remoteContextUrl) {
    +          return {
    +            contextUrl: null,
    +            documentUrl: url,
    +            document: {
    +              "@context": {
    +                ext: "https://example.com/ext",
    +              },
    +            },
    +          };
    +        }
    +        return await mockDocumentLoader(url);
    +      };
    +      const restrictiveContextLoader = async (resource: string) => {
    +        const url = new URL(resource).href;
    +        if (
    +          url === "https://www.w3.org/ns/activitystreams" ||
    +          url === "https://w3id.org/identity/v1"
    +        ) {
    +          return await mockDocumentLoader(url);
    +        }
    +        throw new Error(`Unexpected context: ${url}`);
    +      };
    +      const kv = new MemoryKvStore();
    +      let receivedCount = 0;
    +      let received: Create | null = null;
    +      let receivedRaw: unknown = null;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        contextLoader: restrictiveContextLoader,
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, (ctx, activity) => {
    +          receivedCount++;
    +          receivedRaw = (ctx as unknown as { activity: unknown }).activity;
    +          received = activity;
    +        });
    +      const signed = await signJsonLd(
             {
    -          rel: "self",
    -          type: "application/activity+json",
    -          href: "https://localhost/actor",
    +          "@context": [
    +            remoteContextUrl,
    +            "https://www.w3.org/ns/activitystreams",
    +          ],
    +          id: "https://remote.example/activities/1",
    +          type: "Create",
    +          actor: "https://remote.example/users/alice",
    +          ext: "preserve-me",
    +          object: {
    +            id: "https://remote.example/notes/1",
    +            type: "Note",
    +            attributedTo: "https://remote.example/users/alice",
    +            content: "Hello, world!",
    +          },
             },
    -      ],
    +        rsaPrivateKey3,
    +        rsaPublicKey3.id!,
    +        { contextLoader: sourceContextLoader },
    +      );
    +      const normalizedActivity = await compactJsonLd(
    +        signed,
    +        sourceContextLoader,
    +      );
    +      const messageId = crypto.randomUUID();
    +      await federation.processQueuedTask(
    +        undefined,
    +        {
    +          type: "inbox",
    +          id: messageId,
    +          baseUrl: "https://example.com",
    +          activity: signed,
    +          normalizedActivity,
    +          started: new Date().toISOString(),
    +          attempt: 0,
    +          identifier: null,
    +          traceContext: {},
    +        } satisfies InboxMessage,
    +      );
    +      const delivered = received;
    +      assert(delivered != null);
    +      const deliveredCreate = delivered as Create;
    +      assertInstanceOf(deliveredCreate, Create);
    +      assertEquals(
    +        deliveredCreate.id?.href,
    +        "https://remote.example/activities/1",
    +      );
    +      assertEquals(receivedRaw, signed);
    +      await federation.processQueuedTask(
    +        undefined,
    +        {
    +          type: "inbox",
    +          id: messageId,
    +          baseUrl: "https://example.com",
    +          activity: signed,
    +          normalizedActivity,
    +          started: new Date().toISOString(),
    +          attempt: 0,
    +          identifier: null,
    +          traceContext: {},
    +        } satisfies InboxMessage,
    +      );
    +      assertEquals(receivedCount, 1);
         },
    -  });
    +  );
     
    -  fetchMock.get("https://localhost/actor", {
    -    headers: { "Content-Type": "application/activity+json" },
    -    body: {
    -      "@context": "https://www.w3.org/ns/activitystreams",
    -      "type": "Person",
    -      "id": "https://localhost/actor",
    -      "preferredUsername": "test",
    +  await t.step(
    +    "cached normalizedActivity is rechecked for unsafe JSON-LD keywords",
    +    async () => {
    +      const queuedMessages: Message[] = [];
    +      const queue: MessageQueue = {
    +        enqueue(message, _options) {
    +          queuedMessages.push(message);
    +          return Promise.resolve();
    +        },
    +        listen(_handler, _options) {
    +          return Promise.resolve();
    +        },
    +      };
    +      const kv = new MemoryKvStore();
    +      let receivedCount = 0;
    +      let errorCount = 0;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        queue,
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, () => {
    +          receivedCount++;
    +        })
    +        .onError(() => {
    +          errorCount++;
    +        });
    +      const signed = await signJsonLd(
    +        {
    +          "@context": "https://www.w3.org/ns/activitystreams",
    +          id: "https://remote.example/activities/unsafe-normalized-cache",
    +          type: "Create",
    +          actor: "https://remote.example/users/alice",
    +          object: {
    +            id: "https://remote.example/notes/unsafe-normalized-cache",
    +            type: "Note",
    +            attributedTo: "https://remote.example/users/alice",
    +            content: "Hello from unsafe normalized cache",
    +          },
    +        },
    +        rsaPrivateKey3,
    +        rsaPublicKey3.id!,
    +        { contextLoader: mockDocumentLoader },
    +      );
    +      const normalizedActivity = await compactJsonLd(
    +        signed,
    +        mockDocumentLoader,
    +      );
    +      const tamperedNormalizedActivity = {
    +        ...(normalizedActivity as Record<string, unknown>),
    +        signature: {
    +          ...((normalizedActivity as { signature: Record<string, unknown> })
    +            .signature),
    +          "@included": [
    +            {
    +              id: "https://remote.example/activities/inside-signature",
    +              type: "Undo",
    +            },
    +          ],
    +        },
    +      };
    +      await federation.processQueuedTask(
    +        undefined,
    +        {
    +          type: "inbox",
    +          id: crypto.randomUUID(),
    +          baseUrl: "https://example.com",
    +          activity: signed,
    +          normalizedActivity: tamperedNormalizedActivity,
    +          ldSignatureVerified: false,
    +          started: new Date().toISOString(),
    +          attempt: 0,
    +          identifier: null,
    +          traceContext: {},
    +        } satisfies InboxMessage,
    +      );
    +      assertEquals(receivedCount, 0);
    +      assertEquals(errorCount, 1);
    +      assertEquals(queuedMessages, []);
         },
    -  });
    -
    -  await t.step("allowPrivateAddress: true", async () => {
    -    const federation = createFederation<void>({
    -      kv: new MemoryKvStore(),
    -      allowPrivateAddress: true,
    -    });
    -    const ctx = federation.createContext(new URL("https://example.com/"));
    -    const result = await ctx.lookupObject("@test@localhost");
    -    assertInstanceOf(result, Person);
    -    assertEquals(result.id, new URL("https://localhost/actor"));
    -    assertEquals(result.preferredUsername, "test");
    -  });
    -
    -  await t.step("allowPrivateAddress: false", async () => {
    -    const federation = createFederation<void>({
    -      kv: new MemoryKvStore(),
    -      allowPrivateAddress: false,
    -    });
    -    const ctx = federation.createContext(new URL("https://example.com/"));
    -    const result = await ctx.lookupObject("@test@localhost");
    -    assertEquals(result, null);
    -  });
    +  );
     
    -  fetchMock.hardReset();
    -});
    +  await t.step(
    +    "old queued LDS inbox messages without normalizedActivity still work",
    +    async () => {
    +      const restrictiveContextLoader = async (resource: string) => {
    +        const url = new URL(resource).href;
    +        if (
    +          url === "https://www.w3.org/ns/activitystreams" ||
    +          url === "https://w3id.org/identity/v1"
    +        ) {
    +          return await mockDocumentLoader(url);
    +        }
    +        throw new Error(`Unexpected context: ${url}`);
    +      };
    +      const kv = new MemoryKvStore();
    +      let received: Create | null = null;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        contextLoader: restrictiveContextLoader,
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, (_ctx, activity) => {
    +          received = activity;
    +        });
    +      const compacted = await compactJsonLd(
    +        await signJsonLd(
    +          {
    +            "@context": "https://www.w3.org/ns/activitystreams",
    +            id: "https://remote.example/activities/legacy",
    +            type: "Create",
    +            actor: "https://remote.example/users/alice",
    +            object: {
    +              id: "https://remote.example/notes/legacy",
    +              type: "Note",
    +              attributedTo: "https://remote.example/users/alice",
    +              content: "Hello from legacy queue",
    +            },
    +          },
    +          rsaPrivateKey3,
    +          rsaPublicKey3.id!,
    +          { contextLoader: mockDocumentLoader },
    +        ),
    +        restrictiveContextLoader,
    +      );
    +      await federation.processQueuedTask(
    +        undefined,
    +        {
    +          type: "inbox",
    +          id: crypto.randomUUID(),
    +          baseUrl: "https://example.com",
    +          activity: compacted,
    +          started: new Date().toISOString(),
    +          attempt: 0,
    +          identifier: null,
    +          traceContext: {},
    +        } satisfies InboxMessage,
    +      );
    +      assert(received != null);
    +      assertEquals(
    +        (received as Create).id?.href,
    +        "https://remote.example/activities/legacy",
    +      );
    +    },
    +  );
     
    -test("ContextImpl.sendActivity()", async (t) => {
    -  fetchMock.spyGlobal();
    +  await t.step(
    +    "queued signature-bearing non-LDS inbox messages keep parse-time normalization contexts",
    +    async () => {
    +      const signingContextLoader = async (resource: string) => {
    +        const url = new URL(resource).href;
    +        if (
    +          url === "https://www.w3.org/ns/activitystreams" ||
    +          url === "https://w3id.org/identity/v1" ||
    +          url === "https://w3id.org/security/v1" ||
    +          url === "https://w3id.org/security/data-integrity/v1"
    +        ) {
    +          return await mockDocumentLoader(url);
    +        }
    +        throw new Error(`Unexpected context: ${url}`);
    +      };
    +      const processingContextLoader = async (resource: string) => {
    +        const url = new URL(resource).href;
    +        if (
    +          url === "https://w3id.org/identity/v1" ||
    +          url === "https://w3id.org/security/v1" ||
    +          url === "https://w3id.org/security/data-integrity/v1"
    +        ) {
    +          throw new Error(
    +            "queued non-LDS signed payloads should parse with the normalization loader's built-in signature contexts",
    +          );
    +        }
    +        return await signingContextLoader(resource);
    +      };
    +      const kv = new MemoryKvStore();
    +      let received: Create | null = null;
    +      let receivedRaw: unknown = null;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        contextLoader: processingContextLoader,
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, (ctx, activity) => {
    +          receivedRaw = (ctx as unknown as { activity: unknown }).activity;
    +          received = activity;
    +        });
    +      const signed = await signJsonLd(
    +        {
    +          "@context": [
    +            "https://www.w3.org/ns/activitystreams",
    +            "https://w3id.org/security/v1",
    +          ],
    +          id: "https://remote.example/activities/non-lds-queued-signature",
    +          type: "Create",
    +          actor: "https://remote.example/users/alice",
    +          object: {
    +            id: "https://remote.example/notes/non-lds-queued-signature",
    +            type: "Note",
    +            attributedTo: "https://remote.example/users/alice",
    +            content: "Hello from non-LDS queued signature",
    +          },
    +        },
    +        rsaPrivateKey3,
    +        rsaPublicKey3.id!,
    +        { contextLoader: signingContextLoader },
    +      );
    +      const signedPayload = signed as Record<string, unknown>;
    +      assert(
    +        Array.isArray(signedPayload["@context"]) &&
    +          signedPayload["@context"].includes("https://w3id.org/security/v1"),
    +      );
    +      await federation.processQueuedTask(
    +        undefined,
    +        {
    +          type: "inbox",
    +          id: crypto.randomUUID(),
    +          baseUrl: "https://example.com",
    +          activity: signed,
    +          ldSignatureVerified: false,
    +          started: new Date().toISOString(),
    +          attempt: 0,
    +          identifier: null,
    +          traceContext: {},
    +        } satisfies InboxMessage,
    +      );
    +      if (received == null) throw new Error("Inbox activity not delivered.");
    +      const delivered = received as Create;
    +      assertEquals(
    +        delivered.id?.href,
    +        "https://remote.example/activities/non-lds-queued-signature",
    +      );
    +      assertEquals(receivedRaw, signed);
    +    },
    +  );
     
    -  let verified: ("http" | "ld" | "proof")[] | null = null;
    -  let request: Request | null = null;
    -  let collectionSyncHeader: string | null = null;
    -  fetchMock.post("https://example.com/inbox", async (cl) => {
    -    verified = [];
    -    request = cl.request!.clone() as Request;
    -    collectionSyncHeader = cl.request!.headers.get(
    -      "Collection-Synchronization",
    -    );
    -    const options = {
    -      async documentLoader(url: string) {
    -        const response = await federation.fetch(
    -          new Request(url),
    -          { contextData: undefined },
    -        );
    -        if (response.ok) {
    +  await t.step(
    +    "queued signature-bearing non-LDS inbox messages reuse normalizedActivity for custom contexts",
    +    async () => {
    +      const remoteContextUrl = "https://remote.example/contexts/ext";
    +      const sourceContextLoader = async (resource: string) => {
    +        const url = new URL(resource).href;
    +        if (url === remoteContextUrl) {
               return {
                 contextUrl: null,
    -            document: await response.json(),
    -            documentUrl: response.url,
    +            documentUrl: url,
    +            document: {
    +              "@context": {
    +                ext: "https://example.com/ext",
    +              },
    +            },
               };
             }
             return await mockDocumentLoader(url);
    -      },
    -      contextLoader: mockDocumentLoader,
    -      keyCache: {
    -        async get(keyId: URL) {
    -          const ctx = federation.createContext(
    -            new URL("https://example.com/"),
    -            undefined,
    -          );
    -          const keys = await ctx.getActorKeyPairs("1");
    -          for (const key of keys) {
    -            if (key.keyId.href === keyId.href) {
    -              return key.cryptographicKey;
    -            }
    -            if (key.multikey.id?.href === keyId.href) {
    -              return key.multikey;
    -            }
    -          }
    -          return undefined;
    +      };
    +      const restrictiveContextLoader = async (resource: string) => {
    +        const url = new URL(resource).href;
    +        if (
    +          url === "https://www.w3.org/ns/activitystreams" ||
    +          url === "https://w3id.org/identity/v1"
    +        ) {
    +          return await mockDocumentLoader(url);
    +        }
    +        throw new Error(`Unexpected context: ${url}`);
    +      };
    +      const kv = new MemoryKvStore();
    +      let received: Create | null = null;
    +      let receivedRaw: unknown = null;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        contextLoader: restrictiveContextLoader,
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, (ctx, activity) => {
    +          receivedRaw = (ctx as unknown as { activity: unknown }).activity;
    +          received = activity;
    +        });
    +      const unsignedBody = {
    +        "@context": [
    +          remoteContextUrl,
    +          "https://www.w3.org/ns/activitystreams",
    +          "https://w3id.org/security/v1",
    +        ],
    +        id: "https://remote.example/activities/non-lds-queued-custom-context",
    +        type: "Create",
    +        actor: "https://remote.example/users/alice",
    +        ext: "preserve-me",
    +        object: {
    +          id: "https://remote.example/notes/non-lds-queued-custom-context",
    +          type: "Note",
    +          attributedTo: "https://remote.example/users/alice",
    +          content: "Hello from non-LDS queued custom context",
             },
    -        async set(_keyId: URL, _key: CryptographicKey | Multikey | null) {
    +        signature: {
    +          type: "RsaSignature2017",
    +          creator: "not a url",
    +          created: "2024-09-12T16:50:46Z",
    +          signatureValue: "Zm9v",
             },
    -      } satisfies KeyCache,
    -    };
    -    let json = await cl.request!.json();
    -    if (await verifyJsonLd(json, options)) verified.push("ld");
    -    json = detachSignature(json);
    -    let activity = await verifyObject(Activity, json, options);
    -    if (activity == null) {
    -      activity = await Activity.fromJsonLd(json, options);
    -    } else {
    -      verified.push("proof");
    -    }
    -    const key = await verifyRequest(request, options);
    -    if (key != null && await doesActorOwnKey(activity, key, options)) {
    -      verified.push("http");
    -    }
    -    if (verified.length > 0) return new Response(null, { status: 202 });
    -    return new Response(null, { status: 401 });
    -  });
    -
    -  const kv = new MemoryKvStore();
    -  const federation = new FederationImpl<void>({
    -    kv,
    -    contextLoader: mockDocumentLoader,
    -  });
    +      };
    +      const normalizedActivity = await compactJsonLd(
    +        unsignedBody,
    +        sourceContextLoader,
    +      );
    +      await federation.processQueuedTask(
    +        undefined,
    +        {
    +          type: "inbox",
    +          id: crypto.randomUUID(),
    +          baseUrl: "https://example.com",
    +          activity: unsignedBody,
    +          normalizedActivity,
    +          ldSignatureVerified: false,
    +          started: new Date().toISOString(),
    +          attempt: 0,
    +          identifier: null,
    +          traceContext: {},
    +        } satisfies InboxMessage,
    +      );
    +      if (received == null) throw new Error("Inbox activity not delivered.");
    +      const delivered = received as Create;
    +      assertEquals(
    +        delivered.id?.href,
    +        "https://remote.example/activities/non-lds-queued-custom-context",
    +      );
    +      assertEquals(receivedRaw, unsignedBody);
    +    },
    +  );
     
    -  federation
    -    .setActorDispatcher("/{identifier}", async (ctx, identifier) => {
    -      if (identifier !== "1") return null;
    +  await t.step(
    +    "legacy raw LDS inbox messages without normalizedActivity retry through worker error handling",
    +    async () => {
    +      const remoteContextUrl = "https://remote.example/contexts/ext";
    +      const restrictiveContextLoader = async (resource: string) => {
    +        const url = new URL(resource).href;
    +        if (
    +          url === "https://www.w3.org/ns/activitystreams" ||
    +          url === "https://w3id.org/identity/v1"
    +        ) {
    +          return await mockDocumentLoader(url);
    +        }
    +        throw new Error(`Unexpected context: ${url}`);
    +      };
    +      const queue: MessageQueue = {
    +        enqueue(message, _options) {
    +          queuedMessages.push(message);
    +          return Promise.resolve();
    +        },
    +        listen(_handler, _options) {
    +          return Promise.resolve();
    +        },
    +      };
    +      const kv = new MemoryKvStore();
    +      const queuedMessages: Message[] = [];
    +      let errorCount = 0;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        queue,
    +        contextLoader: restrictiveContextLoader,
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, () => {
    +          throw new Error("listener should not run");
    +        })
    +        .onError(() => {
    +          errorCount++;
    +        });
    +      const signed = await signJsonLd(
    +        {
    +          "@context": [
    +            remoteContextUrl,
    +            "https://www.w3.org/ns/activitystreams",
    +          ],
    +          id: "https://remote.example/activities/legacy-raw",
    +          type: "Create",
    +          actor: "https://remote.example/users/alice",
    +          ext: "preserve-me",
    +          object: {
    +            id: "https://remote.example/notes/legacy-raw",
    +            type: "Note",
    +            attributedTo: "https://remote.example/users/alice",
    +            content: "Hello from raw legacy queue",
    +          },
    +        },
    +        rsaPrivateKey3,
    +        rsaPublicKey3.id!,
    +        {
    +          contextLoader: async (resource: string) => {
    +            const url = new URL(resource).href;
    +            if (url === remoteContextUrl) {
    +              return {
    +                contextUrl: null,
    +                documentUrl: url,
    +                document: {
    +                  "@context": {
    +                    ext: "https://example.com/ext",
    +                  },
    +                },
    +              };
    +            }
    +            return await mockDocumentLoader(url);
    +          },
    +        },
    +      );
    +      const inboxMessage = {
    +        type: "inbox",
    +        id: crypto.randomUUID(),
    +        baseUrl: "https://example.com",
    +        activity: signed,
    +        started: new Date().toISOString(),
    +        attempt: 0,
    +        identifier: null,
    +        traceContext: {},
    +      } satisfies InboxMessage;
    +      await federation.processQueuedTask(undefined, inboxMessage);
    +      assertEquals(errorCount, 1);
    +      assertEquals(queuedMessages.length, 1);
    +      const retried = queuedMessages[0] as InboxMessage;
    +      assertEquals(retried.attempt, 1);
    +      assertEquals(retried.activity, inboxMessage.activity);
    +    },
    +  );
    +
    +  await t.step(
    +    "without inbox queue retriable inbox parse failures bubble to caller",
    +    async () => {
    +      const remoteContextUrl = "https://remote.example/contexts/ext";
    +      const sourceContextLoader = async (resource: string) => {
    +        const url = new URL(resource).href;
    +        if (url === remoteContextUrl) {
    +          return {
    +            contextUrl: null,
    +            documentUrl: url,
    +            document: {
    +              "@context": {
    +                ext: "https://example.com/ext",
    +              },
    +            },
    +          };
    +        }
    +        return await mockDocumentLoader(url);
    +      };
    +      let errorCount = 0;
    +      const federation = new FederationImpl<void>({
    +        kv: new MemoryKvStore(),
    +        contextLoader: async (resource: string) => {
    +          const url = new URL(resource).href;
    +          if (
    +            url === "https://www.w3.org/ns/activitystreams" ||
    +            url === "https://w3id.org/identity/v1"
    +          ) {
    +            return await mockDocumentLoader(url);
    +          }
    +          if (url === remoteContextUrl) {
    +            throw new Error(`Transient remote context failure: ${url}`);
    +          }
    +          throw new Error(`Unexpected context: ${url}`);
    +        },
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, () => {
    +          throw new Error("listener should not run");
    +        })
    +        .onError(() => {
    +          errorCount++;
    +        });
    +      const signed = await signJsonLd(
    +        {
    +          "@context": [
    +            remoteContextUrl,
    +            "https://www.w3.org/ns/activitystreams",
    +          ],
    +          id: "https://remote.example/activities/manual-retry",
    +          type: "Create",
    +          actor: "https://remote.example/users/alice",
    +          ext: "preserve-me",
    +          object: {
    +            id: "https://remote.example/notes/manual-retry",
    +            type: "Note",
    +            attributedTo: "https://remote.example/users/alice",
    +            content: "Hello from manual retry queue",
    +          },
    +        },
    +        rsaPrivateKey3,
    +        rsaPublicKey3.id!,
    +        { contextLoader: sourceContextLoader },
    +      );
    +      await assertRejects(
    +        () =>
    +          federation.processQueuedTask(
    +            undefined,
    +            {
    +              type: "inbox",
    +              id: crypto.randomUUID(),
    +              baseUrl: "https://example.com",
    +              activity: signed,
    +              started: new Date().toISOString(),
    +              attempt: 0,
    +              identifier: null,
    +              traceContext: {},
    +            } satisfies InboxMessage,
    +          ),
    +        Error,
    +      );
    +      assertEquals(errorCount, 1);
    +    },
    +  );
    +
    +  await t.step(
    +    "legacy raw LDS inbox messages with transient InvalidUrl failures retry",
    +    async () => {
    +      const remoteContextUrl = "https://remote.example/contexts/ext";
    +      const queue: MessageQueue = {
    +        enqueue(message, _options) {
    +          queuedMessages.push(message);
    +          return Promise.resolve();
    +        },
    +        listen(_handler, _options) {
    +          return Promise.resolve();
    +        },
    +      };
    +      const kv = new MemoryKvStore();
    +      const queuedMessages: Message[] = [];
    +      let errorCount = 0;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        queue,
    +        contextLoader: async (resource: string) => {
    +          const url = new URL(resource).href;
    +          if (
    +            url === "https://www.w3.org/ns/activitystreams" ||
    +            url === "https://w3id.org/identity/v1"
    +          ) {
    +            return await mockDocumentLoader(url);
    +          }
    +          if (url === remoteContextUrl) {
    +            const error = new Error(
    +              `Transient remote context failure: ${url}`,
    +            ) as Error & { details?: { code: string; url: string } };
    +            error.name = "jsonld.InvalidUrl";
    +            error.details = {
    +              code: "loading remote context failed",
    +              url,
    +            };
    +            throw error;
    +          }
    +          throw new Error(`Unexpected context: ${url}`);
    +        },
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, () => {
    +          throw new Error("listener should not run");
    +        })
    +        .onError(() => {
    +          errorCount++;
    +        });
    +      const signed = await signJsonLd(
    +        {
    +          "@context": [
    +            remoteContextUrl,
    +            "https://www.w3.org/ns/activitystreams",
    +          ],
    +          id: "https://remote.example/activities/legacy-invalid-context",
    +          type: "Create",
    +          actor: "https://remote.example/users/alice",
    +          ext: "preserve-me",
    +          object: {
    +            id: "https://remote.example/notes/legacy-invalid-context",
    +            type: "Note",
    +            attributedTo: "https://remote.example/users/alice",
    +            content: "Hello from invalid legacy queue",
    +          },
    +        },
    +        rsaPrivateKey3,
    +        rsaPublicKey3.id!,
    +        {
    +          contextLoader: async (resource: string) => {
    +            const url = new URL(resource).href;
    +            if (url === remoteContextUrl) {
    +              return {
    +                contextUrl: null,
    +                documentUrl: url,
    +                document: {
    +                  "@context": {
    +                    ext: "https://example.com/ext",
    +                  },
    +                },
    +              };
    +            }
    +            return await mockDocumentLoader(url);
    +          },
    +        },
    +      );
    +      const inboxMessage = {
    +        type: "inbox",
    +        id: crypto.randomUUID(),
    +        baseUrl: "https://example.com",
    +        activity: signed,
    +        started: new Date().toISOString(),
    +        attempt: 0,
    +        identifier: null,
    +        traceContext: {},
    +      } satisfies InboxMessage;
    +      await federation.processQueuedTask(undefined, inboxMessage);
    +      assertEquals(errorCount, 1);
    +      assertEquals(queuedMessages.length, 1);
    +      const retried = queuedMessages[0] as InboxMessage;
    +      assertEquals(retried.attempt, 1);
    +      assertEquals(retried.activity, inboxMessage.activity);
    +    },
    +  );
    +
    +  await t.step(
    +    "legacy raw LDS inbox messages with opaque context ids retry",
    +    async () => {
    +      const queue: MessageQueue = {
    +        enqueue(message, _options) {
    +          queuedMessages.push(message);
    +          return Promise.resolve();
    +        },
    +        listen(_handler, _options) {
    +          return Promise.resolve();
    +        },
    +      };
    +      const kv = new MemoryKvStore();
    +      const queuedMessages: Message[] = [];
    +      let errorCount = 0;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        queue,
    +        contextLoader: async (resource: string) => {
    +          if (resource === "app-context") {
    +            const error = new Error(
    +              `Opaque context backend is unavailable: ${resource}`,
    +            ) as Error & { details?: { code: string; url: string } };
    +            error.name = "jsonld.InvalidUrl";
    +            error.details = {
    +              code: "loading remote context failed",
    +              url: resource,
    +            };
    +            throw error;
    +          }
    +          const url = new URL(resource).href;
    +          if (
    +            url === "https://www.w3.org/ns/activitystreams" ||
    +            url === "https://w3id.org/identity/v1"
    +          ) {
    +            return await mockDocumentLoader(url);
    +          }
    +          throw new Error(`Unexpected context: ${resource}`);
    +        },
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, () => {
    +          throw new Error("listener should not run");
    +        })
    +        .onError(() => {
    +          errorCount++;
    +        });
    +      const signed = await signJsonLd(
    +        {
    +          "@context": "https://www.w3.org/ns/activitystreams",
    +          id: "https://remote.example/activities/legacy-malformed-context",
    +          type: "Create",
    +          actor: "https://remote.example/users/alice",
    +          object: {
    +            id: "https://remote.example/notes/legacy-malformed-context",
    +            type: "Note",
    +            attributedTo: "https://remote.example/users/alice",
    +            content: "Hello from malformed legacy queue",
    +          },
    +        },
    +        rsaPrivateKey3,
    +        rsaPublicKey3.id!,
    +        { contextLoader: mockDocumentLoader },
    +      );
    +      const inboxMessage = {
    +        type: "inbox",
    +        id: crypto.randomUUID(),
    +        baseUrl: "https://example.com",
    +        activity: {
    +          ...signed,
    +          "@context": [
    +            "app-context",
    +            "https://www.w3.org/ns/activitystreams",
    +          ],
    +        },
    +        started: new Date().toISOString(),
    +        attempt: 0,
    +        identifier: null,
    +        traceContext: {},
    +      } satisfies InboxMessage;
    +      await federation.processQueuedTask(undefined, inboxMessage);
    +      assertEquals(errorCount, 1);
    +      assertEquals(queuedMessages, [{ ...inboxMessage, attempt: 1 }]);
    +    },
    +  );
    +
    +  await t.step(
    +    "legacy raw LDS inbox messages with Invalid URL TypeErrors retry",
    +    async () => {
    +      const queue: MessageQueue = {
    +        enqueue(message, _options) {
    +          queuedMessages.push(message);
    +          return Promise.resolve();
    +        },
    +        listen(_handler, _options) {
    +          return Promise.resolve();
    +        },
    +      };
    +      const kv = new MemoryKvStore();
    +      const queuedMessages: Message[] = [];
    +      let errorCount = 0;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        queue,
    +        contextLoader: async (resource: string) => {
    +          if (resource === "app:context") {
    +            throw new TypeError(`Invalid URL: ${resource}`);
    +          }
    +          const url = new URL(resource).href;
    +          if (
    +            url === "https://www.w3.org/ns/activitystreams" ||
    +            url === "https://w3id.org/identity/v1"
    +          ) {
    +            return await mockDocumentLoader(url);
    +          }
    +          throw new Error(`Unexpected context: ${resource}`);
    +        },
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, () => {
    +          throw new Error("listener should not run");
    +        })
    +        .onError(() => {
    +          errorCount++;
    +        });
    +      const signed = await signJsonLd(
    +        {
    +          "@context": "https://www.w3.org/ns/activitystreams",
    +          id: "https://remote.example/activities/legacy-typeerror-invalid-url",
    +          type: "Create",
    +          actor: "https://remote.example/users/alice",
    +          object: {
    +            id: "https://remote.example/notes/legacy-typeerror-invalid-url",
    +            type: "Note",
    +            attributedTo: "https://remote.example/users/alice",
    +            content: "Hello from invalid-url typeerror queue",
    +          },
    +        },
    +        rsaPrivateKey3,
    +        rsaPublicKey3.id!,
    +        { contextLoader: mockDocumentLoader },
    +      );
    +      const inboxMessage = {
    +        type: "inbox",
    +        id: crypto.randomUUID(),
    +        baseUrl: "https://example.com",
    +        activity: {
    +          ...signed,
    +          "@context": [
    +            "app:context",
    +            "https://www.w3.org/ns/activitystreams",
    +          ],
    +        },
    +        started: new Date().toISOString(),
    +        attempt: 0,
    +        identifier: null,
    +        traceContext: {},
    +      } satisfies InboxMessage;
    +      await federation.processQueuedTask(undefined, inboxMessage);
    +      assertEquals(errorCount, 1);
    +      assertEquals(queuedMessages.length, 1);
    +      const retried = queuedMessages[0] as InboxMessage;
    +      assertEquals(retried.attempt, 1);
    +      assertEquals(retried.activity, inboxMessage.activity);
    +    },
    +  );
    +
    +  await t.step(
    +    "legacy raw LDS inbox messages with malformed absolute context refs do not retry",
    +    async () => {
    +      const queue: MessageQueue = {
    +        enqueue(message, _options) {
    +          queuedMessages.push(message);
    +          return Promise.resolve();
    +        },
    +        listen(_handler, _options) {
    +          return Promise.resolve();
    +        },
    +      };
    +      const kv = new MemoryKvStore();
    +      const queuedMessages: Message[] = [];
    +      let errorCount = 0;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        queue,
    +        contextLoader: async (resource: string) => {
    +          if (resource === "http:/[") {
    +            throw new TypeError(`Invalid URL: ${resource}`);
    +          }
    +          const url = new URL(resource).href;
    +          if (
    +            url === "https://www.w3.org/ns/activitystreams" ||
    +            url === "https://w3id.org/identity/v1"
    +          ) {
    +            return await mockDocumentLoader(url);
    +          }
    +          throw new Error(`Unexpected context: ${resource}`);
    +        },
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, () => {
    +          throw new Error("listener should not run");
    +        })
    +        .onError(() => {
    +          errorCount++;
    +        });
    +      const signed = await signJsonLd(
    +        {
    +          "@context": "https://www.w3.org/ns/activitystreams",
    +          id:
    +            "https://remote.example/activities/legacy-malformed-absolute-context",
    +          type: "Create",
    +          actor: "https://remote.example/users/alice",
    +          object: {
    +            id:
    +              "https://remote.example/notes/legacy-malformed-absolute-context",
    +            type: "Note",
    +            attributedTo: "https://remote.example/users/alice",
    +            content: "Hello from malformed absolute context queue",
    +          },
    +        },
    +        rsaPrivateKey3,
    +        rsaPublicKey3.id!,
    +        { contextLoader: mockDocumentLoader },
    +      );
    +      const inboxMessage = {
    +        type: "inbox",
    +        id: crypto.randomUUID(),
    +        baseUrl: "https://example.com",
    +        activity: {
    +          ...signed,
    +          "@context": [
    +            "http:/[",
    +            "https://www.w3.org/ns/activitystreams",
    +          ],
    +        },
    +        started: new Date().toISOString(),
    +        attempt: 0,
    +        identifier: null,
    +        traceContext: {},
    +      } satisfies InboxMessage;
    +      await federation.processQueuedTask(undefined, inboxMessage);
    +      assertEquals(errorCount, 1);
    +      assertEquals(queuedMessages, []);
    +    },
    +  );
    +
    +  await t.step(
    +    "malformed IRI fields are permanent queued inbox parse errors",
    +    async () => {
    +      const queuedMessages: Message[] = [];
    +      const queue: MessageQueue = {
    +        enqueue(message, _options) {
    +          queuedMessages.push(message);
    +          return Promise.resolve();
    +        },
    +        listen(_handler, _options) {
    +          return Promise.resolve();
    +        },
    +      };
    +      const kv = new MemoryKvStore();
    +      let errorCount = 0;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        queue,
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, () => {
    +          throw new Error("listener should not run");
    +        })
    +        .onError(() => {
    +          errorCount++;
    +        });
    +      await federation.processQueuedTask(
    +        undefined,
    +        {
    +          type: "inbox",
    +          id: crypto.randomUUID(),
    +          baseUrl: "https://example.com",
    +          activity: {
    +            "@context": "https://www.w3.org/ns/activitystreams",
    +            id: "http://[",
    +            type: "Create",
    +            actor: "https://remote.example/users/alice",
    +            object: {
    +              id: "https://remote.example/notes/invalid-iri",
    +              type: "Note",
    +              attributedTo: "https://remote.example/users/alice",
    +              content: "Hello from invalid IRI queue",
    +            },
    +          },
    +          started: new Date().toISOString(),
    +          attempt: 0,
    +          identifier: null,
    +          traceContext: {},
    +        } satisfies InboxMessage,
    +      );
    +      assertEquals(errorCount, 1);
    +      assertEquals(queuedMessages, []);
    +    },
    +  );
    +
    +  await t.step(
    +    "legacy raw LDS inbox messages with network-path context ids retry",
    +    async () => {
    +      const queue: MessageQueue = {
    +        enqueue(message, _options) {
    +          queuedMessages.push(message);
    +          return Promise.resolve();
    +        },
    +        listen(_handler, _options) {
    +          return Promise.resolve();
    +        },
    +      };
    +      const kv = new MemoryKvStore();
    +      const queuedMessages: Message[] = [];
    +      let errorCount = 0;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        queue,
    +        contextLoader: async (resource: string) => {
    +          if (resource === "//cdn.example/ctx") {
    +            const error = new Error(
    +              `Network-path context backend is unavailable: ${resource}`,
    +            ) as Error & { details?: { code: string; url: string } };
    +            error.name = "jsonld.InvalidUrl";
    +            error.details = {
    +              code: "loading remote context failed",
    +              url: resource,
    +            };
    +            throw error;
    +          }
    +          const url = new URL(resource).href;
    +          if (
    +            url === "https://www.w3.org/ns/activitystreams" ||
    +            url === "https://w3id.org/identity/v1"
    +          ) {
    +            return await mockDocumentLoader(url);
    +          }
    +          throw new Error(`Unexpected context: ${resource}`);
    +        },
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, () => {
    +          throw new Error("listener should not run");
    +        })
    +        .onError(() => {
    +          errorCount++;
    +        });
    +      const signed = await signJsonLd(
    +        {
    +          "@context": "https://www.w3.org/ns/activitystreams",
    +          id: "https://remote.example/activities/legacy-network-path-context",
    +          type: "Create",
    +          actor: "https://remote.example/users/alice",
    +          object: {
    +            id: "https://remote.example/notes/legacy-network-path-context",
    +            type: "Note",
    +            attributedTo: "https://remote.example/users/alice",
    +            content: "Hello from network-path legacy queue",
    +          },
    +        },
    +        rsaPrivateKey3,
    +        rsaPublicKey3.id!,
    +        { contextLoader: mockDocumentLoader },
    +      );
    +      const inboxMessage = {
    +        type: "inbox",
    +        id: crypto.randomUUID(),
    +        baseUrl: "https://example.com",
    +        activity: {
    +          ...signed,
    +          "@context": [
    +            "//cdn.example/ctx",
    +            "https://www.w3.org/ns/activitystreams",
    +          ],
    +        },
    +        started: new Date().toISOString(),
    +        attempt: 0,
    +        identifier: null,
    +        traceContext: {},
    +      } satisfies InboxMessage;
    +      await federation.processQueuedTask(undefined, inboxMessage);
    +      assertEquals(errorCount, 1);
    +      assertEquals(queuedMessages, [{ ...inboxMessage, attempt: 1 }]);
    +    },
    +  );
    +
    +  await t.step(
    +    "legacy raw LDS inbox messages with malformed network-path refs do not retry",
    +    async () => {
    +      const queue: MessageQueue = {
    +        enqueue(message, _options) {
    +          queuedMessages.push(message);
    +          return Promise.resolve();
    +        },
    +        listen(_handler, _options) {
    +          return Promise.resolve();
    +        },
    +      };
    +      const kv = new MemoryKvStore();
    +      const queuedMessages: Message[] = [];
    +      let errorCount = 0;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        queue,
    +        contextLoader: async (resource: string) => {
    +          if (resource === "//[") {
    +            const error = new Error(
    +              `Malformed network-path context: ${resource}`,
    +            ) as Error & { details?: { code: string; url: string } };
    +            error.name = "jsonld.InvalidUrl";
    +            error.details = {
    +              code: "loading remote context failed",
    +              url: resource,
    +            };
    +            throw error;
    +          }
    +          const url = new URL(resource).href;
    +          if (
    +            url === "https://www.w3.org/ns/activitystreams" ||
    +            url === "https://w3id.org/identity/v1"
    +          ) {
    +            return await mockDocumentLoader(url);
    +          }
    +          throw new Error(`Unexpected context: ${resource}`);
    +        },
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, () => {
    +          throw new Error("listener should not run");
    +        })
    +        .onError(() => {
    +          errorCount++;
    +        });
    +      const signed = await signJsonLd(
    +        {
    +          "@context": "https://www.w3.org/ns/activitystreams",
    +          id:
    +            "https://remote.example/activities/legacy-malformed-network-path-context",
    +          type: "Create",
    +          actor: "https://remote.example/users/alice",
    +          object: {
    +            id:
    +              "https://remote.example/notes/legacy-malformed-network-path-context",
    +            type: "Note",
    +            attributedTo: "https://remote.example/users/alice",
    +            content: "Hello from malformed network-path legacy queue",
    +          },
    +        },
    +        rsaPrivateKey3,
    +        rsaPublicKey3.id!,
    +        { contextLoader: mockDocumentLoader },
    +      );
    +      const inboxMessage = {
    +        type: "inbox",
    +        id: crypto.randomUUID(),
    +        baseUrl: "https://example.com",
    +        activity: {
    +          ...signed,
    +          "@context": [
    +            "//[",
    +            "https://www.w3.org/ns/activitystreams",
    +          ],
    +        },
    +        started: new Date().toISOString(),
    +        attempt: 0,
    +        identifier: null,
    +        traceContext: {},
    +      } satisfies InboxMessage;
    +      await federation.processQueuedTask(undefined, inboxMessage);
    +      assertEquals(errorCount, 1);
    +      assertEquals(queuedMessages, []);
    +    },
    +  );
    +
    +  await t.step(
    +    "legacy raw LDS inbox messages with malformed context URLs do not retry",
    +    async () => {
    +      const queue: MessageQueue = {
    +        enqueue(message, _options) {
    +          queuedMessages.push(message);
    +          return Promise.resolve();
    +        },
    +        listen(_handler, _options) {
    +          return Promise.resolve();
    +        },
    +      };
    +      const kv = new MemoryKvStore();
    +      const queuedMessages: Message[] = [];
    +      let errorCount = 0;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        queue,
    +        contextLoader: async (resource: string) => {
    +          if (resource === "not a url") {
    +            const error = new Error(
    +              `Invalid remote context URL: ${resource}`,
    +            ) as Error & { details?: { code: string; url: string } };
    +            error.name = "jsonld.InvalidUrl";
    +            error.details = {
    +              code: "loading remote context failed",
    +              url: resource,
    +            };
    +            throw error;
    +          }
    +          const url = new URL(resource).href;
    +          if (
    +            url === "https://www.w3.org/ns/activitystreams" ||
    +            url === "https://w3id.org/identity/v1"
    +          ) {
    +            return await mockDocumentLoader(url);
    +          }
    +          throw new Error(`Unexpected context: ${resource}`);
    +        },
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, () => {
    +          throw new Error("listener should not run");
    +        })
    +        .onError(() => {
    +          errorCount++;
    +        });
    +      const signed = await signJsonLd(
    +        {
    +          "@context": "https://www.w3.org/ns/activitystreams",
    +          id: "https://remote.example/activities/legacy-malformed-context",
    +          type: "Create",
    +          actor: "https://remote.example/users/alice",
    +          object: {
    +            id: "https://remote.example/notes/legacy-malformed-context",
    +            type: "Note",
    +            attributedTo: "https://remote.example/users/alice",
    +            content: "Hello from malformed legacy queue",
    +          },
    +        },
    +        rsaPrivateKey3,
    +        rsaPublicKey3.id!,
    +        { contextLoader: mockDocumentLoader },
    +      );
    +      const inboxMessage = {
    +        type: "inbox",
    +        id: crypto.randomUUID(),
    +        baseUrl: "https://example.com",
    +        activity: {
    +          ...signed,
    +          "@context": [
    +            "not a url",
    +            "https://www.w3.org/ns/activitystreams",
    +          ],
    +        },
    +        started: new Date().toISOString(),
    +        attempt: 0,
    +        identifier: null,
    +        traceContext: {},
    +      } satisfies InboxMessage;
    +      await federation.processQueuedTask(undefined, inboxMessage);
    +      assertEquals(errorCount, 1);
    +      assertEquals(queuedMessages, []);
    +    },
    +  );
    +
    +  await t.step(
    +    "legacy raw LDS inbox messages with invalid percent escapes do not retry",
    +    async () => {
    +      const queue: MessageQueue = {
    +        enqueue(message, _options) {
    +          queuedMessages.push(message);
    +          return Promise.resolve();
    +        },
    +        listen(_handler, _options) {
    +          return Promise.resolve();
    +        },
    +      };
    +      const kv = new MemoryKvStore();
    +      const queuedMessages: Message[] = [];
    +      let errorCount = 0;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        queue,
    +        contextLoader: async (resource: string) => {
    +          if (resource === "foo%zz") {
    +            const error = new Error(
    +              `Invalid remote context URL: ${resource}`,
    +            ) as Error & { details?: { code: string; url: string } };
    +            error.name = "jsonld.InvalidUrl";
    +            error.details = {
    +              code: "loading remote context failed",
    +              url: resource,
    +            };
    +            throw error;
    +          }
    +          const url = new URL(resource).href;
    +          if (
    +            url === "https://www.w3.org/ns/activitystreams" ||
    +            url === "https://w3id.org/identity/v1"
    +          ) {
    +            return await mockDocumentLoader(url);
    +          }
    +          throw new Error(`Unexpected context: ${resource}`);
    +        },
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, () => {
    +          throw new Error("listener should not run");
    +        })
    +        .onError(() => {
    +          errorCount++;
    +        });
    +      const signed = await signJsonLd(
    +        {
    +          "@context": "https://www.w3.org/ns/activitystreams",
    +          id:
    +            "https://remote.example/activities/legacy-malformed-percent-context",
    +          type: "Create",
    +          actor: "https://remote.example/users/alice",
    +          object: {
    +            id: "https://remote.example/notes/legacy-malformed-percent-context",
    +            type: "Note",
    +            attributedTo: "https://remote.example/users/alice",
    +            content: "Hello from malformed percent legacy queue",
    +          },
    +        },
    +        rsaPrivateKey3,
    +        rsaPublicKey3.id!,
    +        { contextLoader: mockDocumentLoader },
    +      );
    +      const inboxMessage = {
    +        type: "inbox",
    +        id: crypto.randomUUID(),
    +        baseUrl: "https://example.com",
    +        activity: {
    +          ...signed,
    +          "@context": [
    +            "foo%zz",
    +            "https://www.w3.org/ns/activitystreams",
    +          ],
    +        },
    +        started: new Date().toISOString(),
    +        attempt: 0,
    +        identifier: null,
    +        traceContext: {},
    +      } satisfies InboxMessage;
    +      await federation.processQueuedTask(undefined, inboxMessage);
    +      assertEquals(errorCount, 1);
    +      assertEquals(queuedMessages, []);
    +    },
    +  );
    +
    +  await t.step(
    +    "legacy raw LDS inbox messages with invalid remote contexts do not retry",
    +    async () => {
    +      const remoteContextUrl = "https://remote.example/contexts/ext";
    +      const queue: MessageQueue = {
    +        enqueue(message, _options) {
    +          queuedMessages.push(message);
    +          return Promise.resolve();
    +        },
    +        listen(_handler, _options) {
    +          return Promise.resolve();
    +        },
    +      };
    +      const kv = new MemoryKvStore();
    +      const queuedMessages: Message[] = [];
    +      let errorCount = 0;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        queue,
    +        contextLoader: async (resource: string) => {
    +          const url = new URL(resource).href;
    +          if (
    +            url === "https://www.w3.org/ns/activitystreams" ||
    +            url === "https://w3id.org/identity/v1"
    +          ) {
    +            return await mockDocumentLoader(url);
    +          }
    +          if (url === remoteContextUrl) {
    +            return {
    +              contextUrl: null,
    +              documentUrl: url,
    +              document: ["not", "an", "object"],
    +            };
    +          }
    +          throw new Error(`Unexpected context: ${url}`);
    +        },
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, () => {
    +          throw new Error("listener should not run");
    +        })
    +        .onError(() => {
    +          errorCount++;
    +        });
    +      const signed = await signJsonLd(
    +        {
    +          "@context": [
    +            remoteContextUrl,
    +            "https://www.w3.org/ns/activitystreams",
    +          ],
    +          id: "https://remote.example/activities/legacy-invalid-remote-context",
    +          type: "Create",
    +          actor: "https://remote.example/users/alice",
    +          ext: "preserve-me",
    +          object: {
    +            id: "https://remote.example/notes/legacy-invalid-remote-context",
    +            type: "Note",
    +            attributedTo: "https://remote.example/users/alice",
    +            content: "Hello from invalid remote context queue",
    +          },
    +        },
    +        rsaPrivateKey3,
    +        rsaPublicKey3.id!,
    +        {
    +          contextLoader: async (resource: string) => {
    +            const url = new URL(resource).href;
    +            if (url === remoteContextUrl) {
    +              return {
    +                contextUrl: null,
    +                documentUrl: url,
    +                document: {
    +                  "@context": {
    +                    ext: "https://example.com/ext",
    +                  },
    +                },
    +              };
    +            }
    +            return await mockDocumentLoader(url);
    +          },
    +        },
    +      );
    +      const inboxMessage = {
    +        type: "inbox",
    +        id: crypto.randomUUID(),
    +        baseUrl: "https://example.com",
    +        activity: signed,
    +        started: new Date().toISOString(),
    +        attempt: 0,
    +        identifier: null,
    +        traceContext: {},
    +      } satisfies InboxMessage;
    +      await federation.processQueuedTask(undefined, inboxMessage);
    +      assertEquals(errorCount, 1);
    +      assertEquals(queuedMessages, []);
    +    },
    +  );
    +
    +  await t.step(
    +    "legacy raw LDS inbox messages with string remote contexts retry",
    +    async () => {
    +      const remoteContextUrl = "https://remote.example/contexts/ext";
    +      const queue: MessageQueue = {
    +        enqueue(message, _options) {
    +          queuedMessages.push(message);
    +          return Promise.resolve();
    +        },
    +        listen(_handler, _options) {
    +          return Promise.resolve();
    +        },
    +      };
    +      const kv = new MemoryKvStore();
    +      const queuedMessages: Message[] = [];
    +      let errorCount = 0;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        queue,
    +        contextLoader: async (resource: string) => {
    +          const url = new URL(resource).href;
    +          if (
    +            url === "https://www.w3.org/ns/activitystreams" ||
    +            url === "https://w3id.org/identity/v1"
    +          ) {
    +            return await mockDocumentLoader(url);
    +          }
    +          if (url === remoteContextUrl) {
    +            return {
    +              contextUrl: null,
    +              documentUrl: url,
    +              document: "{not valid json",
    +            };
    +          }
    +          throw new Error(`Unexpected context: ${url}`);
    +        },
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, () => {
    +          throw new Error("listener should not run");
    +        })
    +        .onError(() => {
    +          errorCount++;
    +        });
    +      const signed = await signJsonLd(
    +        {
    +          "@context": [
    +            remoteContextUrl,
    +            "https://www.w3.org/ns/activitystreams",
    +          ],
    +          id: "https://remote.example/activities/legacy-string-remote-context",
    +          type: "Create",
    +          actor: "https://remote.example/users/alice",
    +          ext: "preserve-me",
    +          object: {
    +            id: "https://remote.example/notes/legacy-string-remote-context",
    +            type: "Note",
    +            attributedTo: "https://remote.example/users/alice",
    +            content: "Hello from string remote context queue",
    +          },
    +        },
    +        rsaPrivateKey3,
    +        rsaPublicKey3.id!,
    +        {
    +          contextLoader: async (resource: string) => {
    +            const url = new URL(resource).href;
    +            if (url === remoteContextUrl) {
    +              return {
    +                contextUrl: null,
    +                documentUrl: url,
    +                document: {
    +                  "@context": {
    +                    ext: "https://example.com/ext",
    +                  },
    +                },
    +              };
    +            }
    +            return await mockDocumentLoader(url);
    +          },
    +        },
    +      );
    +      const inboxMessage = {
    +        type: "inbox",
    +        id: crypto.randomUUID(),
    +        baseUrl: "https://example.com",
    +        activity: signed,
    +        started: new Date().toISOString(),
    +        attempt: 0,
    +        identifier: null,
    +        traceContext: {},
    +      } satisfies InboxMessage;
    +      await federation.processQueuedTask(undefined, inboxMessage);
    +      assertEquals(errorCount, 1);
    +      assertEquals(queuedMessages, [{ ...inboxMessage, attempt: 1 }]);
    +    },
    +  );
    +
    +  await t.step(
    +    "legacy raw LDS inbox messages with loader TypeErrors retry",
    +    async () => {
    +      const remoteContextUrl = "https://remote.example/contexts/ext";
    +      const queue: MessageQueue = {
    +        enqueue(message, _options) {
    +          queuedMessages.push(message);
    +          return Promise.resolve();
    +        },
    +        listen(_handler, _options) {
    +          return Promise.resolve();
    +        },
    +      };
    +      const kv = new MemoryKvStore();
    +      const queuedMessages: Message[] = [];
    +      let errorCount = 0;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        queue,
    +        contextLoader: async (resource: string) => {
    +          const url = new URL(resource).href;
    +          if (
    +            url === "https://www.w3.org/ns/activitystreams" ||
    +            url === "https://w3id.org/identity/v1"
    +          ) {
    +            return await mockDocumentLoader(url);
    +          }
    +          if (url === remoteContextUrl) {
    +            throw new TypeError(
    +              `Cannot initialize remote context loader: ${url}`,
    +            );
    +          }
    +          throw new Error(`Unexpected context: ${url}`);
    +        },
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, () => {
    +          throw new Error("listener should not run");
    +        })
    +        .onError(() => {
    +          errorCount++;
    +        });
    +      const signed = await signJsonLd(
    +        {
    +          "@context": [
    +            remoteContextUrl,
    +            "https://www.w3.org/ns/activitystreams",
    +          ],
    +          id: "https://remote.example/activities/legacy-typeerror-context",
    +          type: "Create",
    +          actor: "https://remote.example/users/alice",
    +          ext: "preserve-me",
    +          object: {
    +            id: "https://remote.example/notes/legacy-typeerror-context",
    +            type: "Note",
    +            attributedTo: "https://remote.example/users/alice",
    +            content: "Hello from typeerror legacy queue",
    +          },
    +        },
    +        rsaPrivateKey3,
    +        rsaPublicKey3.id!,
    +        {
    +          contextLoader: async (resource: string) => {
    +            const url = new URL(resource).href;
    +            if (url === remoteContextUrl) {
    +              return {
    +                contextUrl: null,
    +                documentUrl: url,
    +                document: {
    +                  "@context": {
    +                    ext: "https://example.com/ext",
    +                  },
    +                },
    +              };
    +            }
    +            return await mockDocumentLoader(url);
    +          },
    +        },
    +      );
    +      const inboxMessage = {
    +        type: "inbox",
    +        id: crypto.randomUUID(),
    +        baseUrl: "https://example.com",
    +        activity: signed,
    +        started: new Date().toISOString(),
    +        attempt: 0,
    +        identifier: null,
    +        traceContext: {},
    +      } satisfies InboxMessage;
    +      await federation.processQueuedTask(undefined, inboxMessage);
    +      assertEquals(errorCount, 1);
    +      assertEquals(queuedMessages, [{ ...inboxMessage, attempt: 1 }]);
    +    },
    +  );
    +
    +  await t.step(
    +    "legacy raw LDS inbox messages with syntax errors in remote contexts retry",
    +    async () => {
    +      const remoteContextUrl = "https://remote.example/contexts/ext";
    +      const queue: MessageQueue = {
    +        enqueue(message, _options) {
    +          queuedMessages.push(message);
    +          return Promise.resolve();
    +        },
    +        listen(_handler, _options) {
    +          return Promise.resolve();
    +        },
    +      };
    +      const kv = new MemoryKvStore();
    +      const queuedMessages: Message[] = [];
    +      let errorCount = 0;
    +      const federation = new FederationImpl<void>({
    +        kv,
    +        queue,
    +        contextLoader: async (resource: string) => {
    +          const url = new URL(resource).href;
    +          if (
    +            url === "https://www.w3.org/ns/activitystreams" ||
    +            url === "https://w3id.org/identity/v1"
    +          ) {
    +            return await mockDocumentLoader(url);
    +          }
    +          if (url === remoteContextUrl) {
    +            const error = new Error(
    +              `Transient syntax failure: ${url}`,
    +            ) as Error & { details?: { code: string } };
    +            error.name = "jsonld.SyntaxError";
    +            error.details = { code: "loading remote context failed" };
    +            throw error;
    +          }
    +          throw new Error(`Unexpected context: ${url}`);
    +        },
    +      });
    +      federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
    +        .on(Create, () => {
    +          throw new Error("listener should not run");
    +        })
    +        .onError(() => {
    +          errorCount++;
    +        });
    +      const signed = await signJsonLd(
    +        {
    +          "@context": [
    +            remoteContextUrl,
    +            "https://www.w3.org/ns/activitystreams",
    +          ],
    +          id: "https://remote.example/activities/legacy-syntax-context",
    +          type: "Create",
    +          actor: "https://remote.example/users/alice",
    +          ext: "preserve-me",
    +          object: {
    ... [truncated]
    
  • packages/fedify/src/federation/middleware.ts+285 61 modified
    @@ -38,7 +38,17 @@ import {
       verifyRequest,
     } from "../sig/http.ts";
     import { exportJwk, importJwk, validateCryptoKey } from "../sig/key.ts";
    -import { hasSignature, signJsonLd } from "../sig/ld.ts";
    +import {
    +  assertSafeJsonLd,
    +  compactJsonLd,
    +  detachSignature,
    +  getNormalizationContextLoader,
    +  hasSignature,
    +  InvalidContextReferenceError,
    +  isClearlyMalformedContextReference,
    +  signJsonLd,
    +  wrapContextLoaderForJsonLd,
    +} from "../sig/ld.ts";
     import { getKeyOwner, type GetKeyOwnerOptions } from "../sig/owner.ts";
     import { signObject, verifyObject } from "../sig/proof.ts";
     import type { Actor, Recipient } from "../vocab/actor.ts";
    @@ -91,6 +101,7 @@ import {
       handleInbox,
       handleObject,
       handleOrderedCollection,
    +  rawInboxContextFactorySymbol,
     } from "./handler.ts";
     import { routeActivity } from "./inbox.ts";
     import { KvKeyCache } from "./keycache.ts";
    @@ -106,6 +117,59 @@ import type {
     import { createExponentialBackoffPolicy, type RetryPolicy } from "./retry.ts";
     import { RouterError } from "./router.ts";
     import { extractInboxes, sendActivity, type SenderKeyPair } from "./send.ts";
    +import { hasMalformedKnownTemporalLiteral } from "./temporal.ts";
    +
    +function isRemoteContextLoadingFailure(error: unknown): boolean {
    +  return error instanceof Error &&
    +    typeof (error as Error & { details?: { code?: unknown } }).details ===
    +      "object" &&
    +    (error as Error & { details?: { code?: unknown } }).details != null &&
    +    (error as Error & { details: { code?: unknown } }).details.code ===
    +      "loading remote context failed";
    +}
    +
    +function isPermanentRemoteContextError(error: unknown): boolean {
    +  if (!(error instanceof Error) || error.name !== "jsonld.InvalidUrl") {
    +    return false;
    +  }
    +  const details = (error as Error & {
    +    details?: { code?: unknown; url?: unknown };
    +  }).details;
    +  if (details?.code === "invalid remote context") {
    +    return true;
    +  }
    +  return isRemoteContextLoadingFailure(error) &&
    +    typeof details?.url === "string" &&
    +    !URL.canParse(details.url) &&
    +    isClearlyMalformedContextReference(details.url);
    +}
    +
    +function isPermanentInboxParseError(error: unknown): error is Error {
    +  // jsonld.InvalidUrl is only treated as permanent for upstream
    +  // "invalid remote context" failures or for clearly malformed non-URL
    +  // context strings such as values containing whitespace/control characters.
    +  // Opaque or relative context ids may be valid for deployment-specific
    +  // loaders, so loading failures for other non-parseable ids stay retriable
    +  // instead of being forced into the malformed bucket.  compactJsonLd()
    +  // separately tags malformed raw @context/@import references with
    +  // InvalidContextReferenceError so the queue worker can drop sender-side
    +  // defects without conflating them with loader outages or LD signature
    +  // metadata URL failures.  jsonld.SyntaxError is similarly only permanent
    +  // when it is local to the payload rather than a remote-context loading
    +  // failure.  Raw loader TypeErrors for @context resolution are normalized
    +  // earlier at the context-loading layer, so any remaining "Invalid URL ..."
    +  // here comes from sender-controlled ActivityPub IRI fields and stays
    +  // permanent instead of churning the retry queue.
    +  return (error instanceof Error &&
    +    (error.name === "UnsafeJsonLdError" ||
    +      error instanceof InvalidContextReferenceError ||
    +      isPermanentRemoteContextError(error) ||
    +      (error.name === "jsonld.SyntaxError" &&
    +        !isRemoteContextLoadingFailure(error)))) ||
    +    (error instanceof TypeError &&
    +      /^(Invalid JSON-LD:|Invalid type:|Unexpected type:|Invalid URL)/
    +        .test(error.message));
    +}
     
     /**
      * Options for {@link createFederation} function.
    @@ -721,7 +785,7 @@ export class FederationImpl<TContextData>
       async #listenInboxMessage(
         ctxData: TContextData,
         message: InboxMessage,
    -    span: Span,
    +    _span: Span,
       ): Promise<void> {
         const logger = getLogger(["fedify", "federation", "inbox"]);
         const baseUrl = new URL(message.baseUrl);
    @@ -743,62 +807,13 @@ export class FederationImpl<TContextData>
             });
           }
         }
    -    const activity = await Activity.fromJsonLd(message.activity, context);
    -    span.setAttribute("activitypub.activity.type", getTypeId(activity).href);
    -    if (activity.id != null) {
    -      span.setAttribute("activitypub.activity.id", activity.id.href);
    -    }
    -    const cacheKey = activity.id == null ? null : [
    -      ...this.kvPrefixes.activityIdempotence,
    -      context.origin,
    -      activity.id.href,
    -    ] satisfies KvKey;
    -    if (cacheKey != null) {
    -      const cached = await this.kv.get(cacheKey);
    -      if (cached === true) {
    -        logger.debug("Activity {activityId} has already been processed.", {
    -          activityId: activity.id?.href,
    -          activity: message.activity,
    -          recipient: message.identifier,
    -        });
    -        return;
    -      }
    -    }
         await this._getTracer().startActiveSpan(
           "activitypub.dispatch_inbox_listener",
           { kind: SpanKind.INTERNAL },
           async (span) => {
    -        const dispatched = this.inboxListeners?.dispatchWithClass(activity);
    -        if (dispatched == null) {
    -          logger.error(
    -            "Unsupported activity type:\n{activity}",
    -            {
    -              activityId: activity.id?.href,
    -              activity: message.activity,
    -              recipient: message.identifier,
    -              trial: message.attempt,
    -            },
    -          );
    -          span.setStatus({
    -            code: SpanStatusCode.ERROR,
    -            message: `Unsupported activity type: ${getTypeId(activity).href}`,
    -          });
    -          span.end();
    -          return;
    -        }
    -        const { class: cls, listener } = dispatched;
    -        span.updateName(`activitypub.dispatch_inbox_listener ${cls.name}`);
    -        try {
    -          await listener(
    -            context.toInboxContext(
    -              message.identifier,
    -              message.activity,
    -              activity.id?.href,
    -              getTypeId(activity).href,
    -            ),
    -            activity,
    -          );
    -        } catch (error) {
    +        let activity: Activity | null = null;
    +        let cacheKey: KvKey | null = null;
    +        const reportInboxError = async (error: unknown) => {
               try {
                 await this.inboxErrorHandler?.(context, error as Error);
               } catch (error) {
    @@ -807,19 +822,22 @@ export class FederationImpl<TContextData>
                   {
                     error,
                     trial: message.attempt,
    -                activityId: activity.id?.href,
    +                activityId: activity?.id?.href,
                     activity: message.activity,
                     recipient: message.identifier,
                   },
                 );
               }
    +        };
    +        const handleRetriableFailure = async (error: unknown) => {
    +          await reportInboxError(error);
               // Skip retry logic if the message queue backend handles retries automatically
               if (this.inboxQueue?.nativeRetrial) {
                 logger.error(
                   "Failed to process the incoming activity {activityId}; backend will handle retry:\n{error}",
                   {
                     error,
    -                activityId: activity.id?.href,
    +                activityId: activity?.id?.href,
                     activity: message.activity,
                     recipient: message.identifier,
                   },
    @@ -845,11 +863,23 @@ export class FederationImpl<TContextData>
                   {
                     error,
                     attempt: message.attempt,
    -                activityId: activity.id?.href,
    +                activityId: activity?.id?.href,
                     activity: message.activity,
                     recipient: message.identifier,
                   },
                 );
    +            if (this.inboxQueue == null) {
    +              // processQueuedTask() can be called directly without a configured
    +              // inbox queue.  In that manual-processing mode the caller owns
    +              // ack/retry semantics, so retriable failures must bubble out
    +              // instead of being silently acknowledged here.
    +              span.setStatus({
    +                code: SpanStatusCode.ERROR,
    +                message: String(error),
    +              });
    +              span.end();
    +              throw error;
    +            }
                 await this.inboxQueue?.enqueue(
                   {
                     ...message,
    @@ -867,7 +897,7 @@ export class FederationImpl<TContextData>
                     "{trial} attempts; giving up:\n{error}",
                   {
                     error,
    -                activityId: activity.id?.href,
    +                activityId: activity?.id?.href,
                     activity: message.activity,
                     recipient: message.identifier,
                   },
    @@ -878,6 +908,178 @@ export class FederationImpl<TContextData>
                 message: String(error),
               });
               span.end();
    +        };
    +
    +        let dispatched:
    +          | ReturnType<
    +            NonNullable<typeof this.inboxListeners>["dispatchWithClass"]
    +          >
    +          | null
    +          | undefined;
    +        let parseInput: unknown = undefined;
    +        let parseContextLoader = context.contextLoader;
    +        try {
    +          const hasSignatureField = hasSignature(message.activity);
    +          const shouldParseFromNormalizedSignedPayload =
    +            message.ldSignatureVerified === true ||
    +            message.normalizedActivity != null ||
    +            (message.ldSignatureVerified == null && hasSignatureField);
    +          const parseContext = hasSignatureField
    +            ? {
    +              ...context,
    +              // Verified LDS replay, fallback-authenticated queue items with a
    +              // producer-side normalized cache, and legacy queued LDS
    +              // messages may still reference Fedify's built-in signature
    +              // contexts at the root after we detach the signature object, so
    +              // keep the normalization loader shortcut available whenever a
    +              // signature block remains.  Authentication provenance lives in
    +              // ldSignatureVerified; normalizedActivity is a separate parse
    +              // cache that rolling upgrades and stricter worker loaders may
    +              // still depend on.
    +              contextLoader: getNormalizationContextLoader(
    +                context.contextLoader,
    +              ),
    +            }
    +            : {
    +              ...context,
    +              contextLoader: wrapContextLoaderForJsonLd(
    +                context.contextLoader,
    +              ),
    +            };
    +          parseContextLoader = parseContext.contextLoader;
    +          let normalizedActivity: unknown | undefined;
    +          if (shouldParseFromNormalizedSignedPayload) {
    +            normalizedActivity = message.normalizedActivity ??
    +              await compactJsonLd(message.activity, context.contextLoader);
    +            // Queue backends are trusted in the normal deployment model, but a
    +            // cached normalized payload should still satisfy the same JSON-LD
    +            // safety invariants as a freshly compacted one before the worker
    +            // strips the signature block and parses it.
    +            assertSafeJsonLd(normalizedActivity);
    +          }
    +          parseInput = shouldParseFromNormalizedSignedPayload
    +            ? detachSignature(normalizedActivity)
    +            : hasSignatureField
    +            ? detachSignature(message.activity)
    +            : message.activity;
    +          activity = await Activity.fromJsonLd(
    +            parseInput,
    +            parseContext,
    +          );
    +          span.setAttribute(
    +            "activitypub.activity.type",
    +            getTypeId(activity).href,
    +          );
    +          if (activity.id != null) {
    +            span.setAttribute("activitypub.activity.id", activity.id.href);
    +          }
    +          cacheKey = activity.id == null ? null : [
    +            ...this.kvPrefixes.activityIdempotence,
    +            context.origin,
    +            activity.id.href,
    +          ] satisfies KvKey;
    +          if (cacheKey != null) {
    +            const cached = await this.kv.get(cacheKey);
    +            if (cached === true) {
    +              logger.debug(
    +                "Activity {activityId} has already been processed.",
    +                {
    +                  activityId: activity.id?.href,
    +                  activity: message.activity,
    +                  recipient: message.identifier,
    +                },
    +              );
    +              span.end();
    +              return;
    +            }
    +          }
    +          dispatched = this.inboxListeners?.dispatchWithClass(activity);
    +        } catch (error) {
    +          if (
    +            activity == null &&
    +            error instanceof RangeError &&
    +            await hasMalformedKnownTemporalLiteral(
    +              parseInput,
    +              parseContextLoader,
    +            )
    +          ) {
    +            // Patch releases must not change parser exception types to signal
    +            // malformed Temporal literals.  Instead, the queue worker keeps
    +            // loader/KV RangeErrors retriable by default and only restores the
    +            // old drop semantics when the raw/normalized payload at this
    +            // boundary already shows a malformed ActivityPub / proof temporal
    +            // field.
    +            await reportInboxError(error);
    +            logger.error(
    +              "Failed to parse the queued incoming activity {activityId}:\n{error}",
    +              {
    +                error,
    +                trial: message.attempt,
    +                activityId: null,
    +                activity: message.activity,
    +                recipient: message.identifier,
    +              },
    +            );
    +            span.setStatus({
    +              code: SpanStatusCode.ERROR,
    +              message: String(error),
    +            });
    +            span.end();
    +            return;
    +          }
    +          if (isPermanentInboxParseError(error)) {
    +            await reportInboxError(error);
    +            logger.error(
    +              "Failed to parse the queued incoming activity {activityId}:\n{error}",
    +              {
    +                error,
    +                trial: message.attempt,
    +                activityId: activity?.id?.href,
    +                activity: message.activity,
    +                recipient: message.identifier,
    +              },
    +            );
    +            span.setStatus({
    +              code: SpanStatusCode.ERROR,
    +              message: String(error),
    +            });
    +            span.end();
    +            return;
    +          }
    +          await handleRetriableFailure(error);
    +          return;
    +        }
    +        if (dispatched == null) {
    +          logger.error(
    +            "Unsupported activity type:\n{activity}",
    +            {
    +              activityId: activity.id?.href,
    +              activity: message.activity,
    +              recipient: message.identifier,
    +              trial: message.attempt,
    +            },
    +          );
    +          span.setStatus({
    +            code: SpanStatusCode.ERROR,
    +            message: `Unsupported activity type: ${getTypeId(activity).href}`,
    +          });
    +          span.end();
    +          return;
    +        }
    +        const { class: cls, listener } = dispatched;
    +        span.updateName(`activitypub.dispatch_inbox_listener ${cls.name}`);
    +        try {
    +          await listener(
    +            context.toInboxContext(
    +              message.identifier,
    +              message.activity,
    +              activity.id?.href,
    +              getTypeId(activity).href,
    +            ),
    +            activity,
    +          );
    +        } catch (error) {
    +          await handleRetriableFailure(error);
               return;
             }
             if (cacheKey != null) {
    @@ -888,7 +1090,7 @@ export class FederationImpl<TContextData>
             logger.info(
               "Activity {activityId} has been processed.",
               {
    -            activityId: activity.id?.href,
    +            activityId: activity?.id?.href,
                 activity: message.activity,
                 recipient: message.identifier,
               },
    @@ -1363,7 +1565,7 @@ export class FederationImpl<TContextData>
               }),
             });
             // falls through
    -      case "sharedInbox":
    +      case "sharedInbox": {
             if (routeName !== "inbox" && this.sharedInboxKeyDispatcher != null) {
               const identity = await this.sharedInboxKeyDispatcher(context);
               if (identity != null) {
    @@ -1377,10 +1579,17 @@ export class FederationImpl<TContextData>
               }
             }
             if (!this.manuallyStartQueue) this._startQueueInternal(contextData);
    +        const inboxContextFactory = context.toInboxContext.bind(context) as
    +          & typeof context.toInboxContext
    +          & {
    +            [rawInboxContextFactorySymbol]?: typeof context.toInboxContext;
    +          };
    +        inboxContextFactory[rawInboxContextFactorySymbol] = context
    +          .toInboxContext.bind(context);
             return await handleInbox(request, {
               recipient: route.values.identifier ?? route.values.handle ?? null,
               context,
    -          inboxContextFactory: context.toInboxContext.bind(context),
    +          inboxContextFactory,
               kv: this.kv,
               kvPrefixes: this.kvPrefixes,
               queue: this.inboxQueue,
    @@ -1393,6 +1602,7 @@ export class FederationImpl<TContextData>
               tracerProvider: this.tracerProvider,
               idempotencyStrategy: this.idempotencyStrategy,
             });
    +      }
           case "following":
             return await handleCollection(request, {
               name: "following",
    @@ -2575,6 +2785,12 @@ export class ContextImpl<TContextData> implements Context<TContextData> {
         const routeResult = await routeActivity({
           context: this,
           json,
    +      // Programmatic routeActivity() may serialize an Activity that still
    +      // carries a signature block, but this path only authenticated the input
    +      // through proof/dereference rules above. Mark queued work explicitly so
    +      // the worker does not mistake a preserved signature field for verified
    +      // LDS replay.
    +      ldSignatureVerified: false,
           activity,
           recipient,
           inboxListeners: this.federation.inboxListeners,
    @@ -2735,6 +2951,14 @@ class RequestContextImpl<TContextData> extends ContextImpl<TContextData>
     export class InboxContextImpl<TContextData> extends ContextImpl<TContextData>
       implements InboxContext<TContextData> {
       readonly recipient: string | null;
    +  /**
    +   * The original received activity payload.
    +   *
    +   * Fedify may normalize a Linked Data Signature payload internally for safe
    +   * parsing, but forwarding must keep the sender's payload unchanged so
    +   * third-party signatures/proofs remain intact.
    +   * @internal
    +   */
       readonly activity: unknown;
       readonly activityId?: string;
       readonly activityType: string;
    
  • packages/fedify/src/federation/queue.ts+36 0 modified
    @@ -48,6 +48,42 @@ export interface InboxMessage {
       id: ReturnType<typeof crypto.randomUUID>;
       baseUrl: string;
       activity: unknown;
    +  /**
    +   * The normalized JSON-LD representation of a signed inbox activity that
    +   * Fedify already compacted successfully while accepting the request.  Queue
    +   * workers can reuse this producer-side parse cache under stricter loader or
    +   * network constraints without changing the raw payload preserved for
    +   * forwarding.
    +   *
    +   * This may exist even when {@link ldSignatureVerified} is `false`, because
    +   * fallback-authenticated traffic and already-queued backlog items can still
    +   * depend on the cached normalized form to avoid re-fetching remote custom
    +   * contexts during worker processing.
    +   *
    +   * This is optional for backward compatibility with messages that were
    +   * queued by older Fedify versions or that were already in a queue before
    +   * upgrading.
    +   *
    +   * Fedify keeps this on the queued message itself instead of an external
    +   * sidecar because generic queue backends do not provide reliable lifecycle
    +   * guarantees for auxiliary storage across retries and redeliveries.
    +   *
    +   * @internal
    +   */
    +  normalizedActivity?: unknown;
    +  /**
    +   * Whether the producer actually verified the Linked Data Signature before
    +   * queueing this message.  This lets workers distinguish verified LDS replay
    +   * from other authenticated inbox traffic that merely happened to include a
    +   * signature block.  This provenance marker is separate from the optional
    +   * normalizedActivity parse cache.
    +   *
    +   * `undefined` preserves backward compatibility with older queued messages
    +   * that predate this marker.
    +   *
    +   * @internal
    +   */
    +  ldSignatureVerified?: boolean;
       started: string;
       attempt: number;
       identifier: string | null;
    
  • packages/fedify/src/federation/temporal.test.ts+119 0 added
    @@ -0,0 +1,119 @@
    +import { assert, assertFalse } from "@std/assert";
    +import { test } from "../testing/mod.ts";
    +import { hasMalformedKnownTemporalLiteral } from "./temporal.ts";
    +
    +test(
    +  "hasMalformedKnownTemporalLiteral() detects expanded proof timestamps",
    +  async () => {
    +    assert(
    +      await hasMalformedKnownTemporalLiteral(
    +        {
    +          "@context": [
    +            "https://www.w3.org/ns/activitystreams",
    +            "https://w3id.org/security/data-integrity/v1",
    +          ],
    +          id: "https://example.com/activities/invalid-proof-created",
    +          type: "Create",
    +          actor: "https://example.com/person2",
    +          object: {
    +            id: "https://example.com/notes/invalid-proof-created",
    +            type: "Note",
    +            attributedTo: "https://example.com/person2",
    +            content: "Hello, world!",
    +          },
    +          proof: {
    +            type: "DataIntegrityProof",
    +            cryptosuite: "eddsa-jcs-2022",
    +            verificationMethod: "https://example.com/person2#main-key",
    +            proofPurpose: "assertionMethod",
    +            created: { "@value": "not-a-date" },
    +            proofValue:
    +              "zLaewdp4H9kqtwyrLatK4cjY5oRHwVcw4gibPSUDYDMhi4M49v8pcYk3ZB6D69dNpAPbUmY8ocuJ3m9KhKJEEg7z",
    +          },
    +        },
    +        undefined,
    +      ),
    +    );
    +  },
    +);
    +
    +test(
    +  "hasMalformedKnownTemporalLiteral() follows aliases in nested objects",
    +  async () => {
    +    assert(
    +      await hasMalformedKnownTemporalLiteral(
    +        {
    +          "@context": [
    +            "https://www.w3.org/ns/activitystreams",
    +            {
    +              publishedAt: "as:published",
    +            },
    +          ],
    +          id: "https://example.com/activities/invalid-nested-published",
    +          type: "Create",
    +          actor: "https://example.com/person2",
    +          object: {
    +            id: "https://example.com/notes/invalid-nested-published",
    +            type: "Note",
    +            attributedTo: "https://example.com/person2",
    +            content: "Hello, world!",
    +          },
    +          audience: {
    +            type: "Note",
    +            publishedAt: { "@value": "not-a-date" },
    +          },
    +        },
    +        undefined,
    +      ),
    +    );
    +  },
    +);
    +
    +test(
    +  "hasMalformedKnownTemporalLiteral() does not over-classify ignored as:closed values",
    +  async () => {
    +    assertFalse(
    +      await hasMalformedKnownTemporalLiteral(
    +        {
    +          "@context": "https://www.w3.org/ns/activitystreams",
    +          type: "Question",
    +          closed: "not-a-date",
    +        },
    +        undefined,
    +      ),
    +    );
    +  },
    +);
    +
    +test(
    +  "hasMalformedKnownTemporalLiteral() detects date-like invalid as:closed values",
    +  async () => {
    +    assert(
    +      await hasMalformedKnownTemporalLiteral(
    +        {
    +          "@context": "https://www.w3.org/ns/activitystreams",
    +          type: "Question",
    +          closed: "2024-02-31T00:00:00Z",
    +        },
    +        undefined,
    +      ),
    +    );
    +  },
    +);
    +
    +test(
    +  "hasMalformedKnownTemporalLiteral() ignores custom-typed as:closed values",
    +  async () => {
    +    assertFalse(
    +      await hasMalformedKnownTemporalLiteral(
    +        {
    +          "https://www.w3.org/ns/activitystreams#closed": [{
    +            "@value": "2024-02-31T00:00:00Z",
    +            "@type": "https://example.com/ns#customDateTime",
    +          }],
    +        },
    +        undefined,
    +      ),
    +    );
    +  },
    +);
    
  • packages/fedify/src/federation/temporal.ts+152 0 added
    @@ -0,0 +1,152 @@
    +// @ts-ignore TS7016
    +import jsonld from "jsonld";
    +import type { DocumentLoader } from "../runtime/docloader.ts";
    +import { getNormalizationContextLoader } from "../sig/ld.ts";
    +
    +function isPlainObject(value: unknown): value is Record<string, unknown> {
    +  return typeof value === "object" && value != null && !Array.isArray(value);
    +}
    +
    +function normalizeDateTimeLiteral(value: string): string {
    +  return value.substring(19).match(/[Z+-]/) ? value : value + "Z";
    +}
    +
    +function isMalformedDateTimeLiteral(value: unknown): boolean {
    +  if (typeof value !== "string") return false;
    +  try {
    +    Temporal.Instant.from(normalizeDateTimeLiteral(value));
    +    return false;
    +  } catch {
    +    return true;
    +  }
    +}
    +
    +function isMalformedDurationLiteral(value: unknown): boolean {
    +  if (typeof value !== "string") return false;
    +  try {
    +    Temporal.Duration.from(value);
    +    return false;
    +  } catch {
    +    return true;
    +  }
    +}
    +
    +const TEMPORAL_DATE_TIME_IRIS = new Set([
    +  "https://www.w3.org/ns/activitystreams#deleted",
    +  "https://www.w3.org/ns/activitystreams#endTime",
    +  "https://www.w3.org/ns/activitystreams#published",
    +  "https://www.w3.org/ns/activitystreams#startTime",
    +  "https://www.w3.org/ns/activitystreams#updated",
    +  "http://purl.org/dc/terms/created",
    +  "https://w3id.org/security#created",
    +]);
    +
    +const TEMPORAL_DURATION_IRIS = new Set([
    +  "https://www.w3.org/ns/activitystreams#duration",
    +]);
    +
    +const QUESTION_CLOSED_IRI = "https://www.w3.org/ns/activitystreams#closed";
    +const XSD_DATE_TIME_IRI = "http://www.w3.org/2001/XMLSchema#dateTime";
    +
    +function hasMalformedExpandedDateTimeLiteral(value: unknown): boolean {
    +  if (Array.isArray(value)) {
    +    return value.some(hasMalformedExpandedDateTimeLiteral);
    +  }
    +  return isPlainObject(value) && "@value" in value &&
    +    isMalformedDateTimeLiteral(value["@value"]);
    +}
    +
    +function hasMalformedExpandedQuestionClosedLiteral(value: unknown): boolean {
    +  if (Array.isArray(value)) {
    +    return value.some(hasMalformedExpandedQuestionClosedLiteral);
    +  }
    +  if (!isPlainObject(value) || !("@value" in value)) return false;
    +  const literal = value["@value"];
    +  if (typeof literal === "boolean") return false;
    +  if (typeof literal !== "string") return false;
    +  // Mirror the generated Question.closed decoder semantics: values that are
    +  // not even date-like are ignored by the parser, but only xsd:dateTime
    +  // literals
    +  // that pass the Date gate and then fail Temporal.Instant.from() still raise
    +  // RangeError and therefore belong in this boundary recovery path.
    +  if (value["@type"] !== XSD_DATE_TIME_IRI) return false;
    +  if (new Date(literal).toString() === "Invalid Date") return false;
    +  return isMalformedDateTimeLiteral(literal);
    +}
    +
    +function hasMalformedExpandedDurationLiteral(value: unknown): boolean {
    +  if (Array.isArray(value)) {
    +    return value.some(hasMalformedExpandedDurationLiteral);
    +  }
    +  return isPlainObject(value) && "@value" in value &&
    +    isMalformedDurationLiteral(value["@value"]);
    +}
    +
    +function hasMalformedKnownTemporalLiteralInternal(
    +  value: unknown,
    +  visited: Set<object>,
    +): boolean {
    +  if (Array.isArray(value)) {
    +    return value.some((item) =>
    +      hasMalformedKnownTemporalLiteralInternal(item, visited)
    +    );
    +  }
    +  if (!isPlainObject(value)) return false;
    +  if (visited.has(value)) return false;
    +  visited.add(value);
    +
    +  // expanded JSON-LD value objects may contain arbitrary raw JSON for @json
    +  // typed literals inside @value.  Treat those as opaque so the boundary check
    +  // does not reinterpret extension blobs as ActivityPub structure.
    +  if ("@value" in value) return false;
    +
    +  for (const [key, child] of Object.entries(value)) {
    +    if (TEMPORAL_DATE_TIME_IRIS.has(key)) {
    +      if (hasMalformedExpandedDateTimeLiteral(child)) return true;
    +      continue;
    +    }
    +    if (key === QUESTION_CLOSED_IRI) {
    +      if (hasMalformedExpandedQuestionClosedLiteral(child)) return true;
    +      continue;
    +    }
    +    if (TEMPORAL_DURATION_IRIS.has(key)) {
    +      if (hasMalformedExpandedDurationLiteral(child)) return true;
    +      continue;
    +    }
    +    if (hasMalformedKnownTemporalLiteralInternal(child, visited)) return true;
    +  }
    +  return false;
    +}
    +
    +export async function hasMalformedKnownTemporalLiteral(
    +  value: unknown,
    +  contextLoader: DocumentLoader | undefined,
    +): Promise<boolean> {
    +  // Patch releases should not change the exception types thrown by the public
    +  // parsers just to distinguish malformed Temporal literals from transient
    +  // loader / KV failures.  Instead, after a parser raises RangeError we do a
    +  // best-effort JSON-LD expansion pass at the inbox boundary and only restore
    +  // the old 400/drop semantics when expansion positively proves that one of
    +  // ActivityPub's or DataIntegrityProof's well-known temporal IRIs carries an
    +  // invalid literal.  Using jsonld.expand() here is deliberate: it lets this
    +  // boundary-only check follow aliases, expanded literals, and nested object
    +  // structure according to JSON-LD semantics instead of reimplementing those
    +  // rules with another partial compact-form walker.  Keep this set aligned
    +  // with the generated parser behavior rather than "all date-like fields":
    +  // for example, as:closed is a boolean-or-date union, so only date-like
    +  // strings that actually reach Temporal.Instant.from() should be treated as
    +  // malformed.  Non-date strings are ignored by the parser and therefore must
    +  // not be over-classified as permanent defects here.
    +  try {
    +    const expanded = await jsonld.expand(value, {
    +      documentLoader: getNormalizationContextLoader(contextLoader),
    +      keepFreeFloatingNodes: true,
    +    });
    +    return hasMalformedKnownTemporalLiteralInternal(expanded, new Set());
    +  } catch {
    +    // Expansion may fail for the same transient loader or context-resolution
    +    // problems that should remain retriable.  Only use this helper as a
    +    // positive signal for sender-side malformed temporal literals.
    +    return false;
    +  }
    +}
    
  • packages/fedify/src/sig/ld.test.ts+839 0 modified
    @@ -18,10 +18,12 @@ import { CryptographicKey } from "../vocab/vocab.ts";
     import { generateCryptoKeyPair } from "./key.ts";
     import {
       attachSignature,
    +  compactJsonLd,
       createSignature,
       detachSignature,
       type Signature,
       signJsonLd,
    +  UnsafeJsonLdError,
       verifyJsonLd,
       verifySignature,
     } from "./ld.ts";
    @@ -275,4 +277,841 @@ test("verifyJsonLd()", async () => {
       assertFalse(verified2);
     });
     
    +test("compactJsonLd() with restrictive context loader", async () => {
    +  const restrictiveContextLoader = async (resource: string) => {
    +    const url = new URL(resource).href;
    +    if (
    +      url === "https://www.w3.org/ns/activitystreams" ||
    +      url === "https://w3id.org/identity/v1"
    +    ) {
    +      return await mockDocumentLoader(url);
    +    }
    +    throw new Error(`Unexpected context: ${url}`);
    +  };
    +  const doc = {
    +    "@context": [
    +      "https://www.w3.org/ns/activitystreams",
    +      "https://w3id.org/identity/v1",
    +      "https://w3id.org/security/v1",
    +      "https://w3id.org/security/data-integrity/v1",
    +    ],
    +    id: "https://example.com/1",
    +    type: "Create",
    +    actor: "https://example.com/person2",
    +  };
    +  const signed = await signJsonLd(doc, rsaPrivateKey3, rsaPublicKey3.id!, {
    +    contextLoader: mockDocumentLoader,
    +  });
    +  const compacted = await compactJsonLd(signed, restrictiveContextLoader);
    +  assertEquals(compacted, {
    +    "@context": [
    +      "https://w3id.org/identity/v1",
    +      "https://www.w3.org/ns/activitystreams",
    +      "https://w3id.org/security/v1",
    +      "https://w3id.org/security/data-integrity/v1",
    +    ],
    +    id: "https://example.com/1",
    +    type: "Create",
    +    actor: "https://example.com/person2",
    +    signature: signed.signature,
    +  });
    +});
    +
    +test(
    +  "compactJsonLd() caches repeated remote contexts across graph scan and compaction",
    +  async () => {
    +    const remoteUrl = "https://example.com/context";
    +    let calls = 0;
    +    const countingLoader = async (resource: string) => {
    +      const url = new URL(resource).href;
    +      if (url === remoteUrl) {
    +        calls++;
    +        return {
    +          contextUrl: null,
    +          documentUrl: url,
    +          document: {
    +            "@context": {
    +              extra: "https://example.com/extra",
    +            },
    +          },
    +        };
    +      }
    +      return await mockDocumentLoader(url);
    +    };
    +    await compactJsonLd(
    +      {
    +        "@context": "https://www.w3.org/ns/activitystreams",
    +        id: "https://example.com/activities/remote-contexts",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        object: [
    +          {
    +            "@context": [
    +              "https://www.w3.org/ns/activitystreams",
    +              remoteUrl,
    +            ],
    +            type: "Note",
    +            content: "one",
    +          },
    +          {
    +            "@context": [
    +              "https://www.w3.org/ns/activitystreams",
    +              remoteUrl,
    +            ],
    +            type: "Note",
    +            content: "two",
    +          },
    +          {
    +            "@context": [
    +              "https://www.w3.org/ns/activitystreams",
    +              remoteUrl,
    +            ],
    +            type: "Note",
    +            content: "three",
    +          },
    +        ],
    +      },
    +      countingLoader,
    +    );
    +    // The normalization loader is request-scoped and memoized, so the
    +    // pre-compaction safety scan and jsonld.compact() should both reuse the
    +    // same fetched remote context payload.
    +    assertEquals(calls, 1);
    +  },
    +);
    +
    +test(
    +  "compactJsonLd() reuses the same remote context response for graph scan and compaction",
    +  async () => {
    +    const remoteUrl = "https://example.com/context";
    +    let calls = 0;
    +    const compacted = await compactJsonLd(
    +      {
    +        "@context": [
    +          remoteUrl,
    +          "https://www.w3.org/ns/activitystreams",
    +        ],
    +        id: "https://example.com/activities/memoized-remote-context",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        graph: "https://example.com/custom-graph",
    +        object: "https://example.com/notes/1",
    +      },
    +      async (resource: string) => {
    +        const url = new URL(resource).href;
    +        if (url === remoteUrl) {
    +          calls++;
    +          if (calls > 1) {
    +            throw new Error(
    +              `Remote context should not be fetched twice: ${url}`,
    +            );
    +          }
    +          return {
    +            contextUrl: null,
    +            documentUrl: url,
    +            document: {
    +              "@context": {
    +                graph: "https://example.com/graph",
    +              },
    +            },
    +          };
    +        }
    +        return await mockDocumentLoader(url);
    +      },
    +    );
    +    assertEquals(calls, 1);
    +    assertEquals(compacted, {
    +      "@context": [
    +        "https://w3id.org/identity/v1",
    +        "https://www.w3.org/ns/activitystreams",
    +        "https://w3id.org/security/v1",
    +        "https://w3id.org/security/data-integrity/v1",
    +      ],
    +      id: "https://example.com/activities/memoized-remote-context",
    +      type: "Create",
    +      actor: "https://example.com/person2",
    +      "https://example.com/graph": "https://example.com/custom-graph",
    +      object: "https://example.com/notes/1",
    +    });
    +  },
    +);
    +
    +test(
    +  "compactJsonLd() preserves opaque top-level ids and resolves relative " +
    +    "remote contexts against documentUrl during graph scan",
    +  async () => {
    +    const rootContextId = "opaque-root";
    +    const rootContextUrl = "https://example.com/contexts/root";
    +    const childContextUrl = "https://example.com/contexts/child";
    +    const calls: string[] = [];
    +    const customLoader = async (resource: string) => {
    +      calls.push(resource);
    +      if (resource === rootContextId) {
    +        return {
    +          contextUrl: null,
    +          documentUrl: rootContextUrl,
    +          document: {
    +            "@context": {
    +              "@import": "./child",
    +              ext: "https://example.com/ext",
    +            },
    +          },
    +        };
    +      }
    +      if (resource === childContextUrl || resource === "child") {
    +        return {
    +          contextUrl: null,
    +          documentUrl: childContextUrl,
    +          document: {
    +            "@context": {
    +              child: "https://example.com/child",
    +            },
    +          },
    +        };
    +      }
    +      return await mockDocumentLoader(resource);
    +    };
    +    const compacted = await compactJsonLd(
    +      {
    +        "@context": [
    +          rootContextId,
    +          "https://www.w3.org/ns/activitystreams",
    +        ],
    +        id: "https://example.com/activities/custom-loader-contexts",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        ext: "preserve-me",
    +        object: {
    +          type: "Note",
    +          content: "Hello",
    +        },
    +      },
    +      customLoader,
    +    );
    +    assertEquals(compacted, {
    +      "@context": [
    +        "https://w3id.org/identity/v1",
    +        "https://www.w3.org/ns/activitystreams",
    +        "https://w3id.org/security/v1",
    +        "https://w3id.org/security/data-integrity/v1",
    +      ],
    +      id: "https://example.com/activities/custom-loader-contexts",
    +      type: "Create",
    +      actor: "https://example.com/person2",
    +      "https://example.com/ext": "preserve-me",
    +      object: {
    +        type: "Note",
    +        content: "Hello",
    +      },
    +    });
    +    assert(calls.includes(rootContextId));
    +    assert(calls.includes(childContextUrl));
    +    assertFalse(calls.includes("./child"));
    +  },
    +);
    +
    +test(
    +  "compactJsonLd() preserves base URLs for property-scoped remote contexts",
    +  async () => {
    +    const rootContextId = "opaque-root";
    +    const rootContextUrl = "https://example.com/contexts/root";
    +    const childContextUrl = "https://example.com/contexts/child";
    +    const calls: string[] = [];
    +    const customLoader = async (resource: string) => {
    +      calls.push(resource);
    +      if (resource === rootContextId) {
    +        return {
    +          contextUrl: null,
    +          documentUrl: rootContextUrl,
    +          document: {
    +            "@context": {
    +              p: {
    +                "@id": "https://example.com/p",
    +                "@context": "./child",
    +              },
    +            },
    +          },
    +        };
    +      }
    +      if (resource === childContextUrl) {
    +        return {
    +          contextUrl: null,
    +          documentUrl: childContextUrl,
    +          document: {
    +            "@context": {
    +              nested: "https://example.com/nested",
    +            },
    +          },
    +        };
    +      }
    +      return await mockDocumentLoader(resource);
    +    };
    +    const compacted = await compactJsonLd(
    +      {
    +        "@context": [
    +          rootContextId,
    +          "https://www.w3.org/ns/activitystreams",
    +        ],
    +        id: "https://example.com/activities/property-scoped-contexts",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        p: {
    +          nested: "value",
    +        },
    +        object: "https://example.com/notes/1",
    +      },
    +      customLoader,
    +    );
    +    assertEquals(compacted, {
    +      "@context": [
    +        "https://w3id.org/identity/v1",
    +        "https://www.w3.org/ns/activitystreams",
    +        "https://w3id.org/security/v1",
    +        "https://w3id.org/security/data-integrity/v1",
    +      ],
    +      id: "https://example.com/activities/property-scoped-contexts",
    +      type: "Create",
    +      actor: "https://example.com/person2",
    +      "https://example.com/p": {
    +        "https://example.com/nested": "value",
    +      },
    +      object: "https://example.com/notes/1",
    +    });
    +    assert(calls.includes(rootContextId));
    +    assert(calls.includes(childContextUrl));
    +    assertFalse(calls.includes("./child"));
    +  },
    +);
    +
    +test("compactJsonLd() ignores unsafe-looking keys inside @json values", async () => {
    +  const remoteContextUrl = "https://example.com/contexts/json";
    +  const compacted = await compactJsonLd(
    +    {
    +      "@context": [
    +        remoteContextUrl,
    +        "https://www.w3.org/ns/activitystreams",
    +      ],
    +      id: "https://example.com/activities/json-blob",
    +      type: "Create",
    +      actor: "https://example.com/person2",
    +      blob: {
    +        graph: {
    +          nested: true,
    +        },
    +        "@reverse": {
    +          nope: true,
    +        },
    +        "@included": [
    +          {
    +            still: "raw-json",
    +          },
    +        ],
    +      },
    +      object: "https://example.com/notes/1",
    +    },
    +    async (resource: string) => {
    +      const url = new URL(resource).href;
    +      if (url === remoteContextUrl) {
    +        return {
    +          contextUrl: null,
    +          documentUrl: url,
    +          document: {
    +            "@context": {
    +              blob: {
    +                "@id": "https://example.com/blob",
    +                "@type": "@json",
    +              },
    +              graph: "@graph",
    +            },
    +          },
    +        };
    +      }
    +      return await mockDocumentLoader(url);
    +    },
    +  );
    +  assertEquals(compacted, {
    +    "@context": [
    +      "https://w3id.org/identity/v1",
    +      "https://www.w3.org/ns/activitystreams",
    +      "https://w3id.org/security/v1",
    +      "https://w3id.org/security/data-integrity/v1",
    +    ],
    +    id: "https://example.com/activities/json-blob",
    +    type: "Create",
    +    "https://example.com/blob": {
    +      type: "@json",
    +      "@value": {
    +        graph: {
    +          nested: true,
    +        },
    +        "@reverse": {
    +          nope: true,
    +        },
    +        "@included": [
    +          {
    +            still: "raw-json",
    +          },
    +        ],
    +      },
    +    },
    +    actor: "https://example.com/person2",
    +    object: "https://example.com/notes/1",
    +  });
    +});
    +
    +test(
    +  "compactJsonLd() ignores unsafe-looking keys inside inline @json value wrappers",
    +  async () => {
    +    const remoteContextUrl = "https://example.com/contexts/inline-json";
    +    const compacted = await compactJsonLd(
    +      {
    +        "@context": [
    +          remoteContextUrl,
    +          "https://www.w3.org/ns/activitystreams",
    +        ],
    +        id: "https://example.com/activities/inline-json-blob",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        blob: {
    +          "@value": {
    +            graph: {
    +              nested: true,
    +            },
    +            "@reverse": {
    +              nope: true,
    +            },
    +            "@included": [
    +              {
    +                still: "raw-json",
    +              },
    +            ],
    +          },
    +          "@type": "@json",
    +        },
    +        object: "https://example.com/notes/1",
    +      },
    +      async (resource: string) => {
    +        const url = new URL(resource).href;
    +        if (url === remoteContextUrl) {
    +          return {
    +            contextUrl: null,
    +            documentUrl: url,
    +            document: {
    +              "@context": {
    +                blob: "https://example.com/blob",
    +                graph: "@graph",
    +              },
    +            },
    +          };
    +        }
    +        return await mockDocumentLoader(url);
    +      },
    +    );
    +    assertEquals(compacted, {
    +      "@context": [
    +        "https://w3id.org/identity/v1",
    +        "https://www.w3.org/ns/activitystreams",
    +        "https://w3id.org/security/v1",
    +        "https://w3id.org/security/data-integrity/v1",
    +      ],
    +      id: "https://example.com/activities/inline-json-blob",
    +      type: "Create",
    +      "https://example.com/blob": {
    +        type: "@json",
    +        "@value": {
    +          graph: {
    +            nested: true,
    +          },
    +          "@reverse": {
    +            nope: true,
    +          },
    +          "@included": [
    +            {
    +              still: "raw-json",
    +            },
    +          ],
    +        },
    +      },
    +      actor: "https://example.com/person2",
    +      object: "https://example.com/notes/1",
    +    });
    +  },
    +);
    +
    +test("verifyJsonLd() respects @graph alias overrides", async () => {
    +  const doc = {
    +    "@context": [
    +      "https://www.w3.org/ns/activitystreams",
    +      { graph: "@graph" },
    +      { graph: "https://example.com/graph" },
    +    ],
    +    id: "https://example.com/activities/1",
    +    type: "Create",
    +    actor: "https://example.com/person2",
    +    object: "https://example.com/notes/1",
    +    graph: "https://example.com/custom-graph",
    +  };
    +  const signed = await signJsonLd(doc, rsaPrivateKey3, rsaPublicKey3.id!, {
    +    contextLoader: mockDocumentLoader,
    +  });
    +  const verified = await verifyJsonLd(signed, {
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: mockDocumentLoader,
    +  });
    +  assert(verified);
    +});
    +
    +test("compactJsonLd() respects nested @context scope for @graph aliases", async () => {
    +  const doc = {
    +    "@context": [
    +      "https://www.w3.org/ns/activitystreams",
    +      {
    +        graph: "https://example.com/graph",
    +        meta: {
    +          "@id": "https://example.com/meta",
    +          "@context": { graph: "@graph" },
    +        },
    +      },
    +    ],
    +    id: "https://example.com/activities/2",
    +    type: "Create",
    +    actor: "https://example.com/person2",
    +    object: "https://example.com/notes/2",
    +    graph: "https://example.com/custom-graph",
    +    meta: { value: "ok" },
    +  };
    +  const compacted = await compactJsonLd(doc, mockDocumentLoader);
    +  assertEquals(compacted, {
    +    "@context": [
    +      "https://w3id.org/identity/v1",
    +      "https://www.w3.org/ns/activitystreams",
    +      "https://w3id.org/security/v1",
    +      "https://w3id.org/security/data-integrity/v1",
    +    ],
    +    id: "https://example.com/activities/2",
    +    type: "Create",
    +    "https://example.com/graph": "https://example.com/custom-graph",
    +    actor: "https://example.com/person2",
    +    object: "https://example.com/notes/2",
    +    "https://example.com/meta": { value: "ok" },
    +  });
    +});
    +
    +test("compactJsonLd() resets inherited @graph aliases on @context: null", async () => {
    +  const doc = {
    +    "@context": [
    +      "https://www.w3.org/ns/activitystreams",
    +      { g: "@graph" },
    +    ],
    +    id: "https://example.com/activities/3",
    +    type: "Create",
    +    actor: "https://example.com/person2",
    +    object: {
    +      "@context": null,
    +      g: "literal",
    +    },
    +  };
    +  const compacted = await compactJsonLd(doc, mockDocumentLoader);
    +  assertEquals(compacted, {
    +    "@context": [
    +      "https://w3id.org/identity/v1",
    +      "https://www.w3.org/ns/activitystreams",
    +      "https://w3id.org/security/v1",
    +      "https://w3id.org/security/data-integrity/v1",
    +    ],
    +    id: "https://example.com/activities/3",
    +    type: "Create",
    +    actor: "https://example.com/person2",
    +    object: {},
    +  });
    +});
    +
    +test("compactJsonLd() rejects same-object forward @graph alias chains", async () => {
    +  await assertRejects(
    +    () =>
    +      compactJsonLd(
    +        {
    +          "@context": [
    +            "https://www.w3.org/ns/activitystreams",
    +            { a: "b", b: "@graph" },
    +          ],
    +          id: "https://example.com/activities/forward-graph-alias",
    +          type: "Create",
    +          actor: "https://example.com/person2",
    +          a: [
    +            {
    +              id: "https://example.com/notes/forward-graph-alias",
    +              type: "Note",
    +              content: "Hello",
    +            },
    +          ],
    +        },
    +        mockDocumentLoader,
    +      ),
    +    UnsafeJsonLdError,
    +    "Unsupported JSON-LD keyword: @graph.",
    +  );
    +});
    +
    +test("compactJsonLd() preserves captured @graph aliases across later overrides", async () => {
    +  await assertRejects(
    +    () =>
    +      compactJsonLd(
    +        {
    +          "@context": [
    +            "https://www.w3.org/ns/activitystreams",
    +            { b: "@graph" },
    +            { a: "b" },
    +            { b: "https://example.com/b" },
    +          ],
    +          id: "https://example.com/activities/captured-graph-alias",
    +          type: "Create",
    +          actor: "https://example.com/person2",
    +          a: [
    +            {
    +              id: "https://example.com/notes/captured-graph-alias",
    +              type: "Note",
    +              content: "Hello",
    +            },
    +          ],
    +        },
    +        mockDocumentLoader,
    +      ),
    +    UnsafeJsonLdError,
    +    "Unsupported JSON-LD keyword: @graph.",
    +  );
    +});
    +
    +test("compactJsonLd() does not retroactively apply later @graph aliases", async () => {
    +  const compacted = await compactJsonLd(
    +    {
    +      "@context": [
    +        "https://www.w3.org/ns/activitystreams",
    +        { a: "b" },
    +        { b: "@graph" },
    +      ],
    +      id: "https://example.com/activities/non-retroactive-graph-alias",
    +      type: "Create",
    +      actor: "https://example.com/person2",
    +      a: [
    +        {
    +          id: "https://example.com/notes/non-retroactive-graph-alias",
    +          type: "Note",
    +          content: "Hello",
    +        },
    +      ],
    +    },
    +    mockDocumentLoader,
    +  );
    +  assertEquals(compacted, {
    +    "@context": [
    +      "https://w3id.org/identity/v1",
    +      "https://www.w3.org/ns/activitystreams",
    +      "https://w3id.org/security/v1",
    +      "https://w3id.org/security/data-integrity/v1",
    +    ],
    +    id: "https://example.com/activities/non-retroactive-graph-alias",
    +    type: "Create",
    +    b: {
    +      id: "https://example.com/notes/non-retroactive-graph-alias",
    +      type: "Note",
    +      content: "Hello",
    +    },
    +    actor: "https://example.com/person2",
    +  });
    +});
    +
    +test("verifyJsonLd() rejects unsafe JSON-LD keywords", async () => {
    +  const original = {
    +    "@context": "https://www.w3.org/ns/activitystreams",
    +    id: "https://example.com/activities/undo",
    +    type: "Undo",
    +    actor: "https://example.com/person2",
    +    object: {
    +      id: "https://example.com/activities/announce",
    +      type: "Announce",
    +      actor: "https://example.com/person2",
    +      object: "https://example.com/status/1",
    +    },
    +  };
    +  const signed = await signJsonLd(
    +    original,
    +    rsaPrivateKey3,
    +    rsaPublicKey3.id!,
    +    { contextLoader: mockDocumentLoader },
    +  );
    +  const options = {
    +    documentLoader: mockDocumentLoader,
    +    contextLoader: mockDocumentLoader,
    +  };
    +  const cases: [string, unknown][] = [
    +    [
    +      "@reverse",
    +      {
    +        "@context": [
    +          "https://www.w3.org/ns/activitystreams",
    +          { rev: "@reverse" },
    +        ],
    +        id: "https://example.com/activities/announce",
    +        type: "Announce",
    +        actor: "https://example.com/person2",
    +        object: "https://example.com/status/1",
    +        rev: {
    +          object: {
    +            id: "https://example.com/activities/undo",
    +            type: "Undo",
    +            actor: "https://example.com/person2",
    +          },
    +        },
    +        signature: signed.signature,
    +      },
    +    ],
    +    [
    +      "@included",
    +      {
    +        "@context": [
    +          "https://www.w3.org/ns/activitystreams",
    +          { inc: "@included" },
    +        ],
    +        id: "https://example.com/activities/announce",
    +        type: "Announce",
    +        actor: "https://example.com/person2",
    +        object: "https://example.com/status/1",
    +        inc: [{
    +          id: "https://example.com/activities/undo",
    +          type: "Undo",
    +          actor: "https://example.com/person2",
    +          object: "https://example.com/activities/announce",
    +        }],
    +        signature: signed.signature,
    +      },
    +    ],
    +    [
    +      "@graph",
    +      {
    +        "@context": [
    +          "https://www.w3.org/ns/activitystreams",
    +          { graph: "@graph" },
    +        ],
    +        graph: [original],
    +        signature: signed.signature,
    +      },
    +    ],
    +    [
    +      "@graph",
    +      {
    +        "@context": [
    +          "https://www.w3.org/ns/activitystreams",
    +          { graph: "@graph" },
    +        ],
    +        id: "https://example.com/activities/announce",
    +        type: "Announce",
    +        actor: "https://example.com/person2",
    +        object: "https://example.com/status/1",
    +        graph: [{
    +          id: "https://example.com/activities/undo",
    +          type: "Undo",
    +          actor: "https://example.com/person2",
    +          object: "https://example.com/activities/announce",
    +        }],
    +        signature: signed.signature,
    +      },
    +    ],
    +  ];
    +
    +  for (const [keyword, jsonLd] of cases) {
    +    await assertRejects(
    +      () => verifyJsonLd(jsonLd, options),
    +      UnsafeJsonLdError,
    +      `Unsupported JSON-LD keyword: ${keyword}.`,
    +    );
    +  }
    +});
    +
    +test(
    +  "compactJsonLd() rejects unsafe JSON-LD keywords inside signature objects",
    +  async () => {
    +    const signed = await signJsonLd(
    +      {
    +        "@context": "https://www.w3.org/ns/activitystreams",
    +        id: "https://example.com/activities/signed-signature-keywords",
    +        type: "Create",
    +        actor: "https://example.com/person2",
    +        object: "https://example.com/notes/1",
    +      },
    +      rsaPrivateKey3,
    +      rsaPublicKey3.id!,
    +      { contextLoader: mockDocumentLoader },
    +    );
    +    const cases: [string, unknown][] = [
    +      [
    +        "@reverse",
    +        {
    +          object: {
    +            id: "https://example.com/activities/reverse-inside-signature",
    +            type: "Undo",
    +          },
    +        },
    +      ],
    +      [
    +        "@included",
    +        [{
    +          id: "https://example.com/activities/included-inside-signature",
    +          type: "Undo",
    +        }],
    +      ],
    +      [
    +        "@graph",
    +        [{
    +          id: "https://example.com/activities/graph-inside-signature",
    +          type: "Undo",
    +        }],
    +      ],
    +    ];
    +
    +    for (const [keyword, value] of cases) {
    +      await assertRejects(
    +        () =>
    +          compactJsonLd(
    +            {
    +              ...signed,
    +              signature: {
    +                ...signed.signature,
    +                [keyword]: value,
    +              },
    +            },
    +            mockDocumentLoader,
    +          ),
    +        UnsafeJsonLdError,
    +        `Unsupported JSON-LD keyword: ${keyword}.`,
    +      );
    +    }
    +  },
    +);
    +
    +test("compactJsonLd() rejects inputs that compact into @graph wrappers", async () => {
    +  await assertRejects(
    +    () =>
    +      compactJsonLd(
    +        [
    +          {
    +            "@context": "https://www.w3.org/ns/activitystreams",
    +            id: "https://example.com/notes/graph-wrapper-1",
    +            type: "Note",
    +            content: "one",
    +          },
    +          {
    +            "@context": "https://www.w3.org/ns/activitystreams",
    +            id: "https://example.com/notes/graph-wrapper-2",
    +            type: "Note",
    +            content: "two",
    +          },
    +        ],
    +        mockDocumentLoader,
    +      ),
    +    UnsafeJsonLdError,
    +    "Unsupported JSON-LD keyword: @graph.",
    +  );
    +});
    +
     // cSpell: ignore ostatus
    
  • packages/fedify/src/sig/ld.ts+632 2 modified
    @@ -8,12 +8,615 @@ import metadata from "../../deno.json" with { type: "json" };
     import {
       type DocumentLoader,
       getDocumentLoader,
    +  type RemoteDocument,
     } from "../runtime/docloader.ts";
     import { getTypeId } from "../vocab/type.ts";
     import { Activity, CryptographicKey, Object } from "../vocab/vocab.ts";
     import { fetchKey, type KeyCache, validateCryptoKey } from "./key.ts";
     
     const logger = getLogger(["fedify", "sig", "ld"]);
    +// This is the internal compaction target for LD-signature normalization, not
    +// an allow-list of every JSON-LD vocabulary Fedify accepts on the wire.
    +// Custom, Mastodon-specific, FEP, or deployment-defined extension contexts are
    +// still resolved through the caller's context loader and may survive in the
    +// parsed object graph; we only compact signed payloads onto Fedify's built-in
    +// baseline so signature-sensitive parsing runs against a stable local context.
    +const localContext = [
    +  "https://w3id.org/identity/v1",
    +  "https://www.w3.org/ns/activitystreams",
    +  "https://w3id.org/security/v1",
    +  "https://w3id.org/security/data-integrity/v1",
    +] as const;
    +const localContextUrls = new Set<string>(localContext);
    +const builtInContextLoader = getDocumentLoader();
    +// Reject JSON-LD graph-restructuring features for signed activities:
    +// https://github.com/fedify-dev/fedify/security/advisories/GHSA-9rfg-v8g9-9367
    +const disallowedJsonLdKeywords = new Set(["@graph", "@included", "@reverse"]);
    +
    +/** @internal */
    +export class UnsafeJsonLdError extends TypeError {
    +  constructor(readonly keyword: string) {
    +    super(`Unsupported JSON-LD keyword: ${keyword}.`);
    +    this.name = "UnsafeJsonLdError";
    +  }
    +}
    +
    +/** @internal */
    +export class InvalidContextReferenceError extends TypeError {
    +  constructor(readonly reference: string) {
    +    super(`Invalid JSON-LD context reference: ${reference}.`);
    +    this.name = "InvalidContextReferenceError";
    +  }
    +}
    +
    +function createLoadingRemoteContextFailedError(
    +  reference: string,
    +  cause: unknown,
    +): Error & { details: { code: string; url: string } } {
    +  const message = cause instanceof Error ? cause.message : String(cause);
    +  const error = new Error(
    +    `Dereferencing a URL did not result in a valid JSON-LD context: ` +
    +      `${reference}. ${message}`,
    +  ) as Error & {
    +    details: { code: string; url: string };
    +    cause?: unknown;
    +  };
    +  error.name = "jsonld.InvalidUrl";
    +  error.details = {
    +    code: "loading remote context failed",
    +    url: reference,
    +  };
    +  error.cause = cause;
    +  return error;
    +}
    +
    +/** @internal */
    +export function isClearlyMalformedContextReference(
    +  reference: string,
    +): boolean {
    +  // Opaque identifiers such as did:, urn:, or app: may be handled by
    +  // deployment-specific loaders, so a scheme prefix alone is not enough to
    +  // mark an @context reference as permanently malformed.  Treat only clearly
    +  // broken raw strings as sender defects here: embedded whitespace/control
    +  // characters, malformed scheme-prefixed references such as http:/[ or
    +  // http:[, invalid percent escapes, and malformed relative or network-path
    +  // references.  Parseable absolute or opaque identifiers stay retryable at
    +  // this layer because custom loaders may still fail transiently while
    +  // resolving them.
    +  for (const char of reference) {
    +    const code = char.charCodeAt(0);
    +    if (code <= 0x20 || code === 0x7f) return true;
    +  }
    +  if (
    +    /^[A-Za-z][A-Za-z0-9+.-]*:/.test(reference) && !URL.canParse(reference)
    +  ) {
    +    return true;
    +  }
    +  for (let i = 0; i < reference.length; i++) {
    +    if (reference[i] !== "%") continue;
    +    if (
    +      i + 2 >= reference.length ||
    +      !/[0-9A-Fa-f]/.test(reference[i + 1]) ||
    +      !/[0-9A-Fa-f]/.test(reference[i + 2])
    +    ) {
    +      return true;
    +    }
    +    i += 2;
    +  }
    +  if (
    +    reference.startsWith("./") || reference.startsWith("../") ||
    +    reference.startsWith("/") || reference.startsWith("//")
    +  ) {
    +    for (const char of reference) {
    +      if ('[]<>"\\^`{|}'.includes(char)) return true;
    +    }
    +  }
    +  return false;
    +}
    +
    +function cloneRemoteDocument(remoteDocument: RemoteDocument): RemoteDocument {
    +  return structuredClone(remoteDocument);
    +}
    +
    +function createMemoizedDocumentLoader(documentLoader: DocumentLoader) {
    +  const cache = new Map<string, Promise<RemoteDocument>>();
    +  return async (url: string, options?: { signal?: AbortSignal }) => {
    +    const cacheKey = URL.canParse(url) ? new URL(url).href : url;
    +    let remoteDocument = cache.get(cacheKey);
    +    if (remoteDocument == null) {
    +      remoteDocument = Promise.resolve(documentLoader(url, options)).then(
    +        cloneRemoteDocument,
    +      );
    +      remoteDocument.catch(() => {
    +        if (cache.get(cacheKey) === remoteDocument) cache.delete(cacheKey);
    +      });
    +      cache.set(cacheKey, remoteDocument);
    +    }
    +    return cloneRemoteDocument(await remoteDocument);
    +  };
    +}
    +
    +/** @internal */
    +export function wrapContextLoaderForJsonLd(
    +  contextLoader: DocumentLoader | undefined,
    +): DocumentLoader {
    +  const loader = contextLoader ?? builtInContextLoader;
    +  return async (url, options) => {
    +    try {
    +      return await loader(url, options);
    +    } catch (error) {
    +      if (!isInvalidUrlTypeError(error)) throw error;
    +      if (isClearlyMalformedContextReference(url)) {
    +        throw new InvalidContextReferenceError(url);
    +      }
    +      // Keep generic loader-side Invalid URL failures retryable at JSON-LD
    +      // parse boundaries.  The same TypeError text is also used by generated
    +      // ActivityPub decoders for permanently malformed payload IRIs, so only
    +      // the context-loading layer can safely reinterpret it as transient.
    +      throw createLoadingRemoteContextFailedError(url, error);
    +    }
    +  };
    +}
    +
    +/** @internal */
    +export function getNormalizationContextLoader(
    +  contextLoader: DocumentLoader | undefined,
    +): DocumentLoader {
    +  const loader = wrapContextLoaderForJsonLd(contextLoader);
    +  return createMemoizedDocumentLoader(async (url, options) => {
    +    // Normalized LDS documents are compacted against Fedify's built-in context.
    +    // That does not mean callers are limited to these four contexts overall:
    +    // extension contexts from the input document still resolve through the
    +    // caller's loader.  This shortcut only avoids asking the caller to fetch
    +    // Fedify's own baseline contexts during internal normalization, and only
    +    // for references that are already parseable as absolute URLs; raw or
    +    // opaque context ids must still reach the caller's loader unchanged.
    +    // Keep the resulting loader request-scoped and memoized so the pre-scan
    +    // and the actual jsonld.compact() call both see the same remote context
    +    // payload even when the caller's loader is nondeterministic or backed by a
    +    // cache that may change between awaits.
    +    if (URL.canParse(url)) {
    +      const normalizedUrl = new URL(url).href;
    +      if (localContextUrls.has(normalizedUrl)) {
    +        return await builtInContextLoader(normalizedUrl, options);
    +      }
    +    }
    +    return await loader(url, options);
    +  });
    +}
    +
    +/** @internal */
    +export async function compactJsonLd(
    +  jsonLd: unknown,
    +  contextLoader: DocumentLoader | undefined,
    +): Promise<unknown> {
    +  const hasLds = typeof jsonLd === "object" && jsonLd != null &&
    +    "signature" in jsonLd;
    +  const signature = hasLds
    +    ? (jsonLd as { signature: unknown }).signature
    +    : undefined;
    +  const normalizationContextLoader = getNormalizationContextLoader(
    +    contextLoader,
    +  );
    +  const document = hasLds ? detachSignature(jsonLd) : jsonLd;
    +  // Most unsafe JSON-LD keywords remain visible after compaction and can be
    +  // rejected on the normalized document.  @graph is the exception: a source
    +  // document can wrap a single signed node in @graph (or an alias for it), and
    +  // jsonld.compact() may flatten that wrapper away entirely.  We therefore
    +  // reject @graph on source terms before compaction using the active JSON-LD
    +  // context at each object location, then reject the remaining unsafe keywords
    +  // on the compacted representation.
    +  // Use the same request-scoped normalization loader here so built-in Fedify
    +  // contexts do not depend on caller-provided loaders during the pre-scan and
    +  // the remote context payloads observed by the pre-scan cannot diverge from
    +  // the ones observed by jsonld.compact() later in this call.
    +  await assertNoGraphBeforeCompaction(document, normalizationContextLoader);
    +  const compacted = await jsonld.compact(
    +    document,
    +    localContext,
    +    { documentLoader: normalizationContextLoader },
    +  );
    +  if (hasLds && typeof compacted === "object" && compacted != null) {
    +    // Linked Data Signatures are handled out-of-band by this module.
    +    (compacted as Record<string, unknown>).signature = signature;
    +  }
    +  assertSafeJsonLd(compacted);
    +  return compacted;
    +}
    +
    +interface GraphAliasContextState {
    +  readonly graphTerms: Set<string>;
    +  readonly jsonTerms: Set<string>;
    +  readonly propertyContexts: Map<string, GraphAliasPropertyContext>;
    +  readonly termTargets: Map<string, string | null>;
    +}
    +
    +interface GraphAliasRemoteContext {
    +  readonly context: unknown;
    +  readonly baseUrl: string | null;
    +}
    +
    +interface GraphAliasPropertyContext {
    +  readonly context: unknown;
    +  readonly baseUrl: string | null;
    +}
    +
    +type GraphAliasRemoteContextCache = Map<
    +  string,
    +  Promise<GraphAliasRemoteContext>
    +>;
    +
    +function createInvalidRemoteContextError(
    +  reference: string,
    +): Error & { details: { code: string; url: string } } {
    +  const error = new Error(
    +    "Dereferencing a URL did not result in a JSON object. " +
    +      "The response was valid JSON, but it was not a JSON object. " +
    +      `URL: "${reference}".`,
    +  ) as Error & { details: { code: string; url: string } };
    +  error.name = "jsonld.InvalidUrl";
    +  error.details = {
    +    code: "invalid remote context",
    +    url: reference,
    +  };
    +  return error;
    +}
    +
    +function getRemoteContext(
    +  remoteDocument: RemoteDocument,
    +  reference: string,
    +): GraphAliasRemoteContext {
    +  const { contextUrl, documentUrl } = remoteDocument;
    +  let { document } = remoteDocument;
    +  if (typeof document === "string") {
    +    // Match jsonld's remote-context loader semantics: string documents are
    +    // parsed as JSON first, and only the post-parse shape determines whether
    +    // the failure is a permanent invalid-remote-context defect or a retriable
    +    // loading failure.
    +    document = JSON.parse(document) as unknown;
    +  }
    +  if (
    +    typeof document !== "object" || document == null || Array.isArray(document)
    +  ) {
    +    throw createInvalidRemoteContextError(reference);
    +  }
    +  // Mirror jsonld's remote-context handling so the safety scan classifies
    +  // permanently invalid remote context documents the same way the actual
    +  // compaction path does.
    +  let context: unknown = "@context" in document ? document["@context"] : {};
    +  if (contextUrl != null) {
    +    context = Array.isArray(context)
    +      ? [...context, contextUrl]
    +      : [context, contextUrl];
    +  }
    +  return {
    +    context,
    +    baseUrl: documentUrl ?? reference,
    +  };
    +}
    +
    +function createGraphAliasContextState(): GraphAliasContextState {
    +  return {
    +    graphTerms: new Set(),
    +    jsonTerms: new Set(),
    +    propertyContexts: new Map(),
    +    termTargets: new Map(),
    +  };
    +}
    +
    +function cloneGraphAliasContextState(
    +  state: GraphAliasContextState,
    +): GraphAliasContextState {
    +  return {
    +    graphTerms: new Set(state.graphTerms),
    +    jsonTerms: new Set(state.jsonTerms),
    +    propertyContexts: new Map(state.propertyContexts),
    +    termTargets: new Map(state.termTargets),
    +  };
    +}
    +
    +function resolveContextTarget(
    +  target: string,
    +  state: GraphAliasContextState,
    +): string {
    +  if (target === "@graph") return target;
    +  const mapped = state.termTargets.get(target);
    +  if (mapped == null) return target;
    +  return mapped;
    +}
    +
    +function getDirectContextTarget(
    +  definition: unknown,
    +): string | null | undefined {
    +  if (definition === null) return null;
    +  if (typeof definition === "string") return definition;
    +  if (
    +    typeof definition === "object" && definition != null &&
    +    "@id" in definition
    +  ) {
    +    const id = definition["@id"];
    +    if (id === null) return null;
    +    if (typeof id === "string") return id;
    +  }
    +  return undefined;
    +}
    +
    +function isJsonTypedDefinition(definition: unknown): boolean {
    +  return typeof definition === "object" && definition != null &&
    +    "@type" in definition && definition["@type"] === "@json";
    +}
    +
    +function resolveLocalContextTarget(
    +  target: string,
    +  state: GraphAliasContextState,
    +  localTargets: ReadonlyMap<string, string | null>,
    +  seen = new Set<string>(),
    +): string {
    +  if (target === "@graph") return target;
    +  if (seen.has(target)) return target;
    +  seen.add(target);
    +  if (localTargets.has(target)) {
    +    const localTarget = localTargets.get(target);
    +    return localTarget == null
    +      ? target
    +      : resolveLocalContextTarget(localTarget, state, localTargets, seen);
    +  }
    +  return resolveContextTarget(target, state);
    +}
    +
    +function refreshGraphAliases(state: GraphAliasContextState): void {
    +  state.graphTerms.clear();
    +  for (const [term, target] of state.termTargets) {
    +    // termTargets stores the target captured when each local context entry was
    +    // applied. This preserves JSON-LD's sequential @context array semantics:
    +    // same-object forward aliases can resolve through local definitions, but
    +    // later array items do not retroactively rewrite earlier captured terms.
    +    if (target === "@graph") {
    +      state.graphTerms.add(term);
    +    }
    +  }
    +}
    +
    +function normalizeContextReference(
    +  reference: string,
    +  baseUrl: string | null,
    +): string {
    +  // Preserve raw top-level opaque ids so deployment-specific loaders can
    +  // resolve them.  Once a remote context has established a document URL,
    +  // however, nested relative @context/@import references should follow
    +  // JSON-LD's normal base-URL resolution semantics before reaching the
    +  // caller's loader.
    +  if (baseUrl != null) {
    +    return new URL(reference, baseUrl).href;
    +  }
    +  return URL.canParse(reference) ? new URL(reference).href : reference;
    +}
    +
    +function isInvalidUrlTypeError(error: unknown): error is TypeError {
    +  return error instanceof TypeError &&
    +    /^Invalid URL(?::|$)/.test(error.message);
    +}
    +
    +async function applyGraphAliasContext(
    +  state: GraphAliasContextState,
    +  context: unknown,
    +  documentLoader: DocumentLoader,
    +  remoteContextCache: GraphAliasRemoteContextCache,
    +  baseUrl: string | null = null,
    +  processingContexts = new Set<string>(),
    +): Promise<GraphAliasContextState> {
    +  if (context === null) {
    +    // Explicit null resets the active JSON-LD context for the current object.
    +    // The pre-compaction graph-alias scan must mirror that scope boundary so
    +    // aliases like g -> @graph do not leak into fenced-off nested data.
    +    return createGraphAliasContextState();
    +  }
    +  let nextState = cloneGraphAliasContextState(state);
    +  if (Array.isArray(context)) {
    +    for (const item of context) {
    +      nextState = await applyGraphAliasContext(
    +        nextState,
    +        item,
    +        documentLoader,
    +        remoteContextCache,
    +        baseUrl,
    +        processingContexts,
    +      );
    +    }
    +    return nextState;
    +  }
    +  if (typeof context === "string") {
    +    const reference = normalizeContextReference(context, baseUrl);
    +    const cacheKey = `${baseUrl ?? ""}\n${reference}`;
    +    if (processingContexts.has(cacheKey)) return nextState;
    +    processingContexts.add(cacheKey);
    +    try {
    +      let remoteContext = remoteContextCache.get(cacheKey);
    +      if (remoteContext == null) {
    +        // Reuse the fetched remote document across the whole scan while still
    +        // re-applying it against the caller's current JSON-LD context state.
    +        remoteContext = (async () => {
    +          try {
    +            return getRemoteContext(
    +              await documentLoader(reference),
    +              reference,
    +            );
    +          } catch (error) {
    +            if (
    +              reference === context &&
    +              isInvalidUrlTypeError(error) &&
    +              isClearlyMalformedContextReference(context)
    +            ) {
    +              // Only classify raw string references as permanently invalid
    +              // when the string itself is clearly broken.  Deployment-specific
    +              // loaders may still resolve opaque or relative identifiers
    +              // through non-URL backends and may transiently surface the same
    +              // generic TypeError("Invalid URL ...") while doing so.
    +              throw new InvalidContextReferenceError(context);
    +            }
    +            throw error;
    +          }
    +        })();
    +        remoteContextCache.set(cacheKey, remoteContext);
    +      }
    +      const loadedRemoteContext = await remoteContext;
    +      return await applyGraphAliasContext(
    +        nextState,
    +        loadedRemoteContext.context,
    +        documentLoader,
    +        remoteContextCache,
    +        loadedRemoteContext.baseUrl,
    +        processingContexts,
    +      );
    +    } finally {
    +      processingContexts.delete(cacheKey);
    +    }
    +  }
    +  if (typeof context === "object" && context != null) {
    +    if ("@import" in context && typeof context["@import"] === "string") {
    +      nextState = await applyGraphAliasContext(
    +        nextState,
    +        context["@import"],
    +        documentLoader,
    +        remoteContextCache,
    +        baseUrl,
    +        processingContexts,
    +      );
    +    }
    +    const localTargets = new Map<string, string | null>();
    +    for (const [term, definition] of globalThis.Object.entries(context)) {
    +      if (term.startsWith("@")) continue;
    +      const target = getDirectContextTarget(definition);
    +      if (target == null) {
    +        localTargets.set(term, null);
    +      } else if (typeof target === "string") {
    +        localTargets.set(term, target);
    +      } else {
    +        localTargets.delete(term);
    +      }
    +    }
    +    for (const [term, definition] of globalThis.Object.entries(context)) {
    +      if (term.startsWith("@")) continue;
    +      if (localTargets.has(term)) {
    +        const directTarget = localTargets.get(term);
    +        if (directTarget == null) {
    +          nextState.termTargets.set(term, null);
    +        } else {
    +          nextState.termTargets.set(
    +            term,
    +            resolveLocalContextTarget(directTarget, nextState, localTargets),
    +          );
    +        }
    +      } else {
    +        nextState.termTargets.delete(term);
    +      }
    +      if (
    +        typeof definition === "object" && definition != null &&
    +        "@context" in definition
    +      ) {
    +        // Property-scoped contexts can carry relative remote references whose
    +        // meaning depends on the document URL of the context that declared
    +        // them. Preserve that base so the pre-compaction replay below sees the
    +        // same resolution scope as jsonld's actual context processing.
    +        nextState.propertyContexts.set(term, {
    +          context: definition["@context"],
    +          baseUrl,
    +        });
    +      } else {
    +        nextState.propertyContexts.delete(term);
    +      }
    +      if (isJsonTypedDefinition(definition)) {
    +        nextState.jsonTerms.add(term);
    +      } else {
    +        nextState.jsonTerms.delete(term);
    +      }
    +    }
    +    refreshGraphAliases(nextState);
    +  }
    +  return nextState;
    +}
    +
    +async function assertNoGraphBeforeCompaction(
    +  jsonLd: unknown,
    +  documentLoader: DocumentLoader,
    +  inheritedState = createGraphAliasContextState(),
    +  propertyContext?: GraphAliasPropertyContext,
    +  remoteContextCache: GraphAliasRemoteContextCache = new Map(),
    +): Promise<void> {
    +  if (Array.isArray(jsonLd)) {
    +    for (const item of jsonLd) {
    +      await assertNoGraphBeforeCompaction(
    +        item,
    +        documentLoader,
    +        inheritedState,
    +        propertyContext,
    +        remoteContextCache,
    +      );
    +    }
    +    return;
    +  }
    +  if (typeof jsonLd !== "object" || jsonLd == null) return;
    +  const jsonLiteralWrapper = isJsonLiteralWrapper(
    +    jsonLd as Record<string, unknown>,
    +  );
    +  let state = inheritedState;
    +  if (propertyContext !== undefined) {
    +    // Re-apply property-scoped contexts with the base URL they were declared
    +    // under. Otherwise relative child contexts from remote documents would be
    +    // replayed as raw strings and misclassified as invalid before compaction.
    +    state = await applyGraphAliasContext(
    +      state,
    +      propertyContext.context,
    +      documentLoader,
    +      remoteContextCache,
    +      propertyContext.baseUrl,
    +    );
    +  }
    +  if ("@context" in jsonLd) {
    +    state = await applyGraphAliasContext(
    +      state,
    +      jsonLd["@context"],
    +      documentLoader,
    +      remoteContextCache,
    +    );
    +  }
    +  for (const [key, value] of globalThis.Object.entries(jsonLd)) {
    +    if (key === "@context") continue;
    +    if (jsonLiteralWrapper && key === "@value") continue;
    +    if (key === "@graph" || state.graphTerms.has(key)) {
    +      throw new UnsafeJsonLdError("@graph");
    +    }
    +    if (state.jsonTerms.has(key)) continue;
    +    await assertNoGraphBeforeCompaction(
    +      value,
    +      documentLoader,
    +      state,
    +      state.propertyContexts.get(key),
    +      remoteContextCache,
    +    );
    +  }
    +}
    +
    +function isJsonLiteralWrapper(value: Record<string, unknown>): boolean {
    +  return "@value" in value &&
    +    (value["@type"] === "@json" || value.type === "@json");
    +}
    +
    +/** @internal */
    +export function assertSafeJsonLd(jsonLd: unknown): void {
    +  if (Array.isArray(jsonLd)) {
    +    for (const item of jsonLd) assertSafeJsonLd(item);
    +  } else if (typeof jsonLd === "object" && jsonLd != null) {
    +    const jsonLiteralWrapper = isJsonLiteralWrapper(
    +      jsonLd as Record<string, unknown>,
    +    );
    +    for (const [key, value] of globalThis.Object.entries(jsonLd)) {
    +      if (disallowedJsonLdKeywords.has(key)) throw new UnsafeJsonLdError(key);
    +      if (jsonLiteralWrapper && key === "@value") continue;
    +      assertSafeJsonLd(value);
    +    }
    +  }
    +}
     
     /**
      * A signature of a JSON-LD document.
    @@ -372,14 +975,41 @@ export interface VerifyJsonLdOptions extends VerifySignatureOptions {
     export async function verifyJsonLd(
       jsonLd: unknown,
       options: VerifyJsonLdOptions = {},
    +): Promise<boolean> {
    +  return await verifyJsonLdInternal(jsonLd, options, true);
    +}
    +
    +/** @internal */
    +export async function verifyCompactJsonLd(
    +  jsonLd: unknown,
    +  options: VerifyJsonLdOptions = {},
    +): Promise<boolean> {
    +  return await verifyJsonLdInternal(jsonLd, options, false);
    +}
    +
    +async function verifyJsonLdInternal(
    +  jsonLd: unknown,
    +  options: VerifyJsonLdOptions,
    +  compact: boolean,
     ): Promise<boolean> {
       const tracerProvider = options.tracerProvider ?? trace.getTracerProvider();
       const tracer = tracerProvider.getTracer(metadata.name, metadata.version);
       return await tracer.startActiveSpan(
         "ld_signatures.verify",
         async (span) => {
           try {
    -        const object = await Object.fromJsonLd(jsonLd, options);
    +        const verificationOptions = hasSignature(jsonLd)
    +          ? {
    +            ...options,
    +            contextLoader: getNormalizationContextLoader(options.contextLoader),
    +          }
    +          : options;
    +        const compacted = compact
    +          ? hasSignature(jsonLd)
    +            ? await compactJsonLd(jsonLd, options.contextLoader)
    +            : jsonLd
    +          : jsonLd;
    +        const object = await Object.fromJsonLd(compacted, verificationOptions);
             if (object.id != null) {
               span.setAttribute("activitypub.object.id", object.id.href);
             }
    @@ -420,7 +1050,7 @@ export async function verifyJsonLd(
             if (object instanceof Activity) {
               for (const uri of object.actorIds) attributions.add(uri.href);
             }
    -        const key = await verifySignature(jsonLd, options);
    +        const key = await verifySignature(compacted, verificationOptions);
             if (key == null) return false;
             if (key.ownerId == null) {
               logger.debug("Key {keyId} has no owner.", { keyId: key.id?.href });
    
958d2366df59

Handle Bun invalid URL errors

https://github.com/fedify-dev/fedifyHong MinheeMay 20, 2026Fixed in 2.2.3via llm-release-walk
4 files changed · +34 10
  • packages/fedify/src/federation/handler.ts+6 4 modified
    @@ -16,6 +16,7 @@ import {
       hasSignature,
       InvalidContextReferenceError,
       isClearlyMalformedContextReference,
    +  isInvalidUrlTypeError,
       verifyCompactJsonLd,
       wrapContextLoaderForJsonLd,
     } from "../sig/ld.ts";
    @@ -101,8 +102,9 @@ function isInvalidJsonLdError(error: unknown): error is Error {
     
     function isValidationTypeError(error: unknown): error is TypeError {
       return error instanceof TypeError &&
    -    /^(Invalid JSON-LD:|Invalid type:|Unexpected type:|Invalid URL)/
    -      .test(error.message);
    +    (/^(Invalid JSON-LD:|Invalid type:|Unexpected type:)/
    +      .test(error.message) ||
    +      isInvalidUrlTypeError(error));
     }
     
     function isPermanentActivityParseError(error: unknown): error is Error {
    @@ -115,8 +117,8 @@ function isPermanentActivityParseError(error: unknown): error is Error {
       // bucket.  jsonld.SyntaxError is similarly only permanent when it is local
       // to the payload rather than a remote-context loading failure.  Raw loader
       // TypeErrors for @context resolution are normalized earlier at the
    -  // context-loading layer, so any remaining "Invalid URL ..." here comes from
    -  // sender-controlled ActivityPub IRI fields and stays permanent.
    +  // context-loading layer, so any remaining invalid-URL TypeError here comes
    +  // from sender-controlled ActivityPub IRI fields and stays permanent.
       return isInvalidJsonLdError(error) || isValidationTypeError(error);
     }
     
    
  • packages/fedify/src/federation/middleware.ts+6 4 modified
    @@ -46,6 +46,7 @@ import {
       hasSignature,
       InvalidContextReferenceError,
       isClearlyMalformedContextReference,
    +  isInvalidUrlTypeError,
       signJsonLd,
       wrapContextLoaderForJsonLd,
     } from "../sig/ld.ts";
    @@ -157,8 +158,8 @@ function isPermanentInboxParseError(error: unknown): error is Error {
       // metadata URL failures.  jsonld.SyntaxError is similarly only permanent
       // when it is local to the payload rather than a remote-context loading
       // failure.  Raw loader TypeErrors for @context resolution are normalized
    -  // earlier at the context-loading layer, so any remaining "Invalid URL ..."
    -  // here comes from sender-controlled ActivityPub IRI fields and stays
    +  // earlier at the context-loading layer, so any remaining invalid-URL
    +  // TypeError here comes from sender-controlled ActivityPub IRI fields and stays
       // permanent instead of churning the retry queue.
       return (error instanceof Error &&
         (error.name === "UnsafeJsonLdError" ||
    @@ -167,8 +168,9 @@ function isPermanentInboxParseError(error: unknown): error is Error {
           (error.name === "jsonld.SyntaxError" &&
             !isRemoteContextLoadingFailure(error)))) ||
         (error instanceof TypeError &&
    -      /^(Invalid JSON-LD:|Invalid type:|Unexpected type:|Invalid URL)/
    -        .test(error.message));
    +      (/^(Invalid JSON-LD:|Invalid type:|Unexpected type:)/
    +        .test(error.message) ||
    +        isInvalidUrlTypeError(error)));
     }
     
     /**
    
  • packages/fedify/src/sig/ld.test.ts+16 0 modified
    @@ -21,13 +21,29 @@ import {
       compactJsonLd,
       createSignature,
       detachSignature,
    +  isInvalidUrlTypeError,
       type Signature,
       signJsonLd,
       UnsafeJsonLdError,
       verifyJsonLd,
       verifySignature,
     } from "./ld.ts";
     
    +test("isInvalidUrlTypeError()", () => {
    +  assert(isInvalidUrlTypeError(new TypeError("Invalid URL: http://[")));
    +  assert(
    +    isInvalidUrlTypeError(
    +      new TypeError('"http://[" cannot be parsed as a URL.'),
    +    ),
    +  );
    +  const error = new TypeError("Failed to parse URL") as TypeError & {
    +    code?: string;
    +  };
    +  error.code = "ERR_INVALID_URL";
    +  assert(isInvalidUrlTypeError(error));
    +  assertFalse(isInvalidUrlTypeError(new TypeError("Failed to fetch")));
    +});
    +
     test("attachSignature()", () => {
       const sig: Signature = {
         "@context": "https://w3id.org/identity/v1",
    
  • packages/fedify/src/sig/ld.ts+6 2 modified
    @@ -392,9 +392,13 @@ function normalizeContextReference(
       return URL.canParse(reference) ? new URL(reference).href : reference;
     }
     
    -function isInvalidUrlTypeError(error: unknown): error is TypeError {
    +/** @internal */
    +export function isInvalidUrlTypeError(error: unknown): error is TypeError {
    +  const code = (error as { code?: unknown }).code;
       return error instanceof TypeError &&
    -    /^Invalid URL(?::|$)/.test(error.message);
    +    (code === "ERR_INVALID_URL" ||
    +      /^Invalid URL(?::|$)/.test(error.message) ||
    +      / cannot be parsed as a URL\.?$/.test(error.message));
     }
     
     async function applyGraphAliasContext(
    

Vulnerability mechanics

Synthesis attempt was rejected by the grounding validator. Re-run pending.

References

3

News mentions

0

No linked articles in our index yet.