From 5aaf21c8dbc6bdad9e5d6e43c06d741731068b15 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Mon, 7 Sep 2026 22:21:20 +0200 Subject: [PATCH 1/8] fix(cli): support JSON-LD ingestion --- packages/cli/package.json | 6 +- packages/cli/src/rdf-parser.ts | 27 +++--- packages/cli/test/rdf-parser.test.ts | 127 +++++++++++++++++++++++++-- packages/cli/tsconfig.json | 3 +- packages/cli/vitest.unit.config.ts | 1 + 5 files changed, 143 insertions(+), 21 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index fc2d733651..9f5f50d50f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -66,7 +66,8 @@ "jsonc-parser": "3.3.1", "n3": "^2.0.1", "semver": "^7.7.4", - "typescript": "^5.7" + "typescript": "^5.7", + "jsonld": "^8.3.3" }, "devDependencies": { "@types/better-sqlite3": "^7", @@ -75,7 +76,8 @@ "@types/semver": "^7.8.0", "@vitest/coverage-v8": "^4.0.18", "esbuild": "0.27.7", - "vitest": "^4.0.18" + "vitest": "^4.0.18", + "@types/jsonld": "^1.5.15" }, "publishConfig": { "access": "public" diff --git a/packages/cli/src/rdf-parser.ts b/packages/cli/src/rdf-parser.ts index 3012db2ece..7e868c1d79 100644 --- a/packages/cli/src/rdf-parser.ts +++ b/packages/cli/src/rdf-parser.ts @@ -1,3 +1,4 @@ +import { formatCanonicalRdfLiteralTerm } from '@origintrail-official/dkg-rdf-utils'; import { Parser, type Quad as N3Quad } from 'n3'; export interface SimpleQuad { @@ -56,11 +57,11 @@ export async function parseRdf( } if (format === 'jsonld') { - // JSON-LD → N-Quads conversion would require the jsonld library. - // For now, treat as our JSON quad format if it has subject/predicate/object, - // otherwise report unsupported. + // Keep the historical quad-array input while supporting ordinary JSON-LD. const parsed = JSON.parse(content); - if (Array.isArray(parsed) && parsed[0]?.subject) { + if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((q) => q + && typeof q.subject === 'string' && typeof q.predicate === 'string' && typeof q.object === 'string' + && !Object.keys(q).some((key) => key.startsWith('@')))) { return parsed.map((q: any) => ({ subject: q.subject, predicate: q.predicate, @@ -68,7 +69,13 @@ export async function parseRdf( graph: q.graph || defaultGraph, })); } - throw new Error('JSON-LD with @context requires the jsonld library. Use .nq, .nt, .ttl, or .trig instead.'); + if (parsed === null || typeof parsed !== 'object') { + throw new Error('JSON-LD input must be an object or array'); + } + const { default: jsonld } = await import('jsonld'); + const nquads = await jsonld.toRDF(parsed, { format: 'application/n-quads' }); + if (typeof nquads !== 'string') throw new Error('JSON-LD conversion did not return N-Quads'); + return parseRdf(nquads, 'nquads', defaultGraph); } // N3 parser handles N-Triples, N-Quads, Turtle, TriG @@ -95,11 +102,11 @@ export async function parseRdf( function termToString(term: { termType: string; value: string; language?: string; datatype?: { value: string } }): string { if (term.termType === 'Literal') { - if (term.language) return `"${term.value}"@${term.language}`; - if (term.datatype && term.datatype.value !== 'http://www.w3.org/2001/XMLSchema#string') { - return `"${term.value}"^^<${term.datatype.value}>`; - } - return `"${term.value}"`; + return formatCanonicalRdfLiteralTerm(term.language + ? { kind: 'language', value: term.value, language: term.language } + : term.datatype + ? { kind: 'typed', value: term.value, datatype: term.datatype.value } + : { kind: 'plain', value: term.value }); } if (term.termType === 'BlankNode') return `_:${term.value}`; return term.value; diff --git a/packages/cli/test/rdf-parser.test.ts b/packages/cli/test/rdf-parser.test.ts index e0423cc92e..66d80a6a2f 100644 --- a/packages/cli/test/rdf-parser.test.ts +++ b/packages/cli/test/rdf-parser.test.ts @@ -1,4 +1,10 @@ import { describe, it, expect } from 'vitest'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; import { detectFormat, supportedExtensions, parseRdf } from '../src/rdf-parser.js'; describe('detectFormat', () => { @@ -106,15 +112,120 @@ describe('parseRdf', () => { }); describe('jsonld format', () => { - it('throws for @context-based JSON-LD (unsupported)', async () => { - const jsonld = JSON.stringify({ - '@context': 'https://schema.org/', - '@id': 'urn:x', - name: 'Test', + it('expands a local context into RDF and applies the default graph', async () => { + const content = JSON.stringify({ + '@context': { name: 'https://schema.org/name' }, '@id': 'urn:person:alice', name: 'Alice', }); - await expect(parseRdf(jsonld, 'jsonld', DEFAULT_GRAPH)).rejects.toThrow( - /JSON-LD with @context/, - ); + expect(await parseRdf(content, 'jsonld', DEFAULT_GRAPH)).toEqual([ + { subject: 'urn:person:alice', predicate: 'https://schema.org/name', object: '"Alice"', graph: DEFAULT_GRAPH }, + ]); + }); + + it('preserves named graphs, language/datatype literals and nested blank-node links', async () => { + const content = JSON.stringify({ + '@context': { ex: 'https://example.org/', label: 'ex:label', count: 'ex:count', detail: 'ex:detail' }, + '@id': 'urn:named-graph', + '@graph': [{ '@id': 'urn:event', label: { '@value': 'Dobar dan', '@language': 'sr' }, count: 7, + detail: { label: 'Nested' } }], + }); + const quads = await parseRdf(content, 'jsonld', DEFAULT_GRAPH); + expect(quads).toHaveLength(4); + expect(quads.every((q) => q.graph === 'urn:named-graph')).toBe(true); + expect(quads).toContainEqual({ subject: 'urn:event', predicate: 'https://example.org/label', object: '"Dobar dan"@sr', graph: 'urn:named-graph' }); + expect(quads).toContainEqual({ subject: 'urn:event', predicate: 'https://example.org/count', object: '"7"^^', graph: 'urn:named-graph' }); + const detail = quads.find((q) => q.predicate === 'https://example.org/detail')!.object; + expect(detail).toMatch(/^_:/); + expect(quads).toContainEqual({ subject: detail, predicate: 'https://example.org/label', object: '"Nested"', graph: 'urn:named-graph' }); + }); + + it('preserves list order and plain JSON-LD arrays', async () => { + const quads = await parseRdf(JSON.stringify([ + { '@id': 'urn:list', 'https://example.org/items': { '@list': [{ '@id': 'urn:first' }, { '@id': 'urn:second' }] } }, + { '@id': 'urn:other', 'https://example.org/label': 'Other' }, + ]), 'jsonld', DEFAULT_GRAPH); + const rdf = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; + const head = quads.find((q) => q.subject === 'urn:list')!.object; + const tail = quads.find((q) => q.subject === head && q.predicate === `${rdf}rest`)!.object; + expect(quads).toContainEqual({ subject: head, predicate: `${rdf}first`, object: 'urn:first', graph: DEFAULT_GRAPH }); + expect(quads).toContainEqual({ subject: tail, predicate: `${rdf}first`, object: 'urn:second', graph: DEFAULT_GRAPH }); + expect(quads).toContainEqual({ subject: tail, predicate: `${rdf}rest`, object: `${rdf}nil`, graph: DEFAULT_GRAPH }); + expect(quads).toContainEqual({ subject: 'urn:other', predicate: 'https://example.org/label', object: '"Other"', graph: DEFAULT_GRAPH }); + }); + + it('loads a referenced JSON-LD context through the standard document loader', async () => { + let requests = 0; + const server = createServer((_req, res) => { + requests++; + res.writeHead(200, { 'Content-Type': 'application/ld+json' }); + res.end(JSON.stringify({ '@context': { label: 'https://example.org/label' } })); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected a TCP listener'); + try { + const context = `http://127.0.0.1:${address.port}/context`; + const quads = await parseRdf(JSON.stringify({ '@context': context, '@id': 'urn:event', label: 'Remote context' }), 'jsonld', DEFAULT_GRAPH); + expect(requests).toBe(1); + expect(quads).toEqual([{ subject: 'urn:event', predicate: 'https://example.org/label', object: '"Remote context"', graph: DEFAULT_GRAPH }]); + } finally { + await new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve())); + } + }); + + it('round-trips escaped text through actual storage', async () => { + const text = 'He said "hello".\nPath C:\\temp\tŽ'; + const quads = await parseRdf(JSON.stringify({ '@id': 'urn:event', 'https://example.org/text': text }), 'jsonld', DEFAULT_GRAPH); + const store = new OxigraphStore(); + try { + await store.insert(quads); + const result = await store.query(`SELECT ?text WHERE { GRAPH <${DEFAULT_GRAPH}> { ?text } }`); + expect(result).toMatchObject({ type: 'bindings', bindings: [{ text: quads[0].object }] }); + // The canonical lexical form escapes once; it remains valid N-Quads. + expect(await parseRdf(` ${quads[0].object} .`, 'ntriples', DEFAULT_GRAPH)).toEqual(quads); + } finally { + await store.close(); + } + }); + + it.each(['null', '42', '"https://example.org/not-a-document"'])('rejects scalar JSON-LD %s', async (content) => { + await expect(parseRdf(content, 'jsonld', DEFAULT_GRAPH)).rejects.toThrow('JSON-LD input must be an object or array'); + }); + + it('rejects malformed JSON-LD contexts', async () => { + await expect(parseRdf('{"@context":42,"@id":"urn:event"}', 'jsonld', DEFAULT_GRAPH)) + .rejects.toThrow(/context/i); + }); + + it('accepts an empty JSON-LD array as an empty dataset', async () => { + expect(await parseRdf('[]', 'jsonld', DEFAULT_GRAPH)).toEqual([]); + }); + + it('does not mistake contextual JSON-LD fields for a legacy quad', async () => { + const content = [{ + '@context': { '@vocab': 'https://example.org/' }, '@id': 'urn:event', + subject: 'Subject', predicate: 'Predicate', object: 'Object', + }]; + const quads = await parseRdf(JSON.stringify(content), 'jsonld', DEFAULT_GRAPH); + expect(quads).toHaveLength(3); + expect(quads.every((q) => q.subject === 'urn:event')).toBe(true); + expect(quads.map((q) => q.predicate).sort()).toEqual([ + 'https://example.org/object', 'https://example.org/predicate', 'https://example.org/subject', + ]); + }); + + it('loads an advertised .jsonld file through the CLI input boundary', async () => { + const { loadQuadsFromInput } = await import('../src/cli-helpers.js'); + const directory = await mkdtemp(join(tmpdir(), 'dkg-jsonld-ingest-')); + try { + const file = join(directory, 'event.jsonld'); + await writeFile(file, JSON.stringify({ '@context': { name: 'https://schema.org/name' }, '@id': 'urn:event', name: 'CLI input' })); + expect(await loadQuadsFromInput({ file }, DEFAULT_GRAPH)).toEqual([ + { subject: 'urn:event', predicate: 'https://schema.org/name', object: '"CLI input"', graph: DEFAULT_GRAPH }, + ]); + } finally { + await rm(directory, { recursive: true, force: true }); + } }); it('accepts JSON-LD that has subject/predicate/object shape', async () => { diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 68fa7de405..c2cc203b72 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -14,6 +14,7 @@ { "path": "../node-ui" }, { "path": "../adapter-openclaw" }, { "path": "../local-llm" }, - { "path": "../mcp-dkg" } + { "path": "../mcp-dkg" }, + { "path": "../rdf-utils" } ] } diff --git a/packages/cli/vitest.unit.config.ts b/packages/cli/vitest.unit.config.ts index 30daf1bc13..c5a33227bc 100644 --- a/packages/cli/vitest.unit.config.ts +++ b/packages/cli/vitest.unit.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ 'test/mcp-config-document.test.ts', 'test/mcp-physical-config.test.ts', 'test/mcp-config-metadata.test.ts', + 'test/rdf-parser.test.ts', 'test/api-client.test.ts', 'test/finalized-publish-options.test.ts', 'test/messenger-outbox-memory.test.ts', From 83bcabbb23bf708d4411d449edadbf685b2d1bf9 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Mon, 7 Sep 2026 23:30:06 +0200 Subject: [PATCH 2/8] fix(cli): reject lossy JSON-LD and disable external context loading --- packages/cli/README.md | 12 +++++ packages/cli/src/cli-helpers.ts | 3 +- packages/cli/src/rdf-parser.ts | 76 +++++++++++++++++++--------- packages/cli/test/rdf-parser.test.ts | 45 +++++++++++----- 4 files changed, 97 insertions(+), 39 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 50a950e566..3d10521fda 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -64,6 +64,18 @@ the effective lane once at boot. A background query that is shed before execution returns HTTP 503 with `Retry-After: 1` and `code: "STORE_SCHEDULER_BUSY"`. +## RDF file input + +Commands with `--file` accept N-Quads (`.nq`), N-Triples (`.nt`), Turtle (`.ttl`), +TriG (`.trig`), JSON quad arrays (`.json`), and JSON-LD (`.jsonld`). JSON-LD +supports inline `@context`, lists, named graphs and typed/language literals. +Relative identifiers resolve against the input file's `file:` URL unless an +inline `@base` overrides it. Expansion that would discard statements fails. + +JSON-LD ingestion does not fetch remote contexts or `@import` URLs. Embed the +required context inline before importing a file. Existing simple quad arrays +remain supported in both `.json` and `.jsonld` files. + ## Running a Core Node (relay operator) A Core Node is a publicly-reachable host that runs a libp2p circuit-relay v2 diff --git a/packages/cli/src/cli-helpers.ts b/packages/cli/src/cli-helpers.ts index 13c71b4dca..6d4a0bf745 100644 --- a/packages/cli/src/cli-helpers.ts +++ b/packages/cli/src/cli-helpers.ts @@ -156,9 +156,10 @@ async function loadQuadsFromInput( if (opts.file) { const { readFile } = await import('node:fs/promises'); + const { pathToFileURL } = await import('node:url'); const raw = await readFile(opts.file, 'utf-8'); const format = opts.format ?? rdfParser.detectFormat(opts.file); - const quads = await rdfParser.parseRdf(raw, format, defaultGraph); + const quads = await rdfParser.parseRdf(raw, format, defaultGraph, pathToFileURL(opts.file).href); console.log(`Parsed ${quads.length} quad(s) from ${opts.file} (${format})`); return quads; } diff --git a/packages/cli/src/rdf-parser.ts b/packages/cli/src/rdf-parser.ts index 7e868c1d79..a6a86d7fd5 100644 --- a/packages/cli/src/rdf-parser.ts +++ b/packages/cli/src/rdf-parser.ts @@ -1,5 +1,6 @@ import { formatCanonicalRdfLiteralTerm } from '@origintrail-official/dkg-rdf-utils'; import { Parser, type Quad as N3Quad } from 'n3'; +import type { JsonLdDocument, Options as JsonLdOptions } from 'jsonld'; export interface SimpleQuad { subject: string; @@ -44,36 +45,39 @@ export async function parseRdf( content: string, format: RdfFormat, defaultGraph: string, + baseIRI?: string, ): Promise { - if (format === 'json') { - const parsed = JSON.parse(content); - const arr = Array.isArray(parsed) ? parsed : parsed.quads; - return arr.map((q: any) => ({ - subject: q.subject, - predicate: q.predicate, - object: q.object, - graph: q.graph || defaultGraph, - })); - } - - if (format === 'jsonld') { - // Keep the historical quad-array input while supporting ordinary JSON-LD. - const parsed = JSON.parse(content); - if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((q) => q - && typeof q.subject === 'string' && typeof q.predicate === 'string' && typeof q.object === 'string' - && !Object.keys(q).some((key) => key.startsWith('@')))) { - return parsed.map((q: any) => ({ - subject: q.subject, - predicate: q.predicate, - object: q.object, - graph: q.graph || defaultGraph, - })); - } + if (format === 'json' || format === 'jsonld') { + const parsed: unknown = JSON.parse(content); + const legacy = decodeLegacyQuads( + format === 'json' && isRecord(parsed) ? parsed.quads : parsed, + defaultGraph, + ); + if (legacy) return legacy; + if (format === 'json') throw new Error('JSON input must contain an array of subject/predicate/object quads'); if (parsed === null || typeof parsed !== 'object') { throw new Error('JSON-LD input must be an object or array'); } const { default: jsonld } = await import('jsonld'); - const nquads = await jsonld.toRDF(parsed, { format: 'application/n-quads' }); + const remoteContextError = new Error('Remote JSON-LD contexts are disabled; embed an inline @context before ingesting the file'); + // jsonld.js 8 supports safe mode; the older upstream declaration omits it. + const options: JsonLdOptions.ToRdf & { safe: true } = { + format: 'application/n-quads', + base: baseIRI, + safe: true, + documentLoader: async () => { throw remoteContextError; }, + }; + let nquads: object | string; + try { + // jsonld.js validates the JSON-LD grammar and rejects lossy expansion. + nquads = await jsonld.toRDF(parsed as JsonLdDocument, options); + } catch (error) { + // Preserve the actionable policy error that jsonld.js wraps while loading. + if (isRecord(error) && isRecord(error.details) && error.details.cause === remoteContextError) { + throw remoteContextError; + } + throw error; + } if (typeof nquads !== 'string') throw new Error('JSON-LD conversion did not return N-Quads'); return parseRdf(nquads, 'nquads', defaultGraph); } @@ -100,6 +104,28 @@ export async function parseRdf( }); } +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +type LegacyQuad = Omit & { graph?: string | null }; + +function isLegacyQuad(value: unknown): value is LegacyQuad { + return isRecord(value) + && typeof value.subject === 'string' + && typeof value.predicate === 'string' + && typeof value.object === 'string' + && (value.graph == null || typeof value.graph === 'string') + && !Object.keys(value).some((key) => key.startsWith('@')); +} + +function decodeLegacyQuads(value: unknown, defaultGraph: string): SimpleQuad[] | undefined { + if (!Array.isArray(value) || !value.every(isLegacyQuad)) return undefined; + return value.map(({ subject, predicate, object, graph }) => ({ + subject, predicate, object, graph: graph || defaultGraph, + })); +} + function termToString(term: { termType: string; value: string; language?: string; datatype?: { value: string } }): string { if (term.termType === 'Literal') { return formatCanonicalRdfLiteralTerm(term.language diff --git a/packages/cli/test/rdf-parser.test.ts b/packages/cli/test/rdf-parser.test.ts index 66d80a6a2f..da7238e07d 100644 --- a/packages/cli/test/rdf-parser.test.ts +++ b/packages/cli/test/rdf-parser.test.ts @@ -4,6 +4,8 @@ import { once } from 'node:events'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { pathToFileURL } from 'node:url'; +import { Parser } from 'n3'; import { OxigraphStore } from '@origintrail-official/dkg-storage'; import { detectFormat, supportedExtensions, parseRdf } from '../src/rdf-parser.js'; @@ -152,7 +154,7 @@ describe('parseRdf', () => { expect(quads).toContainEqual({ subject: 'urn:other', predicate: 'https://example.org/label', object: '"Other"', graph: DEFAULT_GRAPH }); }); - it('loads a referenced JSON-LD context through the standard document loader', async () => { + it.each(['context', 'import'])('rejects remote %s loading without requesting the local endpoint', async (kind) => { let requests = 0; const server = createServer((_req, res) => { requests++; @@ -165,9 +167,9 @@ describe('parseRdf', () => { if (!address || typeof address === 'string') throw new Error('Expected a TCP listener'); try { const context = `http://127.0.0.1:${address.port}/context`; - const quads = await parseRdf(JSON.stringify({ '@context': context, '@id': 'urn:event', label: 'Remote context' }), 'jsonld', DEFAULT_GRAPH); - expect(requests).toBe(1); - expect(quads).toEqual([{ subject: 'urn:event', predicate: 'https://example.org/label', object: '"Remote context"', graph: DEFAULT_GRAPH }]); + const input = { '@context': kind === 'context' ? context : { '@import': context }, '@id': 'urn:event', label: 'Remote context' }; + await expect(parseRdf(JSON.stringify(input), 'jsonld', DEFAULT_GRAPH)).rejects.toThrow(/Remote JSON-LD contexts are disabled/); + expect(requests).toBe(0); } finally { await new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve())); } @@ -181,6 +183,9 @@ describe('parseRdf', () => { await store.insert(quads); const result = await store.query(`SELECT ?text WHERE { GRAPH <${DEFAULT_GRAPH}> { ?text } }`); expect(result).toMatchObject({ type: 'bindings', bindings: [{ text: quads[0].object }] }); + if (result.type !== 'bindings') throw new Error('Expected literal bindings'); + const stored = new Parser({ format: 'N-Triples' }).parse(` ${result.bindings[0].text} .`); + expect(stored[0].object.value).toBe(text); // The canonical lexical form escapes once; it remains valid N-Quads. expect(await parseRdf(` ${quads[0].object} .`, 'ntriples', DEFAULT_GRAPH)).toEqual(quads); } finally { @@ -188,10 +193,24 @@ describe('parseRdf', () => { } }); + it('honors an inline base for document-relative identifiers', async () => { + const input = { '@context': { '@base': 'https://example.org/data/', name: 'https://schema.org/name' }, '@id': 'asset/1', name: 'Alice' }; + expect(await parseRdf(JSON.stringify(input), 'jsonld', DEFAULT_GRAPH, 'file:///tmp/input.jsonld')).toEqual([ + { subject: 'https://example.org/data/asset/1', predicate: 'https://schema.org/name', object: '"Alice"', graph: DEFAULT_GRAPH }, + ]); + }); + it.each(['null', '42', '"https://example.org/not-a-document"'])('rejects scalar JSON-LD %s', async (content) => { await expect(parseRdf(content, 'jsonld', DEFAULT_GRAPH)).rejects.toThrow('JSON-LD input must be an object or array'); }); + it.each([ + { '@id': 'asset/1', 'https://schema.org/name': 'Alice' }, + { '@id': 'urn:event', name: 'Unmapped property' }, + ])('rejects lossy JSON-LD conversion without silently omitting statements', async (input) => { + await expect(parseRdf(JSON.stringify(input), 'jsonld', DEFAULT_GRAPH)).rejects.toThrow(/Safe mode validation/); + }); + it('rejects malformed JSON-LD contexts', async () => { await expect(parseRdf('{"@context":42,"@id":"urn:event"}', 'jsonld', DEFAULT_GRAPH)) .rejects.toThrow(/context/i); @@ -214,27 +233,27 @@ describe('parseRdf', () => { ]); }); - it('loads an advertised .jsonld file through the CLI input boundary', async () => { + it.each(['urn:event', 'asset/1'])('loads a JSON-LD file with identifier %s through the CLI input boundary', async (id) => { const { loadQuadsFromInput } = await import('../src/cli-helpers.js'); const directory = await mkdtemp(join(tmpdir(), 'dkg-jsonld-ingest-')); try { const file = join(directory, 'event.jsonld'); - await writeFile(file, JSON.stringify({ '@context': { name: 'https://schema.org/name' }, '@id': 'urn:event', name: 'CLI input' })); + await writeFile(file, JSON.stringify({ '@context': { name: 'https://schema.org/name' }, '@id': id, name: 'CLI input' })); expect(await loadQuadsFromInput({ file }, DEFAULT_GRAPH)).toEqual([ - { subject: 'urn:event', predicate: 'https://schema.org/name', object: '"CLI input"', graph: DEFAULT_GRAPH }, + { subject: new URL(id, pathToFileURL(file)).href, predicate: 'https://schema.org/name', object: '"CLI input"', graph: DEFAULT_GRAPH }, ]); } finally { await rm(directory, { recursive: true, force: true }); } }); - it('accepts JSON-LD that has subject/predicate/object shape', async () => { - const content = JSON.stringify([ - { subject: 'urn:a', predicate: 'urn:p', object: '"val"' }, - ]); - const quads = await parseRdf(content, 'jsonld', DEFAULT_GRAPH); - expect(quads).toHaveLength(1); + it.each([undefined, '', 'urn:named'])('keeps legacy JSON and JSON-LD graph handling identical (%s)', async (graph) => { + const content = JSON.stringify([{ subject: 'urn:a', predicate: 'urn:p', object: '"val"', graph }]); + const expected = [{ subject: 'urn:a', predicate: 'urn:p', object: '"val"', graph: graph || DEFAULT_GRAPH }]; + expect(await parseRdf(content, 'jsonld', DEFAULT_GRAPH)).toEqual(expected); + expect(await parseRdf(content, 'json', DEFAULT_GRAPH)).toEqual(expected); }); + }); describe('error handling', () => { From bfd2f531041866eb1d644c7050001c5e5f1e2ae4 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Tue, 8 Sep 2026 00:22:00 +0200 Subject: [PATCH 3/8] fix(cli): preflight named JSON-LD graphs before finalizing create --- packages/cli/README.md | 5 +++ packages/cli/src/cli-helpers.ts | 4 +- packages/cli/src/commands/knowledge-asset.ts | 8 +++- packages/cli/src/rdf-parser.ts | 16 ++++++- .../test/knowledge-asset-cli-smoke.test.ts | 45 +++++++++++++++++++ 5 files changed, 74 insertions(+), 4 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 3d10521fda..e0b87da2d9 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -72,6 +72,11 @@ supports inline `@context`, lists, named graphs and typed/language literals. Relative identifiers resolve against the input file's `file:` URL unless an inline `@base` overrides it. Expansion that would discard statements fails. +JSON-LD named graphs can be stored in Working Memory with `dkg ka create --no-finalize`. +A default-finalizing create rejects them before contacting the daemon: sealing, SWM +sharing and VM publication do not yet preserve named-graph identity. Rewrite the +document into the default graph to use those transitions. + JSON-LD ingestion does not fetch remote contexts or `@import` URLs. Embed the required context inline before importing a file. Existing simple quad arrays remain supported in both `.json` and `.jsonld` files. diff --git a/packages/cli/src/cli-helpers.ts b/packages/cli/src/cli-helpers.ts index 6d4a0bf745..9067f4de77 100644 --- a/packages/cli/src/cli-helpers.ts +++ b/packages/cli/src/cli-helpers.ts @@ -1,3 +1,4 @@ +import type { RdfParseOptions } from './rdf-parser.js'; import { Command } from 'commander'; import { readFileSync } from 'node:fs'; import { createInterface } from 'node:readline'; @@ -151,6 +152,7 @@ function loadStructuredFile(filePath: string): any { async function loadQuadsFromInput( opts: ActionOpts, defaultGraph: string, + parseOptions?: RdfParseOptions, ): Promise> { const rdfParser = await import('./rdf-parser.js'); @@ -159,7 +161,7 @@ async function loadQuadsFromInput( const { pathToFileURL } = await import('node:url'); const raw = await readFile(opts.file, 'utf-8'); const format = opts.format ?? rdfParser.detectFormat(opts.file); - const quads = await rdfParser.parseRdf(raw, format, defaultGraph, pathToFileURL(opts.file).href); + const quads = await rdfParser.parseRdf(raw, format, defaultGraph, pathToFileURL(opts.file).href, parseOptions); console.log(`Parsed ${quads.length} quad(s) from ${opts.file} (${format})`); return quads; } diff --git a/packages/cli/src/commands/knowledge-asset.ts b/packages/cli/src/commands/knowledge-asset.ts index 4c18a340fc..d0a71babe3 100644 --- a/packages/cli/src/commands/knowledge-asset.ts +++ b/packages/cli/src/commands/knowledge-asset.ts @@ -1,3 +1,4 @@ +import type { RdfParseOptions } from '../rdf-parser.js'; import { Command } from 'commander'; import { toErrorMessage } from '@origintrail-official/dkg-core'; import { @@ -53,13 +54,14 @@ function hasQuadInput(opts: ActionOpts): boolean { ); } -async function loadWritableQuads(opts: ActionOpts): Promise { +async function loadWritableQuads(opts: ActionOpts, parseOptions?: RdfParseOptions): Promise { const quads = await loadQuadsFromInput( { ...opts, file: inputFilePath(opts), }, '', + parseOptions, ); return quads.map((quad) => ({ subject: quad.subject, @@ -243,7 +245,9 @@ export function registerKnowledgeAssetCommand(program: Command): void { )))) .action(async (name: string, opts: ActionOpts) => runAction(async () => { const contextGraphId = requiredContextGraphId(opts); - const quads = hasQuadInput(opts) ? await loadWritableQuads(opts) : undefined; + const quads = hasQuadInput(opts) + ? await loadWritableQuads(opts, { jsonLdNamedGraphs: opts.finalize === false ? 'preserve' : 'reject' }) + : undefined; if (opts.share === true && (!quads || quads.length === 0 || opts.finalize === false)) { throw new Error('--share requires non-empty payload quads and finalize enabled'); } diff --git a/packages/cli/src/rdf-parser.ts b/packages/cli/src/rdf-parser.ts index a6a86d7fd5..c8969144c1 100644 --- a/packages/cli/src/rdf-parser.ts +++ b/packages/cli/src/rdf-parser.ts @@ -9,6 +9,11 @@ export interface SimpleQuad { graph: string; } +export interface RdfParseOptions { + /** Sealing cannot preserve JSON-LD named graphs; legacy quad inputs keep their existing contract. */ + jsonLdNamedGraphs?: 'preserve' | 'reject'; +} + export type RdfFormat = 'nquads' | 'ntriples' | 'turtle' | 'trig' | 'json' | 'jsonld'; const EXTENSION_MAP: Record = { @@ -46,6 +51,7 @@ export async function parseRdf( format: RdfFormat, defaultGraph: string, baseIRI?: string, + parseOptions: RdfParseOptions = {}, ): Promise { if (format === 'json' || format === 'jsonld') { const parsed: unknown = JSON.parse(content); @@ -79,7 +85,15 @@ export async function parseRdf( throw error; } if (typeof nquads !== 'string') throw new Error('JSON-LD conversion did not return N-Quads'); - return parseRdf(nquads, 'nquads', defaultGraph); + // Inspect graph identity before applying the caller's default placement graph. + const quads = await parseRdf(nquads, 'nquads', ''); + if (parseOptions.jsonLdNamedGraphs === 'reject' && quads.some((quad) => quad.graph !== '')) { + throw new Error( + 'JSON-LD named graphs cannot be finalized yet. Use ka create --no-finalize to keep them in Working Memory, ' + + 'or rewrite the document into the default graph before finalizing or sharing.', + ); + } + return quads.map((quad) => ({ ...quad, graph: quad.graph || defaultGraph })); } // N3 parser handles N-Triples, N-Quads, Turtle, TriG diff --git a/packages/cli/test/knowledge-asset-cli-smoke.test.ts b/packages/cli/test/knowledge-asset-cli-smoke.test.ts index 50b1942ea9..9bf0d7d9a2 100644 --- a/packages/cli/test/knowledge-asset-cli-smoke.test.ts +++ b/packages/cli/test/knowledge-asset-cli-smoke.test.ts @@ -41,6 +41,15 @@ describe.sequential('knowledge-asset CLI smoke', () => { '', ].join('\n'), ); + await writeFile(join(dkgHome, 'named.jsonld'), JSON.stringify({ + '@context': { name: 'http://schema.org/name' }, + '@id': 'urn:graph:named', + '@graph': [{ '@id': 'urn:company:named', name: 'Named' }], + })); + await writeFile(join(dkgHome, 'legacy.jsonld'), JSON.stringify([{ + subject: 'urn:company:legacy', predicate: 'http://schema.org/name', + object: '"Legacy"', graph: '', + }])); await writeFile( join(dkgHome, 'attestation.json'), JSON.stringify(PRE_SIGNED_AUTHOR_ATTESTATION), @@ -345,6 +354,42 @@ describe.sequential('knowledge-asset CLI smoke', () => { ]); }, 30000); + it('rejects finalizing named-graph JSON-LD before any daemon request', async () => { + calls = []; + await expect(runCli([ + 'ka', 'create', 'named', '-c', 'research', '-f', join(dkgHome, 'named.jsonld'), + ], testEnv(dkgHome, smokeApiPort))).rejects.toMatchObject({ + stderr: expect.stringContaining('--no-finalize'), + }); + expect(calls).toEqual([]); + }, 30000); + + it('preserves named-graph JSON-LD in a WM-only create', async () => { + calls = []; + await runCli([ + 'ka', 'create', 'named', '-c', 'research', '-f', join(dkgHome, 'named.jsonld'), + '--no-finalize', + ], testEnv(dkgHome, smokeApiPort)); + expect(calls.find((call) => call.url === '/api/knowledge-assets')?.body).toMatchObject({ + finalize: false, + quads: [{ subject: 'urn:company:named', predicate: 'http://schema.org/name', + object: '"Named"', graph: 'urn:graph:named' }], + }); + }, 30000); + + it('still submits legacy JSON-LD quad arrays for default-finalizing create', async () => { + calls = []; + await runCli([ + 'ka', 'create', 'legacy', '-c', 'research', '-f', join(dkgHome, 'legacy.jsonld'), + ], testEnv(dkgHome, smokeApiPort)); + const body = calls.find((call) => call.url === '/api/knowledge-assets')?.body; + expect(body?.finalize).toBeUndefined(); + expect(body?.quads).toEqual([{ + subject: 'urn:company:legacy', predicate: 'http://schema.org/name', + object: '"Legacy"', graph: '', + }]); + }, 30000); + it('preserves named graph metadata when the first parsed quad is default graph', async () => { calls = []; const env = testEnv(dkgHome, smokeApiPort); From b69c79dfa0d4b20a46aed5fbb4cf66d9195a8248 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Tue, 8 Sep 2026 00:56:41 +0200 Subject: [PATCH 4/8] fix(cli): keep JSON-LD policy errors loader-owned --- packages/cli/src/rdf-parser.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/rdf-parser.ts b/packages/cli/src/rdf-parser.ts index c8969144c1..b3371b6f89 100644 --- a/packages/cli/src/rdf-parser.ts +++ b/packages/cli/src/rdf-parser.ts @@ -66,22 +66,24 @@ export async function parseRdf( } const { default: jsonld } = await import('jsonld'); const remoteContextError = new Error('Remote JSON-LD contexts are disabled; embed an inline @context before ingesting the file'); + let remoteLoadAttempted = false; // jsonld.js 8 supports safe mode; the older upstream declaration omits it. const options: JsonLdOptions.ToRdf & { safe: true } = { format: 'application/n-quads', base: baseIRI, safe: true, - documentLoader: async () => { throw remoteContextError; }, + documentLoader: async () => { + remoteLoadAttempted = true; + throw remoteContextError; + }, }; let nquads: object | string; try { // jsonld.js validates the JSON-LD grammar and rejects lossy expansion. nquads = await jsonld.toRDF(parsed as JsonLdDocument, options); } catch (error) { - // Preserve the actionable policy error that jsonld.js wraps while loading. - if (isRecord(error) && isRecord(error.details) && error.details.cause === remoteContextError) { - throw remoteContextError; - } + // The loader owns this policy error, regardless of how jsonld.js wraps it. + if (remoteLoadAttempted) throw remoteContextError; throw error; } if (typeof nquads !== 'string') throw new Error('JSON-LD conversion did not return N-Quads'); From 38e8dc6972c892d7b0cc2fad0db135d7838d6ea0 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Tue, 8 Sep 2026 01:57:13 +0200 Subject: [PATCH 5/8] refactor(cli): keep JSON-LD lifecycle policy at the KA boundary --- packages/cli/src/cli-helpers.ts | 24 +++++++------ packages/cli/src/commands/knowledge-asset.ts | 34 +++++++----------- packages/cli/src/rdf-parser.ts | 36 ++++++++++--------- .../test/knowledge-asset-cli-smoke.test.ts | 6 ++-- packages/cli/test/rdf-parser.test.ts | 13 ++++++- 5 files changed, 60 insertions(+), 53 deletions(-) diff --git a/packages/cli/src/cli-helpers.ts b/packages/cli/src/cli-helpers.ts index 9067f4de77..059f5bd652 100644 --- a/packages/cli/src/cli-helpers.ts +++ b/packages/cli/src/cli-helpers.ts @@ -1,4 +1,4 @@ -import type { RdfParseOptions } from './rdf-parser.js'; +import type { ParsedRdf, SimpleQuad } from './rdf-parser.js'; import { Command } from 'commander'; import { readFileSync } from 'node:fs'; import { createInterface } from 'node:readline'; @@ -149,11 +149,10 @@ function loadStructuredFile(filePath: string): any { return yaml.load(content); } -async function loadQuadsFromInput( +async function loadRdfFromInput( opts: ActionOpts, defaultGraph: string, - parseOptions?: RdfParseOptions, -): Promise> { +): Promise { const rdfParser = await import('./rdf-parser.js'); if (opts.file) { @@ -161,31 +160,35 @@ async function loadQuadsFromInput( const { pathToFileURL } = await import('node:url'); const raw = await readFile(opts.file, 'utf-8'); const format = opts.format ?? rdfParser.detectFormat(opts.file); - const quads = await rdfParser.parseRdf(raw, format, defaultGraph, pathToFileURL(opts.file).href, parseOptions); - console.log(`Parsed ${quads.length} quad(s) from ${opts.file} (${format})`); - return quads; + const input = await rdfParser.parseRdfInput(raw, format, defaultGraph, pathToFileURL(opts.file).href); + console.log(`Parsed ${input.quads.length} quad(s) from ${opts.file} (${format})`); + return input; } if (opts.triples) { const parsed = JSON.parse(opts.triples); - return parsed.map((q: Record) => ({ ...q, graph: q.graph || defaultGraph })); + return { sourceKind: 'legacy-quads', quads: parsed.map((q: Record) => ({ ...q, graph: q.graph || defaultGraph })) }; } if (opts.subject && opts.predicate && opts.object) { - return [{ + return { sourceKind: 'legacy-quads', quads: [{ subject: opts.subject, predicate: opts.predicate, object: opts.object.startsWith('"') || opts.object.startsWith('http') || opts.object.startsWith('did:') ? opts.object : `"${opts.object}"`, graph: defaultGraph, - }]; + }] }; } console.error(`Provide --file (${rdfParser.supportedExtensions().join(', ')}), --triples, or --subject/--predicate/--object`); process.exit(1); } +async function loadQuadsFromInput(opts: ActionOpts, defaultGraph: string): Promise { + return (await loadRdfFromInput(opts, defaultGraph)).quads; +} + function probeHostForApiHost(apiHost: string | undefined): string { if (!apiHost || apiHost === '0.0.0.0') return '127.0.0.1'; if (apiHost === '::') return '::1'; @@ -377,6 +380,7 @@ export { parseOptionalVerifyTimeoutOption, loadStructuredFile, loadQuadsFromInput, + loadRdfFromInput, resolveDaemonEntryPoint, probeHostForApiHost, selectedDkgHomeForEnv, diff --git a/packages/cli/src/commands/knowledge-asset.ts b/packages/cli/src/commands/knowledge-asset.ts index d0a71babe3..18cbc10e23 100644 --- a/packages/cli/src/commands/knowledge-asset.ts +++ b/packages/cli/src/commands/knowledge-asset.ts @@ -1,4 +1,3 @@ -import type { RdfParseOptions } from '../rdf-parser.js'; import { Command } from 'commander'; import { toErrorMessage } from '@origintrail-official/dkg-core'; import { @@ -8,12 +7,11 @@ import { type KnowledgeAssetPublishResponse, type KnowledgeAssetShareJobState, type KnowledgeAssetShareResponse, - type KnowledgeAssetWritableQuad, type PreSignedAuthorAttestationPayload, } from '../api-client.js'; import { formatQuadObject, - loadQuadsFromInput, + loadRdfFromInput, loadStructuredFile, type ActionOpts, } from '../cli-helpers.js'; @@ -54,21 +52,8 @@ function hasQuadInput(opts: ActionOpts): boolean { ); } -async function loadWritableQuads(opts: ActionOpts, parseOptions?: RdfParseOptions): Promise { - const quads = await loadQuadsFromInput( - { - ...opts, - file: inputFilePath(opts), - }, - '', - parseOptions, - ); - return quads.map((quad) => ({ - subject: quad.subject, - predicate: quad.predicate, - object: quad.object, - graph: quad.graph ?? '', - })); +async function loadWritableInput(opts: ActionOpts) { + return loadRdfFromInput({ ...opts, file: inputFilePath(opts) }, ''); } function parsePreSignedAuthorAttestation(raw: unknown): PreSignedAuthorAttestationPayload | undefined { @@ -245,9 +230,14 @@ export function registerKnowledgeAssetCommand(program: Command): void { )))) .action(async (name: string, opts: ActionOpts) => runAction(async () => { const contextGraphId = requiredContextGraphId(opts); - const quads = hasQuadInput(opts) - ? await loadWritableQuads(opts, { jsonLdNamedGraphs: opts.finalize === false ? 'preserve' : 'reject' }) - : undefined; + const input = hasQuadInput(opts) ? await loadWritableInput(opts) : undefined; + const quads = input?.quads; + if (opts.finalize !== false && input?.sourceKind === 'jsonld' && quads?.some((quad) => quad.graph !== '')) { + throw new Error( + 'JSON-LD named graphs cannot be finalized yet. Use ka create --no-finalize to keep them in Working Memory, ' + + 'or rewrite the document into the default graph before finalizing or sharing.', + ); + } if (opts.share === true && (!quads || quads.length === 0 || opts.finalize === false)) { throw new Error('--share requires non-empty payload quads and finalize enabled'); } @@ -283,7 +273,7 @@ export function registerKnowledgeAssetCommand(program: Command): void { ))) .action(async (name: string, opts: ActionOpts) => runAction(async () => { const contextGraphId = requiredContextGraphId(opts); - const quads = await loadWritableQuads(opts); + const { quads } = await loadWritableInput(opts); const client = await ApiClient.connect(); const result = await client.knowledgeAssetWrite(contextGraphId, name, quads, { ...(subGraphName(opts) ? { subGraphName: subGraphName(opts) } : {}), diff --git a/packages/cli/src/rdf-parser.ts b/packages/cli/src/rdf-parser.ts index b3371b6f89..f04ee57ed8 100644 --- a/packages/cli/src/rdf-parser.ts +++ b/packages/cli/src/rdf-parser.ts @@ -9,9 +9,10 @@ export interface SimpleQuad { graph: string; } -export interface RdfParseOptions { - /** Sealing cannot preserve JSON-LD named graphs; legacy quad inputs keep their existing contract. */ - jsonLdNamedGraphs?: 'preserve' | 'reject'; +/** Neutral syntax provenance; consumers own policies for the resulting dataset. */ +export interface ParsedRdf { + sourceKind: 'jsonld' | 'legacy-quads' | 'rdf'; + quads: SimpleQuad[]; } export type RdfFormat = 'nquads' | 'ntriples' | 'turtle' | 'trig' | 'json' | 'jsonld'; @@ -46,20 +47,19 @@ export function supportedExtensions(): string[] { * For formats without named graph support (N-Triples, Turtle), * the defaultGraph is used. */ -export async function parseRdf( +export async function parseRdfInput( content: string, format: RdfFormat, defaultGraph: string, baseIRI?: string, - parseOptions: RdfParseOptions = {}, -): Promise { +): Promise { if (format === 'json' || format === 'jsonld') { const parsed: unknown = JSON.parse(content); const legacy = decodeLegacyQuads( format === 'json' && isRecord(parsed) ? parsed.quads : parsed, defaultGraph, ); - if (legacy) return legacy; + if (legacy) return { sourceKind: 'legacy-quads', quads: legacy }; if (format === 'json') throw new Error('JSON input must contain an array of subject/predicate/object quads'); if (parsed === null || typeof parsed !== 'object') { throw new Error('JSON-LD input must be an object or array'); @@ -87,15 +87,7 @@ export async function parseRdf( throw error; } if (typeof nquads !== 'string') throw new Error('JSON-LD conversion did not return N-Quads'); - // Inspect graph identity before applying the caller's default placement graph. - const quads = await parseRdf(nquads, 'nquads', ''); - if (parseOptions.jsonLdNamedGraphs === 'reject' && quads.some((quad) => quad.graph !== '')) { - throw new Error( - 'JSON-LD named graphs cannot be finalized yet. Use ka create --no-finalize to keep them in Working Memory, ' - + 'or rewrite the document into the default graph before finalizing or sharing.', - ); - } - return quads.map((quad) => ({ ...quad, graph: quad.graph || defaultGraph })); + return { sourceKind: 'jsonld', quads: await parseRdf(nquads, 'nquads', defaultGraph) }; } // N3 parser handles N-Triples, N-Quads, Turtle, TriG @@ -108,7 +100,7 @@ export async function parseRdf( parser.parse(content, (error: Error | null, quad: N3Quad | null) => { if (error) { reject(error); return; } - if (!quad) { resolve(quads); return; } + if (!quad) { resolve({ sourceKind: 'rdf', quads }); return; } quads.push({ subject: termToString(quad.subject), @@ -120,6 +112,16 @@ export async function parseRdf( }); } +/** Array-only compatibility facade for callers that do not need source provenance. */ +export async function parseRdf( + content: string, + format: RdfFormat, + defaultGraph: string, + baseIRI?: string, +): Promise { + return (await parseRdfInput(content, format, defaultGraph, baseIRI)).quads; +} + function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } diff --git a/packages/cli/test/knowledge-asset-cli-smoke.test.ts b/packages/cli/test/knowledge-asset-cli-smoke.test.ts index 9bf0d7d9a2..1bd6913f37 100644 --- a/packages/cli/test/knowledge-asset-cli-smoke.test.ts +++ b/packages/cli/test/knowledge-asset-cli-smoke.test.ts @@ -48,7 +48,7 @@ describe.sequential('knowledge-asset CLI smoke', () => { })); await writeFile(join(dkgHome, 'legacy.jsonld'), JSON.stringify([{ subject: 'urn:company:legacy', predicate: 'http://schema.org/name', - object: '"Legacy"', graph: '', + object: '"Legacy"', graph: 'urn:graph:legacy', }])); await writeFile( join(dkgHome, 'attestation.json'), @@ -377,7 +377,7 @@ describe.sequential('knowledge-asset CLI smoke', () => { }); }, 30000); - it('still submits legacy JSON-LD quad arrays for default-finalizing create', async () => { + it('still submits legacy JSON-LD named-graph quad arrays for default-finalizing create', async () => { calls = []; await runCli([ 'ka', 'create', 'legacy', '-c', 'research', '-f', join(dkgHome, 'legacy.jsonld'), @@ -386,7 +386,7 @@ describe.sequential('knowledge-asset CLI smoke', () => { expect(body?.finalize).toBeUndefined(); expect(body?.quads).toEqual([{ subject: 'urn:company:legacy', predicate: 'http://schema.org/name', - object: '"Legacy"', graph: '', + object: '"Legacy"', graph: 'urn:graph:legacy', }]); }, 30000); diff --git a/packages/cli/test/rdf-parser.test.ts b/packages/cli/test/rdf-parser.test.ts index da7238e07d..15a0d1f11c 100644 --- a/packages/cli/test/rdf-parser.test.ts +++ b/packages/cli/test/rdf-parser.test.ts @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os'; import { pathToFileURL } from 'node:url'; import { Parser } from 'n3'; import { OxigraphStore } from '@origintrail-official/dkg-storage'; -import { detectFormat, supportedExtensions, parseRdf } from '../src/rdf-parser.js'; +import { detectFormat, supportedExtensions, parseRdf, parseRdfInput } from '../src/rdf-parser.js'; describe('detectFormat', () => { it.each([ @@ -123,6 +123,17 @@ describe('parseRdf', () => { ]); }); + it('reports syntax provenance while preserving named graphs independently of lifecycle policy', async () => { + const quads = [{ subject: 'urn:s', predicate: 'urn:p', object: '"v"', graph: 'urn:g' }]; + const document = { '@id': 'urn:g', '@graph': [{ '@id': 'urn:s', 'urn:p': 'v' }] }; + expect(await parseRdfInput(JSON.stringify(document), 'jsonld', DEFAULT_GRAPH)) + .toEqual({ sourceKind: 'jsonld', quads }); + expect(await parseRdfInput(JSON.stringify(quads), 'jsonld', DEFAULT_GRAPH)) + .toEqual({ sourceKind: 'legacy-quads', quads }); + expect(await parseRdfInput(' "v" .', 'nquads', DEFAULT_GRAPH)) + .toEqual({ sourceKind: 'rdf', quads }); + }); + it('preserves named graphs, language/datatype literals and nested blank-node links', async () => { const content = JSON.stringify({ '@context': { ex: 'https://example.org/', label: 'ex:label', count: 'ex:count', detail: 'ex:detail' }, From 0ff75d84f046b9830f8e086f100bf07e7dcc085e Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Tue, 8 Sep 2026 02:57:28 +0200 Subject: [PATCH 6/8] fix(cli): preserve JSON-LD blank-node writes through finalization --- packages/cli/src/cli-helpers.ts | 3 +- packages/cli/src/daemon/http-utils.ts | 41 ++++++----- .../cli/src/daemon/routes/knowledge-assets.ts | 6 +- packages/cli/src/daemon/routes/memory.ts | 1 - ...ssue-306-787-write-quad-validation.test.ts | 2 +- .../jsonld-knowledge-asset-lifecycle.test.ts | 69 +++++++++++++++++++ .../test/knowledge-asset-cli-smoke.test.ts | 24 +++++++ packages/cli/test/writable-quad-terms.test.ts | 21 ++++++ packages/cli/vitest.unit.config.ts | 1 + 9 files changed, 143 insertions(+), 25 deletions(-) create mode 100644 packages/cli/test/jsonld-knowledge-asset-lifecycle.test.ts create mode 100644 packages/cli/test/writable-quad-terms.test.ts diff --git a/packages/cli/src/cli-helpers.ts b/packages/cli/src/cli-helpers.ts index 059f5bd652..8c7cae284e 100644 --- a/packages/cli/src/cli-helpers.ts +++ b/packages/cli/src/cli-helpers.ts @@ -166,8 +166,7 @@ async function loadRdfFromInput( } if (opts.triples) { - const parsed = JSON.parse(opts.triples); - return { sourceKind: 'legacy-quads', quads: parsed.map((q: Record) => ({ ...q, graph: q.graph || defaultGraph })) }; + return rdfParser.parseRdfInput(opts.triples, 'json', defaultGraph); } if (opts.subject && opts.predicate && opts.object) { diff --git a/packages/cli/src/daemon/http-utils.ts b/packages/cli/src/daemon/http-utils.ts index 26bce66229..3cffd98c5c 100644 --- a/packages/cli/src/daemon/http-utils.ts +++ b/packages/cli/src/daemon/http-utils.ts @@ -266,26 +266,31 @@ export function validateWritableQuadLiteralSizes( } } -/** - * GH #306 / #787 (follow-up) — validate each quad's `object` term is either a - * quoted RDF literal (`"…"`) or an absolute IRI. Shared by lifecycle write - * routes and other quad-accepting validation paths: the shape guard - * ({@link isWritableQuad}) only checks that fields - * are strings, so an object that is neither a literal nor an IRI (e.g. a bare - * word `hello` or a number `123`) slips past them and crashes the RDF parser - * with an uncaught "No scheme found in an absolute IRI" → HTTP 500 instead of an - * actionable 400. - */ -export function validateQuadObjectTerms( +// N-Triples BLANK_NODE_LABEL (including its Unicode name ranges). +// https://www.w3.org/TR/n-triples/#grammar-production-BLANK_NODE_LABEL +const PN_CHARS_BASE = String.raw`A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\u{10000}-\u{EFFFF}`; +const PN_CHARS_U = `${PN_CHARS_BASE}_:`; +const PN_CHARS = String.raw`${PN_CHARS_U}0-9\-\u00B7\u0300-\u036F\u203F-\u2040`; +const BLANK_NODE_LABEL = new RegExp(`^_:[${PN_CHARS_U}0-9](?:[${PN_CHARS}.]*[${PN_CHARS}])?$`, 'u'); + +/** Preserve valid RDF blank-node links while rejecting unsafe labels before storage. */ +export function validateWritableQuadTerms( label: string, - quads: ReadonlyArray<{ object: string }>, + quads: ReadonlyArray<{ subject: string; object: string }>, ): string | null { - const badIndex = quads.findIndex((q) => { - const object = q.object.trim(); - return !object.startsWith('"') && !isSafeIri(object); - }); - if (badIndex === -1) return null; - return `Invalid "${label}[${badIndex}].object": RDF object must be a quoted literal term or absolute IRI`; + for (const [index, quad] of quads.entries()) { + if (quad.subject.trim().startsWith('_:') && !BLANK_NODE_LABEL.test(quad.subject)) { + return `Invalid "${label}[${index}].subject": RDF blank node must have a valid blank-node label`; + } + const object = quad.object.trim(); + const validObject = object.startsWith('_:') + ? BLANK_NODE_LABEL.test(quad.object) + : object.startsWith('"') || isSafeIri(object); + if (!validObject) { + return `Invalid "${label}[${index}].object": RDF object must be a quoted literal term, absolute IRI, or valid blank-node label`; + } + } + return null; } /** diff --git a/packages/cli/src/daemon/routes/knowledge-assets.ts b/packages/cli/src/daemon/routes/knowledge-assets.ts index 72a2b32688..b2fb82701a 100644 --- a/packages/cli/src/daemon/routes/knowledge-assets.ts +++ b/packages/cli/src/daemon/routes/knowledge-assets.ts @@ -37,7 +37,7 @@ import { validateOptionalSubGraphName, validateRequiredContextGraphId, isWritableQuad, - validateQuadObjectTerms, + validateWritableQuadTerms, respondIfReconcileUnavailable, respondIfStoreUnavailable, classifyStoreUnavailable, @@ -1353,8 +1353,8 @@ export async function handleKnowledgeAssetsRoutes(ctx: RequestContext): Promise< return jsonResponse(res, 400, { error: '"quads" must be an array of { subject, predicate, object } objects (graph optional); string-shaped quads are not accepted' }); } // GH #306/#787 (follow-up) — reject objects that are neither a quoted - // literal nor an absolute IRI before they reach (and crash) the parser. - const wmObjErr = validateQuadObjectTerms("quads", parsed.quads); + // literal, absolute IRI or valid blank node before they reach storage. + const wmObjErr = validateWritableQuadTerms("quads", parsed.quads); if (wmObjErr) return jsonResponse(res, 400, { error: wmObjErr }); const literalSize = validateWritableQuadLiteralSizes("quads", parsed.quads); if (!literalSize.ok) return jsonResponse(res, 400, literalSize.body); diff --git a/packages/cli/src/daemon/routes/memory.ts b/packages/cli/src/daemon/routes/memory.ts index fd30bf294d..144d03a8c5 100644 --- a/packages/cli/src/daemon/routes/memory.ts +++ b/packages/cli/src/daemon/routes/memory.ts @@ -207,7 +207,6 @@ import { import { resolveNameToPeerId, isWritableQuad, - validateQuadObjectTerms, validateWritableQuadLiteralSizes, oversizedRdfLiteralResponseBody, jsonResponse, diff --git a/packages/cli/test/issue-306-787-write-quad-validation.test.ts b/packages/cli/test/issue-306-787-write-quad-validation.test.ts index 69b18b4578..445e4a1ce7 100644 --- a/packages/cli/test/issue-306-787-write-quad-validation.test.ts +++ b/packages/cli/test/issue-306-787-write-quad-validation.test.ts @@ -91,7 +91,7 @@ describe('GH #306 — POST /api/knowledge-assets/{name}/wm/write quad-shape vali * an absolute IRI (e.g. a bare word `hello`, a number `123`) passes the shape * guard (isWritableQuad checks only that the fields are strings) but then crashes * the RDF parser with an uncaught "No scheme found in an absolute IRI" → HTTP 500. - * The fix runs `validateQuadObjectTerms` on the write routes too (it already ran + * The fix runs `validateWritableQuadTerms` on the write routes too (it already ran * on publish), so these now return an actionable 400. */ describe('GH #306/#787 follow-up — malformed object TERM is 4xx, not a 500 parser crash', () => { diff --git a/packages/cli/test/jsonld-knowledge-asset-lifecycle.test.ts b/packages/cli/test/jsonld-knowledge-asset-lifecycle.test.ts new file mode 100644 index 0000000000..8afb4fd7b1 --- /dev/null +++ b/packages/cli/test/jsonld-knowledge-asset-lifecycle.test.ts @@ -0,0 +1,69 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFile } from 'node:child_process'; +import { writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { startLiveDaemon, stopLiveDaemon, postJson, getJson, type LiveDaemon } from './helpers/live-daemon.js'; + +const execFileAsync = promisify(execFile); +const CLI_ENTRY = resolve(import.meta.dirname, '../dist/cli.js'); +const CG = 'jsonld-write-lifecycle'; +const RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; +let daemon: LiveDaemon | undefined; + +async function runCli(args: string[]) { + if (!daemon) throw new Error('daemon is not ready'); + return execFileAsync(process.execPath, [CLI_ENTRY, ...args], { + env: { ...process.env, DKG_HOME: daemon.home, DKG_API_PORT: String(daemon.apiPort), DKG_AUTH_TOKEN: daemon.token ?? '' }, + timeout: 120_000, + }); +} + +beforeAll(async () => { + daemon = await startLiveDaemon({ authEnabled: true }); + const created = await postJson(daemon, '/api/context-graph/create', { id: CG, name: CG, accessPolicy: 1 }); + expect(created.status, JSON.stringify(created.body)).toBeLessThan(300); + const registered = await postJson(daemon, '/api/context-graph/register', { id: CG, accessPolicy: 1 }); + expect(registered.status, JSON.stringify(registered.body)).toBe(200); +}, 120_000); + +afterAll(async () => { await stopLiveDaemon(daemon); }); + +type Quad = { subject: string; predicate: string; object: string; graph: string }; +function assertLinks(quads: Quad[], kind: 'list' | 'nested') { + const root = quads.find((q) => q.subject === `urn:${kind}` && q.predicate === `https://example.org/${kind === 'list' ? 'items' : 'child'}`); + expect(root).toBeDefined(); + if (kind === 'nested') { + expect(quads).toContainEqual(expect.objectContaining({ subject: root!.object, predicate: 'https://example.org/name', object: '"nested"' })); + } else { + const first = quads.find((q) => q.subject === root!.object && q.predicate === `${RDF}first`); + const rest = quads.find((q) => q.subject === root!.object && q.predicate === `${RDF}rest`); + expect(first?.object).toBe('urn:first'); + expect(rest).toBeDefined(); + expect(quads).toContainEqual(expect.objectContaining({ subject: rest!.object, predicate: `${RDF}first`, object: 'urn:second' })); + expect(quads).toContainEqual(expect.objectContaining({ subject: rest!.object, predicate: `${RDF}rest`, object: `${RDF}nil` })); + } +} + +describe('JSON-LD CLI writes through the real daemon', () => { + it.each(['list', 'nested'] as const)('writes and finalizes linked %s RDF without losing blank-node edges', async (kind) => { + const document = kind === 'list' + ? { '@id': 'urn:list', 'https://example.org/items': { '@list': [{ '@id': 'urn:first' }, { '@id': 'urn:second' }] } } + : { '@id': 'urn:nested', 'https://example.org/child': { 'https://example.org/name': 'nested' } }; + const path = join(daemon!.home, `${kind}.jsonld`); + await writeFile(path, JSON.stringify(document)); + await runCli(['ka', 'create', kind, '-c', CG, '--no-finalize']); + await runCli(['ka', 'write', kind, '-c', CG, '-f', path]); + const before = await getJson(daemon!, `/api/knowledge-assets/${kind}/wm/quads?contextGraphId=${CG}`); + expect(before.status, JSON.stringify(before.body)).toBe(200); + const beforeQuads = before.body.quads as Quad[]; + expect(beforeQuads.some((q) => q.object.startsWith('_:'))).toBe(true); + assertLinks(beforeQuads, kind); + await runCli(['ka', 'finalize', kind, '-c', CG]); + const after = await getJson(daemon!, `/api/knowledge-assets/${kind}/wm/quads?contextGraphId=${CG}`); + expect(after.status, JSON.stringify(after.body)).toBe(200); + const afterQuads = after.body.quads as Quad[]; + expect(afterQuads.some((q) => q.subject.startsWith('_:') || q.object.startsWith('_:'))).toBe(false); + assertLinks(afterQuads, kind); + }); +}); diff --git a/packages/cli/test/knowledge-asset-cli-smoke.test.ts b/packages/cli/test/knowledge-asset-cli-smoke.test.ts index 1bd6913f37..a6579a8aba 100644 --- a/packages/cli/test/knowledge-asset-cli-smoke.test.ts +++ b/packages/cli/test/knowledge-asset-cli-smoke.test.ts @@ -46,6 +46,10 @@ describe.sequential('knowledge-asset CLI smoke', () => { '@id': 'urn:graph:named', '@graph': [{ '@id': 'urn:company:named', name: 'Named' }], })); + await writeFile(join(dkgHome, 'mixed.jsonld'), JSON.stringify([ + { '@id': 'urn:company:default', 'http://schema.org/name': 'Default' }, + { '@id': 'urn:graph:named', '@graph': [{ '@id': 'urn:company:named', 'http://schema.org/name': 'Named' }] }, + ])); await writeFile(join(dkgHome, 'legacy.jsonld'), JSON.stringify([{ subject: 'urn:company:legacy', predicate: 'http://schema.org/name', object: '"Legacy"', graph: 'urn:graph:legacy', @@ -364,6 +368,26 @@ describe.sequential('knowledge-asset CLI smoke', () => { expect(calls).toEqual([]); }, 30000); + it('rejects a mixed default/named JSON-LD dataset before any daemon request', async () => { + calls = []; + await expect(runCli([ + 'ka', 'create', 'mixed', '-c', 'research', '-f', join(dkgHome, 'mixed.jsonld'), + ], testEnv(dkgHome, smokeApiPort))).rejects.toMatchObject({ stderr: expect.stringContaining('--no-finalize') }); + expect(calls).toEqual([]); + }, 30000); + + it.each([null, [{ subject: 'urn:invalid', object: '"missing predicate"' }]])('uses the same legacy validation for JSON files and --triples: %j', async (input) => { + calls = []; + const raw = JSON.stringify(input); + const path = join(dkgHome, 'invalid-quads.json'); + await writeFile(path, raw); + for (const inputArgs of [['--file', path], ['--triples', raw]]) { + await expect(runCli(['ka', 'write', 'invalid', '-c', 'research', ...inputArgs], testEnv(dkgHome, smokeApiPort))) + .rejects.toMatchObject({ stderr: expect.stringContaining('JSON input must contain an array of subject/predicate/object quads') }); + } + expect(calls).toEqual([]); + }, 30000); + it('preserves named-graph JSON-LD in a WM-only create', async () => { calls = []; await runCli([ diff --git a/packages/cli/test/writable-quad-terms.test.ts b/packages/cli/test/writable-quad-terms.test.ts new file mode 100644 index 0000000000..3c4c362931 --- /dev/null +++ b/packages/cli/test/writable-quad-terms.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { validateWritableQuadTerms } from '../src/daemon/http-utils.js'; + +const validLabels = ['_:b0', '_:0', '_:a.b', '_:b-1', '_:é', '_:节点', '_:a\u0300', '_:\u{10000}', '_::x']; +const invalidLabels = ['_:', '_:-x', '_:.x', '_:x.', '_:a b', '_:a\n', '_:a', '_:a#comment', '_:a\\u0062', '_:\u0300', '_:\ud800', '_:\u{F0000}', ' _:b0', '_:b0 ']; + +describe.each(['subject', 'object'] as const)('writable RDF blank-node %s', (field) => { + it.each(validLabels)('accepts the complete label %s', (term) => { + expect(validateWritableQuadTerms('quads', [{ subject: 'urn:s', object: '"value"', [field]: term }])).toBeNull(); + }); + it.each(invalidLabels)('rejects malformed or injectable label %s', (term) => { + expect(validateWritableQuadTerms('quads', [{ subject: 'urn:s', object: '"value"', [field]: term }])).toContain(`quads[0].${field}`); + }); +}); + +it.each(['"literal"', 'urn:o', 'https://example.org/o'])('preserves ordinary RDF object %s', (object) => { + expect(validateWritableQuadTerms('quads', [{ subject: 'urn:s', object }])).toBeNull(); +}); +it.each(['hello', '123', 'urn:o> . { + expect(validateWritableQuadTerms('quads', [{ subject: 'urn:s', object }])).toContain('quads[0].object'); +}); diff --git a/packages/cli/vitest.unit.config.ts b/packages/cli/vitest.unit.config.ts index c5a33227bc..259c512f7e 100644 --- a/packages/cli/vitest.unit.config.ts +++ b/packages/cli/vitest.unit.config.ts @@ -199,6 +199,7 @@ export default defineConfig({ // OxigraphWorkerStore; no hardhat needed. 'test/write-preflight-resilience.test.ts', 'test/http-literal-size-validation.test.ts', + 'test/writable-quad-terms.test.ts', // CLI subprocess smoke with stub daemon only; no hardhat needed. 'test/context-graph-join-policy-cli.test.ts', 'test/context-graph-join-policy-route.test.ts', From af7a9950e8fe34618ae0fb149b115d00316aecff Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 9 Sep 2026 23:56:04 +0200 Subject: [PATCH 7/8] refactor(cli): share a direct N3 parsing implementation --- packages/cli/src/rdf-parser.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/rdf-parser.ts b/packages/cli/src/rdf-parser.ts index f04ee57ed8..c29ffd5f4c 100644 --- a/packages/cli/src/rdf-parser.ts +++ b/packages/cli/src/rdf-parser.ts @@ -87,9 +87,17 @@ export async function parseRdfInput( throw error; } if (typeof nquads !== 'string') throw new Error('JSON-LD conversion did not return N-Quads'); - return { sourceKind: 'jsonld', quads: await parseRdf(nquads, 'nquads', defaultGraph) }; + return { sourceKind: 'jsonld', quads: await parseN3Quads(nquads, 'nquads', defaultGraph) }; } + return { sourceKind: 'rdf', quads: await parseN3Quads(content, format, defaultGraph) }; +} + +function parseN3Quads( + content: string, + format: Exclude, + defaultGraph: string, +): Promise { // N3 parser handles N-Triples, N-Quads, Turtle, TriG const n3Format = N3_FORMAT_MAP[format]; if (!n3Format) throw new Error(`Unsupported format: ${format}`); @@ -100,7 +108,7 @@ export async function parseRdfInput( parser.parse(content, (error: Error | null, quad: N3Quad | null) => { if (error) { reject(error); return; } - if (!quad) { resolve({ sourceKind: 'rdf', quads }); return; } + if (!quad) { resolve(quads); return; } quads.push({ subject: termToString(quad.subject), From 81c4e8fb4780863cc9c1b79e62b200fd564650d1 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Mon, 14 Sep 2026 23:36:51 +0200 Subject: [PATCH 8/8] chore(cli): retain JSON-LD workspace dependencies --- packages/cli/package.json | 1 + pnpm-lock.yaml | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/packages/cli/package.json b/packages/cli/package.json index 9f5f50d50f..4985ad13e1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -57,6 +57,7 @@ "@origintrail-official/dkg-node-ui": "workspace:*", "@origintrail-official/dkg-okf": "workspace:*", "@origintrail-official/dkg-publisher": "workspace:*", + "@origintrail-official/dkg-rdf-utils": "workspace:*", "@origintrail-official/dkg-storage": "workspace:*", "@toml-tools/parser": "1.0.0", "better-sqlite3": "12.11.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73b5ba03aa..d3c6c1b850 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -705,6 +705,9 @@ importers: '@origintrail-official/dkg-publisher': specifier: workspace:* version: link:../publisher + '@origintrail-official/dkg-rdf-utils': + specifier: workspace:* + version: link:../rdf-utils '@origintrail-official/dkg-storage': specifier: workspace:* version: link:../storage @@ -726,6 +729,9 @@ importers: jsonc-parser: specifier: 3.3.1 version: 3.3.1 + jsonld: + specifier: ^8.3.3 + version: 8.3.3(web-streams-polyfill@3.3.3) n3: specifier: ^2.0.1 version: 2.0.1 @@ -742,6 +748,9 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 + '@types/jsonld': + specifier: ^1.5.15 + version: 1.5.15 '@types/n3': specifier: ^1.26.1 version: 1.26.1 @@ -3761,6 +3770,9 @@ packages: '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/jsonld@1.5.15': + resolution: {integrity: sha512-PlAFPZjL+AuGYmwlqwKEL0IMP8M8RexH0NIPGfCVWSQ041H2rR/8OlyZSD7KsCVoN8vCfWdtWDBxX8yBVP+xow==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -10432,6 +10444,8 @@ snapshots: '@types/jsesc@2.5.1': {} + '@types/jsonld@1.5.15': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3