Skip to content
8 changes: 8 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ To be released.

### @fedify/vocab-runtime

- Added `canonicalizePortableUri()` and `arePortableUrisEqual()` for
comparing [FEP-ef61] portable ActivityPub URI strings. The helpers accept
`ap:` and `ap+ef61:` values with decoded or percent-encoded DID
authorities, normalize them to `ap+ef61:`, and ignore query hints such as
`gateways` during comparison. [[#828], [#924]]

- Added the [FEP-7aa9] JSON-LD context to the preloaded context registry so
FEP-7aa9 documents can be compacted and expanded without fetching the
context remotely. [[#810], [#914]]
Expand All @@ -108,8 +114,10 @@ To be released.
error pages surface as document loading failures with the response URL and
content type, rather than generic JSON parser crashes. [[#912], [#913]]

[#828]: https://github.com/fedify-dev/fedify/issues/828
[#912]: https://github.com/fedify-dev/fedify/issues/912
[#913]: https://github.com/fedify-dev/fedify/pull/913
[#924]: https://github.com/fedify-dev/fedify/pull/924


Version 2.3.1
Expand Down
6 changes: 6 additions & 0 deletions docs/manual/vocab.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ Both the `ap:` and `ap+ef61:` schemes are accepted, whether the DID authority
is decoded (e.g., `ap+ef61://did:key:.../actor`) or percent-encoded. Fedify
stores these IRIs as `URL` objects with a URL-safe authority internally, and
serializes them as canonical `ap+ef61:` IRIs with the decoded DID authority.
When comparing portable object IDs, use `canonicalizePortableUri()` or
`arePortableUrisEqual()` from `@fedify/vocab-runtime`; these helpers remove
query hints such as `gateways` according to [FEP-ef61]. Pass raw URI strings
to these comparison helpers, because JavaScript `URL` objects normalize opaque
path segments before Fedify can compare them. Serialization keeps those query
hints intact.

> [!TIP]
> You can instantiate an object from a JSON-LD document by calling the
Expand Down
2 changes: 2 additions & 0 deletions packages/vocab-runtime/src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export {
type PropertyPreprocessorContext,
} from "./preprocessor.ts";
export {
arePortableUrisEqual,
canonicalizePortableUri,
expandIPv6Address,
formatIri,
haveSameIriOrigin,
Expand Down
252 changes: 252 additions & 0 deletions packages/vocab-runtime/src/url.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { deepStrictEqual, ok, rejects, throws } from "node:assert";
import { test } from "node:test";
import {
arePortableUrisEqual,
canonicalizePortableUri,
expandIPv6Address,
formatIri,
haveSameIriOrigin,
Expand Down Expand Up @@ -184,6 +186,256 @@ test("formatIri() emits canonical portable ActivityPub URI syntax", () => {
deepStrictEqual(formatIri("/actor"), "/actor");
});

test("canonicalizePortableUri() emits comparison forms", () => {
const cases = [
"ap://did:key:z6Mkabc/actor",
"ap://did%3Akey%3Az6Mkabc/actor",
"ap://did:key:z6Mkabc/actor?gateways=https%3A%2F%2Fa.example",
"ap+ef61://did:key:z6Mkabc/actor",
"ap+ef61://did%3Akey%3Az6Mkabc/actor?gateways=https%3A%2F%2Fa.example",
];
for (const iri of cases) {
deepStrictEqual(
canonicalizePortableUri(iri),
"ap+ef61://did:key:z6Mkabc/actor",
);
}
});

test("canonicalizePortableUri() preserves paths and fragments", () => {
deepStrictEqual(
canonicalizePortableUri(
"ap://did:key:z6Mkabc/objects/1/attachments/2?gateways=https%3A%2F%2Fa.example#image",
),
"ap+ef61://did:key:z6Mkabc/objects/1/attachments/2#image",
);
deepStrictEqual(
canonicalizePortableUri("ap://did:key:z6Mkabc/objects/1#reply"),
"ap+ef61://did:key:z6Mkabc/objects/1#reply",
);
});

test("canonicalizePortableUri() preserves DID-internal pct-encoded characters", () => {
deepStrictEqual(
canonicalizePortableUri("ap://did:example:abc%2Fdef/actor?x=1"),
"ap+ef61://did:example:abc%2Fdef/actor",
);
deepStrictEqual(
canonicalizePortableUri("ap://did%3Aweb%3Aexample.com%253A3000/u/1"),
"ap+ef61://did:web:example.com%3A3000/u/1",
);
});

test("canonicalizePortableUri() normalizes authority pct-encoding casing", () => {
deepStrictEqual(
canonicalizePortableUri("ap://did:example:abc%2fdef/actor"),
"ap+ef61://did:example:abc%2Fdef/actor",
);
deepStrictEqual(
canonicalizePortableUri("ap://did:example:abc%2fdef%3abar/actor"),
"ap+ef61://did:example:abc%2Fdef%3Abar/actor",
);
deepStrictEqual(
canonicalizePortableUri("ap://did%3Aexample%3Aabc%252fdef/actor"),
"ap+ef61://did:example:abc%2Fdef/actor",
);
deepStrictEqual(
canonicalizePortableUri("ap://did%3Akey%3Az6Mk%2Dabc/actor"),
"ap+ef61://did:key:z6Mk-abc/actor",
);
ok(arePortableUrisEqual(
"ap://did:example:abc%2fdef/actor",
"ap://did:example:abc%2Fdef/actor",
));
ok(arePortableUrisEqual(
"ap://did%3Akey%3Az6Mk%2Dabc/actor",
"ap://did:key:z6Mk-abc/actor",
));
});

test("canonicalizePortableUri() normalizes path and fragment pct-encoding casing", () => {
deepStrictEqual(
canonicalizePortableUri("ap://did:key:z6Mkabc/actor%2fprofile#part%2ftwo"),
"ap+ef61://did:key:z6Mkabc/actor%2Fprofile#part%2Ftwo",
);
deepStrictEqual(
canonicalizePortableUri("ap://did:key:z6Mkabc/actor%2fprofile%3aimage"),
"ap+ef61://did:key:z6Mkabc/actor%2Fprofile%3Aimage",
);
deepStrictEqual(
canonicalizePortableUri("ap://did:key:z6Mkabc/actor%2Dprofile#part%7Etwo"),
"ap+ef61://did:key:z6Mkabc/actor-profile#part~two",
);
ok(arePortableUrisEqual(
"ap://did:key:z6Mkabc/actor%2fprofile#part%2ftwo",
"ap://did:key:z6Mkabc/actor%2Fprofile#part%2Ftwo",
));
ok(arePortableUrisEqual(
"ap://did:key:z6Mkabc/actor%2Dprofile#part%7Etwo",
"ap://did:key:z6Mkabc/actor-profile#part~two",
));
});

test("canonicalizePortableUri() encodes raw path and fragment characters", () => {
deepStrictEqual(
canonicalizePortableUri("ap://did:key:z6Mkabc/\u00e9#\u00e9"),
"ap+ef61://did:key:z6Mkabc/%C3%A9#%C3%A9",
);
ok(arePortableUrisEqual(
"ap://did:key:z6Mkabc/\u00e9#\u00e9",
"ap://did:key:z6Mkabc/%C3%A9#%C3%A9",
));
});

test("canonicalizePortableUri() normalizes DID scheme casing", () => {
deepStrictEqual(
canonicalizePortableUri("ap://DID:key:z6Mkabc/actor"),
"ap+ef61://did:key:z6Mkabc/actor",
);
deepStrictEqual(
canonicalizePortableUri("ap://DID%3Akey%3Az6Mkabc/actor"),
"ap+ef61://did:key:z6Mkabc/actor",
);
});

test("canonicalizePortableUri() preserves opaque path segments", () => {
deepStrictEqual(
canonicalizePortableUri("ap://did:key:z6Mkabc/a/../b?gateways=x"),
"ap+ef61://did:key:z6Mkabc/a/../b",
);
deepStrictEqual(
canonicalizePortableUri("ap://did:key:z6Mkabc/a/%2e%2e/b"),
"ap+ef61://did:key:z6Mkabc/a/../b",
);
ok(
!arePortableUrisEqual(
"ap://did:key:z6Mkabc/a/../b",
"ap://did:key:z6Mkabc/b",
),
);
ok(
!arePortableUrisEqual(
"ap://did:key:z6Mkabc/a/%2e%2e/b",
"ap://did:key:z6Mkabc/b",
),
);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test("canonicalizePortableUri() rejects non-portable URIs", () => {
const cases = [
"https://example.com/actor",
"at://did:plc:example/record",
"/actor",
"ap://not-a-did/actor",
"ap://did:key:z6Mkabc",
];
for (const iri of cases) {
throws(() => canonicalizePortableUri(iri), TypeError);
}
throws(
() =>
canonicalizePortableUri(
new URL("ap+ef61://did%3Akey%3Az6Mkabc/actor") as unknown as string,
),
TypeError,
);
});

test("canonicalizePortableUri() rejects invalid path and fragment pct-encoding", () => {
throws(() => canonicalizePortableUri("ap://did:key:z6Mkabc/a%zz"), TypeError);
throws(
() => canonicalizePortableUri("ap://did:key:z6Mkabc/actor#part%zz"),
TypeError,
);
throws(
() => canonicalizePortableUri("ap://did:key:z6Mkabc/\ud800"),
TypeError,
);
throws(
() => canonicalizePortableUri("ap://did:key:z6Mkabc/actor#\ud800"),
TypeError,
);
});

test("arePortableUrisEqual() compares canonical portable URI forms", () => {
ok(arePortableUrisEqual(
"ap://did:key:z6Mkabc/actor",
"ap+ef61://did%3Akey%3Az6Mkabc/actor?gateways=https%3A%2F%2Fa.example",
));
ok(arePortableUrisEqual(
"ap://DID:key:z6Mkabc/actor",
"ap://did:key:z6Mkabc/actor",
));
ok(
!arePortableUrisEqual(
"ap://did:key:z6Mkabc/actor",
"ap://did:key:z6Mkdef/actor",
),
);
ok(
!arePortableUrisEqual(
"ap://did:key:z6Mkabc/actor",
"ap://did:key:z6Mkabc/outbox",
),
);
ok(
!arePortableUrisEqual(
"ap://did:key:z6Mkabc/actor#one",
"ap://did:key:z6Mkabc/actor#two",
),
);
});

test("arePortableUrisEqual() handles non-portable URI strings", () => {
ok(arePortableUrisEqual(
"https://example.com/actor",
"https://example.com/actor",
));
ok(
!arePortableUrisEqual(
"https://example.com/actor",
"https://example.com/outbox",
),
);
ok(
!arePortableUrisEqual(
"ap://did:key:z6Mkabc/actor",
"https://example.com/actor",
),
);
ok(arePortableUrisEqual("ap://not-a-did/actor", "ap://not-a-did/actor"));
ok(
!arePortableUrisEqual(
"ap://not-a-did/actor",
"ap://not-a-did/outbox",
),
);
ok(
!arePortableUrisEqual(
"ap://not-a-did/actor",
"ap://did:key:z6Mkabc/actor",
),
);
ok(
!arePortableUrisEqual(
"ap://did:key:z6Mkabc/a%zz",
"ap://did:key:z6Mkabc/a%25zz",
),
);
ok(
!arePortableUrisEqual(
"ap://did:key:z6Mkabc/\ud800",
"ap://did:key:z6Mkabc/%EF%BF%BD",
),
);
ok(
!arePortableUrisEqual(
"ap://did:key:z6Mkabc/actor#\ud800",
"ap://did:key:z6Mkabc/actor#%EF%BF%BD",
),
);
});

test("formatIri() preserves DID authority pct-encoded delimiters", () => {
const parsed = parseIri("ap://did:example:abc%2Fdef/actor");
deepStrictEqual(
Expand Down
Loading
Loading