javascript-solid-server 0.0.35 → 0.0.36

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.
@@ -97,7 +97,24 @@
97
97
  "Bash(done)",
98
98
  "Bash(for domain in jss.dev jss.sh jss.io jss.app solidserver.dev)",
99
99
  "Bash(host:*)",
100
- "WebFetch(domain:nostr-components.github.io)"
100
+ "WebFetch(domain:nostr-components.github.io)",
101
+ "Bash(ssh melvincarvalho.com \"pm2 list && echo ''---HAPROXY---'' && cat /etc/haproxy/haproxy.cfg 2>/dev/null | grep -A5 ''melvin\\|backend\\|frontend\\|acl host''\")",
102
+ "Bash(ssh:*)",
103
+ "Bash(time curl -s --connect-timeout 10 https://melvin.solid.live/credit/count.ttl)",
104
+ "Bash(time curl -s --connect-timeout 10 https://melvin.solid.live/)",
105
+ "Bash(time curl:*)",
106
+ "Bash(time curl -s 'https://melvin.solid.live/credit/count.ttl')",
107
+ "Bash(grep:*)",
108
+ "Bash(scp:*)",
109
+ "Bash(for i in 1 2 3)",
110
+ "Bash(do echo \"Attempt $i:\")",
111
+ "Bash(for i in 1 2 3 4 5)",
112
+ "Bash(do curl -so /dev/null -w \"%{http_code} \" https://melvincarvalho.com/js/handlemutation.js)",
113
+ "Bash(for i in 1 2 3 4 5 6 7 8 9 10)",
114
+ "Bash(if [ ! -d \"jose\" ])",
115
+ "Bash(then git clone --depth 1 --branch v0.7.0 https://github.com/solid/jose.git)",
116
+ "Bash(fi)",
117
+ "Bash(timeout 45 node:*)"
101
118
  ]
102
119
  }
103
120
  }
