From 5ca16ecae741dd84456ddd3d801600e2165e00c5 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 27 Sep 2026 03:06:14 +0900 Subject: [PATCH] Convert FEP-ef61 compatible identifiers Software that does not understand ap:/ap+ef61: URIs can still refer to portable objects through compatible identifiers: HTTP(S) URLs under a gateway's fixed /.well-known/apgateway/ path. The later FEP-ef61 work (gateway dereferencing, portable inbox delivery, WebFinger, gateway keys, and Context helpers) needs a single, well-tested conversion between the two forms instead of hand-built strings. This adds two helpers to @fedify/vocab-runtime: - fromCompatibleEf61Id() turns a compatible identifier into the portable URI, returning null for URLs that are not compatible identifiers (including the discovery endpoint and hashlink media routes) and throwing a TypeError for malformed ones, such as those with an invalid DID, no object path, bad percent-encoding, credentials, or location hints. - toCompatibleEf61Id() builds a compatible identifier from a portable URI and an HTTP(S) gateway origin. It removes @gateway (and legacy gateways) location hints, which FEP-ef61 forbids in compatible identifiers, keeps other query parameters, and preserves the path and fragment. Characters that an HTTP(S) URL parser would rewrite are percent-encoded the same way canonicalizePortableUri() does, and paths with dot segments are rejected, so the conversion never silently changes which object is identified. Only the fixed well-known gateway path is supported. The existing comparison and origin helpers are unchanged; callers convert compatible identifiers explicitly, and the docs point out that the conversion does not authenticate anything: the object's proof still has to be verified against its DID. https://github.com/fedify-dev/fedify/issues/833 https://github.com/fedify-dev/fedify/issues/288 Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: OpenCode:deepseek-flash Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5-1 --- CHANGES.md | 12 + .../vocab-runtime/compatible-ef61-ids.md | 18 + docs/manual/vocab.md | 43 +++ packages/vocab-runtime/src/mod.ts | 2 + packages/vocab-runtime/src/url.test.ts | 361 ++++++++++++++++++ packages/vocab-runtime/src/url.ts | 235 ++++++++++++ 6 files changed, 671 insertions(+) create mode 100644 changes.d/vocab-runtime/compatible-ef61-ids.md diff --git a/CHANGES.md b/CHANGES.md index ce714b6cf..948484e50 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -550,13 +550,25 @@ To be released. [[#912], [#913]] - Added to preloaded JSON-LD contexts. [[#1037], [#1038]] + - Added `toCompatibleEf61Id()` and `fromCompatibleEf61Id()` for converting + between [FEP-ef61] portable IDs and compatible identifiers, which are + HTTP(S) URLs under a gateway's fixed `/.well-known/apgateway/` path that + software without portable IRI support can use. `toCompatibleEf61Id()` + removes location hints (`@gateway` query parameters, and the legacy + `gateways` parameter), and `fromCompatibleEf61Id()` returns + `null` for URLs that are not compatible identifiers and throws a + `TypeError` for malformed ones, including those with location hints. + Converting a compatible identifier does not authenticate it; the object's + proof still has to be verified against its DID. [[#288], [#833], [#1074]] [#828]: https://github.com/fedify-dev/fedify/issues/828 [#831]: https://github.com/fedify-dev/fedify/issues/831 +[#833]: https://github.com/fedify-dev/fedify/issues/833 [#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 [#935]: https://github.com/fedify-dev/fedify/pull/935 +[#1074]: https://github.com/fedify-dev/fedify/pull/1074 ### @fedify/vocab-tools diff --git a/changes.d/vocab-runtime/compatible-ef61-ids.md b/changes.d/vocab-runtime/compatible-ef61-ids.md new file mode 100644 index 000000000..dae4399d3 --- /dev/null +++ b/changes.d/vocab-runtime/compatible-ef61-ids.md @@ -0,0 +1,18 @@ +--- +links: + '#1074': https://github.com/fedify-dev/fedify/pull/1074 + '#288': https://github.com/fedify-dev/fedify/issues/288 + '#833': https://github.com/fedify-dev/fedify/issues/833 +--- + - Added `toCompatibleEf61Id()` and `fromCompatibleEf61Id()` for converting + between [FEP-ef61] portable IDs and compatible identifiers, which are + HTTP(S) URLs under a gateway's fixed `/.well-known/apgateway/` path that + software without portable IRI support can use. `toCompatibleEf61Id()` + removes location hints (`@gateway` query parameters, and the legacy + `gateways` parameter), and `fromCompatibleEf61Id()` returns + `null` for URLs that are not compatible identifiers and throws a + `TypeError` for malformed ones, including those with location hints. + Converting a compatible identifier does not authenticate it; the object's + proof still has to be verified against its DID. [[#288], [#833], [#1074]] + +[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/docs/manual/vocab.md b/docs/manual/vocab.md index d92bc1768..1a9fb7719 100644 --- a/docs/manual/vocab.md +++ b/docs/manual/vocab.md @@ -251,6 +251,49 @@ const actor = new Person({ Each gateway must be an HTTP(S) base URI with no path, query, or fragment. +Software that does not understand portable IRIs can still refer to portable +objects through *compatible identifiers*: HTTP(S) URLs under a gateway's +fixed `/.well-known/apgateway/` path. Use `toCompatibleEf61Id()` to build +one from a portable ID and a gateway origin, which should be the first item in +the actor's `gateways`, and `fromCompatibleEf61Id()` to get the portable ID +back: + +~~~~ typescript twoslash +import { + canonicalizePortableUri, + fromCompatibleEf61Id, + toCompatibleEf61Id, +} from "@fedify/vocab-runtime"; + +const compatibleId = toCompatibleEf61Id( + "ap+ef61://did:key:z6Mkabc/objects/1", + "https://server1.example", +); +// https://server1.example/.well-known/apgateway/did:key:z6Mkabc/objects/1 + +const portableId = fromCompatibleEf61Id(compatibleId); +if (portableId != null) { + canonicalizePortableUri(portableId.href); + // ap+ef61://did:key:z6Mkabc/objects/1 +} +~~~~ + +`toCompatibleEf61Id()` removes location hints (`@gateway` query parameters, +and the legacy `gateways` parameter) from the query, because FEP-ef61 forbids +them in compatible identifiers. +`fromCompatibleEf61Id()` returns `null` for URLs that are not compatible +identifiers, and throws a `TypeError` for compatible identifiers that are +malformed or carry location hints. Arbitrary gateway paths are not supported +yet. + +> [!WARNING] +> +> A compatible identifier only tells you which portable object it *claims* to +> be. Anyone can serve a compatible identifier for any DID from their own +> server, so the gateway's host is neither the object's origin nor authorized +> to act for the DID. Verify the object's Object Integrity Proof against the +> DID before trusting it. + Links and media/document objects expose `digestMultibase` for the integrity digest required when portable objects reference external resources. Use `computeDigestMultibase()` to compute the SHA-256 multihash and diff --git a/packages/vocab-runtime/src/mod.ts b/packages/vocab-runtime/src/mod.ts index 12c057237..cf5a00fac 100644 --- a/packages/vocab-runtime/src/mod.ts +++ b/packages/vocab-runtime/src/mod.ts @@ -68,6 +68,7 @@ export { canonicalizePortableUri, expandIPv6Address, formatIri, + fromCompatibleEf61Id, getFe34Origin, haveSameFe34Origin, haveSameIriOrigin, @@ -77,6 +78,7 @@ export { parseGatewayUrl, parseIri, parseJsonLdId, + toCompatibleEf61Id, UrlError, validatePublicUrl, } from "./url.ts"; diff --git a/packages/vocab-runtime/src/url.test.ts b/packages/vocab-runtime/src/url.test.ts index c5f8aa10c..819cd4646 100644 --- a/packages/vocab-runtime/src/url.test.ts +++ b/packages/vocab-runtime/src/url.test.ts @@ -5,6 +5,7 @@ import { canonicalizePortableUri, expandIPv6Address, formatIri, + fromCompatibleEf61Id, getFe34Origin, haveSameFe34Origin, haveSameIriOrigin, @@ -14,6 +15,7 @@ import { parseGatewayUrl, parseIri, parseJsonLdId, + toCompatibleEf61Id, UrlError, validateLookupAddresses, validatePublicUrl, @@ -662,6 +664,365 @@ test("parseGatewayUrl() accepts only HTTP(S) base URIs", () => { } }); +test("fromCompatibleEf61Id() converts compatible identifiers", () => { + const cases: [string, string][] = [ + [ + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/objects/1", + "ap+ef61://did%3Akey%3Az6MkAlice/objects/1", + ], + [ + "http://server.example:8080/.well-known/apgateway/did:key:z6MkAlice/actor", + "ap+ef61://did%3Akey%3Az6MkAlice/actor", + ], + [ + "https://server.example/.well-known/apgateway/did%3Akey%3Az6MkAlice/actor", + "ap+ef61://did%3Akey%3Az6MkAlice/actor", + ], + [ + "https://server.example/.well-known/apgateway/DID:key:z6MkAlice/actor", + "ap+ef61://did%3Akey%3Az6MkAlice/actor", + ], + [ + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a/b/c", + "ap+ef61://did%3Akey%3Az6MkAlice/a/b/c", + ], + [ + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor#main-key", + "ap+ef61://did%3Akey%3Az6MkAlice/actor#main-key", + ], + [ + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor#", + "ap+ef61://did%3Akey%3Az6MkAlice/actor#", + ], + [ + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/outbox?page=2", + "ap+ef61://did%3Akey%3Az6MkAlice/outbox?page=2", + ], + [ + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a%2Fb", + "ap+ef61://did%3Akey%3Az6MkAlice/a%2Fb", + ], + [ + "https://server.example/.well-known/apgateway/did:web:example.com%3A8080/actor", + "ap+ef61://did%3Aweb%3Aexample.com%253A8080/actor", + ], + [ + "https://server.example/.well-known/apgateway/did:example:a%2525/x", + "ap+ef61://did%3Aexample%3Aa%2525/x", + ], + ]; + for (const [input, expected] of cases) { + deepStrictEqual(fromCompatibleEf61Id(input)?.href, expected, input); + deepStrictEqual( + fromCompatibleEf61Id(new URL(input))?.href, + expected, + input, + ); + } + const converted = fromCompatibleEf61Id( + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/objects/1", + ); + deepStrictEqual( + formatIri(converted!), + "ap+ef61://did:key:z6MkAlice/objects/1", + ); + deepStrictEqual( + canonicalizePortableUri(converted!.href), + "ap+ef61://did:key:z6MkAlice/objects/1", + ); + ok( + arePortableUrisEqual( + fromCompatibleEf61Id( + "https://a.example/.well-known/apgateway/did:key:z6MkAlice/actor", + )!.href, + fromCompatibleEf61Id( + "https://b.example/.well-known/apgateway/did:key:z6MkAlice/actor", + )!.href, + ), + ); +}); + +test("fromCompatibleEf61Id() returns null for other URLs", () => { + const cases = [ + "https://server.example/users/alice", + "https://server.example/.well-known/webfinger?resource=acct:a@b", + "https://server.example/.well-known/apgateway", + "https://server.example/.well-known/apgateway/", + "https://server.example/.well-known/apgateway/hl:zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n", + "https://server.example/.well-known/apgateway/objects/did:key:z6MkAlice/x", + "https://server.example/.well-known/apgatewayx/did:key:z6MkAlice/x", + "https://server.example/ap/did:key:z6MkAlice/actor", + "https://server.example/?id=/.well-known/apgateway/did:key:z6MkAlice/x", + "ap://did:key:z6MkAlice/actor", + "ap+ef61://did:key:z6MkAlice/actor", + "ftp://server.example/.well-known/apgateway/did:key:z6MkAlice/actor", + "not a URL", + "/.well-known/apgateway/did:key:z6MkAlice/actor", + ]; + for (const input of cases) { + deepStrictEqual(fromCompatibleEf61Id(input), null, input); + } + deepStrictEqual( + fromCompatibleEf61Id(new URL("ap+ef61://did%3Akey%3Az6MkAlice/actor")), + null, + ); + deepStrictEqual(fromCompatibleEf61Id(123 as unknown as string), null); +}); + +test("fromCompatibleEf61Id() rejects malformed compatible identifiers", () => { + const cases = [ + "https://server.example/.well-known/apgateway/did:key:z6MkAlice", + "https://server.example/.well-known/apgateway/did:", + "https://server.example/.well-known/apgateway/did:key/actor", + "https://server.example/.well-known/apgateway/did:ke%y:z6MkAlice/actor", + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a%zz", + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a#%zz", + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a?x=%zz", + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a?@gateway=https%3A%2F%2Fevil.example", + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a?page=2&%40gateway=x", + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a?@%67ateway=x", + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a?gateways=x", + // A readable DID authority is percent-decoded once, like parseIri() does, + // which leaves a bare percent sign here: + "https://server.example/.well-known/apgateway/did:example:a%25/x", + "https://user:pass@server.example/.well-known/apgateway/did:key:z6MkAlice/a", + "https://user@server.example/.well-known/apgateway/did:key:z6MkAlice/a", + ]; + for (const input of cases) { + throws(() => fromCompatibleEf61Id(input), TypeError, input); + } +}); + +test("toCompatibleEf61Id() converts portable URIs", () => { + const cases: [string | URL, string | URL, string][] = [ + [ + "ap+ef61://did:key:z6MkAlice/objects/1", + "https://server.example", + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/objects/1", + ], + [ + "ap://did:key:z6MkAlice/objects/1", + "https://server.example/", + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/objects/1", + ], + [ + "ap://did%3Akey%3Az6MkAlice/actor", + new URL("http://server.example:8080"), + "http://server.example:8080/.well-known/apgateway/did:key:z6MkAlice/actor", + ], + [ + new URL("ap+ef61://did%3Akey%3Az6MkAlice/actor"), + "https://server.example", + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor", + ], + [ + new URL("ap://did%3Akey%3Az6MkAlice/actor"), + "https://SERVER.example", + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor", + ], + [ + "ap://did:key:z6MkAlice/a/b/c", + "https://server.example", + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a/b/c", + ], + [ + "ap://did:key:z6MkAlice/actor#main-key", + "https://server.example", + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor#main-key", + ], + [ + "ap://did:key:z6MkAlice/actor#", + "https://server.example", + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor#", + ], + [ + "ap://DID:key:z6MkAlice/actor", + "https://server.example", + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor", + ], + [ + "ap://did:example:a%2525/x", + "https://server.example", + "https://server.example/.well-known/apgateway/did:example:a%2525/x", + ], + [ + "ap://did:web:example.com%3A8080/actor", + "https://server.example", + "https://server.example/.well-known/apgateway/did:web:example.com%3A8080/actor", + ], + [ + "ap://did:key:z6MkAlice/a%2Fb/%252e", + "https://server.example", + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a%2Fb/%252e", + ], + [ + "ap://did:key:z6MkAlice/a\\b c\té/%7euser", + "https://server.example", + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a%5Cb%20c%09%C3%A9/~user", + ], + ]; + for (const [portableId, gateway, expected] of cases) { + deepStrictEqual( + toCompatibleEf61Id(portableId, gateway).href, + expected, + String(portableId), + ); + } +}); + +test("toCompatibleEf61Id() removes location hints from queries", () => { + const base = "https://server.example/.well-known/apgateway/did:key:z6MkAlice"; + const cases: [string, string][] = [ + [ + "ap://did:key:z6MkAlice/actor?@gateway=https%3A%2F%2Fa.example&@gateway=https%3A%2F%2Fb.example", + `${base}/actor`, + ], + [ + "ap://did:key:z6MkAlice/actor?gateways=https%3A%2F%2Fa.example", + `${base}/actor`, + ], + [ + "ap://did:key:z6MkAlice/actor?%40gateway=https%3A%2F%2Fa.example", + `${base}/actor`, + ], + [ + "ap://did:key:z6MkAlice/actor?@%67ateway=https%3A%2F%2Fa.example", + `${base}/actor`, + ], + [ + "ap://did:key:z6MkAlice/actor?@gateway", + `${base}/actor`, + ], + [ + "ap://did:key:z6MkAlice/c?page=3&@gateway=https%3A%2F%2Fa.example&maxItems=20&page=4", + `${base}/c?page=3&maxItems=20&page=4`, + ], + [ + "ap://did:key:z6MkAlice/c?a=1&&b=2#frag", + `${base}/c?a=1&&b=2#frag`, + ], + ["ap://did:key:z6MkAlice/c?", `${base}/c?`], + ["ap://did:key:z6MkAlice/c?x='y'", `${base}/c?x=%27y%27`], + [ + "ap://did:key:z6MkAlice/c?@gate\tway=https%3A%2F%2Fa.example", + `${base}/c?@gate%09way=https%3A%2F%2Fa.example`, + ], + [ + "ap://did:key:z6MkAlice/c?@gate\nway=1&x=\r2", + `${base}/c?@gate%0Away=1&x=%0D2`, + ], + ["ap://did:key:z6MkAlice/c?%ff=1", `${base}/c?%FF=1`], + ]; + for (const [portableId, expected] of cases) { + const result = toCompatibleEf61Id(portableId, "https://server.example"); + deepStrictEqual(result.href, expected, portableId); + ok(!/[?&](%40|@)gateway(=|&|$)/i.test(result.search), portableId); + } +}); + +test("toCompatibleEf61Id() rejects invalid portable IDs", () => { + const cases: (string | URL)[] = [ + "https://server.example/actor", + "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor", + "at://did:plc:example/record", + "ap://not-a-did/actor", + "ap://did:key:z6MkAlice", + "ap://did:key:z6MkAlice/a%zz", + "ap://did:key:z6MkAlice/a#%zz", + "ap://did:key:z6MkAlice/a?x=%zz", + "ap://did:key:z6MkAlice/.", + "ap://did:key:z6MkAlice/..", + "ap://did:key:z6MkAlice/a/./b", + "ap://did:key:z6MkAlice/a/../b", + "ap://did:key:z6MkAlice/a/%2e/b", + "ap://did:key:z6MkAlice/a/%2E%2e/b", + "ap://did:key:z6MkAlice/a/.%2e/b", + "ap://did:key:z6MkAlice/a/%2e./b", + new URL("https://server.example/actor"), + new URL("ap+ef61://user:pass@did%3Akey%3Az6MkAlice/actor"), + new URL("ap+ef61://did%3Akey%3Az6MkAlice:8080/actor"), + 123 as unknown as string, + ]; + for (const portableId of cases) { + throws( + () => toCompatibleEf61Id(portableId, "https://server.example"), + TypeError, + String(portableId), + ); + } +}); + +test("toCompatibleEf61Id() rejects gateways that are not HTTP(S) origins", () => { + const cases: (string | URL)[] = [ + "ftp://server.example", + "ap+ef61://did:key:z6MkAlice/actor", + "https://user:pass@server.example", + "https://user@server.example", + "https://server.example/ap", + "https://server.example/ap/", + "https://server.example/.well-known/apgateway", + "https://server.example/?x=1", + "https://server.example/?", + "https://server.example/#fragment", + "https://server.example/#", + "server.example", + "", + new URL("https://server.example/?"), + new URL("https://server.example/#"), + new URL("https://server.example/ap"), + new URL("ftp://server.example/"), + 123 as unknown as string, + ]; + for (const gateway of cases) { + throws( + () => toCompatibleEf61Id("ap://did:key:z6MkAlice/actor", gateway), + TypeError, + String(gateway), + ); + } +}); + +test("compatible identifier conversion round-trips", () => { + const cases = [ + "ap+ef61://did:key:z6MkAlice/objects/1", + "ap://did:key:z6MkAlice/actor", + "ap://did%3Akey%3Az6MkAlice/actor/inbox", + "ap://did:key:z6MkAlice/actor#", + "ap://did:key:z6MkAlice/actor#main-key", + "ap://did:key:z6MkAlice/a%2Fb/%252e", + "ap://did:key:z6MkAlice/a\\b c\té", + "ap://did:example:a%2525/x", + "ap://did:web:example.com%3A8080/actor", + "ap://did:key:z6MkAlice/c?page=3&@gateway=https%3A%2F%2Fa.example", + ]; + for (const portableId of cases) { + const compatible = toCompatibleEf61Id(portableId, "https://server.example"); + const converted = fromCompatibleEf61Id(compatible); + ok(converted != null, portableId); + deepStrictEqual( + canonicalizePortableUri(converted.href), + canonicalizePortableUri(portableId), + portableId, + ); + } + ok( + !arePortableUrisEqual( + fromCompatibleEf61Id( + toCompatibleEf61Id( + "ap://did:key:z6MkAlice/actor", + "https://server.example", + ), + )!.href, + fromCompatibleEf61Id( + toCompatibleEf61Id( + "ap://did:key:z6MkAlice/actor#", + "https://server.example", + ), + )!.href, + ), + ); +}); + test("validatePublicUrl()", async () => { await rejects(() => validatePublicUrl("ftp://localhost"), UrlError); await rejects( diff --git a/packages/vocab-runtime/src/url.ts b/packages/vocab-runtime/src/url.ts index 06e6a7fe7..135b58c94 100644 --- a/packages/vocab-runtime/src/url.ts +++ b/packages/vocab-runtime/src/url.ts @@ -346,6 +346,241 @@ export function parseGatewayUrl(url: string): URL { return parsed; } +const COMPATIBLE_ID_PATH_PREFIX = "/.well-known/apgateway/"; +const COMPATIBLE_ID_DID_PATTERN = /^did(?::|%3A)/i; +// `gateways` is the location hint parameter name used by earlier FEP-ef61 +// revisions; strip it as well for compatibility with older publishers. +const LOCATION_HINT_PARAMETERS: ReadonlySet = new Set([ + "@gateway", + "gateways", +]); + +/** + * Converts an [FEP-ef61] compatible identifier into a portable ActivityPub + * URI. + * + * A compatible identifier is an HTTP(S) URL under a gateway's fixed + * `/.well-known/apgateway/` path, such as + * `https://server.example/.well-known/apgateway/did:key:z6Mk.../objects/1`. + * This function removes the gateway part and returns the corresponding + * portable URI, e.g., `ap+ef61://did:key:z6Mk.../objects/1`, in the same + * internal `URL` form that {@link parseIri} produces. The path, query, and + * fragment are preserved, so the result is a portable URI, not a comparison + * form; pass its `href` to {@link canonicalizePortableUri} or + * {@link arePortableUrisEqual} to compare it with other portable URIs. + * + * The conversion only reveals the *claimed* portable identifier. Anyone can + * publish a compatible identifier for any DID on their own server, so the + * gateway that served it is neither the object's origin nor authorized to act + * for the DID. Callers must still verify the retrieved document's Object + * Integrity Proof against the DID, as FEP-ef61 requires, and should keep the + * original URL if they need the gateway as a retrieval hint. + * + * Arbitrary gateway paths are not supported. + * + * [FEP-ef61]: https://w3id.org/fep/ef61 + * + * @param input The URL to convert. + * @returns The portable ActivityPub URI, or `null` if the input is not an + * HTTP(S) URL whose path starts with `/.well-known/apgateway/did:`. + * Other gateway routes, such as the gateway discovery endpoint and + * hashlink media URLs, also yield `null`. + * @throws {TypeError} If the input looks like a compatible identifier but is + * malformed, e.g., it has an invalid DID, no object path, + * invalid percent-encoding, credentials, or location + * hints (`@gateway` query parameters, or the legacy + * `gateways` parameter), which FEP-ef61 forbids in + * compatible identifiers. + * @since 2.4.0 + */ +export function fromCompatibleEf61Id(input: string | URL): URL | null { + return convertCompatibleEf61Id(input)?.url ?? null; +} + +function convertCompatibleEf61Id( + input: string | URL, +): { url: URL; iri: string } | null { + let url: URL; + if (input instanceof URL) url = input; + else if (typeof input === "string" && URL.canParse(input)) { + url = new URL(input); + } else return null; + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + if (!url.pathname.startsWith(COMPATIBLE_ID_PATH_PREFIX)) return null; + const tail = url.pathname.slice(COMPATIBLE_ID_PATH_PREFIX.length); + if (!COMPATIBLE_ID_DID_PATTERN.test(tail)) return null; + if (url.username !== "" || url.password !== "") { + throw new TypeError( + "Invalid FEP-ef61 compatible identifier: credentials are not allowed.", + ); + } + // Slice href instead of concatenating pathname, search, and hash, because + // the latter two drop empty query and fragment delimiters. + const iri = "ap+ef61://" + + url.href.slice(url.origin.length + COMPATIBLE_ID_PATH_PREFIX.length); + try { + const parsed = parsePortableIri(iri); + if (parsed == null) throw new TypeError("Not a portable IRI."); + // parsePortableIri() does not validate path and fragment + // percent-encoding, but canonicalizePortableUri() does: + canonicalizePortableUri(iri); + // canonicalizePortableUri() ignores the query, so validate it here too. + // Compatible identifiers must not have location hints: + const query = url.search === "" + ? [] + : normalizePortableComponent(url.search.slice(1)).split("&"); + if (query.some(isLocationHint)) { + throw new TypeError("Location hints are not allowed."); + } + return { url: parsed, iri }; + } catch (error) { + if (error instanceof TypeError) { + throw new TypeError("Invalid FEP-ef61 compatible identifier.", { + cause: error, + }); + } + throw error; + } +} + +/** + * Converts a portable ActivityPub URI into an [FEP-ef61] compatible + * identifier, which is an HTTP(S) URL under the gateway's fixed + * `/.well-known/apgateway/` path. + * + * For example, `ap+ef61://did:key:z6Mk.../objects/1` and + * `https://server.example` yield + * `https://server.example/.well-known/apgateway/did:key:z6Mk.../objects/1`. + * Both `ap:` and `ap+ef61:` URIs with decoded or percent-encoded DID + * authorities are accepted. Publishers should use the first gateway in the + * actor's `gateways` list, as FEP-ef61 requires. + * + * The path and fragment are preserved, with characters that are not allowed + * in HTTP(S) URLs percent-encoded the same way as + * {@link canonicalizePortableUri} does. FEP-ef61 location hints (`@gateway` + * query parameters, and the legacy `gateways` parameter) are removed, since + * compatible identifiers must not have them; other query parameters are kept + * in order. + * + * Arbitrary gateway paths are not supported. + * + * [FEP-ef61]: https://w3id.org/fep/ef61 + * + * @param portableId The `ap:` or `ap+ef61:` URI to convert. + * @param gateway The gateway's HTTP(S) origin, e.g., `https://server.example`. + * @returns The compatible identifier. + * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:` + * URI, if its path has `.` or `..` segments (which + * HTTP(S) URLs cannot represent), or if the gateway is + * not an HTTP(S) origin with no credentials, path, query, + * or fragment. + * @since 2.4.0 + */ +export function toCompatibleEf61Id( + portableId: string | URL, + gateway: string | URL, +): URL { + const gatewayUrl = parseCompatibleEf61Gateway(gateway); + const raw = getRawPortableIri(portableId); + const match = raw.match(PORTABLE_IRI_PATTERN); + const parsed = parsePortableIri(raw); + if (match == null || parsed == null) { + throw new TypeError("Invalid portable ActivityPub IRI."); + } + // The parser decodes %25 once in a did:-prefixed authority, so escape + // percent signs only when that would otherwise change the DID. Other DIDs + // are kept literal (e.g., did:web:example.com%3A8080) so that gateways + // which read the path segment as is see the same DID: + let did = decodePortableAuthority(parsed.host); + if (/%25/i.test(did)) did = did.replace(/%/g, "%25"); + const path = normalizePortableComponent(match[3]); + if (path.split("/").some((segment) => segment === "." || segment === "..")) { + throw new TypeError( + "FEP-ef61 compatible identifiers cannot represent portable IRI paths " + + "with dot segments.", + ); + } + // Normalize the query before looking for location hints so that characters + // which the URL parser strips (e.g., tabs) cannot form a hint name later: + const query = match[4] == null + ? "" + : stripLocationHints(normalizePortableComponent(match[4].slice(1))); + const fragment = match[5] == null ? "" : normalizePortableComponent(match[5]); + const result = new URL( + gatewayUrl.origin + COMPATIBLE_ID_PATH_PREFIX + did + path + query + + fragment, + ); + // Guard against URL parser normalization that would silently change the + // identified object or reintroduce location hints (the latter makes + // convertCompatibleEf61Id() throw): + const converted = convertCompatibleEf61Id(result); + if ( + converted == null || + canonicalizePortableUri(converted.iri) !== canonicalizePortableUri(raw) + ) { + throw new TypeError( + "The portable ActivityPub IRI cannot be represented as an FEP-ef61 " + + "compatible identifier.", + ); + } + return result; +} + +function parseCompatibleEf61Gateway(gateway: string | URL): URL { + const url = gateway instanceof URL + ? gateway + : typeof gateway === "string" && URL.canParse(gateway) + ? new URL(gateway) + : null; + // Comparing href with the origin also rejects credentials, a path, and + // query and fragment components, including empty ? and # delimiters. + if ( + url == null || (url.protocol !== "http:" && url.protocol !== "https:") || + url.href !== `${url.origin}/` + ) { + throw new TypeError( + "FEP-ef61 gateways for compatible identifiers must be HTTP(S) origins " + + "with no credentials, path, query, or fragment.", + ); + } + return url; +} + +function getRawPortableIri(portableId: string | URL): string { + if (portableId instanceof URL) { + if (portableId.protocol !== "ap:" && portableId.protocol !== "ap+ef61:") { + throw new TypeError("Invalid portable ActivityPub IRI."); + } + // parseIri() would fold a port into the DID and drop credentials: + if ( + portableId.username !== "" || portableId.password !== "" || + portableId.port !== "" + ) { + throw new TypeError("Invalid portable ActivityPub IRI authority."); + } + return portableId.href; + } + if (typeof portableId !== "string") { + throw new TypeError("Invalid portable ActivityPub IRI."); + } + return portableId; +} + +function stripLocationHints(query: string): string { + const pairs = query.split("&").filter((pair) => !isLocationHint(pair)); + return pairs.length < 1 ? "" : `?${pairs.join("&")}`; +} + +function isLocationHint(pair: string): boolean { + const name = pair.split("=", 1)[0].replace(/\+/g, " "); + try { + return LOCATION_HINT_PARAMETERS.has(decodeURIComponent(name)); + } catch (error) { + if (error instanceof URIError) return false; + throw error; + } +} + /** * Validates a URL to prevent SSRF attacks. */