javascript-solid-server 0.0.153 → 0.0.154
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/rdf/turtle.js +53 -8
- package/src/webid/profile.js +21 -2
- package/test/turtle.test.js +104 -0
- package/test/webid.test.js +81 -0
package/package.json
CHANGED
package/src/rdf/turtle.js
CHANGED
|
@@ -189,10 +189,26 @@ function jsonLdToQuads(jsonLd, baseUri) {
|
|
|
189
189
|
|
|
190
190
|
const context = mergedContext;
|
|
191
191
|
|
|
192
|
-
|
|
192
|
+
// BFS over nodes so that nested node objects (e.g. CID `service[]` entries
|
|
193
|
+
// with their own @id/@type/properties) are emitted as their own subjects
|
|
194
|
+
// rather than collapsed to a bare URI reference.
|
|
195
|
+
//
|
|
196
|
+
// Two notes on the traversal shape:
|
|
197
|
+
// - Index-based iteration avoids O(n) array.shift() per step.
|
|
198
|
+
// - We deliberately do NOT skip re-emission when the same @id appears
|
|
199
|
+
// twice. Duplicate triples are harmless in RDF, and documents built
|
|
200
|
+
// from PATCH merges or multi-doc inputs can legitimately carry
|
|
201
|
+
// multiple objects for the same subject. The `enqueuedNested` set
|
|
202
|
+
// (by object identity) is used only to prevent the same nested
|
|
203
|
+
// object from being enqueued twice — i.e. cycle protection, not
|
|
204
|
+
// emission deduplication.
|
|
205
|
+
const enqueuedNested = new WeakSet();
|
|
206
|
+
const queue = [...nodes];
|
|
207
|
+
for (let i = 0; i < queue.length; i++) {
|
|
208
|
+
const node = queue[i];
|
|
193
209
|
if (!node['@id']) continue;
|
|
194
|
-
|
|
195
210
|
const subjectUri = resolveUri(node['@id'], baseUri);
|
|
211
|
+
|
|
196
212
|
const subject = subjectUri.startsWith('_:')
|
|
197
213
|
? blankNode(subjectUri.slice(2))
|
|
198
214
|
: namedNode(subjectUri);
|
|
@@ -227,6 +243,20 @@ function jsonLdToQuads(jsonLd, baseUri) {
|
|
|
227
243
|
if (object) {
|
|
228
244
|
quads.push(quad(subject, predicate, object));
|
|
229
245
|
}
|
|
246
|
+
// If v is a nested node (object with @id and at least one non-@value
|
|
247
|
+
// own property beyond @id), enqueue it so its triples are also
|
|
248
|
+
// emitted. Object-identity tracking (WeakSet) prevents the same
|
|
249
|
+
// nested object from being enqueued twice, which would otherwise
|
|
250
|
+
// loop for graphs that reuse an object reference (cycles).
|
|
251
|
+
if (v && typeof v === 'object' && !Array.isArray(v) &&
|
|
252
|
+
v['@id'] && v['@value'] === undefined &&
|
|
253
|
+
!enqueuedNested.has(v)) {
|
|
254
|
+
const hasOwnClaims = Object.keys(v).some(k => k !== '@id');
|
|
255
|
+
if (hasOwnClaims) {
|
|
256
|
+
enqueuedNested.add(v);
|
|
257
|
+
queue.push(v);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
230
260
|
}
|
|
231
261
|
}
|
|
232
262
|
}
|
|
@@ -378,9 +408,14 @@ function resolveUri(uri, baseUri) {
|
|
|
378
408
|
}
|
|
379
409
|
|
|
380
410
|
/**
|
|
381
|
-
* Expand prefixed URI using context
|
|
411
|
+
* Expand prefixed URI using context.
|
|
412
|
+
*
|
|
413
|
+
* The `seen` parameter guards against cycles in user-supplied contexts
|
|
414
|
+
* (e.g., `foo -> bar -> foo`). Without this a request carrying a malicious
|
|
415
|
+
* JSON-LD context could cause unbounded recursion / stack overflow on the
|
|
416
|
+
* server during conneg conversion — a remote DoS.
|
|
382
417
|
*/
|
|
383
|
-
function expandUri(uri, context) {
|
|
418
|
+
function expandUri(uri, context, seen) {
|
|
384
419
|
if (uri.includes('://')) {
|
|
385
420
|
return uri;
|
|
386
421
|
}
|
|
@@ -388,19 +423,29 @@ function expandUri(uri, context) {
|
|
|
388
423
|
if (uri.includes(':')) {
|
|
389
424
|
const [prefix, local] = uri.split(':', 2);
|
|
390
425
|
const ns = context[prefix] || COMMON_PREFIXES[prefix];
|
|
391
|
-
|
|
426
|
+
// Only concat when the prefix maps to a string namespace. A user-supplied
|
|
427
|
+
// context can legally define a prefix-looking key as a term-definition
|
|
428
|
+
// object; string-concatenating that would produce "[object Object]…".
|
|
429
|
+
if (typeof ns === 'string') {
|
|
392
430
|
return ns + local;
|
|
393
431
|
}
|
|
394
432
|
}
|
|
395
433
|
|
|
396
|
-
// Check if it's a term in context
|
|
434
|
+
// Check if it's a term in context. A context value can itself be a
|
|
435
|
+
// CURIE (`cid:service`) that still needs prefix expansion, so recurse —
|
|
436
|
+
// but only when we haven't already followed this term on the current
|
|
437
|
+
// expansion chain.
|
|
397
438
|
if (context[uri]) {
|
|
439
|
+
const chain = seen || new Set();
|
|
440
|
+
if (chain.has(uri)) return uri;
|
|
441
|
+
chain.add(uri);
|
|
398
442
|
const expansion = context[uri];
|
|
399
443
|
if (typeof expansion === 'string') {
|
|
400
|
-
return expansion;
|
|
444
|
+
return expansion === uri ? uri : expandUri(expansion, context, chain);
|
|
401
445
|
}
|
|
402
446
|
if (expansion['@id']) {
|
|
403
|
-
|
|
447
|
+
const id = expansion['@id'];
|
|
448
|
+
return id === uri ? uri : expandUri(id, context, chain);
|
|
404
449
|
}
|
|
405
450
|
}
|
|
406
451
|
|
package/src/webid/profile.js
CHANGED
|
@@ -12,6 +12,8 @@ const SOLID = 'http://www.w3.org/ns/solid/terms#';
|
|
|
12
12
|
const SCHEMA = 'http://schema.org/';
|
|
13
13
|
const LDP = 'http://www.w3.org/ns/ldp#';
|
|
14
14
|
const PIM = 'http://www.w3.org/ns/pim/space#';
|
|
15
|
+
const CID = 'https://www.w3.org/ns/cid/v1#';
|
|
16
|
+
const LWS = 'https://www.w3.org/ns/lws#';
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Generate JSON-LD data for a WebID profile
|
|
@@ -24,6 +26,9 @@ const PIM = 'http://www.w3.org/ns/pim/space#';
|
|
|
24
26
|
*/
|
|
25
27
|
export function generateProfileJsonLd({ webId, name, podUri, issuer }) {
|
|
26
28
|
const pod = podUri.endsWith('/') ? podUri : podUri + '/';
|
|
29
|
+
// Document URL is the WebID without its fragment; service entries use
|
|
30
|
+
// fragment ids resolved against it.
|
|
31
|
+
const docUrl = webId.split('#')[0];
|
|
27
32
|
|
|
28
33
|
return {
|
|
29
34
|
'@context': {
|
|
@@ -32,6 +37,8 @@ export function generateProfileJsonLd({ webId, name, podUri, issuer }) {
|
|
|
32
37
|
'schema': SCHEMA,
|
|
33
38
|
'pim': PIM,
|
|
34
39
|
'ldp': LDP,
|
|
40
|
+
'cid': CID,
|
|
41
|
+
'lws': LWS,
|
|
35
42
|
'inbox': { '@id': 'ldp:inbox', '@type': '@id' },
|
|
36
43
|
'storage': { '@id': 'pim:storage', '@type': '@id' },
|
|
37
44
|
'oidcIssuer': { '@id': 'solid:oidcIssuer', '@type': '@id' },
|
|
@@ -39,7 +46,9 @@ export function generateProfileJsonLd({ webId, name, podUri, issuer }) {
|
|
|
39
46
|
'publicTypeIndex': { '@id': 'solid:publicTypeIndex', '@type': '@id' },
|
|
40
47
|
'privateTypeIndex': { '@id': 'solid:privateTypeIndex', '@type': '@id' },
|
|
41
48
|
'isPrimaryTopicOf': { '@id': 'foaf:isPrimaryTopicOf', '@type': '@id' },
|
|
42
|
-
'mainEntityOfPage': { '@id': 'schema:mainEntityOfPage', '@type': '@id' }
|
|
49
|
+
'mainEntityOfPage': { '@id': 'schema:mainEntityOfPage', '@type': '@id' },
|
|
50
|
+
'service': { '@id': 'cid:service', '@container': '@set' },
|
|
51
|
+
'serviceEndpoint': { '@id': 'cid:serviceEndpoint', '@type': '@id' }
|
|
43
52
|
},
|
|
44
53
|
'@id': webId,
|
|
45
54
|
'@type': ['foaf:Person', 'schema:Person'],
|
|
@@ -51,7 +60,17 @@ export function generateProfileJsonLd({ webId, name, podUri, issuer }) {
|
|
|
51
60
|
'oidcIssuer': issuer,
|
|
52
61
|
'preferencesFile': `${pod}settings/prefs.jsonld`,
|
|
53
62
|
'publicTypeIndex': `${pod}settings/publicTypeIndex.jsonld`,
|
|
54
|
-
'privateTypeIndex': `${pod}settings/privateTypeIndex.jsonld
|
|
63
|
+
'privateTypeIndex': `${pod}settings/privateTypeIndex.jsonld`,
|
|
64
|
+
// LWS 1.0 Controlled Identifier service entry — mirrors `oidcIssuer` so
|
|
65
|
+
// LWS-aware verifiers can establish trust. Additive; the legacy
|
|
66
|
+
// `solid:oidcIssuer` predicate stays for existing Solid clients.
|
|
67
|
+
'service': [
|
|
68
|
+
{
|
|
69
|
+
'@id': `${docUrl}#oidc`,
|
|
70
|
+
'@type': 'lws:OpenIdProvider',
|
|
71
|
+
'serviceEndpoint': issuer
|
|
72
|
+
}
|
|
73
|
+
]
|
|
55
74
|
};
|
|
56
75
|
}
|
|
57
76
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct unit tests for the JSON-LD → Turtle converter.
|
|
3
|
+
*
|
|
4
|
+
* The focus is on regression coverage for properties that would otherwise
|
|
5
|
+
* be easy to regress silently:
|
|
6
|
+
* - cycle-safety in expandUri (DoS guard — a malicious context must not
|
|
7
|
+
* cause unbounded recursion / stack overflow)
|
|
8
|
+
* - duplicate @id across top-level docs must NOT suppress emission
|
|
9
|
+
* (the visited-set refactor previously dropped data)
|
|
10
|
+
* - cyclical nested node references must not hang the BFS
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it } from 'node:test';
|
|
14
|
+
import assert from 'node:assert';
|
|
15
|
+
import { fromJsonLd } from '../src/rdf/conneg.js';
|
|
16
|
+
|
|
17
|
+
describe('turtle converter — unit (#320 follow-ups)', () => {
|
|
18
|
+
it('expandUri does not recurse forever on a cyclic context (a → b → a)', async () => {
|
|
19
|
+
const doc = {
|
|
20
|
+
'@context': {
|
|
21
|
+
// Pathological: each term points at another term via CURIE, forming a loop.
|
|
22
|
+
'a': { '@id': 'b:x' },
|
|
23
|
+
'b': { '@id': 'a:y' }
|
|
24
|
+
},
|
|
25
|
+
'@id': 'https://example.test/s',
|
|
26
|
+
'a': 'hello'
|
|
27
|
+
};
|
|
28
|
+
// The converter should finish — not stack-overflow — regardless of what
|
|
29
|
+
// the output happens to look like. We only assert it completes with a
|
|
30
|
+
// string result.
|
|
31
|
+
const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true);
|
|
32
|
+
assert.ok(typeof content === 'string');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('expandUri does not recurse forever on a self-loop (a → a)', async () => {
|
|
36
|
+
const doc = {
|
|
37
|
+
'@context': {
|
|
38
|
+
'selfy': 'selfy'
|
|
39
|
+
},
|
|
40
|
+
'@id': 'https://example.test/s',
|
|
41
|
+
'selfy': 'hello'
|
|
42
|
+
};
|
|
43
|
+
const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true);
|
|
44
|
+
assert.ok(typeof content === 'string');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('duplicate top-level @id is not silently dropped', async () => {
|
|
48
|
+
// Two docs describing the same subject — both claims must survive.
|
|
49
|
+
// (Previously the visited-set in the BFS skipped the second pass.)
|
|
50
|
+
const docs = [
|
|
51
|
+
{
|
|
52
|
+
'@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' },
|
|
53
|
+
'@id': 'https://example.test/alice',
|
|
54
|
+
'foaf:name': 'Alice'
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
'@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' },
|
|
58
|
+
'@id': 'https://example.test/alice',
|
|
59
|
+
'foaf:age': 30
|
|
60
|
+
}
|
|
61
|
+
];
|
|
62
|
+
const { content } = await fromJsonLd(docs, 'text/turtle', 'https://example.test/', true);
|
|
63
|
+
assert.ok(content.includes('Alice'), `Turtle should contain the name claim, got:\n${content}`);
|
|
64
|
+
assert.ok(/30|"30"/.test(content), `Turtle should contain the age claim, got:\n${content}`);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('prefix-looking context key defined as an object is not string-concatenated', async () => {
|
|
68
|
+
// A user-supplied context can legally define a prefix-looking key as a
|
|
69
|
+
// term-definition object (not a namespace string). The converter must
|
|
70
|
+
// not treat it as a namespace — string-concatenating the object would
|
|
71
|
+
// produce invalid IRIs like "[object Object]foo".
|
|
72
|
+
const doc = {
|
|
73
|
+
'@context': {
|
|
74
|
+
// `bogus` is defined as a term object, not a namespace string.
|
|
75
|
+
'bogus': { '@id': 'https://example.test/ns#bogus' }
|
|
76
|
+
},
|
|
77
|
+
'@id': 'https://example.test/s',
|
|
78
|
+
// This looks like a CURIE `bogus:foo` but `bogus` is not a valid
|
|
79
|
+
// namespace — the converter should leave it alone.
|
|
80
|
+
'bogus:foo': 'hello'
|
|
81
|
+
};
|
|
82
|
+
const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true);
|
|
83
|
+
assert.ok(typeof content === 'string');
|
|
84
|
+
assert.ok(!content.includes('[object Object]'),
|
|
85
|
+
`Turtle output must not contain object-stringification, got:\n${content}`);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('cyclical nested node reference does not hang', async () => {
|
|
89
|
+
// Two nested nodes reference each other. BFS must not loop.
|
|
90
|
+
const a = { '@id': 'https://example.test/a', 'ex:knows': null };
|
|
91
|
+
const b = { '@id': 'https://example.test/b', 'ex:knows': a };
|
|
92
|
+
a['ex:knows'] = b;
|
|
93
|
+
|
|
94
|
+
const doc = {
|
|
95
|
+
'@context': { 'ex': 'https://example.test/ns#' },
|
|
96
|
+
'@id': 'https://example.test/root',
|
|
97
|
+
'ex:knows': a
|
|
98
|
+
};
|
|
99
|
+
const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true);
|
|
100
|
+
assert.ok(typeof content === 'string');
|
|
101
|
+
assert.ok(content.includes('https://example.test/a'), 'node a should appear');
|
|
102
|
+
assert.ok(content.includes('https://example.test/b'), 'node b should appear');
|
|
103
|
+
});
|
|
104
|
+
});
|
package/test/webid.test.js
CHANGED
|
@@ -98,6 +98,41 @@ describe('WebID Profile', () => {
|
|
|
98
98
|
// Empty string is a relative URI reference to the document itself (JSON-LD)
|
|
99
99
|
assert.strictEqual(jsonLd['isPrimaryTopicOf'], '', 'isPrimaryTopicOf should be "" (self)');
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
// LWS 1.0 Controlled Identifier alignment (#320).
|
|
103
|
+
// These assertions live alongside the WebID predicate assertions — both
|
|
104
|
+
// must continue to hold since the profile is dual-write.
|
|
105
|
+
it('should emit a CID service[] with an lws:OpenIdProvider entry', async () => {
|
|
106
|
+
const res = await request(profilePath);
|
|
107
|
+
const jsonLd = await res.json();
|
|
108
|
+
assert.ok(Array.isArray(jsonLd.service), 'profile should have a service array');
|
|
109
|
+
const oidc = jsonLd.service.find((s) => s['@type'] === 'lws:OpenIdProvider');
|
|
110
|
+
assert.ok(oidc, 'service[] must include an lws:OpenIdProvider entry');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('lws:OpenIdProvider service.serviceEndpoint mirrors oidcIssuer', async () => {
|
|
114
|
+
const res = await request(profilePath);
|
|
115
|
+
const jsonLd = await res.json();
|
|
116
|
+
assert.ok(Array.isArray(jsonLd.service), 'profile should have a service array');
|
|
117
|
+
const oidc = jsonLd.service.find((s) => s['@type'] === 'lws:OpenIdProvider');
|
|
118
|
+
assert.ok(oidc, 'service[] must include an lws:OpenIdProvider entry');
|
|
119
|
+
assert.strictEqual(
|
|
120
|
+
oidc.serviceEndpoint,
|
|
121
|
+
jsonLd.oidcIssuer,
|
|
122
|
+
'serviceEndpoint must equal the existing oidcIssuer value'
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('lws:OpenIdProvider service.id is a fragment on the profile document', async () => {
|
|
127
|
+
const res = await request(profilePath);
|
|
128
|
+
const jsonLd = await res.json();
|
|
129
|
+
assert.ok(Array.isArray(jsonLd.service), 'profile should have a service array');
|
|
130
|
+
const oidc = jsonLd.service.find((s) => s['@type'] === 'lws:OpenIdProvider');
|
|
131
|
+
assert.ok(oidc, 'service[] must include an lws:OpenIdProvider entry');
|
|
132
|
+
const docUrl = jsonLd['@id'].split('#')[0];
|
|
133
|
+
assert.strictEqual(oidc['@id'], `${docUrl}#oidc`,
|
|
134
|
+
'service entry @id should be `<profile-doc>#oidc`');
|
|
135
|
+
});
|
|
101
136
|
});
|
|
102
137
|
|
|
103
138
|
describe('WebID Resolution', () => {
|
|
@@ -119,3 +154,49 @@ describe('WebID Profile', () => {
|
|
|
119
154
|
});
|
|
120
155
|
});
|
|
121
156
|
});
|
|
157
|
+
|
|
158
|
+
// With conneg enabled the profile is converted to Turtle on demand. The
|
|
159
|
+
// CID service[] must survive that conversion — LWS verifiers that ask for
|
|
160
|
+
// Turtle need to see the nested service node's type and serviceEndpoint,
|
|
161
|
+
// not just a bare URI reference to it.
|
|
162
|
+
describe('WebID Profile — Turtle conneg (#320)', () => {
|
|
163
|
+
before(async () => {
|
|
164
|
+
await startTestServer({ conneg: true });
|
|
165
|
+
await createTestPod('webidturtletest');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
after(async () => {
|
|
169
|
+
await stopTestServer();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('Turtle variant includes cid:service with lws:OpenIdProvider and serviceEndpoint', async () => {
|
|
173
|
+
const res = await request('/webidturtletest/profile/card.jsonld', {
|
|
174
|
+
headers: { Accept: 'text/turtle' }
|
|
175
|
+
});
|
|
176
|
+
assertStatus(res, 200);
|
|
177
|
+
assertHeaderContains(res, 'Content-Type', 'text/turtle');
|
|
178
|
+
const ttl = await res.text();
|
|
179
|
+
// Accept either prefixed (cid:service) or expanded full-URI form. The
|
|
180
|
+
// critical property is that the nested service node's data survived the
|
|
181
|
+
// JSON-LD → Turtle conversion — i.e. the type and endpoint are present
|
|
182
|
+
// as their own triples, not dropped.
|
|
183
|
+
assert.ok(
|
|
184
|
+
ttl.includes('cid:service') || ttl.includes('cid/v1#service'),
|
|
185
|
+
`Turtle should reference the CID service predicate, got:\n${ttl}`
|
|
186
|
+
);
|
|
187
|
+
assert.ok(
|
|
188
|
+
ttl.includes('OpenIdProvider'),
|
|
189
|
+
`Turtle should declare the lws:OpenIdProvider type, got:\n${ttl}`
|
|
190
|
+
);
|
|
191
|
+
assert.ok(
|
|
192
|
+
ttl.includes('cid:serviceEndpoint') || ttl.includes('cid/v1#serviceEndpoint'),
|
|
193
|
+
`Turtle should include the cid:serviceEndpoint predicate, got:\n${ttl}`
|
|
194
|
+
);
|
|
195
|
+
// The service entry URI appears as a subject (its own line), proving it
|
|
196
|
+
// was emitted as a first-class node rather than a bare URI reference.
|
|
197
|
+
assert.ok(
|
|
198
|
+
/#oidc>\s+(?:a|<[^>]*#type>)/.test(ttl),
|
|
199
|
+
`Turtle should emit the service entry as a subject, got:\n${ttl}`
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
});
|