javascript-solid-server 0.0.152 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.152",
3
+ "version": "0.0.154",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/rdf/turtle.js CHANGED
@@ -189,10 +189,26 @@ function jsonLdToQuads(jsonLd, baseUri) {
189
189
 
190
190
  const context = mergedContext;
191
191
 
192
- for (const node of nodes) {
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
- if (ns) {
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
- return expansion['@id'];
447
+ const id = expansion['@id'];
448
+ return id === uri ? uri : expandUri(id, context, chain);
404
449
  }
405
450
  }
406
451
 
package/src/server.js CHANGED
@@ -21,6 +21,7 @@ import { dbPlugin } from './db/index.js';
21
21
  import { webrtcPlugin } from './webrtc/index.js';
22
22
  import { tunnelPlugin } from './tunnel/index.js';
23
23
  import { terminalPlugin } from './terminal/index.js';
24
+ import { registerErrorHandler } from './utils/error-handler.js';
24
25
 
25
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
27
 
@@ -154,6 +155,7 @@ export function createServer(options = {}) {
154
155
  }
155
156
 
156
157
  const fastify = Fastify(fastifyOptions);
158
+ registerErrorHandler(fastify);
157
159
 
158
160
  // Add raw body parser for all content types
159
161
  fastify.addContentTypeParser('*', { parseAs: 'buffer' }, (req, body, done) => {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Top-level Fastify error handler that logs the full stack for any 5xx.
3
+ *
4
+ * Without this, 500 responses carry only Fastify's default 91-byte body and
5
+ * leave no trace in logs — production debugging has to infer the exception
6
+ * from the response body alone (see #309 investigation for why that's bad).
7
+ *
8
+ * 4xx errors carry their own statusCode and don't need stack logging — they
9
+ * are expected client errors with self-explanatory messages.
10
+ */
11
+ export function registerErrorHandler(fastify) {
12
+ fastify.setErrorHandler(function (err, request, reply) {
13
+ const statusCode = err.statusCode ?? 500;
14
+ if (statusCode >= 500) {
15
+ request.log.error({
16
+ err,
17
+ method: request.method,
18
+ url: request.url,
19
+ hostname: request.hostname
20
+ }, 'Unhandled 5xx error');
21
+ }
22
+ reply.send(err);
23
+ });
24
+ }
@@ -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,83 @@
1
+ /**
2
+ * Tests for the top-level error handler (#312).
3
+ *
4
+ * Verifies that:
5
+ * 1. Unhandled exceptions from route handlers produce a 500 whose body
6
+ * matches Fastify's default shape — the existing 91-byte response
7
+ * format must not regress (that's the baseline consumers rely on).
8
+ * 2. The error's stack is actually logged (with method/url/hostname
9
+ * context), so production debugging has a real trace.
10
+ * 3. 4xx errors are NOT stack-logged (they're expected client errors).
11
+ *
12
+ * Uses a minimal Fastify instance so the test exercises only the handler,
13
+ * not the full server's auth/routing stack.
14
+ */
15
+
16
+ import { describe, it, before, after } from 'node:test';
17
+ import assert from 'node:assert';
18
+ import Fastify from 'fastify';
19
+ import { registerErrorHandler } from '../src/utils/error-handler.js';
20
+
21
+ class LogCapture {
22
+ constructor() { this.lines = []; }
23
+ write(chunk) {
24
+ try { this.lines.push(JSON.parse(chunk)); } catch { /* non-JSON */ }
25
+ return true;
26
+ }
27
+ errorLines() {
28
+ return this.lines.filter((l) => l.level >= 50);
29
+ }
30
+ clear() { this.lines.length = 0; }
31
+ }
32
+
33
+ describe('registerErrorHandler (#312)', () => {
34
+ let app;
35
+ const capture = new LogCapture();
36
+
37
+ before(async () => {
38
+ app = Fastify({
39
+ logger: { level: 'error', stream: capture },
40
+ disableRequestLogging: true
41
+ });
42
+ registerErrorHandler(app);
43
+ app.get('/throw500', async () => { throw new Error('synthetic 5xx for test'); });
44
+ app.get('/throw400', async () => {
45
+ const err = new Error('bad client input');
46
+ err.statusCode = 400;
47
+ throw err;
48
+ });
49
+ });
50
+
51
+ after(async () => { await app.close(); });
52
+
53
+ it('500 response body matches Fastify default shape (no regression)', async () => {
54
+ capture.clear();
55
+ const res = await app.inject({ method: 'GET', url: '/throw500' });
56
+ assert.strictEqual(res.statusCode, 500);
57
+ assert.deepStrictEqual(res.json(), {
58
+ statusCode: 500,
59
+ error: 'Internal Server Error',
60
+ message: 'synthetic 5xx for test'
61
+ });
62
+ });
63
+
64
+ it('500 logs include the stack and request context', async () => {
65
+ capture.clear();
66
+ await app.inject({ method: 'GET', url: '/throw500', headers: { host: 'example.test' } });
67
+ const line = capture.errorLines().find((l) => l.msg === 'Unhandled 5xx error');
68
+ assert.ok(line, `expected 'Unhandled 5xx error' log line, got: ${JSON.stringify(capture.errorLines())}`);
69
+ assert.strictEqual(line.method, 'GET');
70
+ assert.strictEqual(line.url, '/throw500');
71
+ assert.strictEqual(line.hostname, 'example.test');
72
+ assert.ok(line.err && line.err.stack, 'expected err.stack in log');
73
+ assert.ok(line.err.stack.includes('synthetic 5xx'), 'stack should identify the error');
74
+ });
75
+
76
+ it('4xx errors do not trigger the 5xx stack log', async () => {
77
+ capture.clear();
78
+ const res = await app.inject({ method: 'GET', url: '/throw400' });
79
+ assert.strictEqual(res.statusCode, 400);
80
+ const unhandled = capture.errorLines().filter((l) => l.msg === 'Unhandled 5xx error');
81
+ assert.strictEqual(unhandled.length, 0, '4xx must not produce a 5xx stack log');
82
+ });
83
+ });
@@ -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
+ });
@@ -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
+ });