Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
183 changes: 183 additions & 0 deletions packages/fedify/src/compat/outgoing-jsonld.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
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 ActivityStreams-only context", async () => {
const input = {
"@context": "https://www.w3.org/ns/activitystreams",
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() 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);
});
169 changes: 169 additions & 0 deletions packages/fedify/src/compat/outgoing-jsonld.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { type DocumentLoader, preloadedContexts } from "@fedify/vocab-runtime";
import jsonld from "@fedify/vocab-runtime/jsonld";
import { getLogger } from "@logtape/logtape";
import { normalizePublicAudience } from "./public-audience.ts";

const logger = getLogger(["fedify", "compat", "outgoing-jsonld"]);

const ATTACHMENT_FIELDS = new Set([
"attachment",
"https://www.w3.org/ns/activitystreams#attachment",
]);

const AS_CONTEXT_URL = "https://www.w3.org/ns/activitystreams";

// Keep the traversal bounded for adversarial JSON-LD passed through proof
// verification fallback paths.
const MAX_TRAVERSAL_DEPTH = 64;
Comment thread
dahlia marked this conversation as resolved.

const preloadedOnlyDocumentLoader: DocumentLoader = (url: string) => {
if (Object.hasOwn(preloadedContexts, url)) {
return Promise.resolve({
contextUrl: null,
documentUrl: url,
document: preloadedContexts[url],
});
}
return Promise.reject(
new Error(
"Refusing to fetch a non-preloaded JSON-LD context: " + url,
),
);
};
Comment thread
dahlia marked this conversation as resolved.
Outdated

/**
* Wraps scalar ActivityStreams attachment properties in arrays.
*/
function wrapScalarAttachments(
jsonLd: unknown,
depth: number = 0,
): unknown {
if (depth >= MAX_TRAVERSAL_DEPTH) return jsonLd;

if (Array.isArray(jsonLd)) {
let changed = false;
const normalized = jsonLd.map((item) => {
const next = wrapScalarAttachments(item, depth + 1);
if (next !== item) changed = true;
return next;
});
return changed ? normalized : jsonLd;
Comment thread
dahlia marked this conversation as resolved.
Outdated
}

if (typeof jsonLd !== "object" || jsonLd == null) return jsonLd;

const record = jsonLd as Record<string, unknown>;
let changed = false;
const normalized: Record<string, unknown> = Object.create(null);
for (const key of Object.keys(record)) {
const value = record[key];
const next = key === "@context"
? value
: wrapScalarAttachments(value, depth + 1);
Comment thread
dahlia marked this conversation as resolved.
if (
ATTACHMENT_FIELDS.has(key) &&
next != null &&
!Array.isArray(next)
) {
normalized[key] = [next];
changed = true;
} else {
Comment thread
dahlia marked this conversation as resolved.
Outdated
normalized[key] = next;
if (next !== value) changed = true;
}
}

return changed ? normalized : jsonLd;
Comment thread
dahlia marked this conversation as resolved.
Outdated
}
Comment thread
dahlia marked this conversation as resolved.

function hasNestedContext(value: unknown, depth: number = 0): boolean {
if (depth >= MAX_TRAVERSAL_DEPTH) return true;
if (Array.isArray(value)) {
return value.some((item) => hasNestedContext(item, depth + 1));
}
if (typeof value !== "object" || value == null) return false;
const record = value as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (key === "@context") return true;
Comment thread
dahlia marked this conversation as resolved.
if (hasNestedContext(record[key], depth + 1)) return true;
}
return false;
}
Comment thread
dahlia marked this conversation as resolved.

function hasActivityStreamsOnlyContext(jsonLd: unknown): boolean {
if (typeof jsonLd !== "object" || jsonLd == null) return false;
const record = jsonLd as Record<string, unknown>;
if (!Object.hasOwn(record, "@context")) return false;
const context = record["@context"];
const entries = typeof context === "string"
? [context]
: Array.isArray(context)
? context
: null;
if (entries == null || entries.length < 1) return false;
if (!entries.every((entry) => entry === AS_CONTEXT_URL)) return false;
Comment thread
dahlia marked this conversation as resolved.
Outdated
for (const key of Object.keys(record)) {
if (key === "@context") continue;
if (hasNestedContext(record[key])) return false;
}
return true;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Ensures ActivityStreams attachment properties are represented as arrays
* when doing so preserves the JSON-LD semantics.
*
* JSON-LD compaction collapses single-item arrays into scalar values by
* default. Some ActivityPub implementations, Pixelfed among them, parse
* `attachment` as a plain JSON array rather than a JSON-LD property and reject
* otherwise valid objects whose single attachment is emitted as a scalar.
*/
export async function normalizeAttachmentArrays(
jsonLd: unknown,
contextLoader?: DocumentLoader,
): Promise<unknown> {
const normalized = wrapScalarAttachments(jsonLd);
if (normalized === jsonLd) return jsonLd;
if (hasActivityStreamsOnlyContext(jsonLd)) return normalized;
const loader = contextLoader ?? preloadedOnlyDocumentLoader;
try {
const [before, after] = await Promise.all([
jsonld.canonize(jsonLd, {
format: "application/n-quads",
documentLoader: loader,
}),
Comment thread
dahlia marked this conversation as resolved.
jsonld.canonize(normalized, {
format: "application/n-quads",
documentLoader: loader,
}),
]);
Comment thread
dahlia marked this conversation as resolved.
if (before === after) return normalized;
logger.warn(
Comment thread
dahlia marked this conversation as resolved.
"Wrapping scalar attachment values in arrays would change the " +
"canonical form of the JSON-LD document; leaving it unchanged. " +
"This usually means the active JSON-LD context redefines the " +
"`attachment` term.",
);
} catch (error) {
logger.debug(
"Failed to verify attachment array normalization equivalence via " +
"JSON-LD canonicalization; leaving the JSON-LD document " +
"unchanged.\n{error}",
{ error },
);
}
return jsonLd;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Applies Fedify's internal JSON-LD wire-format interoperability workarounds
* to locally generated outgoing activities before they are signed, enqueued,
* or sent.
*/
export async function normalizeOutgoingActivityJsonLd(
jsonLd: unknown,
contextLoader?: DocumentLoader,
): Promise<unknown> {
jsonLd = await normalizePublicAudience(jsonLd, contextLoader);
return await normalizeAttachmentArrays(jsonLd, contextLoader);
}
Loading
Loading