@fedify/vocab 2.4.0-pr.936.41 → 2.5.0-dev.2271

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 (36) hide show
  1. package/deno.json +1 -1
  2. package/dist/mod.cjs +31412 -8807
  3. package/dist/mod.d.cts +4599 -6
  4. package/dist/mod.d.ts +4599 -6
  5. package/dist/mod.js +31305 -8707
  6. package/dist-tests/actor-NlmVfaZI.mjs +276 -0
  7. package/dist-tests/actor.test.mjs +55 -49
  8. package/dist-tests/{actor-MBpzF3-D.mjs → esm-DxaxLS8O.mjs} +15 -211
  9. package/dist-tests/gateway.test.mjs +1906 -0
  10. package/dist-tests/lookup-BUL82PQy.mjs +480 -0
  11. package/dist-tests/lookup.test.mjs +10 -268
  12. package/dist-tests/object.yaml +15 -0
  13. package/dist-tests/portable-lookup.test.mjs +653 -0
  14. package/dist-tests/signed-representation.test.mjs +189 -0
  15. package/dist-tests/translation.test.mjs +404 -0
  16. package/dist-tests/translation.yaml +91 -0
  17. package/dist-tests/type.test.mjs +2 -2
  18. package/dist-tests/{vocab-Bjwctrxn.mjs → vocab-hNQYgnUG.mjs} +31068 -8752
  19. package/dist-tests/vocab.test.mjs +677 -10
  20. package/package.json +5 -5
  21. package/src/__snapshots__/vocab.test.ts.snap +291 -15
  22. package/src/actor.test.ts +41 -40
  23. package/src/actor.ts +120 -1
  24. package/src/gateway.test.ts +2637 -0
  25. package/src/lookup.test.ts +6 -5
  26. package/src/lookup.ts +460 -33
  27. package/src/mod.ts +9 -0
  28. package/src/object.yaml +15 -0
  29. package/src/portable-lookup.test.ts +889 -0
  30. package/src/preprocessors.ts +1 -1
  31. package/src/signed-representation.test.ts +277 -0
  32. package/src/translation.test.ts +518 -0
  33. package/src/translation.yaml +91 -0
  34. package/src/vocab.test.ts +914 -5
  35. /package/dist-tests/{type-_BlsfT3S.mjs → type-BMdjm4Eg.mjs} +0 -0
  36. /package/dist-tests/{utils-CQjrpUkt.mjs → utils-Df2odC27.mjs} +0 -0
package/src/vocab.test.ts CHANGED
@@ -2,8 +2,11 @@ import { mockDocumentLoader, test } from "@fedify/fixture";
2
2
  import {
3
3
  decodeMultibase,
4
4
  type DocumentLoader,
5
+ FetchError,
6
+ getDocumentLoader,
5
7
  LanguageString,
6
8
  parseDecimal,
9
+ parseIri,
7
10
  type RemoteDocument,
8
11
  } from "@fedify/vocab-runtime";
