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
1 change: 1 addition & 0 deletions .agents/skills/add-vocab/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ Range type reference
| --------------------- | ------------------------------------------------------ | ----------------------------------- |
| `fedify:langTag` | `Intl.Locale` | BCP 47 language tag as plain string |
| `fedify:url` | `URL` | URL stored as `@value` (not `@id`) |
| `fedify:absoluteIri` | `URL` | Absolute IRI stored as `@id`; also reads literals, never resolves relative values |
| `fedify:publicKey` | `CryptoKey` | PEM SPKI-encoded public key |
| `fedify:multibaseKey` | `CryptoKey` | Multibase-encoded key (Ed25519) |
| `fedify:proofPurpose` | `"assertionMethod" \| "authentication" \| ...` | Proof purpose string |
Expand Down
34 changes: 33 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,29 @@ To be released.
[#896]: https://github.com/fedify-dev/fedify/issues/896
[#1204]: https://github.com/fedify-dev/fedify/issues/1204

### @fedify/vocab

- Added vocabulary support for the [FEP-6757] draft, which marks
ActivityPub content with explicit copyright license URIs.
[[#1212], [#1241]]

- Added `Object.licenses` and `Link.licenses` properties for the
alternative licenses of an object or a link. They write the Dublin
Core `license` property and also read the Schema.org and Creative
Commons `license` properties when it is absent. Values that are not
absolute URIs, such as license names or SPDX short identifiers, are
skipped.
- Added `preferredLicense` property to `Application`, `Group`,
`Organization`, `Person`, and `Service` for the license an actor
prefers for its new objects. It is not a fallback for objects without
their own license.
- Fedify does not fill in a default license for objects without license
metadata and never fetches a license URI.

[FEP-6757]: https://w3id.org/fep/6757
[#1212]: https://github.com/fedify-dev/fedify/issues/1212
[#1241]: https://github.com/fedify-dev/fedify/pull/1241

### @fedify/vocab-runtime

- Added `normalizeLanguageTag()` function, which replaces a language tag
Expand All @@ -51,6 +74,16 @@ To be released.
only the canonical property while accepting synonyms. Fixed serialization
of synonyms without a compact name and validation of synonym definitions.
[[#1210], [#1215]]
- Added the `fedify:absoluteIri` range for URLs written as IRI references
that also accept string literals when read. Unlike
`http://www.w3.org/2001/XMLSchema#anyURI`, it never resolves a value
against the object's ID, and skips values that are not absolute instead of
failing the whole object. [[#1212], [#1241]]
- Changed `extraContext` to add its context when the property is populated
even if the default context defines a matching prefix, such as `dc` in
`https://w3id.org/identity/v1`. Previously, such a property compacted to
a prefixed name like `dc:license` without the extra context.
[[#1212], [#1241]]

[#1210]: https://github.com/fedify-dev/fedify/issues/1210
[#1215]: https://github.com/fedify-dev/fedify/pull/1215
Expand Down Expand Up @@ -106,7 +139,6 @@ Released on October 5, 2026.
literals and remain arrays when compacted, matching Mastodon's context.
[[#1233], [#1235]]

[FEP-6757]: https://w3id.org/fep/6757
[#1211]: https://github.com/fedify-dev/fedify/issues/1211
[#1216]: https://github.com/fedify-dev/fedify/pull/1216
[#1233]: https://github.com/fedify-dev/fedify/issues/1233
Expand Down
2 changes: 2 additions & 0 deletions FEDERATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Supported FEPs
- [FEP-044f][]: Consent-respecting quote posts
- [FEP-7aa9][]: Featuring recommendations using a dedicated collection
- [FEP-22cd][]: Attributing translations
- [FEP-6757][]: Content license metadata
- [FEP-0837][]: Federated Marketplace
- [FEP-ae0c][]: Fediverse Relay Protocols: Mastodon and LitePub
- [FEP-ef61][]: Portable Objects (partial; see [below][FEP-ef61 section])
Expand All @@ -60,6 +61,7 @@ Supported FEPs
[FEP-044f]: https://w3id.org/fep/044f
[FEP-7aa9]: https://w3id.org/fep/7aa9
[FEP-22cd]: https://w3id.org/fep/22cd
[FEP-6757]: https://w3id.org/fep/6757
[FEP-0837]: https://w3id.org/fep/0837
[FEP-ae0c]: https://w3id.org/fep/ae0c
[FEP-ef61 section]: #fep-ef61
Expand Down
15 changes: 15 additions & 0 deletions changes.d/vocab-tools/absolute-iri.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
links:
'#1212': https://github.com/fedify-dev/fedify/issues/1212
'#1241': https://github.com/fedify-dev/fedify/pull/1241
---
- Added the `fedify:absoluteIri` range for URLs written as IRI references
that also accept string literals when read. Unlike
`http://www.w3.org/2001/XMLSchema#anyURI`, it never resolves a value
against the object's ID, and skips values that are not absolute instead of
failing the whole object. [[#1212], [#1241]]
- Changed `extraContext` to add its context when the property is populated
even if the default context defines a matching prefix, such as `dc` in
`https://w3id.org/identity/v1`. Previously, such a property compacted to
a prefixed name like `dc:license` without the extra context.
[[#1212], [#1241]]
23 changes: 23 additions & 0 deletions changes.d/vocab/content-license.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
links:
'#1212': https://github.com/fedify-dev/fedify/issues/1212
'#1241': https://github.com/fedify-dev/fedify/pull/1241
---
- Added vocabulary support for the [FEP-6757] draft, which marks
ActivityPub content with explicit copyright license URIs.
[[#1212], [#1241]]

- Added `Object.licenses` and `Link.licenses` properties for the
alternative licenses of an object or a link. They write the Dublin
Core `license` property and also read the Schema.org and Creative
Commons `license` properties when it is absent. Values that are not
absolute URIs, such as license names or SPDX short identifiers, are
skipped.
- Added `preferredLicense` property to `Application`, `Group`,
`Organization`, `Person`, and `Service` for the license an actor
prefers for its new objects. It is not a fallback for objects without
their own license.
- Fedify does not fill in a default license for objects without license
metadata and never fetches a license URI.

[FEP-6757]: https://w3id.org/fep/6757
96 changes: 96 additions & 0 deletions docs/manual/pragmatics.md
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,102 @@ parsing these fields does not verify the credit or authorize an activity.

[FEP-22cd]: https://w3id.org/fep/22cd

### License metadata

`Object.licenses` and `Link.licenses` hold the copyright licenses of a piece of
content as URIs, and actors have a `preferredLicense` for the new objects they
create. Both are defined by [FEP-6757], which recommends a `license` on
content objects such as `Note`, `Article`, `Image`, `Video`, and `Audio`.

> [!WARNING]
> This implements [FEP-6757].
> The proposal may change, and few other implementations read these properties
> yet. A license is a claim made by the publisher; Fedify does not enforce it.

Identify a license by its URI, not by its name or SPDX short identifier.
Prefer the canonical URIs of the [Creative Commons licenses], CC0
(<https://creativecommons.org/publicdomain/zero/1.0/>), the Public Domain Mark
(<https://creativecommons.org/publicdomain/mark/1.0/>), or the
[Rights Statements] such as <https://rightsstatements.org/vocab/InC/1.0/>
(“in copyright”):

~~~~ typescript twoslash
import { Image, Note } from "@fedify/vocab";

const note = new Note({
id: new URL("https://example.com/notes/1"),
content: "Hello, world!",
licenses: [new URL("https://creativecommons.org/licenses/by/4.0/")],
attachments: [
new Image({
url: new URL("https://example.com/media/1.jpg"),
mediaType: "image/jpeg",
licenses: [new URL("https://creativecommons.org/publicdomain/mark/1.0/")],
}),
],
});
~~~~

Multiple licenses are alternatives that a consumer may choose from, so their
order has no meaning. Fedify adds the preloaded `https://w3id.org/fep/6757`
context when license metadata needs it; objects without it keep their existing
output. If you supply an explicit context to
`toJsonLd({ format: "compact", context: ... })`, include the FEP context to get
the `license` and `preferredLicense` term names.

When reading, Fedify also accepts the `https://schema.org/license` and
`http://creativecommons.org/ns#license` properties that the FEP allows as
alternatives, but only when the object has no `license`; the sets are never
merged. The `http://schema.org/license` property is not read, because
PeerTube uses it for licence descriptions that are not URIs. It writes only
`license`. Values that are not absolute URIs, such as `"CC-BY-4.0"`, are
skipped. Fedify never fetches a license URI.

A license applies only to the object that carries it. A license on an actor
covers its profile, not the posts it authored; a license on an activity or a
collection does not cover the activity's object or the collection's items.

#### Missing licenses

An empty `licenses` array means the object carries no license metadata, and
Fedify does not fill in a default. [FEP-6757] suggests that consumers treat
such content as “all rights reserved,” that is, as if its license were
<https://rightsstatements.org/vocab/InC/1.0/>. Apply that policy where you
decide what to do with the content rather than storing it as the object's
license:

~~~~ typescript twoslash
import type { Note } from "@fedify/vocab";
declare const note: Note;
// ---cut-before---
const allRightsReserved = new URL("https://rightsstatements.org/vocab/InC/1.0/");
const licenses = note.licenses.length > 0 ? note.licenses : [allRightsReserved];
~~~~

#### Preferred license of an actor

`preferredLicense` tells a client which license to select by default when the
actor creates a new object; the client should still let the user choose
another:

~~~~ typescript twoslash
import { Note, Person } from "@fedify/vocab";
declare const actor: Person;
// ---cut-before---
const note = new Note({
attribution: actor.id,
content: "Hello, world!",
licenses: actor.preferredLicense == null ? [] : [actor.preferredLicense],
});
~~~~

It is not a fallback: an object without its own `license` is not licensed under
its author's `preferredLicense`.

[FEP-6757]: https://w3id.org/fep/6757
[Creative Commons licenses]: https://creativecommons.org/licenses/
[Rights Statements]: https://rightsstatements.org/page/1.0/

### `Question`: Polls

The `Question` type is used for polls. In Mastodon, the question body comes
Expand Down
150 changes: 150 additions & 0 deletions packages/fedify/src/sig/proof.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3442,6 +3442,156 @@ test("signObject() preserves FEP-22cd contexts through proof verification", asyn
assertEquals(verified.translations[0].language?.baseName, "ko");
});

async function signLicensedNote(
license: Record<string, unknown>,
context: unknown[] = [],
): Promise<Record<string, unknown>> {
return await signPortableJsonLd({
"@context": [...portableContext, ...context],
// Fedify formats portable IDs with the ap+ef61: scheme, so these round-trip
// without changing the signed bytes:
id: `ap+ef61://${portableDid}/objects/licensed`,
type: "Note",
attributedTo: `ap+ef61://${portableDid}/actor`,
content: "A licensed note",
...license,
});
}

const licenseVerifyOptions = {
documentLoader() {
throw new Error("No document fetch expected");
},
contextLoader: mockDocumentLoader,
};

test("verifyObject() keeps FEP-6757 license synonyms of a parsed object", async () => {
const license = "https://creativecommons.org/licenses/by/4.0/";
const signed = await signLicensedNote(
{ "schema:license": license },
[{ schema: "https://schema.org/" }],
);
const verified = await verifyObject(Note, signed, licenseVerifyOptions);
assertInstanceOf(verified, Note);
assertEquals(verified.licenses, [new URL(license)]);
// Reading a synonym must not rewrite the proof-covered input:
const parsed = await Note.fromJsonLd(signed, licenseVerifyOptions);
const json = await parsed.toJsonLd() as Record<string, unknown>;
assertEquals(json, signed);
assertFalse("license" in json);
assertInstanceOf(
await verifyObject(Note, json, licenseVerifyOptions),
Note,
);
});

test("signObject() signs FEP-6757 licenses", async () => {
const license = new URL("https://creativecommons.org/licenses/by/4.0/");
const signed = await signObject(
new Note({
id: parseIri(`ap://${portableDid}/objects/licensed`),
attribution: parseIri(`ap://${portableDid}/actor`),
content: "A licensed note",
licenses: [license],
}),
ed25519PrivateKey,
portableKeyId,
{ contextLoader: mockDocumentLoader },
);
const json = await signed.toJsonLd({
format: "compact",
contextLoader: mockDocumentLoader,
}) as Record<string, unknown>;
assertEquals(json.license, license.href);
assert((json["@context"] as unknown[]).includes("https://w3id.org/fep/6757"));
const verified = await verifyObject(Note, json, licenseVerifyOptions);
assertInstanceOf(verified, Note);
assertEquals(verified.licenses, [license]);

// The signed note embedded in an activity keeps its own representation:
const create = await new Create({
actor: parseIri(`ap://${portableDid}/actor`),
object: signed,
}).toJsonLd({ contextLoader: mockDocumentLoader }) as Record<
string,
unknown
>;
assertEquals(create.object, json);
assertInstanceOf(
await verifyObject(Note, create.object, licenseVerifyOptions),
Note,
);

// Changing the license invalidates the proof until the note is re-signed:
const relicensed = signed.clone({
licenses: [new URL("https://creativecommons.org/publicdomain/zero/1.0/")],
});
assertEquals(
await verifyObject(
Note,
await relicensed.toJsonLd({ contextLoader: mockDocumentLoader }),
licenseVerifyOptions,
),
null,
);
const resigned = await signObject(
relicensed.clone({ proofs: [] }),
ed25519PrivateKey,
portableKeyId,
{ contextLoader: mockDocumentLoader },
);
assertInstanceOf(
await verifyObject(
Note,
await resigned.toJsonLd({ contextLoader: mockDocumentLoader }),
licenseVerifyOptions,
),
Note,
);
});

test("verifyObject() and FEP-6757 licenses that cannot be preserved", async () => {
const license = "https://creativecommons.org/licenses/by/4.0/";
const contexts = ["https://w3id.org/fep/6757"];
// A parsed note embedded in a new activity is serialized again, which
// writes the canonical license property instead of the synonym:
const aliased = await signLicensedNote(
{ "schema:license": license },
[{ schema: "https://schema.org/" }],
);
const create = await new Create({
actor: parseIri(`ap://${portableDid}/actor`),
object: await Note.fromJsonLd(aliased, licenseVerifyOptions),
}).toJsonLd({
format: "compact",
contextLoader: mockDocumentLoader,
}) as Record<string, unknown>;
const object = create.object as Record<string, unknown>;
assertEquals(object.license, license);
assertEquals(
await verifyObject(
Note,
{ ...object, "@context": create["@context"] },
licenseVerifyOptions,
),
null,
);

// A license value that is dropped while parsing makes the parsed object
// lose its cached input, so its serialization no longer verifies:
const invalid = await signLicensedNote(
{ license: ["CC-BY-4.0", license] },
contexts,
);
const verified = await verifyObject(Note, invalid, licenseVerifyOptions);
assertInstanceOf(verified, Note);
assertEquals(verified.licenses, [new URL(license)]);
const parsed = await Note.fromJsonLd(invalid, licenseVerifyOptions);
const json = await parsed.toJsonLd() as Record<string, unknown>;
assertEquals(json.license, license);
assertEquals(await verifyObject(Note, json, licenseVerifyOptions), null);
});

test("verifyObject() rejects a key that claims a forged controller", async () => {
// Object Integrity Proofs clear an object's attributions with the
// `controller` the signing key declares about itself, and this path needs
Expand Down
12 changes: 12 additions & 0 deletions packages/fixture/src/fixtures/w3id.org/fep/6757.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"@context": {
"license": {
"@id": "http://purl.org/dc/terms/license",
"@type": "@id"
},
"preferredLicense": {
"@id": "https://w3id.org/fep/6757#preferredLicense",
"@type": "@id"
}
}
}
Loading
Loading