@fedify/vocab-runtime 2.4.0-dev.1599 → 2.4.0-dev.1634

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/deno.json +1 -1
  2. package/dist/{docloader-DnUMWHaJ.d.cts → docloader-C_dir7Xb.d.ts} +2 -0
  3. package/dist/{docloader-xRGn1azD.d.ts → docloader-D2DTRiyA.d.cts} +2 -0
  4. package/dist/internal/jsonld-cache.cjs +2 -2
  5. package/dist/internal/jsonld-cache.d.cts +1 -1
  6. package/dist/internal/jsonld-cache.d.ts +1 -1
  7. package/dist/internal/jsonld-cache.js +2 -2
  8. package/dist/mod.cjs +16 -6
  9. package/dist/mod.d.cts +28 -2
  10. package/dist/mod.d.ts +28 -2
  11. package/dist/mod.js +13 -7
  12. package/dist/tests/decimal.test.cjs +2 -2
  13. package/dist/tests/decimal.test.mjs +2 -2
  14. package/dist/tests/{docloader-CDde_fhH.mjs → docloader-CNzoXrLM.mjs} +12 -6
  15. package/dist/tests/{docloader-BiSxywul.cjs → docloader-CTd4XwqK.cjs} +12 -6
  16. package/dist/tests/docloader.test.cjs +3 -3
  17. package/dist/tests/docloader.test.mjs +3 -3
  18. package/dist/tests/jsonld-cache.test.cjs +2 -2
  19. package/dist/tests/jsonld-cache.test.mjs +2 -2
  20. package/dist/tests/{request-BBw3lNNz.mjs → request-BWDludWb.mjs} +1 -1
  21. package/dist/tests/{request-C9VKqYdG.cjs → request-Zpjq9m-W.cjs} +1 -1
  22. package/dist/tests/request.test.cjs +1 -1
  23. package/dist/tests/request.test.mjs +1 -1
  24. package/dist/tests/{url-CtKcX6ma.cjs → url-CsOV_B_P.cjs} +90 -3
  25. package/dist/tests/{url-D7ay_5pk.mjs → url-Du7RQQgP.mjs} +67 -4
  26. package/dist/tests/url.test.cjs +62 -3
  27. package/dist/tests/url.test.mjs +62 -3
  28. package/dist/{url-BMDN9GnE.cjs → url-DD4F0ULf.cjs} +90 -3
  29. package/dist/{url-BlFta-Eh.js → url-DGVbSVVi.js} +67 -4
  30. package/package.json +1 -1
  31. package/src/contexts/fep-ef61.json +10 -0
  32. package/src/contexts/security-data-integrity-v1.json +0 -4
  33. package/src/contexts.ts +2 -0
  34. package/src/docloader.ts +2 -0
  35. package/src/internal/jsonld-cache.ts +3 -2
  36. package/src/mod.ts +4 -0
  37. package/src/url.test.ts +152 -2
  38. package/src/url.ts +88 -3
@@ -63,7 +63,7 @@ function canonicalizePortableUri(input) {
63
63
  const parsed = parsePortableIri(input);
64
64
  if (parsed == null) throw new TypeError("Invalid portable ActivityPub IRI.");
65
65
  const match = input.match(PORTABLE_IRI_PATTERN);
66
- return `ap+ef61://${normalizePortableAuthority(decodePortableAuthority(parsed.host).replace(DID_SCHEME_PATTERN, "did:"))}${normalizePortableComponent(match[3])}${match[5] == null ? "" : normalizePortableComponent(match[5])}`;
66
+ return `ap+ef61://${normalizePortableAuthority(getDidUrlOrigin(decodePortableAuthority(parsed.host)))}${normalizePortableComponent(match[3])}${match[5] == null ? "" : normalizePortableComponent(match[5])}`;
67
67
  }