9
12
  import {
@@ -14,6 +17,7 @@ import {
14
17
  } from "@fedify/vocab-tools";
15
18
  import { configure, type LogRecord, reset } from "@logtape/logtape";
16
19
  import { pascalCase } from "es-toolkit";
20
+ import fetchMock from "fetch-mock";
17
21
  import {
18
22
  deepStrictEqual,
19
23
  notDeepStrictEqual,
@@ -21,6 +25,7 @@ import {
21
25
  rejects,
22
26
  throws,
23
27
  } from "node:assert/strict";
28
+ import { inspect } from "node:util";
24
29
  import { assertInstanceOf } from "./utils.ts";
25
30
  import * as vocab from "./vocab.ts";
26
31
  import {
@@ -58,6 +63,7 @@ import {
58
63
  Organization,
59
64
  Person,
60
65
  Place,
66
+ PropertyValue,
61
67
  Proposal,
62
68
  Question,
63
69
  QuoteAuthorization,
@@ -397,6 +403,41 @@ test("Object.toJsonLd()", async () => {
397
403
  });
398
404
  });
399
405
 
406
+ test("Note.fromJsonLd() ignores malformed language tags", async () => {
407
+ const note = await Note.fromJsonLd({
408
+ "@context": "https://www.w3.org/ns/activitystreams",
409
+ type: "Note",
410
+ contentMap: {
411
+ invalid_tag: "Hello",
412
+ en: "Hi",
413
+ },
414
+ });
415
+
416
+ deepStrictEqual(note.contents, [new LanguageString("Hi", "en")]);
417
+ const jsonLd = await note.toJsonLd() as Record<string, unknown>;
418
+ deepStrictEqual(jsonLd.contentMap, { en: "Hi" });
419
+ });
420
+
421
+ test("Note.fromJsonLd() ignores malformed language tags in nested objects", async () => {
422
+ const note = await Note.fromJsonLd({
423
+ "@type": ["https://www.w3.org/ns/activitystreams#Note"],
424
+ "https://www.w3.org/ns/activitystreams#attachment": [{
425
+ "@type": ["http://schema.org#PropertyValue"],
426
+ "https://www.w3.org/ns/activitystreams#name": [
427
+ { "@value": "Malformed", "@language": "invalid_tag" },
428
+ { "@value": "Valid", "@language": "en" },
429
+ ],
430
+ }],
431
+ });
432
+
433
+ deepStrictEqual(await Array.fromAsync(note.getAttachments()), [
434
+ new PropertyValue({ name: new LanguageString("Valid", "en") }),
435
+ ]);
436
+ const jsonLd = await note.toJsonLd() as Record<string, unknown>;
437
+ const attachment = jsonLd.attachment as Record<string, unknown>;
438
+ deepStrictEqual(attachment.nameMap, { en: "Valid" });
439
+ });
440
+
400
441
  test("Note.toJsonLd()", async () => {
401
442
  const note = new Note({
402
443
  tags: [
@@ -628,6 +669,31 @@ test("fromJsonLd() handles portable ActivityPub IRIs", async () => {
628
669
  );
629
670
  });
630
671
 
672
+ test("fromJsonLd() refuses portable IDs changed by URL parsing", async () => {
673
+ const base = "ap://did:key:z6Mkabc";
674
+ for (
675
+ const id of [
676
+ `${base}/a/../b`,
677
+ `${base}/a/%2e%2E/b`,
678
+ "https://gw.example/.well-known/apgateway/did:key:z6Mkabc/a/../b",
679
+ "https:/gw.example/.well-known/apgateway/did:key:z6Mkabc/a/../b",
680
+ ]
681
+ ) {
682
+ await rejects(
683
+ () =>
684
+ Note.fromJsonLd({
685
+ "@context": "https://www.w3.org/ns/activitystreams",
686
+ type: "Note",
687
+ id,
688
+ }, {
689
+ documentLoader: mockDocumentLoader,
690
+ contextLoader: mockDocumentLoader,
691
+ }),
692
+ TypeError,
693
+ );
694
+ }
695
+ });
696
+
631
697
  test("FEP-ef61: actor gateways round-trip as an ordered URI list", async () => {
632
698
  const actorClasses = [Application, Group, Organization, Person, Service];
633
699
  const gateways = [
@@ -647,15 +713,123 @@ test("FEP-ef61: actor gateways round-trip as an ordered URI list", async () => {
647
713
  });
648
714
  deepStrictEqual(actor.gateways, gateways);
649
715
 
650
- const jsonLd = await actor.toJsonLd() as Record<string, unknown>;
716
+ const jsonLd = await actor.toJsonLd({
717
+ format: "compact",
718
+ }) as Record<string, unknown>;
651
719
  deepStrictEqual(jsonLd.type, ActorClass.name);
652
- deepStrictEqual(jsonLd.gateways, gateways.map((gateway) => gateway.href));
720
+ deepStrictEqual(jsonLd.gateways, gateways.map((gateway) => gateway.origin));
653
721
 
654
722
  const restored = await ActorClass.fromJsonLd(jsonLd);
655
723
  deepStrictEqual(restored.gateways, gateways);
656
724
  }
657
725
  });
658
726
 
727
+ test("FEP-ef61: actor gateways serialize as origins", async () => {
728
+ const actorClasses = [Application, Group, Organization, Person, Service];
729
+ const gateways = [
730
+ new URL("https://first.example:443/"),
731
+ new URL("http://second.example:80/"),
732
+ new URL("https://third.example:8443/"),
733
+ new URL("http://fourth.example:8080/"),
734
+ ];
735
+ const origins = [
736
+ "https://first.example",
737
+ "http://second.example",
738
+ "https://third.example:8443",
739
+ "http://fourth.example:8080",
740
+ ];
741
+ for (const ActorClass of actorClasses) {
742
+ const actor = new ActorClass({
743
+ id: parseIri("ap://did:key:z6Mkabc/actor"),
744
+ gateways,
745
+ });
746
+ const compact = await actor.toJsonLd() as Record<string, unknown>;
747
+ deepStrictEqual(compact.gateways, origins);
748
+ const expanded = await actor.toJsonLd({
749
+ format: "expand",
750
+ }) as Record<string, unknown>[];
751
+ deepStrictEqual(expanded[0]["https://w3id.org/fep/ef61/gateways"], [
752
+ { "@list": origins.map((origin) => ({ "@id": origin })) },
753
+ ]);
754
+ deepStrictEqual(actor.gateways, gateways);
755
+ deepStrictEqual(
756
+ actor.gateways.map((gateway) => gateway.href),
757
+ origins.map((origin) => `${origin}/`),
758
+ );
759
+ deepStrictEqual((await ActorClass.fromJsonLd(compact)).gateways, gateways);
760
+ deepStrictEqual((await ActorClass.fromJsonLd(expanded)).gateways, gateways);
761
+ }
762
+ });
763
+
764
+ test("FEP-ef61: portable actors parse unmapped gateway lists", async () => {
765
+ const actorClasses = [Application, Group, Organization, Person, Service];
766
+ const gateways = ["https://first.example", "https://second.example"];
767
+ const ids = [
768
+ "ap://did:key:z6Mkabc/actor",
769
+ "https://first.example/.well-known/apgateway/did:key:z6Mkabc/actor",
770
+ ];
771
+ for (const ActorClass of actorClasses) {
772
+ for (const id of ids) {
773
+ const actor = await ActorClass.fromJsonLd({
774
+ "@context": "https://www.w3.org/ns/activitystreams",
775
+ type: ActorClass.name,
776
+ id,
777
+ inbox: `${id}/inbox`,
778
+ outbox: `${id}/outbox`,
779
+ gateways,
780
+ });
781
+ deepStrictEqual(actor.gateways, gateways.map((g) => new URL(g)));
782
+ }
783
+ }
784
+ });
785
+
786
+ test("FEP-ef61: unmapped gateways retain their context meaning", async () => {
787
+ const actorClasses = [Application, Group, Organization, Person, Service];
788
+ const id = "ap://did:key:z6Mkabc/actor";
789
+ for (const ActorClass of actorClasses) {
790
+ const document = {
791
+ type: ActorClass.name,
792
+ id,
793
+ inbox: `${id}/inbox`,
794
+ outbox: `${id}/outbox`,
795
+ gateways: ["https://unmapped.example"],
796
+ };
797
+ for (
798
+ const context of [
799
+ { gateways: "https://example.com/unrelated" },
800
+ { gateways: null },
801
+ { "@vocab": "https://example.com/" },
802
+ {
803
+ [ActorClass.name]: {
804
+ "@id": `https://www.w3.org/ns/activitystreams#${ActorClass.name}`,
805
+ "@context": { gateways: "https://example.com/scoped" },
806
+ },
807
+ },
808
+ ]
809
+ ) {
810
+ const actor = await ActorClass.fromJsonLd({
811
+ "@context": ["https://www.w3.org/ns/activitystreams", context],
812
+ ...document,
813
+ });
814
+ deepStrictEqual(actor.gateways, []);
815
+ }
816
+ const ordinary = await ActorClass.fromJsonLd({
817
+ "@context": "https://www.w3.org/ns/activitystreams",
818
+ ...document,
819
+ id: "https://ordinary.example/actor",
820
+ });
821
+ deepStrictEqual(ordinary.gateways, []);
822
+ const canonical = await ActorClass.fromJsonLd({
823
+ "@context": "https://www.w3.org/ns/activitystreams",
824
+ ...document,
825
+ "https://w3id.org/fep/ef61/gateways": {
826
+ "@list": [{ "@id": "https://canonical.example" }],
827
+ },
828
+ });
829
+ deepStrictEqual(canonical.gateways, [new URL("https://canonical.example")]);
830
+ }
831
+ });
832
+
659
833
  test("FEP-ef61: actor gateways preserve single, empty, and invalid cases", async () => {
660
834
  const singleGateway = new Person({
661
835
  id: new URL("ap+ef61://did%3Akey%3Az6Mkabc/actor"),
@@ -663,7 +837,7 @@ test("FEP-ef61: actor gateways preserve single, empty, and invalid cases", async
663
837
  });
664
838
  deepStrictEqual(
665
839
  (await singleGateway.toJsonLd() as Record<string, unknown>).gateways,
666
- ["https://server.example/"],
840
+ ["https://server.example"],
667
841
  );
668
842
 
669
843
  const noGateways = new Person({
@@ -724,7 +898,9 @@ test("FEP-ef61: actor gateways must be HTTP(S) base URIs", async () => {
724
898
  "https://user@server.example/",
725
899
  "https://server.example/path",
726
900
  "https://server.example/?x=1",
901
+ "https://server.example/?",
727
902
  "https://server.example/#fragment",
903
+ "https://server.example/#",
728
904
  ]
729
905
  ) {
730
906
  throws(
@@ -1768,6 +1944,130 @@ test({
1768
1944
  });
1769
1945
  },
1770
1946
  });
1947
+ test({
1948
+ name: "Announce.getObject() logs suppressed failures as warnings",
1949
+ permissions: { env: true, read: true },
1950
+ async fn() {
1951
+ const records: LogRecord[] = [];
1952
+ const notFoundUrl = "https://example.com/suppressed-not-found";
1953
+ const invalidObjectUrl = "https://example.com/invalid-object";
1954
+
1955
+ await reset();
1956
+ fetchMock.spyGlobal();
1957
+
1958
+ try {
1959
+ await configure({
1960
+ sinks: {
1961
+ buffer(record: LogRecord): void {
1962
+ records.push(record);
1963
+ },
1964
+ },
1965
+ filters: {},
1966
+ loggers: [{ category: [], sinks: ["buffer"] }],
1967
+ });
1968
+
1969
+ fetchMock.get(notFoundUrl, { status: 404 });
1970
+
1971
+ const suppressedFetch = new Announce({
1972
+ object: new URL(notFoundUrl),
1973
+ });
1974
+ deepStrictEqual(
1975
+ await suppressedFetch.getObject({ suppressError: true }),
1976
+ null,
1977
+ );
1978
+ ok(
1979
+ records.some((record) =>
1980
+ record.level === "warning" &&
1981
+ record.rawMessage ===
1982
+ "Failed to fetch document: {status} {url} {headers}"
1983
+ ),
1984
+ );
1985
+ ok(
1986
+ records.some((record) =>
1987
+ record.level === "warning" &&
1988
+ record.rawMessage === "Failed to fetch {url}: {error}"
1989
+ ),
1990
+ );
1991
+ deepStrictEqual(
1992
+ records.some((record) =>
1993
+ record.level === "error" &&
1994
+ (
1995
+ record.rawMessage ===
1996
+ "Failed to fetch document: {status} {url} {headers}" ||
1997
+ record.rawMessage === "Failed to fetch {url}: {error}"
1998
+ )
1999
+ ),
2000
+ false,
2001
+ );
2002
+
2003
+ records.length = 0;
2004
+
2005
+ const unsuppressedFetch = new Announce({
2006
+ object: new URL(notFoundUrl),
2007
+ });
2008
+ await rejects(
2009
+ () => unsuppressedFetch.getObject(),
2010
+ FetchError,
2011
+ );
2012
+ ok(
2013
+ records.some((record) =>
2014
+ record.level === "error" &&
2015
+ record.rawMessage ===
2016
+ "Failed to fetch document: {status} {url} {headers}"
2017
+ ),
2018
+ );
2019
+
2020
+ records.length = 0;
2021
+
2022
+ // deno-lint-ignore require-await
2023
+ const invalidDocumentLoader: DocumentLoader = async (url) => ({
2024
+ contextUrl: null,
2025
+ documentUrl: url,
2026
+ document: null,
2027
+ });
2028
+
2029
+ const suppressedParsing = new Announce({
2030
+ object: new URL(invalidObjectUrl),
2031
+ });
2032
+ deepStrictEqual(
2033
+ await suppressedParsing.getObject({
2034
+ documentLoader: invalidDocumentLoader,
2035
+ suppressError: true,
2036
+ }),
2037
+ null,
2038
+ );
2039
+ ok(
2040
+ records.some((record) =>
2041
+ record.level === "warning" &&
2042
+ record.rawMessage === "Failed to parse {url}: {error}"
2043
+ ),
2044
+ );
2045
+ deepStrictEqual(
2046
+ records.some((record) =>
2047
+ record.level === "error" &&
2048
+ record.rawMessage === "Failed to parse {url}: {error}"
2049
+ ),
2050
+ false,
2051
+ );
2052
+
2053
+ records.length = 0;
2054
+
2055
+ const unsuppressedParsing = new Announce({
2056
+ object: new URL(invalidObjectUrl),
2057
+ });
2058
+ await rejects(
2059
+ () =>
2060
+ unsuppressedParsing.getObject({
2061
+ documentLoader: invalidDocumentLoader,
2062
+ }),
2063
+ TypeError,
2064
+ );
2065
+ } finally {
2066
+ fetchMock.hardReset();
2067
+ await reset();
2068
+ }
2069
+ },
2070
+ });
1771
2071
 
1772
2072
  test("Activity.getObject() fetches canonical portable IRIs", async () => {
1773
2073
  const fetchedUrls: string[] = [];
@@ -1794,6 +2094,8 @@ test("Activity.getObject() fetches canonical portable IRIs", async () => {
1794
2094
  const object = await activity.getObject({
1795
2095
  documentLoader,
1796
2096
  contextLoader: mockDocumentLoader,
2097
+ // deno-lint-ignore require-await
2098
+ verifyPortableObject: async () => ({ verified: true }),
1797
2099
  });
1798
2100
 
1799
2101
  assertInstanceOf(object, Note);
@@ -1842,6 +2144,104 @@ test({
1842
2144
  },
1843
2145
  });
1844
2146
 
2147
+ // The alias is inherited during initial expansion, but is unavailable when
2148
+ // an embedded value is reparsed standalone from the cached JSON-LD.
2149
+ function embeddedPropertyDocument(property: "attachment" | "object") {
2150
+ const context = "https://www.w3.org/ns/activitystreams";
2151
+ return {
2152
+ "@context": [context, { CustomImage: `${context}#Image` }],
2153
+ type: property === "attachment" ? "Note" : "Create",
2154
+ [property]: [
2155
+ {
2156
+ "@context": [context, { "@vocab": "https://example.com/" }],
2157
+ type: "CustomImage",
2158
+ name: "Malformed when reparsed",
2159
+ },
2160
+ { "@context": context, type: "Image", name: "Cached image" },
2161
+ { type: "Image", name: "Decoded image" },
2162
+ ],
2163
+ };
2164
+ }
2165
+
2166
+ for (const suppressError of [undefined, false, true]) {
2167
+ test(
2168
+ `Note.getAttachments() cached parse failure (suppressError: ${suppressError})`,
2169
+ async () => {
2170
+ const jsonLd = embeddedPropertyDocument("attachment");
2171
+ const note = await Note.fromJsonLd(jsonLd);
2172
+ if (suppressError) {
2173
+ for (let i = 0; i < 2; i++) {
2174
+ const attachments: (Object | Link | PropertyValue)[] = await Array
2175
+ .fromAsync(
2176
+ note.getAttachments({ suppressError }),
2177
+ );
2178
+ deepStrictEqual(
2179
+ attachments.map((attachment) => attachment.name),
2180
+ ["Cached image", "Decoded image"],
2181
+ );
2182
+ }
2183
+ // Suppression must not remove or replace the cached malformed value.
2184
+ await rejects(() => Array.fromAsync(note.getAttachments()), TypeError);
2185
+ } else {
2186
+ await rejects(
2187
+ () => Array.fromAsync(note.getAttachments({ suppressError })),
2188
+ TypeError,
2189
+ );
2190
+ }
2191
+ deepStrictEqual(await note.toJsonLd(), jsonLd);
2192
+ },
2193
+ );
2194
+
2195
+ test(
2196
+ `Activity.getObject() cached parse failure (suppressError: ${suppressError})`,
2197
+ async () => {
2198
+ const jsonLd = embeddedPropertyDocument("object");
2199
+ const activity = await Activity.fromJsonLd(jsonLd);
2200
+ if (suppressError) {
2201
+ deepStrictEqual(await activity.getObject({ suppressError }), null);
2202
+ deepStrictEqual(await activity.getObject({ suppressError }), null);
2203
+ await rejects(() => activity.getObject(), TypeError);
2204
+ } else {
2205
+ await rejects(() => activity.getObject({ suppressError }), TypeError);
2206
+ }
2207
+ deepStrictEqual(await activity.toJsonLd(), jsonLd);
2208
+ },
2209
+ );
2210
+ }
2211
+
2212
+ test("Note.getAttachments() suppresses cached context loader failures", async () => {
2213
+ const note = await Note.fromJsonLd(embeddedPropertyDocument("attachment"));
2214
+ const contextLoader = () =>
2215
+ Promise.reject(new Error("Context loader failed"));
2216
+ await rejects(
2217
+ () => Array.fromAsync(note.getAttachments({ contextLoader })),
2218
+ (error: unknown) => error instanceof Error && !(error instanceof TypeError),
2219
+ );
2220
+ // Both cached values need the failing loader; the decoded value remains usable.
2221
+ const attachments: (Object | Link | PropertyValue)[] = await Array.fromAsync(
2222
+ note.getAttachments({ contextLoader, suppressError: true }),
2223
+ );
2224
+ deepStrictEqual(attachments.map((attachment) => attachment.name), [
2225
+ "Decoded image",
2226
+ ]);
2227
+ });
2228
+
2229
+ test("Activity.getObject() suppresses cached context loader failures", async () => {
2230
+ const activity = await Activity.fromJsonLd(
2231
+ embeddedPropertyDocument("object"),
2232
+ );
2233
+ const contextLoader = () =>
2234
+ Promise.reject(new Error("Context loader failed"));
2235
+ await rejects(
2236
+ () => activity.getObject({ contextLoader }),
2237
+ (error: unknown) => error instanceof Error && !(error instanceof TypeError),
2238
+ );
2239
+ deepStrictEqual(
2240
+ await activity.getObject({ contextLoader, suppressError: true }),
2241
+ null,
2242
+ );
2243
+ });
2244
+
1845
2245
  test("Activity.clone()", async () => {
1846
2246
  const activity = new Activity({
1847
2247
  actor: new Person({
@@ -1922,6 +2322,26 @@ test({
1922
2322
  },
1923
2323
  });
1924
2324
 
2325
+ test("inspecting an object prints portable IRIs in their canonical form", () => {
2326
+ const did = "did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2";
2327
+ const obj = new Object({
2328
+ id: parseIri(`ap://${did}/note`),
2329
+ attribution: parseIri(`ap://${did}/actor`),
2330
+ });
2331
+ const inspected = [inspect(obj, { colors: false })];
2332
+ if ("Deno" in globalThis) {
2333
+ inspected.push(Deno.inspect(obj, { colors: false }));
2334
+ }
2335
+ for (const text of inspected) {
2336
+ ok(text.includes(`ap+ef61://${did}/note`), text);
2337
+ ok(text.includes(`ap+ef61://${did}/actor`), text);
2338
+ ok(!text.includes("%3A"), text);
2339
+ }
2340
+ // A URL that cannot be formatted as an IRI does not break inspection:
2341
+ const malformed = new Object({ id: new URL("ap://not-a-did/note") });
2342
+ ok(inspect(malformed).includes("ap://not-a-did/note"));
2343
+ });
2344
+
1925
2345
  test("Person.fromJsonLd()", async () => {
1926
2346
  const person = await Person.fromJsonLd({
1927
2347
  "@context": [
@@ -3189,6 +3609,105 @@ test("Delete.toJsonLd() compacts embedded QuoteRequest", async () => {
3189
3609
  );
3190
3610
  });
3191
3611
 
3612
+ test("Note.quoteUrl (IRI-typed alias terms)", async () => {
3613
+ const jsonLd: Record<string, unknown> = {
3614
+ "@context": [
3615
+ "https://www.w3.org/ns/activitystreams",
3616
+ {
3617
+ fedibird: "http://fedibird.com/ns#",
3618
+ misskey: "https://misskey-hub.net/ns#",
3619
+ _misskey_quote: {
3620
+ "@id": "misskey:_misskey_quote",
3621
+ "@type": "@id",
3622
+ },
3623
+ quoteUri: {
3624
+ "@id": "fedibird:quoteUri",
3625
+ "@type": "@id",
3626
+ },
3627
+ },
3628
+ ],
3629
+ id: "https://example.com/notes/1",
3630
+ type: "Note",
3631
+ _misskey_quote: "https://example.com/notes/quoted",
3632
+ quoteUri: "https://example.com/notes/quoted2",
3633
+ };
3634
+
3635
+ const loaded = await Note.fromJsonLd(jsonLd);
3636
+ deepStrictEqual(loaded.quoteUrl, new URL("https://example.com/notes/quoted"));
3637
+
3638
+ delete jsonLd._misskey_quote;
3639
+ const loaded2 = await Note.fromJsonLd(jsonLd);
3640
+ deepStrictEqual(
3641
+ loaded2.quoteUrl,
3642
+ new URL("https://example.com/notes/quoted2"),
3643
+ );
3644
+ });
3645
+
3646
+ test("Note.quoteUrl (IRI-typed primary term)", async () => {
3647
+ const context = [
3648
+ "https://www.w3.org/ns/activitystreams",
3649
+ {
3650
+ misskey: "https://misskey-hub.net/ns#",
3651
+ quoteUrl: { "@id": "as:quoteUrl", "@type": "@id" },
3652
+ _misskey_quote: "misskey:_misskey_quote",
3653
+ },
3654
+ ];
3655
+
3656
+ const loaded = await Note.fromJsonLd({
3657
+ "@context": context,
3658
+ type: "Note",
3659
+ quoteUrl: "https://example.com/object",
3660
+ });
3661
+ deepStrictEqual(loaded.quoteUrl, new URL("https://example.com/object"));
3662
+
3663
+ // An IRI-valued quoteUrl still takes precedence over a plain-string alias:
3664
+ const loaded2 = await Note.fromJsonLd({
3665
+ "@context": context,
3666
+ type: "Note",
3667
+ quoteUrl: "https://example.com/object",
3668
+ _misskey_quote: "https://example.com/object2",
3669
+ });
3670
+ deepStrictEqual(loaded2.quoteUrl, new URL("https://example.com/object"));
3671
+ });
3672
+
3673
+ test("Note.quoteUrl (unparsable IRI-valued terms)", async () => {
3674
+ const context = [
3675
+ "https://www.w3.org/ns/activitystreams",
3676
+ {
3677
+ misskey: "https://misskey-hub.net/ns#",
3678
+ _misskey_quote: { "@id": "misskey:_misskey_quote", "@type": "@id" },
3679
+ },
3680
+ ];
3681
+ for (
3682
+ const quote of [
3683
+ { type: "Note", content: "An inlined quote without an id" },
3684
+ "_:b0",
3685
+ "",
3686
+ "notes/relative",
3687
+ ]
3688
+ ) {
3689
+ const loaded = await Note.fromJsonLd({
3690
+ "@context": context,
3691
+ id: "https://example.com/notes/1",
3692
+ type: "Note",
3693
+ content: "Hello",
3694
+ _misskey_quote: quote,
3695
+ });
3696
+ deepStrictEqual(loaded.quoteUrl, null, JSON.stringify(quote));
3697
+ deepStrictEqual(loaded.content, "Hello");
3698
+ }
3699
+
3700
+ const atUri = await Note.fromJsonLd({
3701
+ "@context": context,
3702
+ type: "Note",
3703
+ _misskey_quote: "at://did:plc:abc/app.bsky.feed.post/xyz",
3704
+ });
3705
+ deepStrictEqual(
3706
+ atUri.quoteUrl,
3707
+ new URL("at://" + encodeURIComponent("did:plc:abc/app.bsky.feed.post/xyz")),
3708
+ );
3709
+ });
3710
+
3192
3711
  test("Key.publicKey", async () => {
3193
3712
  const jwk = {
3194
3713
  kty: "RSA",
@@ -4248,7 +4767,12 @@ test(
4248
4767
  throw new Error("Document not found");
4249
4768
  };
4250
4769
 
4251
- const result = await create.getObject({ documentLoader });
4770
+ const result = await create.getObject({
4771
+ documentLoader,
4772
+ contextLoader: mockDocumentLoader,
4773
+ // deno-lint-ignore require-await
4774
+ verifyPortableObject: async () => ({ verified: true }),
4775
+ });
4252
4776
  assertInstanceOf(result, Note);
4253
4777
  deepStrictEqual(result.content, "Fetched portable note");
4254
4778
  },
@@ -5379,7 +5903,25 @@ for (const typeUri in types) {
5379
5903
  const jsonLd = await instance.toJsonLd({
5380
5904
  contextLoader: mockDocumentLoader,
5381
5905
  });
5382
- deepStrictEqual(jsonLd["@context"], type.defaultContext);
5906
+ const defaultContexts = Array.isArray(type.defaultContext)
5907
+ ? type.defaultContext
5908
+ : [type.defaultContext];
5909
+ const extraContexts = [
5910
+ ...new Set(
5911
+ allProperties.flatMap((property) =>
5912
+ property.extraContext == null ||
5913
+ defaultContexts.some((context) => context === property.extraContext)
5914
+ ? []
5915
+ : [property.extraContext]
5916
+ ),
5917
+ ),
5918
+ ];
5919
+ deepStrictEqual(
5920
+ jsonLd["@context"],
5921
+ extraContexts.length === 0
5922
+ ? type.defaultContext
5923
+ : [...defaultContexts, ...extraContexts],
5924
+ );
5383
5925
  if (type.entity) deepStrictEqual(jsonLd.id, "https://example.com/");
5384
5926
  const restored = await cls.fromJsonLd(jsonLd, {
5385
5927
  documentLoader: mockDocumentLoader,
@@ -5498,3 +6040,370 @@ for (const typeUri in types) {
5498
6040
  deepStrictEqual(cls.typeId, new URL(type.uri));
5499
6041
  });
5500
6042
  }
6043
+
6044
+ test("Announce.getObject() suppresses redirect, context, and private URL logs", async () => {
6045
+ const records: LogRecord[] = [];
6046
+ const objectUrl = "https://example.com/suppression-object";
6047
+ const contextUrl = "https://example.com/suppression-context";
6048
+ const scenarios = [
6049
+ "redirect limit",
6050
+ "redirect loop",
6051
+ "context",
6052
+ "private",
6053
+ "private redirect",
6054
+ ];
6055
+ await reset();
6056
+ await configure({
6057
+ sinks: {
6058
+ capture: (record) => {
6059
+ records.push(record);
6060
+ },
6061
+ },
6062
+ loggers: [{
6063
+ category: ["fedify"],
6064
+ lowestLevel: "debug",
6065
+ sinks: ["capture"],
6066
+ }],
6067
+ });
6068
+ try {
6069
+ for (const scenario of scenarios) {
6070
+ fetchMock.spyGlobal();
6071
+ let fetches = 0;
6072
+ fetchMock.get("begin:https://example.com/", (call) => {
6073
+ fetches++;
6074
+ if (scenario === "redirect limit") {
6075
+ return Response.redirect(
6076
+ `https://example.com/redirect-${fetches}`,
6077
+ 302,
6078
+ );
6079
+ }
6080
+ if (scenario === "redirect loop") {
6081
+ return Response.redirect(objectUrl, 302);
6082
+ }
6083
+ if (scenario === "private redirect") {
6084
+ return Response.redirect("http://127.0.0.1/private", 302);
6085
+ }
6086
+ if (call.url === contextUrl) return new Response(null, { status: 404 });
6087
+ return new Response(
6088
+ JSON.stringify({
6089
+ "@context": ["https://www.w3.org/ns/activitystreams", contextUrl],
6090
+ id: objectUrl,
6091
+ type: "Note",
6092
+ }),
6093
+ { headers: { "Content-Type": "application/activity+json" } },
6094
+ );
6095
+ });
6096
+ try {
6097
+ for (const suppressError of [true, false, undefined]) {
6098
+ records.length = 0;
6099
+ fetches = 0;
6100
+ const announce = new Announce({
6101
+ object: new URL(
6102
+ scenario === "private" ? "http://127.0.0.1/private" : objectUrl,
6103
+ ),
6104
+ });
6105
+ if (suppressError) {
6106
+ deepStrictEqual(await announce.getObject({ suppressError }), null);
6107
+ deepStrictEqual(
6108
+ records.filter((r) => r.level === "error"),
6109
+ [],
6110
+ scenario,
6111
+ );
6112
+ ok(records.some((r) => r.level === "warning"), scenario);
6113
+ } else {
6114
+ await rejects(() => announce.getObject({ suppressError }));
6115
+ ok(records.some((r) => r.level === "error"), scenario);
6116
+ }
6117
+ if (scenario === "private") deepStrictEqual(fetches, 0);
6118
+ if (scenario === "private redirect") deepStrictEqual(fetches, 1);
6119
+ }
6120
+ } finally {
6121
+ fetchMock.hardReset();
6122
+ }
6123
+ }
6124
+ } finally {
6125
+ await reset();
6126
+ }
6127
+ });
6128
+
6129
+ test("suppressed context loading does not persist on fetched objects", async () => {
6130
+ const states: (boolean | undefined)[] = [];
6131
+ const contextLoader: DocumentLoader = (url, options) => {
6132
+ if (url === "https://www.w3.org/ns/activitystreams") {
6133
+ return mockDocumentLoader(url);
6134
+ }
6135
+ states.push(options?.suppressError);
6136
+ return Promise.resolve({
6137
+ contextUrl: null,
6138
+ documentUrl: url,
6139
+ document: {
6140
+ "@context": {
6141
+ as: "https://www.w3.org/ns/activitystreams#",
6142
+ name: "as:name",
6143
+ },
6144
+ },
6145
+ });
6146
+ };
6147
+ const documentLoader: DocumentLoader = (url) =>
6148
+ Promise.resolve({
6149
+ contextUrl: null,
6150
+ documentUrl: url,
6151
+ document: {
6152
+ "@context": [
6153
+ "https://www.w3.org/ns/activitystreams",
6154
+ "https://example.com/scoped-context",
6155
+ ],
6156
+ id: url,
6157
+ type: url.endsWith("actor") ? "Person" : "Note",
6158
+ attributedTo: "https://example.com/scoped-actor",
6159
+ },
6160
+ });
6161
+ const note = await new Announce({
6162
+ object: new URL("https://example.com/scoped-note"),
6163
+ }).getObject({
6164
+ documentLoader,
6165
+ contextLoader,
6166
+ suppressError: true,
6167
+ });
6168
+ assertInstanceOf(note, Note);
6169
+ ok(states.includes(true));
6170
+ states.length = 0;
6171
+ const actor = await note.getAttribution();
6172
+ assertInstanceOf(actor, Person);
6173
+ ok(states.length > 0);
6174
+ deepStrictEqual(states.includes(true), false);
6175
+ });
6176
+
6177
+ test("attribution chains alternate ordinary and portable suppression", async () => {
6178
+ const states: (boolean | undefined)[] = [];
6179
+ const depths: number[][] = [];
6180
+ let hop = 0;
6181
+ const contextUrl = "https://example.com/attribution-context";
6182
+ const contextLoader: DocumentLoader = (url, options) => {
6183
+ if (url !== contextUrl) return mockDocumentLoader(url, options);
6184
+ states.push(options?.suppressError);
6185
+ const stack = new Error().stack;
6186
+ ok(stack);
6187
+ depths[hop]?.push(stack.split("\n").length);
6188
+ return Promise.resolve({
6189
+ contextUrl: null,
6190
+ documentUrl: url,
6191
+ document: {
6192
+ "@context": { name: "https://www.w3.org/ns/activitystreams#name" },
6193
+ },
6194
+ });
6195
+ };
6196
+ const documentLoader: DocumentLoader = (url) => {
6197
+ const hop = Number(new URL(url).pathname.split("/").at(-1));
6198
+ return Promise.resolve({
6199
+ contextUrl: null,
6200
+ documentUrl: url,
6201
+ document: {
6202
+ "@context": ["https://www.w3.org/ns/activitystreams", contextUrl],
6203
+ id: url,
6204
+ type: "Person",
6205
+ name: `Actor ${hop}`,
6206
+ attributedTo: `https://example.com/actors/${hop + 1}`,
6207
+ },
6208
+ });
6209
+ };
6210
+ let person = new Person({
6211
+ id: new URL("https://example.com/actors/root"),
6212
+ attribution: new URL("https://example.com/actors/0"),
6213
+ }, { documentLoader, contextLoader });
6214
+ const stackTraceLimit = globalThis.Object.getOwnPropertyDescriptor(
6215
+ Error,
6216
+ "stackTraceLimit",
6217
+ );
6218
+ try {
6219
+ ok(Reflect.set(Error, "stackTraceLimit", Infinity));
6220
+ for (; hop < 100; hop++) {
6221
+ depths[hop] = [];
6222
+ const next = await person.getAttribution({
6223
+ suppressError: true,
6224
+ ...(hop % 2 === 0 ? {} : {
6225
+ verifyPortableObject: () =>
6226
+ Promise.resolve({ verified: true as const }),
6227
+ }),
6228
+ });
6229
+ assertInstanceOf(next, Person);
6230
+ deepStrictEqual(next.id, new URL(`https://example.com/actors/${hop}`));
6231
+ person = next;
6232
+ }
6233
+ // Compare the same mode after warmup; a retained wrapper per hop would
6234
+ // increase the synchronous call depth even before the stack overflows.
6235
+ ok(depths.every((values) => values.length > 0));
6236
+ deepStrictEqual(depths[98], depths[2]);
6237
+ deepStrictEqual(depths[99], depths[3]);
6238
+ ok(states.length >= 100);
6239
+ ok(states.every((state) => state === true));
6240
+ states.length = 0;
6241
+ assertInstanceOf(await person.getAttribution(), Person);
6242
+ ok(states.length > 0);
6243
+ ok(states.every((state) => state !== true));
6244
+ } finally {
6245
+ if (stackTraceLimit == null) {
6246
+ Reflect.deleteProperty(Error, "stackTraceLimit");
6247
+ } else {
6248
+ globalThis.Object.defineProperty(
6249
+ Error,
6250
+ "stackTraceLimit",
6251
+ stackTraceLimit,
6252
+ );
6253
+ }
6254
+ }
6255
+ });
6256
+
6257
+ test("portable accessors suppress document and context failure logs", async () => {
6258
+ const records: LogRecord[] = [];
6259
+ const id = new URL("ap+ef61://did%3Akey%3Az6Mkabc/objects/1");
6260
+ const contextUrl = "https://example.com/portable-suppression-context";
6261
+ await reset();
6262
+ await configure({
6263
+ sinks: {
6264
+ capture: (record) => {
6265
+ records.push(record);
6266
+ },
6267
+ },
6268
+ loggers: [{
6269
+ category: ["fedify"],
6270
+ lowestLevel: "debug",
6271
+ sinks: ["capture"],
6272
+ }],
6273
+ });
6274
+ try {
6275
+ for (const failContext of [false, true]) {
6276
+ fetchMock.spyGlobal();
6277
+ fetchMock.get("begin:https://example.com/", (call) => {
6278
+ if (!failContext || call.url === contextUrl) {
6279
+ return new Response(null, { status: 404 });
6280
+ }
6281
+ return new Response(
6282
+ JSON.stringify({
6283
+ "@context": ["https://www.w3.org/ns/activitystreams", contextUrl],
6284
+ id: "ap://did:key:z6Mkabc/objects/1",
6285
+ type: "Note",
6286
+ }),
6287
+ { headers: { "Content-Type": "application/activity+json" } },
6288
+ );
6289
+ });
6290
+ try {
6291
+ for (const suppressError of [true, false, undefined]) {
6292
+ records.length = 0;
6293
+ const announce = new Announce({ object: id });
6294
+ const options = {
6295
+ suppressError,
6296
+ gateways: ["https://example.com/"],
6297
+ verifyPortableObject: () =>
6298
+ Promise.resolve({ verified: true as const }),
6299
+ };
6300
+ if (suppressError) {
6301
+ deepStrictEqual(await announce.getObject(options), null);
6302
+ deepStrictEqual(records.filter((r) => r.level === "error"), []);
6303
+ ok(records.some((r) => r.level === "warning"));
6304
+ } else {
6305
+ await rejects(() => announce.getObject(options));
6306
+ ok(records.some((r) => r.level === "error"));
6307
+ }
6308
+ }
6309
+ } finally {
6310
+ fetchMock.hardReset();
6311
+ }
6312
+ }
6313
+ } finally {
6314
+ await reset();
6315
+ }
6316
+ });
6317
+
6318
+ test("portable verifiers suppress document logs only during dereferencing", async () => {
6319
+ const records: LogRecord[] = [];
6320
+ const id = new URL("ap+ef61://did%3Akey%3Az6Mkabc/objects/1");
6321
+ const verificationUrl = "https://example.com/verification-document";
6322
+ const missingUrl = "https://example.com/missing-verification-document";
6323
+ const signal = new AbortController().signal;
6324
+ await reset();
6325
+ await configure({
6326
+ sinks: { capture: (record) => records.push(record) },
6327
+ loggers: [{
6328
+ category: ["fedify"],
6329
+ lowestLevel: "debug",
6330
+ sinks: ["capture"],
6331
+ }],
6332
+ });
6333
+ try {
6334
+ for (const failVerification of [false, true]) {
6335
+ fetchMock.spyGlobal();
6336
+ fetchMock.get("begin:https://example.com/.well-known/", {
6337
+ headers: { "Content-Type": "application/activity+json" },
6338
+ body: {
6339
+ "@context": "https://www.w3.org/ns/activitystreams",
6340
+ id: "ap://did:key:z6Mkabc/objects/1",
6341
+ type: "Note",
6342
+ },
6343
+ });
6344
+ fetchMock.get(verificationUrl, {
6345
+ status: failVerification ? 404 : 200,
6346
+ headers: { "Content-Type": "application/ld+json" },
6347
+ body: {},
6348
+ });
6349
+ fetchMock.get(missingUrl, { status: 404 });
6350
+ try {
6351
+ for (const suppressError of [true, false, undefined]) {
6352
+ records.length = 0;
6353
+ const baseLoader = getDocumentLoader();
6354
+ const calls: Parameters<DocumentLoader>[1][] = [];
6355
+ const documentLoader: DocumentLoader = (url, options) => {
6356
+ calls.push(options);
6357
+ return baseLoader(url, options);
6358
+ };
6359
+ let retainedLoader: DocumentLoader | undefined;
6360
+ const loaderOptions = { signal };
6361
+ const announce = new Announce({ object: id });
6362
+ const object = await announce.getObject({
6363
+ suppressError,
6364
+ documentLoader,
6365
+ gateways: ["https://example.com/"],
6366
+ verifyPortableObject: async (_document, options) => {
6367
+ retainedLoader = options.documentLoader;
6368
+ ok(options.documentLoader);
6369
+ await options.documentLoader(verificationUrl, loaderOptions);
6370
+ return { verified: true };
6371
+ },
6372
+ });
6373
+ if (failVerification) {
6374
+ deepStrictEqual(object, null);
6375
+ deepStrictEqual(
6376
+ records.some((record) => record.level === "error"),
6377
+ !suppressError,
6378
+ );
6379
+ ok(records.some((record) => record.level === "warning"));
6380
+ } else {
6381
+ assertInstanceOf(object, Note);
6382
+ }
6383
+ deepStrictEqual(calls.at(-1)?.signal, signal);
6384
+ deepStrictEqual(
6385
+ calls.at(-1)?.suppressError,
6386
+ suppressError ? true : undefined,
6387
+ );
6388
+ deepStrictEqual(loaderOptions, { signal });
6389
+
6390
+ // A verifier may retain the loader after success or rejection.
6391
+ // Suppression must end when the accessor returns in either case.
6392
+ records.length = 0;
6393
+ const releasedLoader = retainedLoader;
6394
+ ok(releasedLoader);
6395
+ await rejects(
6396
+ () => releasedLoader(missingUrl, loaderOptions),
6397
+ FetchError,
6398
+ );
6399
+ deepStrictEqual(calls.at(-1), loaderOptions);
6400
+ ok(records.some((record) => record.level === "error"));
6401
+ }
6402
+ } finally {
6403
+ fetchMock.hardReset();
6404
+ }
6405
+ }
6406
+ } finally {
6407
+ await reset();
6408
+ }
6409
+ });