Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 6 additions & 0 deletions docs/manual/send.md
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,12 @@ 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.

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.

It can be configured by setting
the [`activityTransformers`](./federation.md#activitytransformers) option.
Comment thread
dahlia marked this conversation as resolved.
Outdated
By default, the following activity transformers are enabled:
Expand Down
203 changes: 203 additions & 0 deletions packages/fedify/src/compat/outgoing-jsonld.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { mockDocumentLoader, test } from "@fedify/fixture";
import { Create, Document, Note, PUBLIC_COLLECTION } from "@fedify/vocab";
import { assertEquals } from "@std/assert/assert-equals";
import {
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("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() 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);
assertEquals(typeof output, "object");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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