javascript-solid-server 0.0.29 → 0.0.32

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/README.md CHANGED
@@ -4,7 +4,7 @@ A minimal, fast, JSON-LD native Solid server.
4
4
 
5
5
  ## Features
6
6
 
7
- ### Implemented (v0.0.23)
7
+ ### Implemented (v0.0.31)
8
8
 
9
9
  - **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
10
10
  - **N3 Patch** - Solid's native patch format for RDF updates
@@ -17,14 +17,14 @@ A minimal, fast, JSON-LD native Solid server.
17
17
  - **Multi-user Pods** - Path-based (`/alice/`) or subdomain-based (`alice.example.com`)
18
18
  - **Subdomain Mode** - XSS protection via origin isolation
19
19
  - **Mashlib Data Browser** - Optional SolidOS UI (CDN or local hosting)
20
- - **WebID Profiles** - JSON-LD structured data in HTML at pod root
20
+ - **WebID Profiles** - HTML with JSON-LD data islands, rendered with mashlib-jss + solidos-lite
21
21
  - **Web Access Control (WAC)** - `.acl` file-based authorization
22
22
  - **Solid-OIDC Identity Provider** - Built-in IdP with DPoP, dynamic registration
23
23
  - **Solid-OIDC Resource Server** - Accept DPoP-bound access tokens from external IdPs
24
24
  - **NSS-style Registration** - Username/password auth compatible with Solid apps
25
25
  - **Nostr Authentication** - NIP-98 HTTP Auth with Schnorr signatures
26
26
  - **Simple Auth Tokens** - Built-in token authentication for development
27
- - **Content Negotiation** - Optional Turtle <-> JSON-LD conversion
27
+ - **Content Negotiation** - Turtle <-> JSON-LD conversion, including HTML data islands
28
28
  - **CORS Support** - Full cross-origin resource sharing
29
29
 
30
30
  ### HTTP Methods
@@ -36,7 +36,7 @@ A minimal, fast, JSON-LD native Solid server.
36
36
  | PUT | Full - Create/update resources |
37
37
  | POST | Full - Create in containers |
38
38
  | DELETE | Full |
39
- | PATCH | N3 Patch format |
39
+ | PATCH | N3 Patch + SPARQL Update |
40
40
  | OPTIONS | Full with CORS |
41
41
 
42
42
  ## Getting Started
@@ -285,7 +285,15 @@ npm install && npm run build
285
285
  3. Mashlib fetches the actual data via content negotiation
286
286
  4. Mashlib renders an interactive, editable view
287
287
 