package/AGENTS.md ADDED
@@ -0,0 +1,152 @@
1
+ # AGENTS.md - AI Assistant Context for JSS
2
+
3
+ This document provides context for AI assistants working on JavaScript Solid Server (JSS).
4
+
5
+ ## What is JSS?
6
+
7
+ A lightweight Solid server implementation focused on simplicity and modern JavaScript. Alternative to Node Solid Server (NSS) and Community Solid Server (CSS).
8
+
9
+ **Key differences from other Solid servers:**
10
+ - Single-file JSON-LD storage (no quad stores)
11
+ - Content negotiation converts JSON-LD ↔ Turtle on the fly
12
+ - Built on Fastify (not Express)
13
+ - Uses oidc-provider for identity
14
+ - Supports Nostr NIP-98 authentication (unique to JSS)
15
+
16
+ ## Architecture
17
+
18
+ ```
19
+ src/
20
+ ├── server.js # Fastify setup, route registration
21
+ ├── handlers/ # LDP operations (GET, PUT, POST, PATCH, DELETE)
22
+ │ ├── resource.js # File operations
23
+ │ └── container.js # Directory operations, pod creation
24
+ ├── auth/ # Authentication
25
+ │ ├── middleware.js # WAC authorization hook
26
+ │ ├── solid-oidc.js # DPoP token verification
27
+ │ ├── nostr.js # NIP-98 Schnorr signatures
28
+ │ └── token.js # Simple Bearer tokens
29
+ ├── idp/ # Identity Provider (oidc-provider)
30
+ │ ├── provider.js # OIDC configuration
31
+ │ ├── interactions.js # Login/consent UI handlers
32
+ │ └── accounts.js # User account storage
33
+ ├── wac/ # Web Access Control
34
+ │ ├── checker.js # Permission checking
35
+ │ └── parser.js # ACL file parsing/generation
36
+ ├── rdf/ # RDF handling
37
+ │ ├── conneg.js # Content negotiation
38
+ │ └── turtle.js # Turtle ↔ JSON-LD conversion
39
+ ├── notifications/ # WebSocket real-time updates
40
+ │ ├── websocket.js # solid-0.1 protocol handler
41
+ │ └── events.js # Event emitter for changes
42
+ ├── ldp/ # Linked Data Platform
43
+ │ ├── headers.js # LDP response headers
44
+ │ └── container.js # Container JSON-LD generation
45
+ └── storage/ # File system operations
46
+ └── filesystem.js # Read/write/stat/list
47
+ ```
48
+
49
+ ## Key Design Decisions
50
+
51
+ ### JSON-LD as canonical storage
52
+ All RDF is stored as JSON-LD. When clients request Turtle, we convert on the fly. This simplifies storage and allows non-RDF tools to read the data.
53
+
54
+ ### HTML profiles with JSON-LD data islands
55
+ WebID profiles are HTML documents with embedded `<script type="application/ld+json">`. This allows:
56
+ - Human-readable profiles in browsers
57
+ - Machine-readable RDF via content negotiation
58
+ - Mashlib renders the profile using the embedded data
59
+
60
+ ### Subdomain mode for XSS isolation
61
+ When `subdomains: true`, each pod gets its own subdomain (alice.example.com). This provides browser security isolation. Storage path includes pod name, but URLs use subdomains.
62
+
63
+ ### Settings folder conventions
64
+ Mashlib expects `Settings/` (capital S) with:
65
+ - `Settings/Preferences.ttl`
66
+ - `Settings/publicTypeIndex.ttl`
67
+ - `Settings/privateTypeIndex.ttl`
68
+
69
+ Earlier versions used lowercase `settings/prefs` which broke mashlib.
70
+
71
+ ## Common Gotchas
72
+
73
+ ### Content negotiation
74
+ - Files stored as JSON-LD regardless of upload format
75
+ - `.ttl` extension triggers Turtle response regardless of Accept header
76
+ - Container listings need conneg too (fixed in v0.0.33)
77
+
78
+ ### Authentication paths that skip auth
79
+ These paths bypass the auth middleware:
80
+ - `/.pods` - Pod creation
81
+ - `/.notifications` - WebSocket endpoint
82
+ - `/idp/*` - Identity provider routes
83
+ - `/.well-known/*` - Discovery endpoints
84
+ - OPTIONS requests
85
+
86
+ ### WebSocket notifications
87
+ Uses legacy `solid-0.1` protocol (not Solid Notifications Protocol):
88
+ ```
89
+ Server: protocol solid-0.1
90
+ Client: sub https://example.org/resource
91
+ Server: ack https://example.org/resource
92
+ Server: pub https://example.org/resource (on change)
93
+ ```
94
+ Discovered via `Updates-Via` header.
95
+
96
+ ### DPoP token verification
97
+ Solid-OIDC uses DPoP-bound tokens. The DPoP proof must match:
98
+ - HTTP method (htm)
99
+ - Request URL (htu)
100
+ - Be recent (iat within 5 minutes)
101
+ - Key thumbprint matches token binding (cnf.jkt)
102
+
103
+ ### ACL inheritance
104
+ WAC ACLs use `acl:default` for inheritance. When checking permissions:
105
+ 1. Look for resource-specific ACL (resource.acl or .acl for containers)
106
+ 2. Walk up to parent containers checking for `acl:default` rules
107
+ 3. Stop at pod root
108
+
109
+ ## Testing
110
+
111
+ ```bash
112
+ npm test # Run all tests
113
+ npm run test:cth # Conformance Test Harness (requires setup)
114
+ ```
115
+
116
+ Tests use in-memory server instances. See `test/helpers.js` for test utilities.
117
+
118
+ ## Deployment
119
+
120
+ ### Production setup
121
+ ```bash
122
+ npm install -g javascript-solid-server
123
+ jss --port 443 --ssl-key key.pem --ssl-cert cert.pem --idp --multiuser
124
+ ```
125
+
126
+ ### With HAProxy (recommended)
127
+ HAProxy handles SSL termination, JSS runs on localhost:8443. Wildcard cert needed for subdomain mode.
128
+
129
+ ### PM2 process management
130
+ ```bash
131
+ pm2 start "jss --config config.json" --name solid
132
+ pm2 logs solid
133
+ pm2 restart solid
134
+ ```
135
+
136
+ ## External Dependencies
137
+
138
+ - **oidc-provider**: OpenID Connect implementation (complex, many warnings are normal)
139
+ - **n3**: Turtle/N3 parsing and serialization
140
+ - **jose**: JWT/JWK handling for Solid-OIDC
141
+ - **@fastify/websocket**: WebSocket support
142
+
143
+ ## Related Specs
144
+
145
+ - [Solid Protocol](https://solidproject.org/TR/protocol)
146
+ - [Solid-OIDC](https://solidproject.org/TR/oidc)
147
+ - [Web Access Control](https://solidproject.org/TR/wac)
148
+ - [Linked Data Platform](https://www.w3.org/TR/ldp/)
149
+
150
+ ## Contact
151
+
152
+ Issues: https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues
@@ -0,0 +1,353 @@
1
+ # Design: JSS as Nostr Relay++
2
+
3
+ ## Overview
4
+
5
+ Replace the Solid Notifications Protocol with Nostr relay functionality. JSS becomes a Nostr relay that also serves LDP resources, unifying identity, storage, and real-time notifications.
6
+
7
+ ## Motivation
8
+
9
+ **Solid Notifications Protocol problems:**
10
+ - Complex discovery mechanism
11
+ - JSON-LD channel descriptions
12
+ - No federation
13
+ - No existing ecosystem
14
+ - Reinvents pub/sub poorly
15
+
16
+ **Nostr advantages:**
17
+ - Simple WebSocket protocol (NIP-01)
18
+ - Cryptographic identity built-in
19
+ - Federation via relay gossip
20
+ - Millions of existing users
21
+ - Mobile push infrastructure exists
22
+ - Battle-tested
23
+
24
+ **JSS already has:**
25
+ - NIP-98 HTTP authentication
26
+ - WebSocket infrastructure (solid-0.1)
27
+ - JSON-LD storage
28
+
29
+ ## Architecture
30
+
31
+ ```
32
+ ┌─────────────────────────────────────────────────────────────┐
33
+ │ JSS Server │
34
+ ├──────────────────────┬──────────────────────────────────────┤
35
+ │ LDP Layer │ Nostr Relay Layer │
36
+ │ │ │
37
+ │ GET/PUT/POST/PATCH │ EVENT/REQ/CLOSE/EOSE │
38
+ │ DELETE/OPTIONS │ │
39
+ │ │ │
40
+ │ ┌────────────────┐ │ ┌─────────────────────────────────┐ │
41
+ │ │ Resources │◄─┼─►│ Events (kind:30078) │ │
42
+ │ │ /alice/doc.ttl│ │ │ Addressable by d-tag = URI │ │
43
+ │ └────────────────┘ │ └─────────────────────────────────┘ │
44
+ │ │ │
45
+ │ Auth: Solid-OIDC │ Auth: NIP-98 / NIP-42 │
46
+ │ NIP-98 │ │
47
+ └──────────────────────┴──────────────────────────────────────┘
48
+
49
+
50
+ ┌─────────────────┐
51
+ │ Other Relays │
52
+ │ (Federation) │
53
+ └─────────────────┘
54
+ ```
55
+
56
+ ## Protocol Mapping
57
+
58
+ ### Resource ↔ Event Mapping
59
+
60
+ LDP resources map to Nostr replaceable events:
61
+
62
+ ```
63
+ Resource URL: https://alice.solid.social/notes/idea.json
64
+
65
+ Nostr Event:
66
+ {
67
+ "kind": 30078, // Arbitrary JSON (NIP-78)
68
+ "pubkey": "<alice-pubkey>",
69
+ "created_at": 1703888888,
70
+ "tags": [
71
+ ["d", "https://alice.solid.social/notes/idea.json"],
72
+ ["solid:type", "ldp:Resource"],
73
+ ["solid:contentType", "application/ld+json"]
74
+ ],
75
+ "content": "{\"@context\": ..., \"title\": \"My Idea\"}",
76
+ "sig": "<signature>"
77
+ }
78
+ ```
79
+
80
+ ### Kind Assignments
81
+
82
+ | Kind | Purpose | NIP |
83
+ |------|---------|-----|
84
+ | 30078 | LDP Resource (JSON content) | NIP-78 |
85
+ | 30079 | LDP Container listing | Custom |
86
+ | 30080 | ACL document | Custom |
87
+ | 10078 | Resource deletion marker | Custom |
88
+ | 1 | Social posts (optional integration) | NIP-01 |
89
+
90
+ Using 30xxx range for addressable replaceable events (d-tag = resource URI).
91
+
92
+ ### Subscription Filters
93
+
94
+ Subscribe to resource changes:
95
+
96
+ ```json
97
+ // Subscribe to single resource
98
+ ["REQ", "sub1", {
99
+ "kinds": [30078],
100
+ "#d": ["https://alice.solid.social/notes/idea.json"]
101
+ }]
102
+
103
+ // Subscribe to container (all resources under path)
104
+ ["REQ", "sub2", {
105
+ "kinds": [30078, 30079],
106
+ "#d": ["https://alice.solid.social/notes/"]
107
+ }]
108
+
109
+ // Subscribe to all changes by user
110
+ ["REQ", "sub3", {
111
+ "kinds": [30078],
112
+ "authors": ["<alice-pubkey>"]
113
+ }]
114
+ ```
115
+
116
+ ## Implementation
117
+
118
+ ### Phase 1: Basic Relay
119
+
120
+ Add NIP-01 relay functionality to existing WebSocket endpoint:
121
+
122
+ ```javascript
123
+ // src/notifications/nostr-relay.js
124
+
125
+ export function handleNostrMessage(socket, message) {
126
+ const [type, ...params] = JSON.parse(message);
127
+
128
+ switch (type) {
129
+ case 'EVENT':
130
+ return handleEvent(socket, params[0]);
131
+ case 'REQ':
132
+ return handleSubscription(socket, params[0], params.slice(1));
133
+ case 'CLOSE':
134
+ return handleClose(socket, params[0]);
135
+ }
136
+ }
137
+ ```
138
+
139
+ ### Phase 2: LDP-Event Bridge
140
+
141
+ When LDP resources change, emit Nostr events:
142
+
143
+ ```javascript
144
+ // src/handlers/resource.js (modified)
145
+
146
+ export async function handlePut(request, reply) {
147
+ // ... existing LDP logic ...
148
+
149
+ // After successful write, emit Nostr event
150
+ if (request.nostrPubkey) {
151
+ await emitResourceEvent({
152
+ pubkey: request.nostrPubkey,
153
+ resourceUrl,
154
+ content,
155
+ contentType
156
+ });
157
+ }
158
+ }
159
+ ```
160
+
161
+ ### Phase 3: Federation
162
+
163
+ Connect to other relays for event propagation:
164
+
165
+ ```javascript
166
+ // src/notifications/federation.js
167
+
168
+ const FEDERATION_RELAYS = [
169
+ 'wss://relay.damus.io',
170
+ 'wss://nos.lol',
171
+ 'wss://relay.nostr.band'
172
+ ];
173
+
174
+ export async function federateEvent(event) {
175
+ // Only federate public resources
176
+ if (await isPublicResource(event.tags.find(t => t[0] === 'd')[1])) {
177
+ for (const relay of FEDERATION_RELAYS) {
178
+ publishToRelay(relay, event);
179
+ }
180
+ }
181
+ }
182
+ ```
183
+
184
+ ### Phase 4: Identity Unification
185
+
186
+ WebID document includes Nostr pubkey:
187
+
188
+ ```json
189
+ {
190
+ "@context": {...},
191
+ "@id": "https://alice.solid.social/profile/card#me",
192
+ "foaf:name": "Alice",
193
+ "nostr:pubkey": "npub1abc...",
194
+ "nostr:relays": ["wss://alice.solid.social"]
195
+ }
196
+ ```
197
+
198
+ Nostr profile (kind:0) links to WebID:
199
+
200
+ ```json
201
+ {
202
+ "kind": 0,
203
+ "content": "{\"name\":\"Alice\",\"webid\":\"https://alice.solid.social/profile/card#me\"}"
204
+ }
205
+ ```
206
+
207
+ ## WebSocket Endpoint
208
+
209
+ Single endpoint handles both protocols:
210
+
211
+ ```
212
+ wss://alice.solid.social/.notifications
213
+
214
+ Protocol detection:
215
+ - If first message is JSON array starting with "EVENT"/"REQ" → Nostr
216
+ - If first message is "sub <uri>" → Legacy solid-0.1
217
+ ```
218
+
219
+ ```javascript
220
+ // src/notifications/websocket.js (modified)
221
+
222
+ export function handleWebSocket(socket, request) {
223
+ socket.on('message', (message) => {
224
+ const msg = message.toString().trim();
225
+
226
+ // Detect protocol
227
+ if (msg.startsWith('[')) {
228
+ // Nostr protocol
229
+ handleNostrMessage(socket, msg);
230
+ } else if (msg.startsWith('sub ') || msg.startsWith('unsub ')) {
231
+ // Legacy solid-0.1
232
+ handleSolidMessage(socket, msg);
233
+ }
234
+ });
235
+ }
236
+ ```
237
+
238
+ ## Access Control
239
+
240
+ ### Public Resources
241
+ - Events federate to other relays
242
+ - Anyone can subscribe
243
+
244
+ ### Private Resources
245
+ - Events stay local (no federation)
246
+ - NIP-42 AUTH required to subscribe
247
+ - Subscription filter must match authorized pubkeys
248
+
249
+ ```javascript
250
+ // NIP-42 AUTH flow
251
+ ["AUTH", "<signed-event>"]
252
+
253
+ // Server validates and restricts subscriptions
254
+ // to resources the pubkey has access to
255
+ ```
256
+
257
+ ### ACL Mapping
258
+
259
+ ```
260
+ acl:Read → Can subscribe to events
261
+ acl:Write → Can publish events (create/update)
262
+ acl:Control → Can modify ACL events
263
+ ```
264
+
265
+ ## Storage
266
+
267
+ Two options:
268
+
269
+ ### Option A: Dual Storage (Recommended for Phase 1)
270
+ - LDP resources in filesystem (existing)
271
+ - Nostr events in SQLite/memory (relay state)
272
+ - Bridge syncs between them
273
+
274
+ ### Option B: Event-Native Storage (Future)
275
+ - All resources stored as Nostr events
276
+ - LDP is a view over event history
277
+ - Full audit trail built-in
278
+ - Replaces filesystem storage
279
+
280
+ ## Configuration
281
+
282
+ ```json
283
+ {
284
+ "nostr": {
285
+ "enabled": true,
286
+ "relay": {
287
+ "nip01": true,
288
+ "nip42": true,
289
+ "nip78": true
290
+ },
291
+ "federation": {
292
+ "enabled": false,
293
+ "relays": [],
294
+ "publicOnly": true
295
+ },
296
+ "kinds": {
297
+ "resource": 30078,
298
+ "container": 30079,
299
+ "acl": 30080
300
+ }
301
+ }
302
+ }
303
+ ```
304
+
305
+ ## Migration Path
306
+
307
+ 1. **Phase 1**: Add relay alongside existing WebSocket
308
+ - Both protocols on same endpoint
309
+ - No breaking changes
310
+
311
+ 2. **Phase 2**: LDP-Event bridge
312
+ - Changes emit events
313
+ - Subscriptions work via Nostr
314
+
315
+ 3. **Phase 3**: Federation (optional)
316
+ - Public resources propagate
317
+ - Discovery via relay network
318
+
319
+ 4. **Phase 4**: Deprecate solid-0.1
320
+ - Nostr becomes primary notification protocol
321
+ - Mashlib adapter if needed
322
+
323
+ ## Benefits
324
+
325
+ | Feature | Solid Notifications | Nostr Relay++ |
326
+ |---------|--------------------|--------------|
327
+ | Protocol complexity | High | Low |
328
+ | Existing clients | ~0 | Millions |
329
+ | Federation | No | Yes |
330
+ | Mobile push | Build it yourself | Existing infrastructure |
331
+ | Identity | Separate (WebID) | Integrated (npub) |
332
+ | Signatures | Optional | Every event |
333
+ | Ecosystem | Academic | Active |
334
+
335
+ ## Open Questions
336
+
337
+ 1. **Kind numbers**: Apply for official NIP allocation or use 30078-30080 range?
338
+
339
+ 2. **Content encoding**: Store JSON-LD directly in content, or reference by hash?
340
+
341
+ 3. **Large resources**: Nostr events have size limits. Use NIP-94/NIP-96 for large files?
342
+
343
+ 4. **Container semantics**: How to represent ldp:contains in events?
344
+
345
+ 5. **Conflict resolution**: Last-write-wins via created_at, or something smarter?
346
+
347
+ ## References
348
+
349
+ - [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md)
350
+ - [NIP-42: Authentication](https://github.com/nostr-protocol/nips/blob/master/42.md)
351
+ - [NIP-78: Arbitrary Custom App Data](https://github.com/nostr-protocol/nips/blob/master/78.md)
352
+ - [NIP-98: HTTP Auth](https://github.com/nostr-protocol/nips/blob/master/98.md)
353
+ - [Solid Protocol](https://solidproject.org/TR/protocol)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.35",
3
+ "version": "0.0.36",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -75,36 +75,57 @@ export async function handleGet(request, reply) {
75
75
  acceptHeader.includes('text/n3') ||
76
76
  acceptHeader.includes('application/n-triples')
77
77
  );
78
+ const wantsJsonLd = connegEnabled && (
79
+ acceptHeader.includes('application/ld+json') ||
80
+ acceptHeader.includes('application/json')
81
+ );
78
82
 
79
- if (wantsTurtle) {
80
- // Extract JSON-LD from HTML and convert to Turtle
83
+ if (wantsTurtle || wantsJsonLd) {
84
+ // Extract JSON-LD from HTML data island
81
85
  try {
82
86
  const htmlStr = content.toString();
83
- const jsonLdMatch = htmlStr.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/);
87
+ const jsonLdMatch = htmlStr.match(/<script type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/);
84
88
  if (jsonLdMatch) {
85
89
  const jsonLd = JSON.parse(jsonLdMatch[1]);
86
- const { content: turtleContent } = await fromJsonLd(
87
- jsonLd,
88
- 'text/turtle',
89
- resourceUrl,
90
- true
91
- );
92
-
93
- const headers = getAllHeaders({
94
- isContainer: true,
95
- etag: indexStats?.etag || stats.etag,
96
- contentType: 'text/turtle',
97
- origin,
98
- resourceUrl,
99
- connegEnabled
100
- });
101
90
 
102
- Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
103
- return reply.send(turtleContent);
91
+ if (wantsTurtle) {
92
+ // Convert to Turtle
93
+ const { content: turtleContent } = await fromJsonLd(
94
+ jsonLd,
95
+ 'text/turtle',
96
+ resourceUrl,
97
+ true
98
+ );
99
+
100
+ const headers = getAllHeaders({
101
+ isContainer: true,
102
+ etag: indexStats?.etag || stats.etag,
103
+ contentType: 'text/turtle',
104
+ origin,
105
+ resourceUrl,
106
+ connegEnabled
107
+ });
108
+
109
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
110
+ return reply.send(turtleContent);
111
+ } else {
112
+ // Return JSON-LD directly
113
+ const headers = getAllHeaders({
114
+ isContainer: true,
115
+ etag: indexStats?.etag || stats.etag,
116
+ contentType: 'application/ld+json',
117
+ origin,
118
+ resourceUrl,
119
+ connegEnabled
120
+ });
121
+
122
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
123
+ return reply.send(JSON.stringify(jsonLd, null, 2));
124
+ }
104
125
  }
105
126
  } catch (err) {
106
127
  // Fall through to serve HTML if conversion fails
107
- console.error('Failed to convert profile to Turtle:', err.message);
128
+ console.error('Failed to convert profile to RDF:', err.message);
108
129
  }
109
130
  }
110
131
 
@@ -329,14 +350,45 @@ export async function handleHead(request, reply) {
329
350
  }
330
351
 
331
352
  const origin = request.headers.origin;
332
- const contentType = stats.isDirectory ? 'application/ld+json' : getContentType(storagePath);
353
+ const connegEnabled = request.connegEnabled || false;
354
+ let contentType;
355
+
356
+ if (stats.isDirectory) {
357
+ // For directories with index.html, determine content type based on Accept header
358
+ const indexPath = storagePath.endsWith('/') ? `${storagePath}index.html` : `${storagePath}/index.html`;
359
+ const indexExists = await storage.exists(indexPath);
360
+
361
+ if (indexExists && connegEnabled) {
362
+ const acceptHeader = request.headers.accept || '';
363
+ const wantsTurtle = acceptHeader.includes('text/turtle') ||
364
+ acceptHeader.includes('text/n3') ||
365
+ acceptHeader.includes('application/n-triples');
366
+ const wantsJsonLd = acceptHeader.includes('application/ld+json') ||
367
+ acceptHeader.includes('application/json');
368
+
369
+ if (wantsTurtle) {
370
+ contentType = 'text/turtle';
371
+ } else if (wantsJsonLd) {
372
+ contentType = 'application/ld+json';
373
+ } else {
374
+ contentType = 'text/html';
375
+ }
376
+ } else if (indexExists) {
377
+ contentType = 'text/html';
378
+ } else {
379
+ contentType = 'application/ld+json';
380
+ }
381
+ } else {
382
+ contentType = getContentType(storagePath);
383
+ }
333
384
 
334
385
  const headers = getAllHeaders({
335
386
  isContainer: stats.isDirectory,
336
387
  etag: stats.etag,
337
388
  contentType,
338
389
  origin,
339
- resourceUrl
390
+ resourceUrl,
391
+ connegEnabled
340
392
  });
341
393
 
342
394
  if (!stats.isDirectory) {
@@ -106,7 +106,8 @@ export async function handleCredentials(request, reply, issuer) {
106
106
  // Always generate a proper JWT - CTH requires JWT format
107
107
  const jwks = await getJwks();
108
108
  const signingKey = jwks.keys[0];
109
- const privateKey = await jose.importJWK(signingKey, 'ES256');
109
+ const signingAlg = signingKey.alg || 'ES256'; // Use key's algorithm
110
+ const privateKey = await jose.importJWK(signingKey, signingAlg);
110
111
 
111
112
  const now = Math.floor(Date.now() / 1000);
112
113
  const tokenPayload = {
@@ -131,7 +132,7 @@ export async function handleCredentials(request, reply, issuer) {
131
132
  }
132
133
 
133
134
  const accessToken = await new jose.SignJWT(tokenPayload)
134
- .setProtectedHeader({ alg: 'ES256', kid: signingKey.kid })
135
+ .setProtectedHeader({ alg: signingAlg, kid: signingKey.kid })
135
136
  .sign(privateKey);
136
137
 
137
138
  // Response
package/src/idp/index.js CHANGED
@@ -179,7 +179,7 @@ export async function idpPlugin(fastify, options) {
179
179
  response_modes_supported: ['query', 'fragment', 'form_post'],
180
180
  grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
181
181
  subject_types_supported: ['public'],
182
- id_token_signing_alg_values_supported: ['ES256'],
182
+ id_token_signing_alg_values_supported: ['RS256', 'ES256'],
183
183
  token_endpoint_auth_methods_supported: ['none', 'client_secret_basic', 'client_secret_post'],
184
184
  claims_supported: ['sub', 'webid', 'name', 'email', 'email_verified'],
185
185
  code_challenge_methods_supported: ['S256'],
package/src/idp/keys.js CHANGED
@@ -21,16 +21,15 @@ function getJwksPath() {
21
21
  }
22
22
 
23
23
  /**
24
- * Generate a new EC P-256 key pair for signing
24
+ * Generate a new EC P-256 key pair for signing (ES256)
25
25
  * @returns {Promise<object>} - JWK key pair with private key
26
26
  */
27
- async function generateSigningKey() {
27
+ async function generateES256Key() {
28
28
  const { publicKey, privateKey } = await jose.generateKeyPair('ES256', {
29
29
  extractable: true,
30
30
  });
31
31
 
32
32
  const privateJwk = await jose.exportJWK(privateKey);
33
- const publicJwk = await jose.exportJWK(publicKey);
34
33
 
35
34
  // Add metadata
36
35
  const kid = crypto.randomUUID();
@@ -45,6 +44,44 @@ async function generateSigningKey() {
45
44
  };
46
45
  }
47
46
 
47
+ /**
48
+ * Generate a new RSA key pair for signing (RS256)
49
+ * NSS v5.x may only support RS256 for external IdP verification
50
+ * @returns {Promise<object>} - JWK key pair with private key
51
+ */
52
+ async function generateRS256Key() {
53
+ const { publicKey, privateKey } = await jose.generateKeyPair('RS256', {
54
+ modulusLength: 2048,
55
+ extractable: true,
56
+ });
57
+
58
+ const privateJwk = await jose.exportJWK(privateKey);
59
+
60
+ // Add metadata
61
+ const kid = crypto.randomUUID();
62
+ const now = Math.floor(Date.now() / 1000);
63
+
64
+ return {
65
+ ...privateJwk,
66
+ kid,
67
+ use: 'sig',
68
+ alg: 'RS256',
69
+ iat: now,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Generate signing keys (both ES256 and RS256 for compatibility)
75
+ * @returns {Promise<object[]>} - Array of JWK key pairs
76
+ */
77
+ async function generateSigningKeys() {
78
+ // Generate RS256 first (primary, for NSS compatibility)
79
+ const rs256Key = await generateRS256Key();
80
+ // Also generate ES256 for modern clients
81
+ const es256Key = await generateES256Key();
82
+ return [rs256Key, es256Key];
83
+ }
84
+
48
85
  /**
49
86
  * Generate cookie signing keys
50
87
  * @returns {string[]} - Array of random secret strings
@@ -66,25 +103,36 @@ export async function initializeKeys() {
66
103
  try {
67
104
  // Try to load existing keys
68
105
  const data = await fs.readJson(getJwksPath());
106
+
107
+ // Check if we have RS256 key (needed for NSS compatibility)
108
+ const hasRS256 = data.jwks.keys.some((k) => k.alg === 'RS256');
109
+ if (!hasRS256) {
110
+ console.log('Adding RS256 key for NSS compatibility...');
111
+ const rs256Key = await generateRS256Key();
112
+ data.jwks.keys.unshift(rs256Key); // RS256 first (primary)
113
+ await fs.writeJson(getJwksPath(), data, { spaces: 2 });
114
+ console.log('RS256 key added.');
115
+ }
116
+
69
117
  return data;
70
118
  } catch (err) {
71
119
  if (err.code !== 'ENOENT') throw err;
72
120
 
73
- // Generate new keys
121
+ // Generate new keys (both RS256 and ES256)
74
122
  console.log('Generating new IdP signing keys...');
75
- const signingKey = await generateSigningKey();
123
+ const signingKeys = await generateSigningKeys();
76
124
  const cookieKeys = generateCookieKeys();
77
125
 
78
126
  const data = {
79
127
  jwks: {
80
- keys: [signingKey],
128
+ keys: signingKeys,
81
129
  },
82
130
  cookieKeys,
83
131
  createdAt: new Date().toISOString(),
84
132
  };
85
133
 
86
134
  await fs.writeJson(getJwksPath(), data, { spaces: 2 });
87
- console.log('IdP signing keys generated and saved.');
135
+ console.log('IdP signing keys generated and saved (RS256 + ES256).');
88
136
 
89
137
  return data;
90
138
  }
@@ -100,7 +148,8 @@ export async function getPublicJwks() {
100
148
  // Return only public key components
101
149
  const publicKeys = jwks.keys.map((key) => {
102
150
  // For EC keys, remove 'd' (private key component)
103
- const { d, ...publicKey } = key;
151
+ // For RSA keys, remove 'd', 'p', 'q', 'dp', 'dq', 'qi' (private components)
152
+ const { d, p, q, dp, dq, qi, ...publicKey } = key;
104
153
  return publicKey;
105
154
  });
106
155
 
@@ -8,6 +8,68 @@ import { createAdapter } from './adapter.js';
8
8
  import { getJwks, getCookieKeys } from './keys.js';
9
9
  import { getAccountForProvider } from './accounts.js';
10
10
 
11
+ // Cache for fetched client documents
12
+ const clientDocumentCache = new Map();
13
+ const CLIENT_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
14
+
15
+ /**
16
+ * Fetch and validate a Solid-OIDC Client Identifier Document
17
+ * @param {string} clientId - URL to the client document
18
+ * @returns {Promise<object|null>} - Client metadata or null
19
+ */
20
+ async function fetchClientDocument(clientId) {
21
+ try {
22
+ // Check cache
23
+ const cached = clientDocumentCache.get(clientId);
24
+ if (cached && Date.now() - cached.timestamp < CLIENT_CACHE_TTL) {
25
+ return cached.data;
26
+ }
27
+
28
+ const response = await fetch(clientId, {
29
+ headers: { 'Accept': 'application/json, application/ld+json' },
30
+ });
31
+
32
+ if (!response.ok) {
33
+ console.error(`Failed to fetch client document from ${clientId}: ${response.status}`);
34
+ return null;
35
+ }
36
+
37
+ const doc = await response.json();
38
+
39
+ // Validate required fields for Solid-OIDC client
40
+ // The client_id in the document must match the URL we fetched
41
+ if (doc.client_id && doc.client_id !== clientId) {
42
+ console.error(`Client ID mismatch: document says ${doc.client_id}, URL is ${clientId}`);
43
+ return null;
44
+ }
45
+
46
+ // Build client metadata compatible with oidc-provider
47
+ const clientMeta = {
48
+ client_id: clientId,
49
+ client_name: doc.client_name || doc.name || 'Unknown Client',
50
+ redirect_uris: doc.redirect_uris || [],
51
+ response_types: ['code'],
52
+ grant_types: ['authorization_code', 'refresh_token'],
53
+ token_endpoint_auth_method: 'none', // Public client
54
+ application_type: 'web',
55
+ // Copy other useful metadata
56
+ logo_uri: doc.logo_uri,
57
+ client_uri: doc.client_uri,
58
+ policy_uri: doc.policy_uri,
59
+ tos_uri: doc.tos_uri,
60
+ scope: doc.scope || 'openid webid',
61
+ };
62
+
63
+ // Cache the result
64
+ clientDocumentCache.set(clientId, { data: clientMeta, timestamp: Date.now() });
65
+
66
+ return clientMeta;
67
+ } catch (err) {
68
+ console.error(`Error fetching client document from ${clientId}:`, err.message);
69
+ return null;
70
+ }
71
+ }
72
+
11
73
  /**
12
74
  * Create and configure the OIDC provider
13
75
  * @param {string} issuer - The issuer URL (e.g., 'https://example.com')
@@ -242,12 +304,17 @@ export async function createProvider(issuer) {
242
304
  return true;
243
305
  },
244
306
 
307
+ // Extra client metadata fields to allow
308
+ extraClientMetadata: {
309
+ properties: ['client_name', 'logo_uri', 'client_uri', 'policy_uri', 'tos_uri'],
310
+ },
311
+
245
312
  // Client defaults
246
313
  clientDefaults: {
247
314
  grant_types: ['authorization_code', 'refresh_token'],
248
315
  response_types: ['code'],
249
316
  token_endpoint_auth_method: 'none', // Public clients by default
250
- id_token_signed_response_alg: 'ES256', // ES256 is what we support
317
+ id_token_signed_response_alg: 'RS256', // RS256 for NSS compatibility
251
318
  },
252
319
 
253
320
  // Response modes
@@ -263,9 +330,12 @@ export async function createProvider(issuer) {
263
330
  methods: ['S256'],
264
331
  },
265
332
 
266
- // Enable RS256 for DPoP (CTH uses RS256)
333
+ // Enable RS256 for DPoP and ID tokens (NSS requires RS256)
267
334
  enabledJWA: {
268
335
  dPoPSigningAlgValues: ['ES256', 'RS256', 'Ed25519', 'EdDSA'],
336
+ idTokenSigningAlgValues: ['RS256', 'ES256'],
337
+ userinfoSigningAlgValues: ['RS256', 'ES256'],
338
+ introspectionSigningAlgValues: ['RS256', 'ES256'],
269
339
  },
270
340
 
271
341
  // Enable request parameter
@@ -337,5 +407,33 @@ export async function createProvider(issuer) {
337
407
  // Allow localhost for development
338
408
  provider.proxy = true;
339
409
 
410
+ // Override Client.find to support Solid-OIDC Client Identifier Documents
411
+ // When client_id is a URL, fetch the document and create a client from it
412
+ const originalClientFind = provider.Client.find.bind(provider.Client);
413
+ provider.Client.find = async function(id, ...args) {
414
+ // First try the normal lookup (registered clients)
415
+ let client = await originalClientFind(id, ...args);
416
+ if (client) {
417
+ return client;
418
+ }
419
+
420
+ // If client_id looks like a URL, try to fetch the client document
421
+ if (id && (id.startsWith('http://') || id.startsWith('https://'))) {
422
+ const clientMeta = await fetchClientDocument(id);
423
+ if (clientMeta) {
424
+ // Create a temporary client object from the fetched metadata
425
+ // Use the Client constructor with the metadata
426
+ try {
427
+ client = new provider.Client(clientMeta, undefined);
428
+ return client;
429
+ } catch (err) {
430
+ console.error('Failed to create client from document:', err.message);
431
+ }
432
+ }
433
+ }
434
+
435
+ return undefined;
436
+ };
437
+
340
438
  return provider;
341
439
  }
package/src/rdf/turtle.js CHANGED
@@ -66,9 +66,11 @@ export async function jsonLdToTurtle(jsonLd, baseUri) {
66
66
  try {
67
67
  const quads = jsonLdToQuads(jsonLd, baseUri);
68
68
 
69
+ // Don't use baseIRI in writer - output absolute URIs for compatibility
70
+ // Some Solid servers (like NSS) may not properly resolve relative URIs
71
+ // when verifying oidcIssuer claims
69
72
  const writer = new Writer({
70
- prefixes: COMMON_PREFIXES,
71
- baseIRI: baseUri
73
+ prefixes: COMMON_PREFIXES
72
74
  });
73
75
 
74
76
  for (const q of quads) {