Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,28 @@ To be released.
`@context` that redefines the `as:` prefix or the bare `Public` term
is preserved as is. The rewrite is also applied before
`eddsa-jcs-2022` Object Integrity Proof signing so the signed bytes
match what is sent on the wire. [[#710]]
match what is sent on the wire. [[#710], [#721]]

- Improved interoperability with [Pixelfed] by serializing outgoing
activities' `attachment` fields as arrays even when there is only one
attachment. JSON-LD compaction would otherwise emit a scalar value for
single attachments, but Pixelfed currently expects an array and may reject
incoming posts; see [pixelfed/pixelfed#6588]. [[#721]]

[Agent Skills]: https://agentskills.io/
[skills-npm]: https://github.com/antfu/skills-npm
[Lemmy]: https://join-lemmy.org/
[LemmyNet/lemmy#6465]: https://github.com/LemmyNet/lemmy/issues/6465
[Pixelfed]: https://pixelfed.org/
[pixelfed/pixelfed#6588]: https://github.com/pixelfed/pixelfed/issues/6588
[#430]: https://github.com/fedify-dev/fedify/issues/430
[#644]: https://github.com/fedify-dev/fedify/issues/644
[#680]: https://github.com/fedify-dev/fedify/pull/680
[#688]: https://github.com/fedify-dev/fedify/pull/688
[#710]: https://github.com/fedify-dev/fedify/pull/710
[#711]: https://github.com/fedify-dev/fedify/issues/711
[#712]: https://github.com/fedify-dev/fedify/pull/712
[#721]: https://github.com/fedify-dev/fedify/pull/721

### @fedify/lint

Expand Down
22 changes: 21 additions & 1 deletion docs/manual/send.md
Original file line number Diff line number Diff line change
Expand Up @@ -1112,7 +1112,27 @@ additional information to the activity.
The activity transformers are applied before they are signed with the sender's
private key and sent to the recipients.

It can be configured by setting
Fedify also applies a small set of internal JSON-LD wire-format compatibility
fixes after serializing the transformed activity. Unlike activity transformers,
these fixes operate on the compact JSON-LD document rather than the `Activity`
object, so they can preserve representation details such as array-valued
properties that JSON-LD compaction would otherwise collapse. These internal
fixes are applied automatically after serialization for unsigned activities and
for proofs Fedify creates while sending.

Activities that already carry cryptographic proofs are sent unchanged by
default, so the compact JSON-LD bytes stay consistent with the existing
signature. If you pre-sign an activity locally with `signObject()` or
`createProof()` and then pass it to `Context.sendActivity()`, set
`normalizeExistingProofs: true` so the outgoing wire form matches the
normalized bytes covered by the proof.

When an outgoing document uses custom or inline JSON-LD contexts, Fedify may
canonicalize the document before and after a representation fix to confirm
that the fix preserves JSON-LD semantics. Documents that are too deeply
nested for safe traversal are sent unchanged instead.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Activity transformers can be configured by setting
the [`activityTransformers`](./federation.md#activitytransformers) option.
By default, the following activity transformers are enabled:

Expand Down
287 changes: 287 additions & 0 deletions packages/fedify/src/compat/outgoing-jsonld.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
import { mockDocumentLoader, test } from "@fedify/fixture";
import { Create, Document, Note, PUBLIC_COLLECTION } from "@fedify/vocab";
import { assertEquals } from "@std/assert/assert-equals";
import { assertStrictEquals } from "@std/assert/assert-strict-equals";
import {
isPreloadedContextAttachmentSafe,
normalizeAttachmentArrays,
normalizeOutgoingActivityJsonLd,
} from "./outgoing-jsonld.ts";

test("normalizeAttachmentArrays() wraps scalar attachments", async () => {
const input = {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Create",
object: {
type: "Note",
attachment: {
type: "Document",
mediaType: "image/png",
url: "https://example.com/image.png",
},
},
};
const output = await normalizeAttachmentArrays(input) as Record<
string,
unknown
>;
const object = output.object as Record<string, unknown>;
assertEquals(object.attachment, [
{
type: "Document",
mediaType: "image/png",
url: "https://example.com/image.png",
},
]);
});

test("normalizeAttachmentArrays() skips canonicalization for known-safe contexts", async () => {
const input = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/data-integrity/v1",
],
type: "Note",
attachment: {
type: "Document",
mediaType: "image/png",
url: "https://example.com/image.png",
},
};
const output = await normalizeAttachmentArrays(input, () => {
throw new Error("context loader should not be called");
}) as Record<string, unknown>;
assertEquals(output.attachment, [
{
type: "Document",
mediaType: "image/png",
url: "https://example.com/image.png",
},
]);
});

test("isPreloadedContextAttachmentSafe() checks scoped contexts", () => {
assertEquals(
isPreloadedContextAttachmentSafe({
"@context": {
attachment: {
"@id": "as:attachment",
"@type": "@id",
},
Example: {
"@context": {
attachment: {
"@id": "as:attachment",
"@type": "@id",
},
},
},
},
}),
true,
);
assertEquals(
isPreloadedContextAttachmentSafe({
"@context": {
Example: {
"@context": {
attachment: "https://example.com/custom-attachment",
},
},
},
}),
false,
);
});

test("normalizeAttachmentArrays() does not wrap JSON-LD list objects", async () => {
const attachment = {
"@list": [
{ type: "Document", url: "https://example.com/image.png" },
],
};
const input = {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Note",
attachment,
};
const output = await normalizeAttachmentArrays(input, () => {
throw new Error("context loader should not be called");
}) as Record<string, unknown>;
assertEquals(output.attachment, attachment);
});

test("normalizeAttachmentArrays() does not traverse JSON-LD value payloads", async () => {
const input = {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Note",
attachment: { type: "Document" },
content: {
"@type": "@json",
"@value": {
"@context": {
attachment: "https://example.com/custom-attachment",
},
attachment: "https://example.com/metadata",
},
},
};
const output = await normalizeAttachmentArrays(input, () => {
throw new Error("context loader should not be called");
}) as Record<string, unknown>;
assertEquals(output.attachment, [{ type: "Document" }]);
assertEquals(output.content, {
"@type": "@json",
"@value": {
"@context": {
attachment: "https://example.com/custom-attachment",
},
attachment: "https://example.com/metadata",
},
});
});

test("normalizeAttachmentArrays() leaves attachment arrays unchanged", async () => {
const attachment = [
{
type: "Document",
mediaType: "image/png",
url: "https://example.com/image.png",
},
];
const input = {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Note",
attachment,
};
const output = await normalizeAttachmentArrays(input) as Record<
string,
unknown
>;
assertEquals(output.attachment, attachment);
});

test("normalizeAttachmentArrays() leaves documents without attachments unchanged", async () => {
const input = {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Note",
content: "Hello",
};
assertEquals(await normalizeAttachmentArrays(input), input);
});

test("normalizeAttachmentArrays() leaves @context subtrees untouched", async () => {
const input = {
"@context": [
"https://www.w3.org/ns/activitystreams",
{ attachment: "https://example.com/custom-attachment" },
],
type: "Note",
attachment: "https://example.com/attachment",
};
const output = await normalizeAttachmentArrays(input) as Record<
string,
unknown
>;
const context = output["@context"] as unknown[];
assertEquals(context[1], {
attachment: "https://example.com/custom-attachment",
});
assertEquals(output.attachment, ["https://example.com/attachment"]);
});

test("normalizeAttachmentArrays() bails out when wrapping changes semantics", async () => {
const input = {
"@context": {
attachment: {
"@id": "https://example.com/custom-attachment",
"@type": "@json",
},
},
attachment: {
custom: true,
},
};
const output = await normalizeAttachmentArrays(input) as Record<
string,
unknown
>;
assertEquals(output.attachment, { custom: true });
});

test("normalizeAttachmentArrays() does not poison the global prototype via a __proto__ key", async () => {
const input = JSON.parse(`{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Note",
"attachment": { "type": "Document" },
"__proto__": { "polluted": true }
}`);
await normalizeAttachmentArrays(input);
assertEquals(
(Object.prototype as Record<string, unknown>).polluted,
undefined,
);
});

test("normalizeAttachmentArrays() stops before blowing the stack on pathological nesting", async () => {
let deep: Record<string, unknown> = { attachment: { type: "Document" } };
for (let i = 0; i < 256; i++) deep = { object: deep };
const input = {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Create",
object: deep,
};
const output = await normalizeAttachmentArrays(input);
assertStrictEquals(output, input);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test("normalizeAttachmentArrays() skips canonicalization for pathological nesting", async () => {
let deep: Record<string, unknown> = { type: "Note" };
for (let i = 0; i < 256; i++) deep = { object: deep };
const input = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://example.com/context",
],
type: "Note",
attachment: { type: "Document" },
object: deep,
};
const output = await normalizeAttachmentArrays(input, () => {
throw new Error("context loader should not be called");
}) as Record<string, unknown>;
assertEquals(output.attachment, { type: "Document" });
});

test("normalizeOutgoingActivityJsonLd() applies outgoing JSON-LD workarounds", async () => {
const activity = new Create({
id: new URL("https://example.com/activities/1"),
actor: new URL("https://example.com/alice"),
object: new Note({
id: new URL("https://example.com/notes/1"),
tos: [PUBLIC_COLLECTION],
attachments: [
new Document({
mediaType: "image/png",
url: new URL("https://example.com/image.png"),
}),
],
}),
tos: [PUBLIC_COLLECTION],
});
const compact = await activity.toJsonLd({ format: "compact" }) as Record<
string,
unknown
>;
assertEquals(compact.to, "as:Public");
const compactObject = compact.object as Record<string, unknown>;
assertEquals(Array.isArray(compactObject.attachment), false);

const normalized = await normalizeOutgoingActivityJsonLd(
compact,
mockDocumentLoader,
) as Record<string, unknown>;
assertEquals(normalized.to, PUBLIC_COLLECTION.href);
const normalizedObject = normalized.object as Record<string, unknown>;
assertEquals(Array.isArray(normalizedObject.attachment), true);
});
Loading
Loading