288
- **Note:** Mashlib works best with `--conneg` enabled for Turtle support. Pod profiles (`/alice/`) continue to serve our JSON-LD-in-HTML format.
288
+ **Note:** Mashlib works best with `--conneg` enabled for Turtle support.
289
+
290
+ ### Profile Pages
291
+
292
+ Pod profiles (`/alice/`) use HTML with embedded JSON-LD data islands and are rendered using:
293
+ - [mashlib-jss](https://github.com/JavaScriptSolidServer/mashlib-jss) - A fork of mashlib with `getPod()` fix for path-based pods
294
+ - [solidos-lite](https://github.com/SolidOS/solidos-lite) - Parses JSON-LD data islands into the RDF store
295
+
296
+ This allows profiles to work without server-side content negotiation while still providing full SolidOS editing capabilities.
289
297
 
290
298
  ### WebSocket Notifications
291
299
 
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Clock Updater - Updates the Solid clock every second using Nostr auth
3
+ * Usage: node clock-updater.mjs
4
+ */
5
+
6
+ import { getPublicKey, finalizeEvent } from 'nostr-tools';
7
+ import { getToken } from 'nostr-tools/nip98';
8
+
9
+ // Nostr keypair (in production, load from env/file)
10
+ const SK_HEX = '3f188544fb81bd324ead7be9697fd9503d18345e233a7b0182915b0b582ddd70';
11
+ const sk = Uint8Array.from(Buffer.from(SK_HEX, 'hex'));
12
+ const pk = getPublicKey(sk);
13
+
14
+ const CLOCK_URL = 'https://solid.social/melvin/public/clock.json';
15
+
16
+ async function updateClock() {
17
+ const now = Math.floor(Date.now() / 1000);
18
+ const isoDate = new Date(now * 1000).toISOString();
19
+
20
+ const clockData = {
21
+ '@context': { 'schema': 'http://schema.org/' },
22
+ '@id': '#clock',
23
+ '@type': 'schema:Clock',
24
+ 'schema:dateModified': isoDate,
25
+ 'schema:value': now
26
+ };
27
+
28
+ try {
29
+ const token = await getToken(CLOCK_URL, 'PUT', (e) => finalizeEvent(e, sk));
30
+
31
+ const res = await fetch(CLOCK_URL, {
32
+ method: 'PUT',
33
+ headers: {
34
+ 'Content-Type': 'application/ld+json',
35
+ 'Authorization': 'Nostr ' + token
36
+ },
37
+ body: JSON.stringify(clockData)
38
+ });
39
+
40
+ const time = isoDate.split('T')[1].replace('Z', '');
41
+ if (res.ok) {
42
+ process.stdout.write(`\r${time} - Updated`);
43
+ } else {
44
+ console.log(`\n${time} - Error: ${res.status} ${res.statusText}`);
45
+ }
46
+ } catch (err) {
47
+ console.log(`\nError: ${err.message}`);
48
+ }
49
+ }
50
+
51
+ console.log('Clock Updater started');
52
+ console.log('did:nostr:', 'did:nostr:' + pk);
53
+ console.log('Target:', CLOCK_URL);
54
+ console.log('Press Ctrl+C to stop\n');
55
+
56
+ // Run immediately, then every second
57
+ updateClock();
58
+ setInterval(updateClock, 1000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.29",
3
+ "version": "0.0.32",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "@fastify/websocket": "^8.3.1",
28
28
  "bcrypt": "^6.0.0",
29
29
  "commander": "^14.0.2",
30
- "fastify": "^4.25.2",
30
+ "fastify": "^4.29.1",
31
31
  "fs-extra": "^11.2.0",
32
32
  "jose": "^6.1.3",
33
33
  "n3": "^1.26.0",
@@ -192,38 +192,70 @@ export async function handleGet(request, reply) {
192
192
  return reply.code(500).send({ error: 'Read error' });
193
193
  }
194
194
 
195
- // Content negotiation for RDF resources
196
- if (connegEnabled && isRdfContentType(storedContentType)) {
197
- try {
198
- // Parse stored content as JSON-LD
199
- const jsonLd = JSON.parse(content.toString());
200
-
201
- // Select output format based on Accept header
202
- const acceptHeader = request.headers.accept;
203
- const targetType = selectContentType(acceptHeader, connegEnabled);
204
-
205
- // Convert to requested format
206
- const { content: outputContent, contentType: outputType } = await fromJsonLd(
207
- jsonLd,
208
- targetType,
209
- resourceUrl,
210
- connegEnabled
211
- );
212
-
213
- const headers = getAllHeaders({
214
- isContainer: false,
215
- etag: stats.etag,
216
- contentType: outputType,
217
- origin,
218
- resourceUrl,
219
- connegEnabled
220
- });
221
- headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
195
+ // Content negotiation for RDF resources (including HTML with JSON-LD data islands)
196
+ if (connegEnabled) {
197
+ const contentStr = content.toString();
198
+ const acceptHeader = request.headers.accept || '';
199
+ const wantsTurtle = acceptHeader.includes('text/turtle') ||
200
+ acceptHeader.includes('text/n3') ||
201
+ acceptHeader.includes('application/n-triples');
202
+
203
+ // Check if this is HTML with JSON-LD data island
204
+ const isHtmlWithDataIsland = contentStr.trimStart().startsWith('<!DOCTYPE') ||
205
+ contentStr.trimStart().startsWith('<html');
206
+
207
+ if (isHtmlWithDataIsland && wantsTurtle) {
208
+ // Extract JSON-LD from HTML data island and convert to Turtle
209
+ try {
210
+ const jsonLdMatch = contentStr.match(/<script\s+type=["']application\/ld\+json["']\s*>([\s\S]*?)<\/script>/i);
211
+ if (jsonLdMatch) {
212
+ const jsonLd = JSON.parse(jsonLdMatch[1]);
213
+ const { content: turtleContent } = await fromJsonLd(jsonLd, 'text/turtle', resourceUrl, true);
214
+
215
+ const headers = getAllHeaders({
216
+ isContainer: false,
217
+ etag: stats.etag,
218
+ contentType: 'text/turtle',
219
+ origin,
220
+ resourceUrl,
221
+ connegEnabled
222
+ });
223
+ headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
224
+
225
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
226
+ return reply.send(turtleContent);
227
+ }
228
+ } catch (err) {
229
+ // Fall through to serve HTML if conversion fails
230
+ console.error('Failed to convert HTML data island to Turtle:', err.message);
231
+ }
232
+ } else if (isRdfContentType(storedContentType)) {
233
+ // Plain JSON-LD file
234
+ try {
235
+ const jsonLd = JSON.parse(contentStr);
236
+ const targetType = selectContentType(acceptHeader, connegEnabled);
237
+ const { content: outputContent, contentType: outputType } = await fromJsonLd(
238
+ jsonLd,
239
+ targetType,
240
+ resourceUrl,
241
+ connegEnabled
242
+ );
243
+
244
+ const headers = getAllHeaders({
245
+ isContainer: false,
246
+ etag: stats.etag,
247
+ contentType: outputType,
248
+ origin,
249
+ resourceUrl,
250
+ connegEnabled
251
+ });
252
+ headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
222
253
 
223
- Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
224
- return reply.send(outputContent);
225
- } catch (e) {
226
- // If not valid JSON-LD, serve as-is
254
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
255
+ return reply.send(outputContent);
256
+ } catch (e) {
257
+ // If not valid JSON-LD, serve as-is
258
+ }
227
259
  }
228
260
  }
229
261
 
@@ -533,10 +565,86 @@ export async function handlePatch(request, reply) {
533
565
  });
534
566
  }
535
567
  } else {
536
- // Parse as plain JSON-LD
568
+ // Try to parse as JSON-LD first
537
569
  try {
538
570
  document = JSON.parse(contentStr);
539
571
  } catch (e) {
572
+ // Not JSON - might be Turtle, handle with RDF store for SPARQL Update
573
+ if (isSparqlUpdate) {
574
+ // Parse Turtle and apply SPARQL Update directly
575
+ const { Parser, Writer } = await import('n3');
576
+ const parser = new Parser({ baseIRI: resourceUrl });
577
+ let quads;
578
+ try {
579
+ quads = parser.parse(contentStr);
580
+ } catch (parseErr) {
581
+ return reply.code(409).send({
582
+ error: 'Conflict',
583
+ message: 'Resource is not valid Turtle: ' + parseErr.message
584
+ });
585
+ }
586
+
587
+ // Parse the SPARQL Update
588
+ const patchContent = Buffer.isBuffer(request.body) ? request.body.toString() : request.body;
589
+ let update;
590
+ try {
591
+ update = parseSparqlUpdate(patchContent, resourceUrl);
592
+ } catch (parseErr) {
593
+ return reply.code(400).send({
594
+ error: 'Bad Request',
595
+ message: 'Invalid SPARQL Update: ' + parseErr.message
596
+ });
597
+ }
598
+
599
+ // Apply deletes
600
+ for (const triple of update.deletes) {
601
+ quads = quads.filter(q => {
602
+ const matches = q.subject.value === triple.subject &&
603
+ q.predicate.value === triple.predicate &&
604
+ (q.object.value === (triple.object['@id'] || triple.object['@value'] || triple.object));
605
+ return !matches;
606
+ });
607
+ }
608
+
609
+ // Apply inserts
610
+ const { DataFactory } = await import('n3');
611
+ const { namedNode, literal } = DataFactory;
612
+ for (const triple of update.inserts) {
613
+ const subj = namedNode(triple.subject);
614
+ const pred = namedNode(triple.predicate);
615
+ let obj;
616
+ if (triple.object['@id']) {
617
+ obj = namedNode(triple.object['@id']);
618
+ } else if (typeof triple.object === 'string') {
619
+ obj = literal(triple.object);
620
+ } else {
621
+ obj = literal(triple.object['@value'] || triple.object);
622
+ }
623
+ quads.push(DataFactory.quad(subj, pred, obj));
624
+ }
625
+
626
+ // Serialize back to Turtle
627
+ const writer = new Writer({ prefixes: {} });
628
+ quads.forEach(q => writer.addQuad(q));
629
+ let turtleOutput;
630
+ writer.end((err, result) => { turtleOutput = result; });
631
+
632
+ const success = await storage.write(storagePath, Buffer.from(turtleOutput));
633
+ if (!success) {
634
+ return reply.code(500).send({ error: 'Write failed' });
635
+ }
636
+
637
+ const origin = request.headers.origin;
638
+ const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
639
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
640
+
641
+ if (request.notificationsEnabled) {
642
+ emitChange(resourceUrl);
643
+ }
644
+
645
+ return reply.code(resourceExists ? 204 : 201).send();
646
+ }
647
+
540
648
  return reply.code(409).send({
541
649
  error: 'Conflict',
542
650
  message: 'Resource is not valid JSON-LD and cannot be patched'
package/src/idp/index.js CHANGED
@@ -184,6 +184,8 @@ export async function idpPlugin(fastify, options) {
184
184
  claims_supported: ['sub', 'webid', 'name', 'email', 'email_verified'],
185
185
  code_challenge_methods_supported: ['S256'],
186
186
  dpop_signing_alg_values_supported: ['ES256', 'RS256'],
187
+ // RFC 9207 - OAuth 2.0 Authorization Server Issuer Identification
188
+ authorization_response_iss_parameter_supported: true,
187
189
  // Solid-OIDC specific
188
190
  solid_oidc_supported: 'https://solidproject.org/TR/solid-oidc',
189
191
  };
package/src/rdf/turtle.js CHANGED
@@ -151,14 +151,15 @@ function quadsToJsonLd(quads, baseUri, prefixes = {}) {
151
151
  nodes.push(jsonNode);
152
152
  }
153
153
 
154
- // Build result
154
+ // Build result - return array if multiple nodes, single object otherwise
155
155
  const context = buildContext(prefixes);
156
156
 
157
157
  if (nodes.length === 1) {
158
158
  return { '@context': context, ...nodes[0] };
159
159
  }
160
160
 
161
- return { '@context': context, '@graph': nodes };
161
+ // Multiple nodes: return as array (no @graph)
162
+ return nodes.map((node, i) => i === 0 ? { '@context': context, ...node } : node);
162
163
  }
163
164
 
164
165
  /**
@@ -166,10 +167,25 @@ function quadsToJsonLd(quads, baseUri, prefixes = {}) {
166
167
  */
167
168
  function jsonLdToQuads(jsonLd, baseUri) {
168
169
  const quads = [];
169
- const context = jsonLd['@context'] || {};
170
170
 
171
- // Handle @graph or single object
172
- const nodes = jsonLd['@graph'] || [jsonLd];
171
+ // Handle array of JSON-LD objects (e.g., from multiple PATCH operations)
172
+ const documents = Array.isArray(jsonLd) ? jsonLd : [jsonLd];
173
+
174
+ // Merge all contexts and collect all nodes
175
+ let mergedContext = {};
176
+ let nodes = [];
177
+
178
+ for (const doc of documents) {
179
+ if (doc['@context']) {
180
+ mergedContext = { ...mergedContext, ...doc['@context'] };
181
+ }
182
+ // Each document with @id is a node (no @graph needed)
183
+ if (doc['@id']) {
184
+ nodes.push(doc);
185
+ }
186
+ }
187
+
188
+ const context = mergedContext;
173
189
 
174
190
  for (const node of nodes) {
175
191
  if (!node['@id']) continue;
@@ -33,25 +33,17 @@ export function generateProfileJsonLd({ webId, name, podUri, issuer }) {
33
33
  'inbox': { '@id': 'ldp:inbox', '@type': '@id' },
34
34
  'storage': { '@id': 'pim:storage', '@type': '@id' },
35
35
  'oidcIssuer': { '@id': 'solid:oidcIssuer', '@type': '@id' },
36
- 'preferencesFile': { '@id': 'pim:preferencesFile', '@type': '@id' }
36
+ 'preferencesFile': { '@id': 'pim:preferencesFile', '@type': '@id' },
37
+ 'mainEntityOfPage': { '@id': 'schema:mainEntityOfPage', '@type': '@id' }
37
38
  },
38
- '@graph': [
39
- {
40
- '@id': profileDoc,
41
- '@type': 'foaf:PersonalProfileDocument',
42
- 'foaf:maker': { '@id': webId },
43
- 'foaf:primaryTopic': { '@id': webId }
44
- },
45
- {
46
- '@id': webId,
47
- '@type': ['foaf:Person', 'schema:Person'],
48
- 'foaf:name': name,
49
- 'inbox': `${pod}inbox/`,
50
- 'storage': pod,
51
- 'oidcIssuer': issuer,
52
- 'preferencesFile': `${pod}settings/prefs`
53
- }
54
- ]
39
+ '@id': webId,
40
+ '@type': ['foaf:Person', 'schema:Person'],
41
+ 'foaf:name': name,
42
+ 'mainEntityOfPage': profileDoc,
43
+ 'inbox': `${pod}inbox/`,
44
+ 'storage': pod,
45
+ 'oidcIssuer': issuer,
46
+ 'preferencesFile': `${pod}settings/prefs`
55
47
  };
56
48
  }
57
49