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 +13 -5
- package/clock-updater.mjs +58 -0
- package/package.json +2 -2
- package/src/handlers/resource.js +140 -32
- package/src/idp/index.js +2 -0
- package/src/rdf/turtle.js +21 -5
- package/src/webid/profile.js +10 -18
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.
|
|
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
|
|
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** -
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
30
|
+
"fastify": "^4.29.1",
|
|
31
31
|
"fs-extra": "^11.2.0",
|
|
32
32
|
"jose": "^6.1.3",
|
|
33
33
|
"n3": "^1.26.0",
|
package/src/handlers/resource.js
CHANGED
|
@@ -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
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
172
|
-
const
|
|
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;
|
package/src/webid/profile.js
CHANGED
|
@@ -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
|
-
'@
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|