68
68
  /**
69
69
  * Checks whether two FEP-ef61 portable ActivityPub URIs identify the same
@@ -88,6 +88,45 @@ function arePortableUrisEqual(left, right) {
88
88
  }
89
89
  }
90
90
  /**
91
+ * Computes an IRI's FEP-fe34 origin.
92
+ *
93
+ * HTTP(S) IRIs use their web origin. FEP-ef61 portable ActivityPub IRIs and
94
+ * DID URLs use their DID as a cryptographic origin.
95
+ *
96
+ * @throws {TypeError} If the IRI does not have a supported FEP-fe34 origin.
97
+ * @since 2.4.0
98
+ */
99
+ function getFe34Origin(input) {
100
+ if (input instanceof URL) {
101
+ const portable = normalizePortableUrl(input);
102
+ if (portable != null) return getPortableCryptographicOrigin(portable);
103
+ if (input.protocol === "did:") return getDidUrlOrigin(input.href);
104
+ if (input.protocol === "http:" || input.protocol === "https:") return input.origin;
105
+ throw new TypeError("Unsupported FEP-fe34 origin IRI.");
106
+ }
107
+ const portable = parsePortableIri(input);
108
+ if (portable != null) return getPortableCryptographicOrigin(portable);
109
+ if (DID_SCHEME_PATTERN.test(input)) return getDidUrlOrigin(input);
110
+ const parsed = new URL(input);
111
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") return parsed.origin;
112
+ throw new TypeError("Unsupported FEP-fe34 origin IRI.");
113
+ }
114
+ /**
115
+ * Checks whether two IRIs have the same FEP-fe34 origin.
116
+ *
117
+ * Malformed or unsupported IRIs are treated as non-matching.
118
+ *
119
+ * @since 2.4.0
120
+ */
121
+ function haveSameFe34Origin(left, right) {
122
+ try {
123
+ return getFe34Origin(left) === getFe34Origin(right);
124
+ } catch (error) {
125
+ if (error instanceof TypeError) return false;
126
+ throw error;
127
+ }
128
+ }
129
+ /**
91
130
  * Checks whether two IRIs have the same origin.
92
131
  */
