From 93ecc6d9ac9e0f448bb49613fe1892f1480d00a3 Mon Sep 17 00:00:00 2001 From: Palcimer Date: Sat, 19 Sep 2026 14:49:40 +0900 Subject: [PATCH 1/6] Accept IRI-valued quote URL properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vocabulary decoding threw `TypeError: Invalid URL` when a JSON-LD context declared `_misskey_quote` or `quoteUri` with `"@type": "@id"`. Such terms expand to `{"@id": …}` nodes, but the `fedify:url` scalar type read `@value` only. Update the `fedify:url` decoder to read whichever of the two the node carries. Also widen its `dataCheck()` to accept both shapes. Fixes https://github.com/fedify-dev/fedify/issues/1015 Assisted-by: Claude Code:claude-opus-5 --- .../src/__snapshots__/class.test.ts.deno.snap | 8 ++++---- .../src/__snapshots__/class.test.ts.node.snap | 8 ++++---- .../vocab-tools/src/__snapshots__/class.test.ts.snap | 8 ++++---- packages/vocab-tools/src/type.ts | 10 ++++++---- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/vocab-tools/src/__snapshots__/class.test.ts.deno.snap b/packages/vocab-tools/src/__snapshots__/class.test.ts.deno.snap index f13ef5af3..dcd48b5d6 100644 --- a/packages/vocab-tools/src/__snapshots__/class.test.ts.deno.snap +++ b/packages/vocab-tools/src/__snapshots__/class.test.ts.deno.snap @@ -12236,7 +12236,7 @@ proofs?: (DataIntegrityProof | URL)[];quoteUrl?: URL | null;} if (v == null) continue; const decoded = - new URL(v[\\"@value\\"]) + new URL(typeof v[\\"@value\\"] === \\"string\\" ? v[\\"@value\\"] : v[\\"@id\\"]) ; if (!this._shouldCacheDecodedJsonLd(decoded)) { @@ -29346,7 +29346,7 @@ proofs?: (DataIntegrityProof | URL)[];quoteUrl?: URL | null;} if (v == null) continue; const decoded = - new URL(v[\\"@value\\"]) + new URL(typeof v[\\"@value\\"] === \\"string\\" ? v[\\"@value\\"] : v[\\"@id\\"]) ; if (!this._shouldCacheDecodedJsonLd(decoded)) { @@ -52519,7 +52519,7 @@ proofs?: (DataIntegrityProof | URL)[];quoteUrl?: URL | null;} if (v == null) continue; const decoded = - new URL(v[\\"@value\\"]) + new URL(typeof v[\\"@value\\"] === \\"string\\" ? v[\\"@value\\"] : v[\\"@id\\"]) ; if (!this._shouldCacheDecodedJsonLd(decoded)) { @@ -71062,7 +71062,7 @@ instruments?: (Object | URL)[];exclusiveOptions?: (Object | URL)[];inclusiveOpti if (v == null) continue; const decoded = - new URL(v[\\"@value\\"]) + new URL(typeof v[\\"@value\\"] === \\"string\\" ? v[\\"@value\\"] : v[\\"@id\\"]) ; if (!this._shouldCacheDecodedJsonLd(decoded)) { diff --git a/packages/vocab-tools/src/__snapshots__/class.test.ts.node.snap b/packages/vocab-tools/src/__snapshots__/class.test.ts.node.snap index fba6830d4..3e5c5d1de 100644 --- a/packages/vocab-tools/src/__snapshots__/class.test.ts.node.snap +++ b/packages/vocab-tools/src/__snapshots__/class.test.ts.node.snap @@ -12234,7 +12234,7 @@ proofs?: (DataIntegrityProof | URL)[];quoteUrl?: URL | null;} if (v == null) continue; const decoded = - new URL(v[\\"@value\\"]) + new URL(typeof v[\\"@value\\"] === \\"string\\" ? v[\\"@value\\"] : v[\\"@id\\"]) ; if (!this._shouldCacheDecodedJsonLd(decoded)) { @@ -29344,7 +29344,7 @@ proofs?: (DataIntegrityProof | URL)[];quoteUrl?: URL | null;} if (v == null) continue; const decoded = - new URL(v[\\"@value\\"]) + new URL(typeof v[\\"@value\\"] === \\"string\\" ? v[\\"@value\\"] : v[\\"@id\\"]) ; if (!this._shouldCacheDecodedJsonLd(decoded)) { @@ -52517,7 +52517,7 @@ proofs?: (DataIntegrityProof | URL)[];quoteUrl?: URL | null;} if (v == null) continue; const decoded = - new URL(v[\\"@value\\"]) + new URL(typeof v[\\"@value\\"] === \\"string\\" ? v[\\"@value\\"] : v[\\"@id\\"]) ; if (!this._shouldCacheDecodedJsonLd(decoded)) { @@ -71060,7 +71060,7 @@ instruments?: (Object | URL)[];exclusiveOptions?: (Object | URL)[];inclusiveOpti if (v == null) continue; const decoded = - new URL(v[\\"@value\\"]) + new URL(typeof v[\\"@value\\"] === \\"string\\" ? v[\\"@value\\"] : v[\\"@id\\"]) ; if (!this._shouldCacheDecodedJsonLd(decoded)) { diff --git a/packages/vocab-tools/src/__snapshots__/class.test.ts.snap b/packages/vocab-tools/src/__snapshots__/class.test.ts.snap index 5a4b4ea05..9c9b937bd 100644 --- a/packages/vocab-tools/src/__snapshots__/class.test.ts.snap +++ b/packages/vocab-tools/src/__snapshots__/class.test.ts.snap @@ -12236,7 +12236,7 @@ proofs?: (DataIntegrityProof | URL)[];quoteUrl?: URL | null;} if (v == null) continue; const decoded = - new URL(v["@value"]) + new URL(typeof v["@value"] === "string" ? v["@value"] : v["@id"]) ; if (!this._shouldCacheDecodedJsonLd(decoded)) { @@ -29346,7 +29346,7 @@ proofs?: (DataIntegrityProof | URL)[];quoteUrl?: URL | null;} if (v == null) continue; const decoded = - new URL(v["@value"]) + new URL(typeof v["@value"] === "string" ? v["@value"] : v["@id"]) ; if (!this._shouldCacheDecodedJsonLd(decoded)) { @@ -52519,7 +52519,7 @@ proofs?: (DataIntegrityProof | URL)[];quoteUrl?: URL | null;} if (v == null) continue; const decoded = - new URL(v["@value"]) + new URL(typeof v["@value"] === "string" ? v["@value"] : v["@id"]) ; if (!this._shouldCacheDecodedJsonLd(decoded)) { @@ -71062,7 +71062,7 @@ instruments?: (Object | URL)[];exclusiveOptions?: (Object | URL)[];inclusiveOpti if (v == null) continue; const decoded = - new URL(v["@value"]) + new URL(typeof v["@value"] === "string" ? v["@value"] : v["@id"]) ; if (!this._shouldCacheDecodedJsonLd(decoded)) { diff --git a/packages/vocab-tools/src/type.ts b/packages/vocab-tools/src/type.ts index 9d5e62e8d..8552d905f 100644 --- a/packages/vocab-tools/src/type.ts +++ b/packages/vocab-tools/src/type.ts @@ -308,12 +308,14 @@ const scalarTypes: Record = { return `${v}.href`; }, dataCheck(v) { - return `typeof ${v} === "object" && "@value" in ${v} - && typeof ${v}["@value"] === "string" - && ${v}["@value"] !== "" && ${v}["@value"] !== "/"`; + return `typeof ${v} === "object" && + (("@value" in ${v} && typeof ${v}["@value"] === "string" && + ${v}["@value"] !== "" && ${v}["@value"] !== "/") || + ("@id" in ${v} && typeof ${v}["@id"] === "string" && + ${v}["@id"] !== "" && ${v}["@id"] !== "/"))`; }, decoder(v) { - return `new URL(${v}["@value"])`; + return `new URL(typeof ${v}["@value"] === "string" ? ${v}["@value"] : ${v}["@id"])`; }, }, "fedify:publicKey": { From fa32e7ad33bc222a005a93436dac0c54ba668f38 Mon Sep 17 00:00:00 2001 From: Palcimer Date: Sat, 19 Sep 2026 14:55:14 +0900 Subject: [PATCH 2/6] Add a regression test for changes in `@fedify/vocab` https://github.com/fedify-dev/fedify/issues/1015 Assisted-by: Claude Code:claude-opus-5 --- packages/vocab/src/vocab.test.ts | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/vocab/src/vocab.test.ts b/packages/vocab/src/vocab.test.ts index e332d318f..60de3288b 100644 --- a/packages/vocab/src/vocab.test.ts +++ b/packages/vocab/src/vocab.test.ts @@ -809,6 +809,40 @@ test("Note.quoteUrl", async () => { deepStrictEqual(loaded3.quoteUrl, new URL("https://example.com/object3")); }); +test("Note.quoteUrl (IRI-typed alias terms)", async () => { + const jsonLd: Record = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + fedibird: "http://fedibird.com/ns#", + misskey: "https://misskey-hub.net/ns#", + _misskey_quote: { + "@id": "misskey:_misskey_quote", + "@type": "@id", + }, + quoteUri: { + "@id": "fedibird:quoteUri", + "@type": "@id", + }, + }, + ], + id: "https://example.com/notes/1", + type: "Note", + _misskey_quote: "https://example.com/notes/quoted", + quoteUri: "https://example.com/notes/quoted2", + }; + + const loaded = await Note.fromJsonLd(jsonLd); + deepStrictEqual(loaded.quoteUrl, new URL("https://example.com/notes/quoted")); + + delete jsonLd._misskey_quote; + const loaded2 = await Note.fromJsonLd(jsonLd); + deepStrictEqual( + loaded2.quoteUrl, + new URL("https://example.com/notes/quoted2"), + ); +}); + test("Key.publicKey", async () => { const jwk = { kty: "RSA", From c59d7e2985ce29bffc81b538a1f431bd33c9f540 Mon Sep 17 00:00:00 2001 From: Palcimer Date: Sat, 19 Sep 2026 14:59:02 +0900 Subject: [PATCH 3/6] Add @fedify/vocab changes in CHANGES.md https://github.com/fedify-dev/fedify/issues/1015 Assisted-by: Claude Code:claude-opus-5 --- CHANGES.md | 9 +++++++++ changes.d/vocab/iri-valued-quote-url.md | 8 ++++++++ 2 files changed, 17 insertions(+) create mode 100644 changes.d/vocab/iri-valued-quote-url.md diff --git a/CHANGES.md b/CHANGES.md index 5a033f547..eaabc10fe 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -87,6 +87,15 @@ To be released. [#1081]: https://github.com/fedify-dev/fedify/issues/1081 [#1089]: https://github.com/fedify-dev/fedify/pull/1089 +### @fedify/vocab + + - Updated the `fedify:url` decoder to read `@id` when `@value` is absent, + allowing it to accept IRI-valued quote URL aliases (`_misskey_quote` or + `quoteUri`). Also widened its `dataCheck()` to accept both forms. + [[#1015] by Jang Hanarae\] + +[#1015]: https://github.com/fedify-dev/fedify/issues/1015 + ### @fedify/vocab-runtime - Added `UrlError.reason` to distinguish DNS resolution failures (`"dns"`) diff --git a/changes.d/vocab/iri-valued-quote-url.md b/changes.d/vocab/iri-valued-quote-url.md new file mode 100644 index 000000000..f393a312b --- /dev/null +++ b/changes.d/vocab/iri-valued-quote-url.md @@ -0,0 +1,8 @@ +--- +links: + '#1015': https://github.com/fedify-dev/fedify/issues/1015 +--- + - Updated the `fedify:url` decoder to read `@id` when `@value` is absent, + allowing it to accept IRI-valued quote URL aliases (`_misskey_quote` or + `quoteUri`). Also widened its `dataCheck()` to accept both forms. + [[#1015] by Jang Hanarae] From 64d391e869cf60d5a171540e9575a13d6b2d07d6 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 28 Sep 2026 18:10:19 +0900 Subject: [PATCH 4/6] Skip quote URLs that cannot be parsed Reading @id as well as @value let quoteUrl accept IRI-valued _misskey_quote and quoteUri terms, but the generated decoder still had no guard. The fedify:url type is only ever the sole range of a property, and the generator emits the dataCheck() guard and the skip for an undefined result only for properties with several ranges. So Note.fromJsonLd() still threw "TypeError: Invalid URL" for a quote that expands to a node it cannot turn into a URL: an inlined object without an id (the same error the issue reports), a blank node, an empty string, or a relative IRI. A single bad quote URL from a remote server took down the whole object. Scalar types can now opt into skipping values that fail dataCheck() through a skipUnparsable flag, and fedify:url does. Applying the guard to every single-range property instead, which is closer to what main does, breaks type checking on this branch: the ternary widens literal unions such as fedify:proofPurpose to string. The generated module also gains canDecodeIri() and decodeIri(), which accept at:// URIs the way the non-scalar @id path already does, so ATProto quote URLs parse instead of being dropped. They are not named parseIri() because the generated module on main imports a function of that name from @fedify/vocab-runtime, and a local one would collide when this is merged forward. Both dataCheck() and the decoder now read @id before @value, the order fedify:gatewayUrl uses on main. https://github.com/fedify-dev/fedify/issues/1015 https://github.com/fedify-dev/fedify/pull/1043#discussion_r4068938609 https://github.com/fedify-dev/fedify/pull/1043#discussion_r4068938621 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/__snapshots__/class.test.ts.deno.snap | 54 +++++++++++++++++-- .../src/__snapshots__/class.test.ts.node.snap | 54 +++++++++++++++++-- .../src/__snapshots__/class.test.ts.snap | 54 +++++++++++++++++-- packages/vocab-tools/src/class.ts | 11 ++++ packages/vocab-tools/src/codec.ts | 11 +++- packages/vocab-tools/src/type.ts | 21 ++++++-- 6 files changed, 187 insertions(+), 18 deletions(-) diff --git a/packages/vocab-tools/src/__snapshots__/class.test.ts.deno.snap b/packages/vocab-tools/src/__snapshots__/class.test.ts.deno.snap index dcd48b5d6..227dbc673 100644 --- a/packages/vocab-tools/src/__snapshots__/class.test.ts.deno.snap +++ b/packages/vocab-tools/src/__snapshots__/class.test.ts.deno.snap @@ -23,6 +23,16 @@ import { isTemporalInstant, } from \\"@fedify/vocab-runtime/temporal\\"; +function canDecodeIri(iri: string): boolean { + return URL.canParse(iri) || iri.startsWith(\\"at://\\"); +} + +function decodeIri(iri: string): URL { + return !URL.canParse(iri) && iri.startsWith(\\"at://\\") + ? new URL(\\"at://\\" + encodeURIComponent(iri.substring(5))) + : new URL(iri); +} + function isValidLanguageTag(language: string): boolean { try { new Intl.Locale(language); @@ -12236,9 +12246,18 @@ proofs?: (DataIntegrityProof | URL)[];quoteUrl?: URL | null;} if (v == null) continue; const decoded = - new URL(typeof v[\\"@value\\"] === \\"string\\" ? v[\\"@value\\"] : v[\\"@id\\"]) + typeof v === \\"object\\" && + ((typeof v[\\"@id\\"] === \\"string\\" && canDecodeIri(v[\\"@id\\"])) || + (typeof v[\\"@value\\"] === \\"string\\" && canDecodeIri(v[\\"@value\\"]))) ? decodeIri( + typeof v[\\"@id\\"] === \\"string\\" ? v[\\"@id\\"] : v[\\"@value\\"] + ) : undefined ; + if (typeof decoded === \\"undefined\\") { + shouldCacheJsonLd = false; + continue; + } + if (!this._shouldCacheDecodedJsonLd(decoded)) { shouldCacheJsonLd = false; } @@ -29346,9 +29365,18 @@ proofs?: (DataIntegrityProof | URL)[];quoteUrl?: URL | null;} if (v == null) continue; const decoded = - new URL(typeof v[\\"@value\\"] === \\"string\\" ? v[\\"@value\\"] : v[\\"@id\\"]) + typeof v === \\"object\\" && + ((typeof v[\\"@id\\"] === \\"string\\" && canDecodeIri(v[\\"@id\\"])) || + (typeof v[\\"@value\\"] === \\"string\\" && canDecodeIri(v[\\"@value\\"]))) ? decodeIri( + typeof v[\\"@id\\"] === \\"string\\" ? v[\\"@id\\"] : v[\\"@value\\"] + ) : undefined ; + if (typeof decoded === \\"undefined\\") { + shouldCacheJsonLd = false; + continue; + } + if (!this._shouldCacheDecodedJsonLd(decoded)) { shouldCacheJsonLd = false; } @@ -52519,9 +52547,18 @@ proofs?: (DataIntegrityProof | URL)[];quoteUrl?: URL | null;} if (v == null) continue; const decoded = - new URL(typeof v[\\"@value\\"] === \\"string\\" ? v[\\"@value\\"] : v[\\"@id\\"]) + typeof v === \\"object\\" && + ((typeof v[\\"@id\\"] === \\"string\\" && canDecodeIri(v[\\"@id\\"])) || + (typeof v[\\"@value\\"] === \\"string\\" && canDecodeIri(v[\\"@value\\"]))) ? decodeIri( + typeof v[\\"@id\\"] === \\"string\\" ? v[\\"@id\\"] : v[\\"@value\\"] + ) : undefined ; + if (typeof decoded === \\"undefined\\") { + shouldCacheJsonLd = false; + continue; + } + if (!this._shouldCacheDecodedJsonLd(decoded)) { shouldCacheJsonLd = false; } @@ -71062,9 +71099,18 @@ instruments?: (Object | URL)[];exclusiveOptions?: (Object | URL)[];inclusiveOpti if (v == null) continue; const decoded = - new URL(typeof v[\\"@value\\"] === \\"string\\" ? v[\\"@value\\"] : v[\\"@id\\"]) + typeof v === \\"object\\" && + ((typeof v[\\"@id\\"] === \\"string\\" && canDecodeIri(v[\\"@id\\"])) || + (typeof v[\\"@value\\"] === \\"string\\" && canDecodeIri(v[\\"@value\\"]))) ? decodeIri( + typeof v[\\"@id\\"] === \\"string\\" ? v[\\"@id\\"] : v[\\"@value\\"] + ) : undefined ; + if (typeof decoded === \\"undefined\\") { + shouldCacheJsonLd = false; + continue; + } + if (!this._shouldCacheDecodedJsonLd(decoded)) { shouldCacheJsonLd = false; } diff --git a/packages/vocab-tools/src/__snapshots__/class.test.ts.node.snap b/packages/vocab-tools/src/__snapshots__/class.test.ts.node.snap index 3e5c5d1de..aa2c00212 100644 --- a/packages/vocab-tools/src/__snapshots__/class.test.ts.node.snap +++ b/packages/vocab-tools/src/__snapshots__/class.test.ts.node.snap @@ -21,6 +21,16 @@ import { isTemporalInstant, } from \\"@fedify/vocab-runtime/temporal\\"; +function canDecodeIri(iri: string): boolean { + return URL.canParse(iri) || iri.startsWith(\\"at://\\"); +} + +function decodeIri(iri: string): URL { + return !URL.canParse(iri) && iri.startsWith(\\"at://\\") + ? new URL(\\"at://\\" + encodeURIComponent(iri.substring(5))) + : new URL(iri); +} + function isValidLanguageTag(language: string): boolean { try { new Intl.Locale(language); @@ -12234,9 +12244,18 @@ proofs?: (DataIntegrityProof | URL)[];quoteUrl?: URL | null;} if (v == null) continue; const decoded = - new URL(typeof v[\\"@value\\"] === \\"string\\" ? v[\\"@value\\"] : v[\\"@id\\"]) + typeof v === \\"object\\" && + ((typeof v[\\"@id\\"] === \\"string\\" && canDecodeIri(v[\\"@id\\"])) || + (typeof v[\\"@value\\"] === \\"string\\" && canDecodeIri(v[\\"@value\\"]))) ? decodeIri( + typeof v[\\"@id\\"] === \\"string\\" ? v[\\"@id\\"] : v[\\"@value\\"] + ) : undefined ; + if (typeof decoded === \\"undefined\\") { + shouldCacheJsonLd = false; + continue; + } + if (!this._shouldCacheDecodedJsonLd(decoded)) { shouldCacheJsonLd = false; } @@ -29344,9 +29363,18 @@ proofs?: (DataIntegrityProof | URL)[];quoteUrl?: URL | null;} if (v == null) continue; const decoded = - new URL(typeof v[\\"@value\\"] === \\"string\\" ? v[\\"@value\\"] : v[\\"@id\\"]) + typeof v === \\"object\\" && + ((typeof v[\\"@id\\"] === \\"string\\" && canDecodeIri(v[\\"@id\\"])) || + (typeof v[\\"@value\\"] === \\"string\\" && canDecodeIri(v[\\"@value\\"]))) ? decodeIri( + typeof v[\\"@id\\"] === \\"string\\" ? v[\\"@id\\"] : v[\\"@value\\"] + ) : undefined ; + if (typeof decoded === \\"undefined\\") { + shouldCacheJsonLd = false; + continue; + } + if (!this._shouldCacheDecodedJsonLd(decoded)) { shouldCacheJsonLd = false; } @@ -52517,9 +52545,18 @@ proofs?: (DataIntegrityProof | URL)[];quoteUrl?: URL | null;} if (v == null) continue; const decoded = - new URL(typeof v[\\"@value\\"] === \\"string\\" ? v[\\"@value\\"] : v[\\"@id\\"]) + typeof v === \\"object\\" && + ((typeof v[\\"@id\\"] === \\"string\\" && canDecodeIri(v[\\"@id\\"])) || + (typeof v[\\"@value\\"] === \\"string\\" && canDecodeIri(v[\\"@value\\"]))) ? decodeIri( + typeof v[\\"@id\\"] === \\"string\\" ? v[\\"@id\\"] : v[\\"@value\\"] + ) : undefined ; + if (typeof decoded === \\"undefined\\") { + shouldCacheJsonLd = false; + continue; + } + if (!this._shouldCacheDecodedJsonLd(decoded)) { shouldCacheJsonLd = false; } @@ -71060,9 +71097,18 @@ instruments?: (Object | URL)[];exclusiveOptions?: (Object | URL)[];inclusiveOpti if (v == null) continue; const decoded = - new URL(typeof v[\\"@value\\"] === \\"string\\" ? v[\\"@value\\"] : v[\\"@id\\"]) + typeof v === \\"object\\" && + ((typeof v[\\"@id\\"] === \\"string\\" && canDecodeIri(v[\\"@id\\"])) || + (typeof v[\\"@value\\"] === \\"string\\" && canDecodeIri(v[\\"@value\\"]))) ? decodeIri( + typeof v[\\"@id\\"] === \\"string\\" ? v[\\"@id\\"] : v[\\"@value\\"] + ) : undefined ; + if (typeof decoded === \\"undefined\\") { + shouldCacheJsonLd = false; + continue; + } + if (!this._shouldCacheDecodedJsonLd(decoded)) { shouldCacheJsonLd = false; } diff --git a/packages/vocab-tools/src/__snapshots__/class.test.ts.snap b/packages/vocab-tools/src/__snapshots__/class.test.ts.snap index 9c9b937bd..f98b49bf7 100644 --- a/packages/vocab-tools/src/__snapshots__/class.test.ts.snap +++ b/packages/vocab-tools/src/__snapshots__/class.test.ts.snap @@ -23,6 +23,16 @@ import { isTemporalInstant, } from "@fedify/vocab-runtime/temporal"; +function canDecodeIri(iri: string): boolean { + return URL.canParse(iri) || iri.startsWith("at://"); +} + +function decodeIri(iri: string): URL { + return !URL.canParse(iri) && iri.startsWith("at://") + ? new URL("at://" + encodeURIComponent(iri.substring(5))) + : new URL(iri); +} + function isValidLanguageTag(language: string): boolean { try { new Intl.Locale(language); @@ -12236,9 +12246,18 @@ proofs?: (DataIntegrityProof | URL)[];quoteUrl?: URL | null;} if (v == null) continue; const decoded = - new URL(typeof v["@value"] === "string" ? v["@value"] : v["@id"]) + typeof v === "object" && + ((typeof v["@id"] === "string" && canDecodeIri(v["@id"])) || + (typeof v["@value"] === "string" && canDecodeIri(v["@value"]))) ? decodeIri( + typeof v["@id"] === "string" ? v["@id"] : v["@value"] + ) : undefined ; + if (typeof decoded === "undefined") { + shouldCacheJsonLd = false; + continue; + } + if (!this._shouldCacheDecodedJsonLd(decoded)) { shouldCacheJsonLd = false; } @@ -29346,9 +29365,18 @@ proofs?: (DataIntegrityProof | URL)[];quoteUrl?: URL | null;} if (v == null) continue; const decoded = - new URL(typeof v["@value"] === "string" ? v["@value"] : v["@id"]) + typeof v === "object" && + ((typeof v["@id"] === "string" && canDecodeIri(v["@id"])) || + (typeof v["@value"] === "string" && canDecodeIri(v["@value"]))) ? decodeIri( + typeof v["@id"] === "string" ? v["@id"] : v["@value"] + ) : undefined ; + if (typeof decoded === "undefined") { + shouldCacheJsonLd = false; + continue; + } + if (!this._shouldCacheDecodedJsonLd(decoded)) { shouldCacheJsonLd = false; } @@ -52519,9 +52547,18 @@ proofs?: (DataIntegrityProof | URL)[];quoteUrl?: URL | null;} if (v == null) continue; const decoded = - new URL(typeof v["@value"] === "string" ? v["@value"] : v["@id"]) + typeof v === "object" && + ((typeof v["@id"] === "string" && canDecodeIri(v["@id"])) || + (typeof v["@value"] === "string" && canDecodeIri(v["@value"]))) ? decodeIri( + typeof v["@id"] === "string" ? v["@id"] : v["@value"] + ) : undefined ; + if (typeof decoded === "undefined") { + shouldCacheJsonLd = false; + continue; + } + if (!this._shouldCacheDecodedJsonLd(decoded)) { shouldCacheJsonLd = false; } @@ -71062,9 +71099,18 @@ instruments?: (Object | URL)[];exclusiveOptions?: (Object | URL)[];inclusiveOpti if (v == null) continue; const decoded = - new URL(typeof v["@value"] === "string" ? v["@value"] : v["@id"]) + typeof v === "object" && + ((typeof v["@id"] === "string" && canDecodeIri(v["@id"])) || + (typeof v["@value"] === "string" && canDecodeIri(v["@value"]))) ? decodeIri( + typeof v["@id"] === "string" ? v["@id"] : v["@value"] + ) : undefined ; + if (typeof decoded === "undefined") { + shouldCacheJsonLd = false; + continue; + } + if (!this._shouldCacheDecodedJsonLd(decoded)) { shouldCacheJsonLd = false; } diff --git a/packages/vocab-tools/src/class.ts b/packages/vocab-tools/src/class.ts index f712e1399..19fcabab4 100644 --- a/packages/vocab-tools/src/class.ts +++ b/packages/vocab-tools/src/class.ts @@ -154,6 +154,17 @@ export async function* generateClasses( isTemporalInstant, } from "@fedify/vocab-runtime/temporal";\n`; yield ` +function canDecodeIri(iri: string): boolean { + return URL.canParse(iri) || iri.startsWith("at://"); +} + +function decodeIri(iri: string): URL { + return !URL.canParse(iri) && iri.startsWith("at://") + ? new URL("at://" + encodeURIComponent(iri.substring(5))) + : new URL(iri); +} +`; + yield ` function isValidLanguageTag(language: string): boolean { try { new Intl.Locale(language); diff --git a/packages/vocab-tools/src/codec.ts b/packages/vocab-tools/src/codec.ts index c71db51d4..408ea36da 100644 --- a/packages/vocab-tools/src/codec.ts +++ b/packages/vocab-tools/src/codec.ts @@ -6,11 +6,13 @@ import { areAllScalarTypes, emitOverride, getAllProperties, + getDataCheck, getDecoder, getDecoders, getEncoders, getSubtypes, isCompactableType, + skipsUnparsable, } from "./type.ts"; export async function* generateEncoder( @@ -441,7 +443,13 @@ export async function* generateDecoder( yield ` const decoded = `; + const lenient = property.range.length == 1 && + skipsUnparsable(property.range[0]); if (property.range.length == 1) { + if (lenient) { + yield getDataCheck(property.range[0], types, "v"); + yield " ? "; + } yield getDecoder( property.range[0], types, @@ -449,6 +457,7 @@ export async function* generateDecoder( "options", `(values["@id"] == null ? options.baseUrl : new URL(values["@id"]))`, ); + if (lenient) yield " : undefined"; } else { const decoders = getDecoders( property.range, @@ -462,7 +471,7 @@ export async function* generateDecoder( yield ` ; `; - if (property.range.length > 1) { + if (property.range.length > 1 || lenient) { yield ` if (typeof decoded === "undefined") { shouldCacheJsonLd = false; diff --git a/packages/vocab-tools/src/type.ts b/packages/vocab-tools/src/type.ts index 8552d905f..22e2efa99 100644 --- a/packages/vocab-tools/src/type.ts +++ b/packages/vocab-tools/src/type.ts @@ -17,6 +17,12 @@ interface ScalarType { compactEncoder?: (variable: string) => string; dataCheck(variable: string): string; decoder(variable: string, baseUrlVar: string): string; + /** + * Whether a value that fails `dataCheck()` should be skipped instead of + * being handed to `decoder()`. Set this for types decoded from untrusted + * remote input, where one malformed value must not fail the whole object. + */ + skipUnparsable?: boolean; } const scalarTypes: Record = { @@ -309,14 +315,15 @@ const scalarTypes: Record = { }, dataCheck(v) { return `typeof ${v} === "object" && - (("@value" in ${v} && typeof ${v}["@value"] === "string" && - ${v}["@value"] !== "" && ${v}["@value"] !== "/") || - ("@id" in ${v} && typeof ${v}["@id"] === "string" && - ${v}["@id"] !== "" && ${v}["@id"] !== "/"))`; + ((typeof ${v}["@id"] === "string" && canDecodeIri(${v}["@id"])) || + (typeof ${v}["@value"] === "string" && canDecodeIri(${v}["@value"])))`; }, decoder(v) { - return `new URL(typeof ${v}["@value"] === "string" ? ${v}["@value"] : ${v}["@id"])`; + return `decodeIri( + typeof ${v}["@id"] === "string" ? ${v}["@id"] : ${v}["@value"] + )`; }, + skipUnparsable: true, }, "fedify:publicKey": { name: "CryptoKey", @@ -597,6 +604,10 @@ export function getDecoder( throw new Error(`Unknown type: ${typeUri}`); } +export function skipsUnparsable(typeUri: string): boolean { + return scalarTypes[typeUri]?.skipUnparsable ?? false; +} + export function getDataCheck( typeUri: string, types: Record, From 87d4b6a11130c92eff9d8c9951926556314cebe0 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 28 Sep 2026 18:16:20 +0900 Subject: [PATCH 5/6] Cover more IRI-valued quote URL shapes in tests The regression test only declared the two aliases as IRI-valued, so it left the primary quoteUrl term and the precedence between the two value shapes untested, and nothing checked that a quote URL which cannot be parsed is dropped rather than failing the whole object. The new tests declare quoteUrl itself with "@type": "@id", check that it still wins over a plain-string _misskey_quote, and feed an inlined quote without an id, a blank node, an empty string and a relative IRI, each of which must leave quoteUrl null while the rest of the Note still parses. An at:// URI must parse the way the non-scalar @id path already parses it. https://github.com/fedify-dev/fedify/issues/1015 https://github.com/fedify-dev/fedify/pull/1043#discussion_r4068938626 Assisted-by: Claude Code:claude-opus-5-5 --- packages/vocab/src/vocab.test.ts | 65 ++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/vocab/src/vocab.test.ts b/packages/vocab/src/vocab.test.ts index 60de3288b..a8cd686f0 100644 --- a/packages/vocab/src/vocab.test.ts +++ b/packages/vocab/src/vocab.test.ts @@ -843,6 +843,71 @@ test("Note.quoteUrl (IRI-typed alias terms)", async () => { ); }); +test("Note.quoteUrl (IRI-typed primary term)", async () => { + const context = [ + "https://www.w3.org/ns/activitystreams", + { + misskey: "https://misskey-hub.net/ns#", + quoteUrl: { "@id": "as:quoteUrl", "@type": "@id" }, + _misskey_quote: "misskey:_misskey_quote", + }, + ]; + + const loaded = await Note.fromJsonLd({ + "@context": context, + type: "Note", + quoteUrl: "https://example.com/object", + }); + deepStrictEqual(loaded.quoteUrl, new URL("https://example.com/object")); + + // An IRI-valued quoteUrl still takes precedence over a plain-string alias: + const loaded2 = await Note.fromJsonLd({ + "@context": context, + type: "Note", + quoteUrl: "https://example.com/object", + _misskey_quote: "https://example.com/object2", + }); + deepStrictEqual(loaded2.quoteUrl, new URL("https://example.com/object")); +}); + +test("Note.quoteUrl (unparsable IRI-valued terms)", async () => { + const context = [ + "https://www.w3.org/ns/activitystreams", + { + misskey: "https://misskey-hub.net/ns#", + _misskey_quote: { "@id": "misskey:_misskey_quote", "@type": "@id" }, + }, + ]; + for ( + const quote of [ + { type: "Note", content: "An inlined quote without an id" }, + "_:b0", + "", + "notes/relative", + ] + ) { + const loaded = await Note.fromJsonLd({ + "@context": context, + id: "https://example.com/notes/1", + type: "Note", + content: "Hello", + _misskey_quote: quote, + }); + deepStrictEqual(loaded.quoteUrl, null, JSON.stringify(quote)); + deepStrictEqual(loaded.content, "Hello"); + } + + const atUri = await Note.fromJsonLd({ + "@context": context, + type: "Note", + _misskey_quote: "at://did:plc:abc/app.bsky.feed.post/xyz", + }); + deepStrictEqual( + atUri.quoteUrl, + new URL("at://" + encodeURIComponent("did:plc:abc/app.bsky.feed.post/xyz")), + ); +}); + test("Key.publicKey", async () => { const jwk = { kty: "RSA", From ba956881c86fe4ca0846c16ddfaa9b7429e0bbbb Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 28 Sep 2026 18:18:11 +0900 Subject: [PATCH 6/6] Describe the quote URL fix for users in the changelog The entry named generator internals (the fedify:url decoder and its dataCheck()) that no user of @fedify/vocab ever sees, and described a dataCheck() change that generated no code at the time. It now says what failed for users and what happens instead, cites the pull request alongside the issue, and opens with "Fixed" like the other bug fix entries. The source change lives in @fedify/vocab-tools, which is published on its own and whose fedify:url range type custom vocabularies can use, so that package gets its own entry too. https://github.com/fedify-dev/fedify/issues/1015 https://github.com/fedify-dev/fedify/pull/1043#discussion_r4068938632 https://github.com/fedify-dev/fedify/pull/1043#discussion_r4068938638 Assisted-by: Claude Code:claude-opus-5-5 --- CHANGES.md | 21 +++++++++++++++++---- changes.d/vocab-tools/iri-valued-url.md | 10 ++++++++++ changes.d/vocab/iri-valued-quote-url.md | 13 +++++++++---- 3 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 changes.d/vocab-tools/iri-valued-url.md diff --git a/CHANGES.md b/CHANGES.md index eaabc10fe..ce221fa91 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -89,12 +89,17 @@ To be released. ### @fedify/vocab - - Updated the `fedify:url` decoder to read `@id` when `@value` is absent, - allowing it to accept IRI-valued quote URL aliases (`_misskey_quote` or - `quoteUri`). Also widened its `dataCheck()` to accept both forms. - [[#1015] by Jang Hanarae\] + - Fixed parsing a `Note`, `Article`, `ChatMessage`, or `Question` throwing + `TypeError: Invalid URL` when the sender's JSON-LD context declared + `_misskey_quote`, `quoteUri`, or `quoteUrl` with `"@type": "@id"`, as + Misskey-compatible servers do. Such terms expand to a node carrying `@id` + rather than `@value`, and only `@value` was read. A quote URL that cannot + be parsed at all, such as an inlined quote object without an `id`, is now + ignored instead of failing the whole object, and ATProto `at://` quote + URLs are accepted. [[#1015], [#1043] by Jang Hanarae\] [#1015]: https://github.com/fedify-dev/fedify/issues/1015 +[#1043]: https://github.com/fedify-dev/fedify/pull/1043 ### @fedify/vocab-runtime @@ -116,6 +121,14 @@ To be released. [#1078]: https://github.com/fedify-dev/fedify/issues/1078 [#1079]: https://github.com/fedify-dev/fedify/pull/1079 +### @fedify/vocab-tools + + - Fixed generated decoders for properties whose range is `fedify:url` + reading only literal (`@value`) values, so an IRI-valued (`@id`) value + made them throw `TypeError: Invalid URL`. They now read both forms, + accept ATProto `at://` URIs, and skip a value that cannot be parsed + instead of throwing. [[#1015], [#1043] by Jang Hanarae\] + ### @fedify/webfinger - Fixed `lookupWebFinger()` logging hostnames that fail to resolve as diff --git a/changes.d/vocab-tools/iri-valued-url.md b/changes.d/vocab-tools/iri-valued-url.md new file mode 100644 index 000000000..e5d3134fa --- /dev/null +++ b/changes.d/vocab-tools/iri-valued-url.md @@ -0,0 +1,10 @@ +--- +links: + '#1015': https://github.com/fedify-dev/fedify/issues/1015 + '#1043': https://github.com/fedify-dev/fedify/pull/1043 +--- + - Fixed generated decoders for properties whose range is `fedify:url` + reading only literal (`@value`) values, so an IRI-valued (`@id`) value + made them throw `TypeError: Invalid URL`. They now read both forms, + accept ATProto `at://` URIs, and skip a value that cannot be parsed + instead of throwing. [[#1015], [#1043] by Jang Hanarae] diff --git a/changes.d/vocab/iri-valued-quote-url.md b/changes.d/vocab/iri-valued-quote-url.md index f393a312b..ad6d92187 100644 --- a/changes.d/vocab/iri-valued-quote-url.md +++ b/changes.d/vocab/iri-valued-quote-url.md @@ -1,8 +1,13 @@ --- links: '#1015': https://github.com/fedify-dev/fedify/issues/1015 + '#1043': https://github.com/fedify-dev/fedify/pull/1043 --- - - Updated the `fedify:url` decoder to read `@id` when `@value` is absent, - allowing it to accept IRI-valued quote URL aliases (`_misskey_quote` or - `quoteUri`). Also widened its `dataCheck()` to accept both forms. - [[#1015] by Jang Hanarae] + - Fixed parsing a `Note`, `Article`, `ChatMessage`, or `Question` throwing + `TypeError: Invalid URL` when the sender's JSON-LD context declared + `_misskey_quote`, `quoteUri`, or `quoteUrl` with `"@type": "@id"`, as + Misskey-compatible servers do. Such terms expand to a node carrying `@id` + rather than `@value`, and only `@value` was read. A quote URL that cannot + be parsed at all, such as an inlined quote object without an `id`, is now + ignored instead of failing the whole object, and ATProto `at://` quote + URLs are accepted. [[#1015], [#1043] by Jang Hanarae]