From 4d05d6901ef5aff05312ca6007dcb37748bbea23 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 10 May 2026 10:50:28 +0200 Subject: [PATCH 1/2] rdf/turtle: accept JSON-LD `id`/`type` aliases on nested objects (#415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solid profiles in the wild use the JSON-LD 1.1 standard alias form for nested resources — `id` and `type`, not `@id`/`@type`. The converter only checked for the explicit `@`-prefixed names, so a nested CID v1 verificationMethod authored as: { "id": ".../card.jsonld#nostr-key-1", "type": "Multikey", "controller": ".../card.jsonld#me", "publicKeyMultibase": "fe70102de7ec..." } was silently dropped from the Turtle output. The two breakage points: - `valueToTerm()` checks `value['@id']`; for `{id:...}` it returns null, and the parent `cid:verificationMethod` quad is never emitted. - The BFS enqueue at the bottom of `jsonLdToQuads` tests `v['@id']`, so the nested object's own triples (its type, controller, publicKeyMultibase) are never emitted either. Net effect on test.solid.social's profile: the JSON-LD form had the full Multikey VM, the conneg-converted Turtle dropped both the `cid:verificationMethod` predicate AND the entire `#nostr-key-1` resource block. JSS's own backend is unaffected (LWS10-CID verifier requests JSON-LD), but third-party Turtle consumers — RDF stores, SPARQL endpoints, generic Solid clients that default to text/turtle — saw `cid:authentication <#nostr-key-1>` with zero triples about `#nostr-key-1`. Fix: - Two helper functions: `getNodeId(n)` returns `n['@id'] ?? n.id`, `getNodeType(n)` returns `n['@type'] ?? n.type`. Used wherever the converter consults the identifier or type of a node. - `jsonLdToQuads` accepts `id`/`type` for top-level docs, nested-object enqueue, and emits the rdf:type triple from either form. - `valueToTerm` accepts `id` as the IRI-reference key (so the predicate-→-IRI quad gets emitted for nested objects using the alias). - Predicate loop now also skips `id`/`type` keys (they were handled above as @id/@type aliases — emitting them as predicates would produce malformed triples like `` / `` since the names don't expand via context). - Nested-claim probe in the enqueue check ignores both `@id` and `id` when deciding whether the object has any own claims worth emitting. Sibling of #389 (array-context lost) and #390 (`@type: '@json'` literals not emitted) — same family of CID-v1 conversion gaps. New test: writes a profile with the exact pattern test.solid.social ships (nested VM with `id`/`type` aliases) and asserts the Turtle output includes `cid:verificationMethod`, the Multikey type, the controller predicate, and the publicKeyMultibase literal. Test count: 770 → 771 in full suite. --- src/rdf/turtle.js | 74 ++++++++++++++++++++++++++++++++++----------- test/turtle.test.js | 44 +++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 18 deletions(-) diff --git a/src/rdf/turtle.js b/src/rdf/turtle.js index e0b81e31..5193ade6 100644 --- a/src/rdf/turtle.js +++ b/src/rdf/turtle.js @@ -164,6 +164,32 @@ function quadsToJsonLd(quads, baseUri, prefixes = {}) { return nodes.map((node, i) => i === 0 ? { '@context': context, ...node } : node); } +/** + * Read a JSON-LD node's identifier, accepting both the explicit + * `@id` form AND the unprefixed `id` alias that JSON-LD 1.1 treats + * as equivalent (and that Solid profiles in the wild use). Same + * fallback for `@type` / `type`. + * + * Without this aliasing, nested objects authored with `id`/`type` + * (e.g. a CID v1 verificationMethod entry) get silently dropped: + * - the predicate-→-IRI quad isn't emitted (valueToTerm sees + * no `@id` and returns null) + * - the BFS enqueue check (`v['@id']`) is false, so the nested + * object's own triples are never written either + * - net result: the entire `cid:verificationMethod` predicate + * and the `#nostr-key-1` resource block disappear from Turtle. + * + * #415. + */ +function getNodeId(n) { + if (!n || typeof n !== 'object') return undefined; + return n['@id'] !== undefined ? n['@id'] : n.id; +} +function getNodeType(n) { + if (!n || typeof n !== 'object') return undefined; + return n['@type'] !== undefined ? n['@type'] : n.type; +} + /** * Convert JSON-LD to N3.js quads */ @@ -181,8 +207,8 @@ function jsonLdToQuads(jsonLd, baseUri) { if (doc['@context']) { mergedContext = { ...mergedContext, ...doc['@context'] }; } - // Each document with @id is a node (no @graph needed) - if (doc['@id']) { + // Each document with @id (or `id` alias) is a node (no @graph needed) + if (getNodeId(doc) !== undefined) { nodes.push(doc); } } @@ -206,16 +232,18 @@ function jsonLdToQuads(jsonLd, baseUri) { const queue = [...nodes]; for (let i = 0; i < queue.length; i++) { const node = queue[i]; - if (!node['@id']) continue; - const subjectUri = resolveUri(node['@id'], baseUri); + const nodeId = getNodeId(node); + if (nodeId === undefined) continue; + const subjectUri = resolveUri(nodeId, baseUri); const subject = subjectUri.startsWith('_:') ? blankNode(subjectUri.slice(2)) : namedNode(subjectUri); - // Handle @type - if (node['@type']) { - const types = Array.isArray(node['@type']) ? node['@type'] : [node['@type']]; + // Handle @type (or `type` alias). + const nodeType = getNodeType(node); + if (nodeType !== undefined) { + const types = Array.isArray(nodeType) ? nodeType : [nodeType]; for (const type of types) { const typeUri = expandUri(type, context); quads.push(quad( @@ -226,9 +254,13 @@ function jsonLdToQuads(jsonLd, baseUri) { } } - // Handle other properties + // Handle other properties. Skip `@`-prefixed keys AND the `id`/ + // `type` aliases (handled above as @id/@type) — emitting them as + // predicates would produce malformed triples like `` and + // `` since the names don't expand to URIs via context. for (const [key, value] of Object.entries(node)) { if (key.startsWith('@')) continue; + if (key === 'id' || key === 'type') continue; const predicateUri = expandUri(key, context); const predicate = namedNode(predicateUri); @@ -243,15 +275,16 @@ function jsonLdToQuads(jsonLd, baseUri) { if (object) { quads.push(quad(subject, predicate, object)); } - // If v is a nested node (object with @id and at least one non-@value - // own property beyond @id), enqueue it so its triples are also - // emitted. Object-identity tracking (WeakSet) prevents the same - // nested object from being enqueued twice, which would otherwise - // loop for graphs that reuse an object reference (cycles). + // If v is a nested node (object with @id/id and at least one + // own property beyond the identifier), enqueue it so its + // triples are also emitted. Object-identity tracking + // (WeakSet) prevents the same nested object from being + // enqueued twice, which would otherwise loop for graphs + // that reuse an object reference (cycles). if (v && typeof v === 'object' && !Array.isArray(v) && - v['@id'] && v['@value'] === undefined && + getNodeId(v) !== undefined && v['@value'] === undefined && !enqueuedNested.has(v)) { - const hasOwnClaims = Object.keys(v).some(k => k !== '@id'); + const hasOwnClaims = Object.keys(v).some(k => k !== '@id' && k !== 'id'); if (hasOwnClaims) { enqueuedNested.add(v); queue.push(v); @@ -348,9 +381,14 @@ function valueToTerm(value, baseUri, context, isIdType = false) { // Object values if (typeof value === 'object') { - // @id reference - if (value['@id']) { - const uri = resolveUri(value['@id'], baseUri); + // @id reference (or `id` alias — same JSON-LD 1.1 convention). + // This is what makes the predicate-→-IRI quad get emitted for + // nested objects authored with `id` instead of `@id`. Without + // it, an inline verificationMethod with `id`/`type` returned + // null here and the parent predicate triple was lost. + const objId = value['@id'] !== undefined ? value['@id'] : value.id; + if (objId !== undefined) { + const uri = resolveUri(objId, baseUri); return uri.startsWith('_:') ? blankNode(uri.slice(2)) : namedNode(uri); diff --git a/test/turtle.test.js b/test/turtle.test.js index c29ba2be..0bb97a8d 100644 --- a/test/turtle.test.js +++ b/test/turtle.test.js @@ -85,6 +85,50 @@ describe('turtle converter — unit (#320 follow-ups)', () => { `Turtle output must not contain object-stringification, got:\n${content}`); }); + it('nested object with `id`/`type` aliases survives the conversion (#415)', async () => { + // Solid profiles use the JSON-LD 1.1 `id`/`type` aliases for + // nested resources (no `@`). The converter must accept both + // forms — without this, a CID v1 verificationMethod object + // gets silently dropped: + // - the `cid:verificationMethod` predicate isn't emitted + // - the nested `#nostr-key-1` resource (Multikey, controller, + // publicKeyMultibase) isn't emitted either + // Net: third-party Turtle consumers see `cid:authentication + // <#nostr-key-1>` with no description of `#nostr-key-1`. + const doc = { + '@context': { + cid: 'https://www.w3.org/ns/cid/v1#', + verificationMethod: { '@id': 'cid:verificationMethod', '@container': '@set' }, + authentication: { '@id': 'cid:authentication', '@type': '@id', '@container': '@set' }, + controller: { '@id': 'cid:controller', '@type': '@id' }, + publicKeyMultibase: { '@id': 'cid:publicKeyMultibase' }, + }, + '@id': 'https://example.test/profile/card.jsonld#me', + verificationMethod: [{ + // Aliases — `id`/`type`, not `@id`/`@type`. + id: 'https://example.test/profile/card.jsonld#k', + type: 'Multikey', + controller: 'https://example.test/profile/card.jsonld#me', + publicKeyMultibase: 'fe70102de7ec', + }], + authentication: ['https://example.test/profile/card.jsonld#k'], + }; + const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true); + + // The cid:verificationMethod predicate must connect #me to the VM. + assert.match(content, /cid:verificationMethod|/, + `cid:verificationMethod predicate missing from Turtle:\n${content}`); + // The VM resource must be described — its type, controller, key. + assert.ok(content.includes('https://example.test/profile/card.jsonld#k'), + `VM #k must appear in Turtle:\n${content}`); + assert.match(content, /Multikey|/, + `Multikey type missing from Turtle:\n${content}`); + assert.ok(content.includes('fe70102de7ec'), + `publicKeyMultibase value missing from Turtle:\n${content}`); + assert.match(content, /cid:controller|/, + `cid:controller predicate missing on the VM:\n${content}`); + }); + it('cyclical nested node reference does not hang', async () => { // Two nested nodes reference each other. BFS must not loop. const a = { '@id': 'https://example.test/a', 'ex:knows': null }; From 78a7a0ebf3a6838eeaba4f1149644dfc1df29892 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 10 May 2026 10:57:14 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Address=20copilot=20pass=201=20on=20#416=20?= =?UTF-8?q?=E2=80=94=20type-guard=20the=20alias=20readers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings, both real. `getNodeId` and `getNodeType` returned whatever they found at `@id`/`id` and `@type`/`type` — including `null`, numbers, or nested objects from malformed user-authored profiles. Downstream they were passed to `resolveUri` and `expandUri`, which call `.startsWith` and `.includes` respectively — a TypeError on the request path for any malformed profile. `valueToTerm` had the same gap on its added `id` alias branch. Fix: - getNodeId returns `undefined` unless the value is a string. Treats `id: 42` or `id: null` as "no identifier." - getNodeType accepts string OR array. Arrays are filtered to string entries; if no strings remain, treated as absent. This preserves the "string entries still emit" property when a user writes `type: ['Multikey', 42]` — the valid Multikey emits, the 42 is dropped. - valueToTerm's id-alias branch checks `typeof rawObjId === 'string'` before calling resolveUri. New test asserts: - nested `id: 42` doesn't crash conneg (returns valid string) - nested `type: null` doesn't crash - mixed `type: ['Multikey', 42, null]` — string entries survive Test count: 771 → 772 in full suite. --- src/rdf/turtle.js | 29 ++++++++++++++++++++++++----- test/turtle.test.js | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/rdf/turtle.js b/src/rdf/turtle.js index 5193ade6..b0ee59cf 100644 --- a/src/rdf/turtle.js +++ b/src/rdf/turtle.js @@ -183,11 +183,26 @@ function quadsToJsonLd(quads, baseUri, prefixes = {}) { */ function getNodeId(n) { if (!n || typeof n !== 'object') return undefined; - return n['@id'] !== undefined ? n['@id'] : n.id; + const v = n['@id'] !== undefined ? n['@id'] : n.id; + // Strict string-only — downstream resolveUri/`.startsWith` would + // throw on a number, null, or object. Malformed user content + // (a profile that authored `id: 42`) shouldn't crash conneg; + // treat non-string identifiers as absent. + return typeof v === 'string' ? v : undefined; } function getNodeType(n) { if (!n || typeof n !== 'object') return undefined; - return n['@type'] !== undefined ? n['@type'] : n.type; + const v = n['@type'] !== undefined ? n['@type'] : n.type; + // Accept string OR array — expandUri/`.includes` would throw on + // anything else. For arrays, filter to string entries downstream + // (handled by Array.isArray + the per-entry expandUri call which + // assumes string; we filter here to be safe). + if (typeof v === 'string') return v; + if (Array.isArray(v)) { + const strs = v.filter(t => typeof t === 'string'); + return strs.length > 0 ? strs : undefined; + } + return undefined; } /** @@ -386,9 +401,13 @@ function valueToTerm(value, baseUri, context, isIdType = false) { // nested objects authored with `id` instead of `@id`. Without // it, an inline verificationMethod with `id`/`type` returned // null here and the parent predicate triple was lost. - const objId = value['@id'] !== undefined ? value['@id'] : value.id; - if (objId !== undefined) { - const uri = resolveUri(objId, baseUri); + // + // String-only — a numeric or null `@id`/`id` would crash + // resolveUri's `.startsWith`. Treat as absent and fall through + // to the @value/@language branches below. + const rawObjId = value['@id'] !== undefined ? value['@id'] : value.id; + if (typeof rawObjId === 'string') { + const uri = resolveUri(rawObjId, baseUri); return uri.startsWith('_:') ? blankNode(uri.slice(2)) : namedNode(uri); diff --git a/test/turtle.test.js b/test/turtle.test.js index 0bb97a8d..efbf99f1 100644 --- a/test/turtle.test.js +++ b/test/turtle.test.js @@ -129,6 +129,30 @@ describe('turtle converter — unit (#320 follow-ups)', () => { `cid:controller predicate missing on the VM:\n${content}`); }); + it('malformed `id`/`type` values are silently dropped, not crashed on (#415 review)', async () => { + // Profiles in the wild can have malformed user-authored content + // — e.g. `id: 42` or `type: null`. The converter must NOT throw + // (downstream `resolveUri.startsWith` and `expandUri.includes` + // assume strings); it should treat the malformed value as absent + // and skip the affected resource cleanly. + const doc = { + '@context': { 'cid': 'https://www.w3.org/ns/cid/v1#' }, + '@id': 'https://example.test/s', + // Nested object with a non-string `id` — must not crash. + 'cid:bad1': { id: 42, 'cid:foo': 'x' }, + // Nested object with a null `type` — must not crash. + 'cid:bad2': { id: 'https://example.test/n2', type: null, 'cid:foo': 'x' }, + // Array `type` with mixed string/non-string entries — string + // entries should still emit. + 'cid:mixed': { id: 'https://example.test/n3', type: ['Multikey', 42, null], 'cid:foo': 'x' }, + }; + const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true); + assert.ok(typeof content === 'string', 'must produce a string output, not throw'); + // The valid string type entry should survive in the mixed-type case. + assert.ok(content.includes('https://example.test/n3'), + `node n3 should appear:\n${content}`); + }); + it('cyclical nested node reference does not hang', async () => { // Two nested nodes reference each other. BFS must not loop. const a = { '@id': 'https://example.test/a', 'ex:knows': null };