93
132
  function haveSameIriOrigin(left, right) {
@@ -97,15 +136,25 @@ function getComparableIriOrigin(iri) {
97
136
  iri = normalizePortableUrl(iri) ?? iri;
98
137
  if (iri.origin !== "null") return iri.origin;
99
138
  if (iri.host !== "") {
100
- const host = iri.protocol === "ap+ef61:" ? encodeURIComponent(decodePortableAuthority(iri.host).replace(DID_SCHEME_PATTERN, "did:")) : iri.host;
139
+ const host = iri.protocol === "ap+ef61:" ? encodeURIComponent(getDidUrlOrigin(decodePortableAuthority(iri.host))) : iri.host;
101
140
  return `${iri.protocol}//${host}`;
102
141
  }
103
142
  return iri.href;
104
143
  }
144
+ function getPortableCryptographicOrigin(iri) {
145
+ return getDidUrlOrigin(decodePortableAuthority(iri.host));
146
+ }
147
+ function getDidUrlOrigin(iri) {
148
+ const did = iri.split(/[/?#]/, 1)[0].replace(DID_SCHEME_PATTERN, "did:");
149
+ if (!DID_PATTERN.test(did)) throw new TypeError("Invalid DID URL.");
150
+ const parts = did.split(":");
151
+ parts[1] = parts[1].toLowerCase();
152
+ return normalizePortableAuthority(parts.join(":"));
153
+ }
105
154
  function parsePortableIri(iri) {
106
155
  const match = iri.match(PORTABLE_IRI_PATTERN);
107
156
  if (match == null) return null;
108
- const authority = decodePortableAuthority(match[2]);
157
+ const authority = getDidUrlOrigin(decodePortableAuthority(match[2]));
109
158
  if (!DID_PATTERN.test(authority)) throw new TypeError("Invalid portable ActivityPub IRI authority.");
110
159
  if (match[3] === "") throw new TypeError("Invalid portable ActivityPub IRI path.");
111
160
  return new URL(`ap+ef61://${encodeURIComponent(authority)}${match[3]}${match[4] ?? ""}${match[5] ?? ""}`);
@@ -162,6 +211,20 @@ function parseAtUri(uri) {
162
211
  return new URL("at://" + encodeURIComponent(authority) + path);
163
212
  }
164
213
  /**
214
+ * Checks whether the URL is an FEP-ef61 gateway base URI.
215
+ */
216
+ function isGatewayUrl(url) {
217
+ return (url.protocol === "http:" || url.protocol === "https:") && url.username === "" && url.password === "" && url.pathname === "/" && url.search === "" && url.hash === "";
218
+ }
219
+ /**
220
+ * Parses and validates an FEP-ef61 gateway base URI.
221
+ */
222
+ function parseGatewayUrl(url) {
223
+ const parsed = parseIri(url);
224
+ if (!isGatewayUrl(parsed)) throw new TypeError("FEP-ef61 gateways must be HTTP(S) base URIs with no credentials, path, query, or fragment.");
225
+ return parsed;
226
+ }
227
+ /**
165
228
  * Validates a URL to prevent SSRF attacks.
166
229
  */
167
230
  async function validatePublicUrl(url) {
@@ -327,4 +390,4 @@ function matchesIPv6Prefix(address, prefixWords, prefixLength) {
327
390
  return true;
328
391
  }
329
392
  //#endregion
330
- export { formatIri as a, isValidPublicIPv6Address as c, validatePublicUrl as d, expandIPv6Address as i, parseIri as l, arePortableUrisEqual as n, haveSameIriOrigin as o, canonicalizePortableUri as r, isValidPublicIPv4Address as s, UrlError as t, parseJsonLdId as u };
393
+ export { formatIri as a, haveSameIriOrigin as c, isValidPublicIPv6Address as d, parseGatewayUrl as f, validatePublicUrl as h, expandIPv6Address as i, isGatewayUrl as l, parseJsonLdId as m, arePortableUrisEqual as n, getFe34Origin as o, parseIri as p, canonicalizePortableUri as r, haveSameFe34Origin as s, UrlError as t, isValidPublicIPv4Address as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab-runtime",
3
- "version": "2.4.0-dev.1599+7b19967f",
3
+ "version": "2.4.0-dev.1634+3b4bfbb6",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,10 @@
1
+ {
2
+ "@context": {
3
+ "gateways": {
4
+ "@id": "https://w3id.org/fep/ef61/gateways",
5
+ "@type": "@id",
6
+ "@container": "@list"
7
+ },
8
+ "digestMultibase": "https://www.w3.org/ns/credentials/v2#digestMultibase"
9
+ }
10
+ }
@@ -3,10 +3,6 @@
3
3
  "id": "@id",
4
4
  "type": "@type",
5
5
  "@protected": true,
6
- "digestMultibase": {
7
- "@id": "https://w3id.org/security#digestMultibase",
8
- "@type": "https://w3id.org/security#multibase"
9
- },
10
6
  "proof": {
11
7
  "@id": "https://w3id.org/security#proof",
12
8
  "@type": "@id",
package/src/contexts.ts CHANGED
@@ -8,6 +8,7 @@ import activitystreams from "./contexts/activitystreams.json" with {
8
8
  import didV1 from "./contexts/did-v1.json" with { type: "json" };
9
9
  import fep5711 from "./contexts/fep-5711.json" with { type: "json" };
10
10
  import fep7aa9 from "./contexts/fep-7aa9.json" with { type: "json" };
11
+ import fepEf61 from "./contexts/fep-ef61.json" with { type: "json" };
11
12
  import gotosocial from "./contexts/gotosocial.json" with { type: "json" };
12
13
  import identityV1 from "./contexts/identity-v1.json" with { type: "json" };
13
14
  import joinLemmyContext from "./contexts/join-lemmy.json" with { type: "json" };
@@ -37,6 +38,7 @@ const preloadedContexts: Record<string, unknown> = {
37
38
  "https://gotosocial.org/ns": gotosocial,
38
39
  "https://w3id.org/fep/5711": fep5711,
39
40
  "https://w3id.org/fep/7aa9": fep7aa9,
41
+ "https://w3id.org/fep/ef61": fepEf61,
40
42
 
41
43
  // Lemmy's context document is served as application/json without the JSON-LD
42
44
  // context Link header. The default document loader treats that as a regular
package/src/docloader.ts CHANGED
@@ -351,8 +351,10 @@ export interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
351
351
  * - <https://www.w3.org/ns/activitystreams>
352
352
  * - <https://w3id.org/security/v1>
353
353
  * - <https://w3id.org/security/data-integrity/v1>
354
+ * - <https://w3id.org/security/data-integrity/v2>
354
355
  * - <https://www.w3.org/ns/did/v1>
355
356
  * - <https://w3id.org/security/multikey/v1>
357
+ * - <https://w3id.org/fep/ef61>
356
358
  * - <https://purl.archive.org/socialweb/webfinger>
357
359
  * - <http://schema.org/>
358
360
  * @param options Options for the document loader.
@@ -1,6 +1,6 @@
1
1
  import type { DocumentLoader } from "../docloader.ts";
2
2
  import jsonld from "../jsonld.ts";
3
- import { formatIri, haveSameIriOrigin } from "../url.ts";
3
+ import { formatIri, haveSameFe34Origin, haveSameIriOrigin } from "../url.ts";
4
4
 
5
5
  const noJsonLdContext = Symbol("noJsonLdContext");
6
6
 
@@ -28,7 +28,8 @@ export function isTrustedIriOrigin(
28
28
  right: URL | null | undefined,
29
29
  ): boolean {
30
30
  return options.crossOrigin === "trust" || left == null ||
31
- (right != null && haveSameIriOrigin(left, right));
31
+ (right != null &&
32
+ (haveSameIriOrigin(left, right) || haveSameFe34Origin(left, right)));
32
33
  }
33
34
 
34
35
  /**
package/src/mod.ts CHANGED
@@ -58,9 +58,13 @@ export {
58
58
  canonicalizePortableUri,
59
59
  expandIPv6Address,
60
60
  formatIri,
61
+ getFe34Origin,
62
+ haveSameFe34Origin,
61
63
  haveSameIriOrigin,
64
+ isGatewayUrl,
62
65
  isValidPublicIPv4Address,
63
66
  isValidPublicIPv6Address,
67
+ parseGatewayUrl,
64
68
  parseIri,
65
69
  parseJsonLdId,
66
70
  UrlError,
package/src/url.test.ts CHANGED
@@ -5,9 +5,13 @@ import {
5
5
  canonicalizePortableUri,
6
6
  expandIPv6Address,
7
7
  formatIri,
8
+ getFe34Origin,
9
+ haveSameFe34Origin,
8
10
  haveSameIriOrigin,
11
+ isGatewayUrl,
9
12
  isValidPublicIPv4Address,
10
13
  isValidPublicIPv6Address,
14
+ parseGatewayUrl,
11
15
  parseIri,
12
16
  parseJsonLdId,
13
17
  UrlError,
@@ -30,15 +34,16 @@ test("parseIri() accepts portable ActivityPub URI schemes", () => {
30
34
  }
31
35
  });
32
36
 
33
- test("parseIri() accepts DID schemes case-insensitively", () => {
37
+ test("parseIri() normalizes DID scheme and method casing", () => {
34
38
  const cases = [
35
39
  "ap://DID:key:z6Mkabc/actor",
36
40
  "ap://DID%3Akey%3Az6Mkabc/actor",
41
+ "ap://did:KEY:z6Mkabc/actor",
37
42
  ];
38
43
  for (const iri of cases) {
39
44
  deepStrictEqual(
40
45
  parseIri(iri),
41
- new URL("ap+ef61://DID%3Akey%3Az6Mkabc/actor"),
46
+ new URL("ap+ef61://did%3Akey%3Az6Mkabc/actor"),
42
47
  );
43
48
  }
44
49
  });
@@ -139,6 +144,14 @@ test("parseIri() rejects malformed portable DID authorities", () => {
139
144
  });
140
145
 
141
146
  test("haveSameIriOrigin() compares portable IRI authorities", () => {
147
+ ok(haveSameIriOrigin(
148
+ new URL("ftp://example.com/pub/1"),
149
+ new URL("ftp://example.com/pub/2"),
150
+ ));
151
+ ok(haveSameIriOrigin(
152
+ new URL("mailto:alice@example.com"),
153
+ new URL("mailto:alice@example.com"),
154
+ ));
142
155
  ok(haveSameIriOrigin(
143
156
  parseIri("ap://did:key:z6Mkabc/actor"),
144
157
  parseIri("ap://did:key:z6Mkabc/outbox"),
@@ -163,6 +176,122 @@ test("haveSameIriOrigin() compares portable IRI authorities", () => {
163
176
  );
164
177
  });
165
178
 
179
+ test("getFe34Origin() computes web and cryptographic origins", () => {
180
+ deepStrictEqual(
181
+ getFe34Origin("https://Example.COM:443/users/alice"),
182
+ "https://example.com",
183
+ );
184
+ deepStrictEqual(
185
+ getFe34Origin(new URL("http://example.com:8080/notes/1")),
186
+ "http://example.com:8080",
187
+ );
188
+ deepStrictEqual(
189
+ getFe34Origin(
190
+ "ap://did:key:z6Mkabc/actor?gateways=https%3A%2F%2Fa.example",
191
+ ),
192
+ "did:key:z6Mkabc",
193
+ );
194
+ deepStrictEqual(
195
+ getFe34Origin("ap+ef61://did%3Akey%3Az6Mkabc/objects/1#fragment"),
196
+ "did:key:z6Mkabc",
197
+ );
198
+ deepStrictEqual(
199
+ getFe34Origin(new URL("ap+ef61://did%3Akey%3Az6Mkabc/actor")),
200
+ "did:key:z6Mkabc",
201
+ );
202
+ deepStrictEqual(
203
+ getFe34Origin("did:key:z6Mkabc#z6Mkabc"),
204
+ "did:key:z6Mkabc",
205
+ );
206
+ deepStrictEqual(
207
+ getFe34Origin(new URL("did:key:z6Mkabc?service=activitypub#key")),
208
+ "did:key:z6Mkabc",
209
+ );
210
+ deepStrictEqual(
211
+ getFe34Origin("did:key:z6Mkabc/path/to/resource?service=activitypub#key"),
212
+ "did:key:z6Mkabc",
213
+ );
214
+ deepStrictEqual(
215
+ getFe34Origin("did:KEY:z6Mkabc#z6Mkabc"),
216
+ "did:key:z6Mkabc",
217
+ );
218
+ deepStrictEqual(
219
+ getFe34Origin("ap://did%3Aweb%3Afoo%2Dbar.example/actor"),
220
+ "did:web:foo-bar.example",
221
+ );
222
+ deepStrictEqual(
223
+ getFe34Origin("did:web:foo%2dbar.example#key"),
224
+ "did:web:foo-bar.example",
225
+ );
226
+ });
227
+
228
+ test("getFe34Origin() rejects unsupported or malformed identifiers", () => {
229
+ for (
230
+ const iri of [
231
+ "mailto:alice@example.com",
232
+ "ap://not-a-did/actor",
233
+ "ap://did:key/actor",
234
+ "did:key",
235
+ "did:key:",
236
+ "did:",
237
+ ]
238
+ ) {
239
+ throws(() => getFe34Origin(iri), TypeError);
240
+ }
241
+ });
242
+
243
+ test("haveSameFe34Origin() compares web and cryptographic origins", () => {
244
+ ok(haveSameFe34Origin(
245
+ "https://example.com/users/alice",
246
+ "https://example.com/notes/1",
247
+ ));
248
+ ok(
249
+ !haveSameFe34Origin(
250
+ "https://example.com/users/alice",
251
+ "http://example.com/users/alice",
252
+ ),
253
+ );
254
+ ok(
255
+ !haveSameFe34Origin(
256
+ "https://example.com/users/alice",
257
+ "https://example.com:8443/users/alice",
258
+ ),
259
+ );
260
+ ok(haveSameFe34Origin(
261
+ "ap://did:key:z6Mkabc/actor",
262
+ "ap+ef61://did%3Akey%3Az6Mkabc/objects/1",
263
+ ));
264
+ ok(haveSameFe34Origin(
265
+ "ap+ef61://did:key:z6Mkabc/actor",
266
+ "did:key:z6Mkabc#z6Mkabc",
267
+ ));
268
+ ok(haveSameFe34Origin(
269
+ "ap://did:KEY:z6Mkabc/actor",
270
+ "did:key:z6Mkabc#z6Mkabc",
271
+ ));
272
+ ok(haveSameFe34Origin(
273
+ "ap://did%3Aweb%3Afoo%2Dbar.example/actor",
274
+ "ap://did:web:foo-bar.example/note",
275
+ ));
276
+ ok(haveSameFe34Origin(
277
+ "ap://did%3Aexample%3Aabc%252fdef/actor",
278
+ "did:example:abc%2Fdef#key",
279
+ ));
280
+ ok(
281
+ !haveSameFe34Origin(
282
+ "ap+ef61://did:key:z6Mkabc/actor",
283
+ "did:key:z6Mkdef#z6Mkdef",
284
+ ),
285
+ );
286
+ ok(
287
+ !haveSameFe34Origin(
288
+ "ap+ef61://did:key:z6Mkabc/actor",
289
+ "https://example.com/actor",
290
+ ),
291
+ );
292
+ ok(!haveSameFe34Origin("ap://not-a-did/actor", "ap://not-a-did/actor"));
293
+ });
294
+
166
295
  test("parseIri() normalizes portable URL instances", () => {
167
296
  deepStrictEqual(
168
297
  parseIri(new URL("ap+ef61://did%3Aexample%3Aabc%2Fdef/actor")),
@@ -511,6 +640,27 @@ test("parseIri() preserves encoded percent signs while decoding delimiters", ()
511
640
  );
512
641
  });
513
642
 
643
+ test("parseGatewayUrl() accepts only HTTP(S) base URIs", () => {
644
+ for (const url of ["https://server.example/", "http://server.example/"]) {
645
+ deepStrictEqual(parseGatewayUrl(url), new URL(url));
646
+ ok(isGatewayUrl(new URL(url)));
647
+ }
648
+
649
+ for (
650
+ const url of [
651
+ "ftp://server.example/",
652
+ "https://user:pass@server.example/",
653
+ "https://user@server.example/",
654
+ "https://server.example/path",
655
+ "https://server.example/?x=1",
656
+ "https://server.example/#fragment",
657
+ ]
658
+ ) {
659
+ throws(() => parseGatewayUrl(url), TypeError);
660
+ ok(!isGatewayUrl(new URL(url)));
661
+ }
662
+ });
663
+
514
664
  test("validatePublicUrl()", async () => {
515
665
  await rejects(() => validatePublicUrl("ftp://localhost"), UrlError);
516
666
  await rejects(
package/src/url.ts CHANGED
@@ -93,7 +93,7 @@ export function canonicalizePortableUri(input: string): string {
93
93
  // decodePortableAuthority() reverses the shared percent-encoded authority
94
94
  // path here rather than the raw did:-prefixed branch.
95
95
  const authority = normalizePortableAuthority(
96
- decodePortableAuthority(parsed.host).replace(DID_SCHEME_PATTERN, "did:"),
96
+ getDidUrlOrigin(decodePortableAuthority(parsed.host)),
97
97
  );
98
98
  // Keep path and fragment text from the raw match to avoid URL dot-segment
99
99
  // normalization, but still encode raw characters and normalize
@@ -131,6 +131,56 @@ export function arePortableUrisEqual(
131
131
  }
132
132
  }
133
133
 
134
+ /**
135
+ * Computes an IRI's FEP-fe34 origin.
136
+ *
137
+ * HTTP(S) IRIs use their web origin. FEP-ef61 portable ActivityPub IRIs and
138
+ * DID URLs use their DID as a cryptographic origin.
139
+ *
140
+ * @throws {TypeError} If the IRI does not have a supported FEP-fe34 origin.
141
+ * @since 2.4.0
142
+ */
143
+ export function getFe34Origin(input: string | URL): string {
144
+ if (input instanceof URL) {
145
+ const portable = normalizePortableUrl(input);
146
+ if (portable != null) return getPortableCryptographicOrigin(portable);
147
+ if (input.protocol === "did:") return getDidUrlOrigin(input.href);
148
+ if (input.protocol === "http:" || input.protocol === "https:") {
149
+ return input.origin;
150
+ }
151
+ throw new TypeError("Unsupported FEP-fe34 origin IRI.");
152
+ }
153
+
154
+ const portable = parsePortableIri(input);
155
+ if (portable != null) return getPortableCryptographicOrigin(portable);
156
+ if (DID_SCHEME_PATTERN.test(input)) return getDidUrlOrigin(input);
157
+
158
+ const parsed = new URL(input);
159
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
160
+ return parsed.origin;
161
+ }
162
+ throw new TypeError("Unsupported FEP-fe34 origin IRI.");
163
+ }
164
+
165
+ /**
166
+ * Checks whether two IRIs have the same FEP-fe34 origin.
167
+ *
168
+ * Malformed or unsupported IRIs are treated as non-matching.
169
+ *
170
+ * @since 2.4.0
171
+ */
172
+ export function haveSameFe34Origin(
173
+ left: string | URL,
174
+ right: string | URL,
175
+ ): boolean {
176
+ try {
177
+ return getFe34Origin(left) === getFe34Origin(right);
178
+ } catch (error) {
179
+ if (error instanceof TypeError) return false;
180
+ throw error;
181
+ }
182
+ }
183
+
134
184
  /**
135
185
  * Checks whether two IRIs have the same origin.
136
186
  */
@@ -144,7 +194,7 @@ function getComparableIriOrigin(iri: URL): string {
144
194
  if (iri.host !== "") {
145
195
  const host = iri.protocol === "ap+ef61:"
146
196
  ? encodeURIComponent(
147
- decodePortableAuthority(iri.host).replace(DID_SCHEME_PATTERN, "did:"),
197
+ getDidUrlOrigin(decodePortableAuthority(iri.host)),
148
198
  )
149
199
  : iri.host;
150
200
  return `${iri.protocol}//${host}`;
@@ -152,6 +202,18 @@ function getComparableIriOrigin(iri: URL): string {
152
202
  return iri.href;
153
203
  }
154
204
 
205
+ function getPortableCryptographicOrigin(iri: URL): string {
206
+ return getDidUrlOrigin(decodePortableAuthority(iri.host));
207
+ }
208
+
209
+ function getDidUrlOrigin(iri: string): string {
210
+ const did = iri.split(/[/?#]/, 1)[0].replace(DID_SCHEME_PATTERN, "did:");
211
+ if (!DID_PATTERN.test(did)) throw new TypeError("Invalid DID URL.");
212
+ const parts = did.split(":");
213
+ parts[1] = parts[1].toLowerCase();
214
+ return normalizePortableAuthority(parts.join(":"));
215
+ }
216
+
155
217
  function parsePortableIri(iri: string): URL | null {
156
218
  const match = iri.match(PORTABLE_IRI_PATTERN);
157
219
  if (match == null) return null;
@@ -160,7 +222,7 @@ function parsePortableIri(iri: string): URL | null {
160
222
  // current FEP-ef61 interoperability, but normalize it to a percent-encoded
161
223
  // URL authority internally. The ap: URI syntax may change later; see:
162
224
  // https://bnewbold.leaflet.pub/3mph4hzvbdc2v
163
- const authority = decodePortableAuthority(match[2]);
225
+ const authority = getDidUrlOrigin(decodePortableAuthority(match[2]));
164
226
  if (!DID_PATTERN.test(authority)) {
165
227
  throw new TypeError("Invalid portable ActivityPub IRI authority.");
166
228
  }
@@ -261,6 +323,29 @@ function parseAtUri(uri: string): URL {
261
323
  return new URL("at://" + encodeURIComponent(authority) + path);
262
324
  }
263
325
 
326
+ /**
327
+ * Checks whether the URL is an FEP-ef61 gateway base URI.
328
+ */
329
+ export function isGatewayUrl(url: URL): boolean {
330
+ return (url.protocol === "http:" || url.protocol === "https:") &&
331
+ url.username === "" && url.password === "" &&
332
+ url.pathname === "/" && url.search === "" && url.hash === "";
333
+ }
334
+
335
+ /**
336
+ * Parses and validates an FEP-ef61 gateway base URI.
337
+ */
338
+ export function parseGatewayUrl(url: string): URL {
339
+ const parsed = parseIri(url);
340
+ if (!isGatewayUrl(parsed)) {
341
+ throw new TypeError(
342
+ "FEP-ef61 gateways must be HTTP(S) base URIs with no credentials, " +
343
+ "path, query, or fragment.",
344
+ );
345
+ }
346
+ return parsed;
347
+ }
348
+
264
349
  /**
265
350
  * Validates a URL to prevent SSRF attacks.
266
351
  */