javascript-solid-server 0.0.57 → 0.0.59

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.
@@ -207,7 +207,13 @@
207
207
  "WebFetch(domain:solid-chat.com)",
208
208
  "WebFetch(domain:developer.chrome.com)",
209
209
  "WebFetch(domain:css-tricks.com)",
210
- "Bash(node bin/jss.js:*)"
210
+ "Bash(node bin/jss.js:*)",
211
+ "WebFetch(domain:nostr.social)",
212
+ "Bash(xargs curl -s)",
213
+ "Bash(ssh phone:*)",
214
+ "Bash(dig:*)",
215
+ "WebFetch(domain:fonstr.com)",
216
+ "Bash(node -e \"import\\(''nostr-tools''\\).then\\(m => console.log\\(Object.keys\\(m\\).join\\(''\\\\n''\\)\\)\\)\":*)"
211
217
  ]
212
218
  }
213
219
  }
package/README.md CHANGED
@@ -6,7 +6,7 @@ A minimal, fast, JSON-LD native Solid server.
6
6
 
7
7
  ## Features
8
8
 
9
- ### Implemented (v0.0.57)
9
+ ### Implemented (v0.0.59)
10
10
 
11
11
  - **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
12
12
  - **N3 Patch** - Solid's native patch format for RDF updates
@@ -24,11 +24,12 @@ A minimal, fast, JSON-LD native Solid server.
24
24
  - **Solid-OIDC Identity Provider** - Built-in IdP with DPoP, RS256/ES256, dynamic registration
25
25
  - **Solid-OIDC Resource Server** - Accept DPoP-bound access tokens from external IdPs
26
26
  - **NSS-style Registration** - Username/password auth compatible with Solid apps
27
- - **Nostr Authentication** - NIP-98 HTTP Auth with Schnorr signatures
27
+ - **Nostr Authentication** - NIP-98 HTTP Auth with Schnorr signatures, did:nostr → WebID resolution
28
28
  - **Simple Auth Tokens** - Built-in token authentication for development
29
29
  - **Content Negotiation** - Turtle <-> JSON-LD conversion, including HTML data islands
30
30
  - **CORS Support** - Full cross-origin resource sharing
31
31
  - **Git HTTP Backend** - Clone and push to containers via `git` protocol
32
+ - **Nostr Relay** - Integrated NIP-01 relay on the same port (`wss://your.pod/relay`)
32
33
  - **Invite-Only Registration** - CLI-managed invite codes for controlled signups
33
34
  - **Storage Quotas** - Per-user storage limits with CLI management
34
35
  - **Security** - Blocks access to dotfiles (`.git/`, `.env`, etc.) except Solid-specific ones
@@ -103,6 +104,9 @@ jss --help # Show help
103
104
  | `--mashlib-cdn` | Enable Mashlib (CDN mode) | false |
104
105
  | `--mashlib-version <ver>` | Mashlib CDN version | 2.0.0 |
105
106
  | `--git` | Enable Git HTTP backend | false |
107
+ | `--nostr` | Enable Nostr relay | false |
108
+ | `--nostr-path <path>` | Nostr relay WebSocket path | /relay |
109
+ | `--nostr-max-events <n>` | Max events in relay memory | 1000 |
106
110
  | `--invite-only` | Require invite code for registration | false |
107
111
  | `--default-quota <size>` | Default storage quota per pod (e.g., 50MB) | 50MB |
108
112
  | `-q, --quiet` | Suppress logs | false |
@@ -119,6 +123,7 @@ export JSS_CONNEG=true
119
123
  export JSS_SUBDOMAINS=true
120
124
  export JSS_BASE_DOMAIN=example.com
121
125
  export JSS_MASHLIB=true
126
+ export JSS_NOSTR=true
122
127
  export JSS_INVITE_ONLY=true
123
128
  export JSS_DEFAULT_QUOTA=100MB
124
129
  jss start
@@ -387,6 +392,35 @@ git add .acl && git commit -m "Add ACL"
387
392
 
388
393
  See [git-credential-nostr](https://github.com/JavaScriptSolidServer/git-credential-nostr) for more details.
389
394
 
395
+ ### Linking Nostr to WebID (did:nostr)
396
+
397
+ Bridge your Nostr identity to a Solid WebID for seamless authentication:
398
+
399
+ **Step 1:** Add your WebID to your Nostr profile (kind 0 event):
400
+ ```json
401
+ {
402
+ "name": "alice",
403
+ "alsoKnownAs": ["https://solid.social/alice/profile/card#me"]
404
+ }
405
+ ```
406
+
407
+ **Step 2:** Add the did:nostr link to your WebID profile:
408
+ ```json
409
+ {
410
+ "@id": "#me",
411
+ "owl:sameAs": "did:nostr:<your-64-char-hex-pubkey>"
412
+ }
413
+ ```
414
+
415
+ **How it works:**
416
+ 1. NIP-98 signature is verified (existing flow)
417
+ 2. DID document is fetched from `nostr.social/.well-known/did/nostr/<pubkey>.json`
418
+ 3. `alsoKnownAs` is checked for a WebID URL
419
+ 4. WebID profile is fetched and `owl:sameAs` verified
420
+ 5. If bidirectional link exists → authenticated as WebID
421
+
422
+ This enables Nostr users to access their Solid pods using existing NIP-07 browser extensions.
423
+
390
424
  ## Invite-Only Registration
391
425
 
392
426
  Control who can create accounts by requiring invite codes:
@@ -735,7 +769,8 @@ src/
735
769
  │ ├── middleware.js # Auth hook
736
770
  │ ├── token.js # Simple token auth
737
771
  │ ├── solid-oidc.js # DPoP verification
738
- └── nostr.js # NIP-98 Nostr authentication
772
+ ├── nostr.js # NIP-98 Nostr authentication
773
+ │ └── did-nostr.js # did:nostr → WebID resolution
739
774
  ├── wac/
740
775
  │ ├── parser.js # ACL parsing
741
776
  │ └── checker.js # Permission checking
package/bin/jss.js CHANGED
@@ -59,6 +59,10 @@ program
59
59
  .option('--mashlib-version <version>', 'Mashlib version for CDN mode (default: 2.0.0)')
60
60
  .option('--git', 'Enable Git HTTP backend (clone/push support)')
61
61
  .option('--no-git', 'Disable Git HTTP backend')
62
+ .option('--nostr', 'Enable Nostr relay')
63
+ .option('--no-nostr', 'Disable Nostr relay')
64
+ .option('--nostr-path <path>', 'Nostr relay WebSocket path (default: /relay)')
65
+ .option('--nostr-max-events <n>', 'Max events in relay memory (default: 1000)', parseInt)
62
66
  .option('--invite-only', 'Require invite code for registration')
63
67
  .option('--no-invite-only', 'Allow open registration')
64
68
  .option('-q, --quiet', 'Suppress log output')
@@ -103,6 +107,9 @@ program
103
107
  mashlibCdn: config.mashlibCdn,
104
108
  mashlibVersion: config.mashlibVersion,
105
109
  git: config.git,
110
+ nostr: config.nostr,
111
+ nostrPath: config.nostrPath,
112
+ nostrMaxEvents: config.nostrMaxEvents,
106
113
  inviteOnly: config.inviteOnly,
107
114
  });
108
115
 
@@ -123,6 +130,7 @@ program
123
130
  console.log(` Mashlib: local (data browser enabled)`);
124
131
  }
125
132
  if (config.git) console.log(' Git: enabled (clone/push support)');
133
+ if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
126
134
  if (config.inviteOnly) console.log(' Registration: invite-only');
127
135
  console.log('\n Press Ctrl+C to stop\n');
128
136
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.57",
3
+ "version": "0.0.59",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,205 @@
1
+ /**
2
+ * DID:nostr Resolution
3
+ *
4
+ * Resolves did:nostr:<pubkey> to a Solid WebID by:
5
+ * 1. Fetching DID document from nostr.social
6
+ * 2. Extracting alsoKnownAs WebID
7
+ * 3. Verifying bidirectional link (WebID links back to did:nostr)
8
+ */
9
+
10
+ // Default DID resolver endpoint
11
+ const DEFAULT_DID_RESOLVER = 'https://nostr.social/.well-known/did/nostr';
12
+
13
+ // Cache for resolved DIDs (pubkey -> webId or null)
14
+ const cache = new Map();
15
+ const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
16
+
17
+ /**
18
+ * Fetch with timeout
19
+ */
20
+ async function fetchWithTimeout(url, options = {}, timeout = 5000) {
21
+ const controller = new AbortController();
22
+ const id = setTimeout(() => controller.abort(), timeout);
23
+ try {
24
+ const response = await fetch(url, { ...options, signal: controller.signal });
25
+ clearTimeout(id);
26
+ return response;
27
+ } catch (err) {
28
+ clearTimeout(id);
29
+ throw err;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Resolve did:nostr pubkey to WebID via DID document
35
+ * @param {string} pubkey - 64-char hex Nostr pubkey
36
+ * @param {string} resolverUrl - DID resolver base URL
37
+ * @returns {Promise<string|null>} WebID URL or null
38
+ */
39
+ export async function resolveDidNostrToWebId(pubkey, resolverUrl = DEFAULT_DID_RESOLVER) {
40
+ if (!pubkey || pubkey.length !== 64) {
41
+ return null;
42
+ }
43
+
44
+ // Check cache
45
+ const cacheKey = pubkey.toLowerCase();
46
+ const cached = cache.get(cacheKey);
47
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
48
+ return cached.webId;
49
+ }
50
+
51
+ try {
52
+ // Fetch DID document
53
+ const didUrl = `${resolverUrl}/${pubkey}.json`;
54
+ const didRes = await fetchWithTimeout(didUrl, {
55
+ headers: { 'Accept': 'application/did+json, application/json' }
56
+ });
57
+
58
+ if (!didRes.ok) {
59
+ cache.set(cacheKey, { webId: null, timestamp: Date.now() });
60
+ return null;
61
+ }
62
+
63
+ const didDoc = await didRes.json();
64
+
65
+ // Extract WebID from alsoKnownAs (array) or profile.webid or profile.sameAs
66
+ let webId = null;
67
+
68
+ if (Array.isArray(didDoc.alsoKnownAs) && didDoc.alsoKnownAs.length > 0) {
69
+ // Find first HTTP(S) URL that looks like a WebID
70
+ webId = didDoc.alsoKnownAs.find(aka =>
71
+ typeof aka === 'string' && aka.startsWith('https://'));
72
+ }
73
+
74
+ // Fallback to profile fields
75
+ if (!webId && didDoc.profile) {
76
+ webId = didDoc.profile.webid || didDoc.profile.sameAs;
77
+ }
78
+
79
+ if (!webId) {
80
+ cache.set(cacheKey, { webId: null, timestamp: Date.now() });
81
+ return null;
82
+ }
83
+
84
+ // Verify bidirectional link - WebID must link back to did:nostr
85
+ const verified = await verifyWebIdBacklink(webId, pubkey);
86
+
87
+ if (verified) {
88
+ cache.set(cacheKey, { webId, timestamp: Date.now() });
89
+ return webId;
90
+ }
91
+
92
+ cache.set(cacheKey, { webId: null, timestamp: Date.now() });
93
+ return null;
94
+
95
+ } catch (err) {
96
+ // Network error or timeout - don't cache failures
97
+ console.error(`DID resolution error for ${pubkey}:`, err.message);
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Verify WebID profile links back to did:nostr
104
+ * @param {string} webId - WebID URL
105
+ * @param {string} pubkey - Nostr pubkey
106
+ * @returns {Promise<boolean>}
107
+ */
108
+ async function verifyWebIdBacklink(webId, pubkey) {
109
+ try {
110
+ const expectedDid = `did:nostr:${pubkey.toLowerCase()}`;
111
+
112
+ // Fetch WebID profile
113
+ const res = await fetchWithTimeout(webId, {
114
+ headers: { 'Accept': 'application/ld+json, application/json, text/html' }
115
+ });
116
+
117
+ if (!res.ok) {
118
+ return false;
119
+ }
120
+
121
+ const contentType = res.headers.get('content-type') || '';
122
+ const text = await res.text();
123
+
124
+ // Handle HTML with JSON-LD data island
125
+ if (contentType.includes('text/html')) {
126
+ const jsonLdMatch = text.match(/<script\s+type=["']application\/ld\+json["']\s*>([\s\S]*?)<\/script>/i);
127
+ if (jsonLdMatch) {
128
+ try {
129
+ const jsonLd = JSON.parse(jsonLdMatch[1]);
130
+ return checkSameAsLink(jsonLd, expectedDid);
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+ return false;
136
+ }
137
+
138
+ // Handle JSON-LD directly
139
+ if (contentType.includes('json')) {
140
+ try {
141
+ const jsonLd = JSON.parse(text);
142
+ return checkSameAsLink(jsonLd, expectedDid);
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ return false;
149
+
150
+ } catch (err) {
151
+ console.error(`WebID backlink verification error for ${webId}:`, err.message);
152
+ return false;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Check if JSON-LD contains sameAs/owl:sameAs link to expected DID
158
+ * @param {object} jsonLd - Parsed JSON-LD
159
+ * @param {string} expectedDid - Expected did:nostr:pubkey
160
+ * @returns {boolean}
161
+ */
162
+ function checkSameAsLink(jsonLd, expectedDid) {
163
+ // Check various sameAs fields
164
+ const sameAsFields = [
165
+ jsonLd['owl:sameAs'],
166
+ jsonLd['sameAs'],
167
+ jsonLd['schema:sameAs'],
168
+ jsonLd['http://www.w3.org/2002/07/owl#sameAs']
169
+ ];
170
+
171
+ for (const field of sameAsFields) {
172
+ if (!field) continue;
173
+
174
+ // Handle string value
175
+ if (typeof field === 'string' && field.toLowerCase() === expectedDid) {
176
+ return true;
177
+ }
178
+
179
+ // Handle object with @id
180
+ if (field && typeof field === 'object' && field['@id']?.toLowerCase() === expectedDid) {
181
+ return true;
182
+ }
183
+
184
+ // Handle array
185
+ if (Array.isArray(field)) {
186
+ for (const item of field) {
187
+ if (typeof item === 'string' && item.toLowerCase() === expectedDid) {
188
+ return true;
189
+ }
190
+ if (item && typeof item === 'object' && item['@id']?.toLowerCase() === expectedDid) {
191
+ return true;
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ return false;
198
+ }
199
+
200
+ /**
201
+ * Clear the resolution cache (for testing)
202
+ */
203
+ export function clearCache() {
204
+ cache.clear();
205
+ }
package/src/auth/nostr.js CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { verifyEvent } from 'nostr-tools';
15
15
  import crypto from 'crypto';
16
+ import { resolveDidNostrToWebId } from './did-nostr.js';
16
17
 
17
18
  // NIP-98 event kind (references RFC 7235)
18
19
  const HTTP_AUTH_KIND = 27235;
@@ -186,11 +187,9 @@ export async function verifyNostrAuth(request) {
186
187
 
187
188
  // Validate method tag matches request method
188
189
  // For git clients: allow '*' as wildcard method
190
+ // If method tag is missing, infer from HTTP request (lenient mode)
189
191
  const eventMethod = getTagValue(event, 'method');
190
- if (!eventMethod) {
191
- return { webId: null, error: 'Missing method tag in event' };
192
- }
193
- if (eventMethod !== '*' && eventMethod.toUpperCase() !== request.method.toUpperCase()) {
192
+ if (eventMethod && eventMethod !== '*' && eventMethod.toUpperCase() !== request.method.toUpperCase()) {
194
193
  return { webId: null, error: `Method mismatch: expected ${request.method}, got ${eventMethod}` };
195
194
  }
196
195
 
@@ -217,13 +216,34 @@ export async function verifyNostrAuth(request) {
217
216
  return { webId: null, error: 'Invalid or missing pubkey' };
218
217
  }
219
218
 
219
+ // Compute event id if missing (lenient mode for nosdav compatibility)
220
+ if (!event.id) {
221
+ const serialized = JSON.stringify([
222
+ 0,
223
+ event.pubkey,
224
+ event.created_at,
225
+ event.kind,
226
+ event.tags,
227
+ event.content
228
+ ]);
229
+ event.id = crypto.createHash('sha256').update(serialized).digest('hex');
230
+ }
231
+
220
232
  // Verify Schnorr signature
221
233
  const isValid = verifyEvent(event);
222
234
  if (!isValid) {
223
235
  return { webId: null, error: 'Invalid Schnorr signature' };
224
236
  }
225
237
 
226
- // Return did:nostr as the agent identifier
238
+ // Try to resolve did:nostr to a linked WebID
239
+ // This checks if the pubkey has an alsoKnownAs pointing to a WebID
240
+ // and verifies the WebID links back to did:nostr (bidirectional)
241
+ const resolvedWebId = await resolveDidNostrToWebId(event.pubkey);
242
+ if (resolvedWebId) {
243
+ return { webId: resolvedWebId, error: null };
244
+ }
245
+
246
+ // Fall back to did:nostr as the agent identifier
227
247
  const didNostr = pubkeyToDidNostr(event.pubkey);
228
248
 
229
249
  return { webId: didNostr, error: null };
package/src/config.js CHANGED
@@ -45,6 +45,11 @@ export const defaults = {
45
45
  // Git HTTP backend
46
46
  git: false,
47
47
 
48
+ // Nostr relay
49
+ nostr: false,
50
+ nostrPath: '/relay',
51
+ nostrMaxEvents: 1000,
52
+
48
53
  // Invite-only registration
49
54
  inviteOnly: false,
50
55
 
@@ -81,6 +86,9 @@ const envMap = {
81
86
  JSS_MASHLIB_CDN: 'mashlibCdn',
82
87
  JSS_MASHLIB_VERSION: 'mashlibVersion',
83
88
  JSS_GIT: 'git',
89
+ JSS_NOSTR: 'nostr',
90
+ JSS_NOSTR_PATH: 'nostrPath',
91
+ JSS_NOSTR_MAX_EVENTS: 'nostrMaxEvents',
84
92
  JSS_INVITE_ONLY: 'inviteOnly',
85
93
  JSS_DEFAULT_QUOTA: 'defaultQuota',
86
94
  };
@@ -109,7 +117,7 @@ function parseEnvValue(value, key) {
109
117
  if (value.toLowerCase() === 'false') return false;
110
118
 
111
119
  // Numeric values for known numeric keys
112
- if (key === 'port' && !isNaN(value)) {
120
+ if ((key === 'port' || key === 'nostrMaxEvents') && !isNaN(value)) {
113
121
  return parseInt(value, 10);
114
122
  }
115
123
 
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Nostr Relay Module
3
+ *
4
+ * Lightweight Nostr relay (NIP-01) integrated into JSS.
5
+ * Based on Fonstr (https://github.com/nostrapps/fonstr)
6
+ *
7
+ * Usage: jss start --nostr
8
+ * Endpoint: wss://your.pod/relay
9
+ */
10
+
11
+ import { validateEvent, verifyEvent } from 'nostr-tools';
12
+ import websocket from '@fastify/websocket';
13
+
14
+ // Default max events to prevent memory exhaustion
15
+ const DEFAULT_MAX_EVENTS = 1000;
16
+ // Rate limiting: max events per socket per minute
17
+ const DEFAULT_RATE_LIMIT = 60;
18
+ const RATE_WINDOW_MS = 60000;
19
+
20
+ /**
21
+ * Check if event passes filter (NIP-01)
22
+ */
23
+ function eventPassesFilter(event, filter) {
24
+ if (filter.ids && !filter.ids.includes(event.id)) {
25
+ return false;
26
+ }
27
+
28
+ if (filter.authors && !filter.authors.includes(event.pubkey)) {
29
+ return false;
30
+ }
31
+
32
+ if (filter.kinds && !filter.kinds.includes(event.kind)) {
33
+ return false;
34
+ }
35
+
36
+ if (filter.since && event.created_at < filter.since) {
37
+ return false;
38
+ }
39
+
40
+ if (filter.until && event.created_at > filter.until) {
41
+ return false;
42
+ }
43
+
44
+ // Tag filters (#e, #p, etc.)
45
+ for (const [key, values] of Object.entries(filter)) {
46
+ if (key.startsWith('#') && key.length === 2) {
47
+ const tagName = key[1];
48
+ const eventTagValues = event.tags
49
+ .filter(tag => tag[0] === tagName)
50
+ .map(tag => tag[1]);
51
+
52
+ if (!values.some(v => eventTagValues.includes(v))) {
53
+ return false;
54
+ }
55
+ }
56
+ }
57
+
58
+ return true;
59
+ }
60
+
61
+ /**
62
+ * Event kind helpers (NIP-01, NIP-16)
63
+ */
64
+ function isReplaceableKind(kind) {
65
+ return (kind >= 10000 && kind < 20000) || kind === 0 || kind === 3;
66
+ }
67
+
68
+ function isEphemeralKind(kind) {
69
+ return kind >= 20000 && kind < 30000;
70
+ }
71
+
72
+ function isParameterizedReplaceable(kind) {
73
+ return kind >= 30000 && kind < 40000;
74
+ }
75
+
76
+ function getDTagValue(tags) {
77
+ for (const tag of tags) {
78
+ if (tag[0] === 'd') {
79
+ return tag[1];
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Register Nostr relay routes on Fastify instance
87
+ *
88
+ * @param {object} fastify - Fastify instance
89
+ * @param {object} options - Options
90
+ * @param {string} options.path - WebSocket path (default: '/relay')
91
+ * @param {number} options.maxEvents - Max events in memory (default: 1000)
92
+ */
93
+ export async function registerNostrRelay(fastify, options = {}) {
94
+ const path = options.path || '/relay';
95
+ const maxEvents = options.maxEvents || DEFAULT_MAX_EVENTS;
96
+
97
+ // In-memory storage
98
+ const events = [];
99
+ const subscribers = new Map();
100
+ const rateLimits = new Map(); // socket -> { count, resetTime }
101
+
102
+ /**
103
+ * Check rate limit for socket
104
+ */
105
+ function checkRateLimit(socket) {
106
+ const now = Date.now();
107
+ let limit = rateLimits.get(socket);
108
+
109
+ if (!limit || now > limit.resetTime) {
110
+ limit = { count: 0, resetTime: now + RATE_WINDOW_MS };
111
+ rateLimits.set(socket, limit);
112
+ }
113
+
114
+ limit.count++;
115
+ return limit.count <= DEFAULT_RATE_LIMIT;
116
+ }
117
+
118
+ /**
119
+ * Process incoming message
120
+ */
121
+ async function processMessage(type, value, rest, socket) {
122
+ switch (type) {
123
+ case 'EVENT': {
124
+ // Rate limit check
125
+ if (!checkRateLimit(socket)) {
126
+ socket.send(JSON.stringify(['OK', value?.id || '', false, 'rate-limited: too many events']));
127
+ return;
128
+ }
129
+
130
+ const event = value;
131
+ const isValid = validateEvent(event) && verifyEvent(event);
132
+
133
+ if (!isValid) {
134
+ socket.send(JSON.stringify(['OK', event?.id || '', false, 'invalid: bad signature or format']));
135
+ return;
136
+ }
137
+
138
+ // Handle different event kinds
139
+ if (isEphemeralKind(event.kind)) {
140
+ // Ephemeral: don't store, just broadcast
141
+ } else if (isReplaceableKind(event.kind) || isParameterizedReplaceable(event.kind)) {
142
+ // Replaceable: find and update existing
143
+ let indexToReplace = -1;
144
+ for (let i = 0; i < events.length; i++) {
145
+ if (events[i].pubkey === event.pubkey && events[i].kind === event.kind) {
146
+ if (isParameterizedReplaceable(event.kind)) {
147
+ const dTagValue = getDTagValue(event.tags);
148
+ const existingDTagValue = getDTagValue(events[i].tags);
149
+ if (dTagValue === existingDTagValue) {
150
+ indexToReplace = i;
151
+ break;
152
+ }
153
+ } else {
154
+ indexToReplace = i;
155
+ break;
156
+ }
157
+ }
158
+ }
159
+
160
+ if (indexToReplace !== -1) {
161
+ events[indexToReplace] = event;
162
+ } else {
163
+ if (events.length >= maxEvents) {
164
+ events.shift();
165
+ }
166
+ events.push(event);
167
+ }
168
+ } else {
169
+ // Regular event
170
+ if (events.length >= maxEvents) {
171
+ events.shift();
172
+ }
173
+ events.push(event);
174
+ }
175
+
176
+ // Broadcast to matching subscribers
177
+ subscribers.forEach((filters, subscriber) => {
178
+ filters.forEach(filter => {
179
+ if (eventPassesFilter(event, filter)) {
180
+ try {
181
+ subscriber.send(JSON.stringify(['EVENT', filter.subscription_id, event]));
182
+ } catch (e) {
183
+ // Socket closed, will be cleaned up
184
+ }
185
+ }
186
+ });
187
+ });
188
+
189
+ socket.send(JSON.stringify(['OK', event.id, true, '']));
190
+ break;
191
+ }
192
+
193
+ case 'REQ': {
194
+ const subscriptionId = value;
195
+ const filters = rest.map(filter => ({ ...filter, subscription_id: subscriptionId }));
196
+ subscribers.set(socket, filters);
197
+
198
+ // Send matching historical events
199
+ filters.forEach(filter => {
200
+ const matchingEvents = events.filter(event => eventPassesFilter(event, filter));
201
+ const limited = filter.limit ? matchingEvents.slice(-filter.limit) : matchingEvents;
202
+ limited.forEach(event => {
203
+ socket.send(JSON.stringify(['EVENT', filter.subscription_id, event]));
204
+ });
205
+ });
206
+
207
+ socket.send(JSON.stringify(['EOSE', subscriptionId]));
208
+ break;
209
+ }
210
+
211
+ case 'CLOSE': {
212
+ const subId = value;
213
+ if (subscribers.has(socket)) {
214
+ const updatedFilters = subscribers.get(socket).filter(
215
+ filter => filter.subscription_id !== subId
216
+ );
217
+ if (updatedFilters.length === 0) {
218
+ subscribers.delete(socket);
219
+ } else {
220
+ subscribers.set(socket, updatedFilters);
221
+ }
222
+ }
223
+ break;
224
+ }
225
+
226
+ default:
227
+ socket.send(JSON.stringify(['NOTICE', `Unknown message type: ${type}`]));
228
+ }
229
+ }
230
+
231
+ // Register websocket plugin if not already registered
232
+ if (!fastify.websocketServer) {
233
+ await fastify.register(websocket);
234
+ }
235
+
236
+ // Register WebSocket route for Nostr relay
237
+ fastify.get(path, { websocket: true }, (connection, request) => {
238
+ const socket = connection.socket;
239
+
240
+ socket.on('message', async (data) => {
241
+ try {
242
+ const message = JSON.parse(data.toString());
243
+ const [type, value, ...rest] = message;
244
+ await processMessage(type, value, rest, socket);
245
+ } catch (e) {
246
+ socket.send(JSON.stringify(['NOTICE', `Error: ${e.message}`]));
247
+ }
248
+ });
249
+
250
+ socket.on('close', () => {
251
+ subscribers.delete(socket);
252
+ rateLimits.delete(socket);
253
+ });
254
+
255
+ socket.on('error', () => {
256
+ subscribers.delete(socket);
257
+ rateLimits.delete(socket);
258
+ });
259
+ });
260
+
261
+ // NIP-11: Relay Information Document at /relay/info
262
+ fastify.get(path + '/info', (request, reply) => {
263
+ const relayInfo = {
264
+ name: 'JSS Nostr Relay',
265
+ description: 'Nostr relay integrated with JavaScript Solid Server',
266
+ pubkey: '',
267
+ contact: '',
268
+ supported_nips: [1, 11, 16],
269
+ software: 'https://github.com/JavaScriptSolidServer/JavaScriptSolidServer',
270
+ version: '0.0.1'
271
+ };
272
+
273
+ return reply
274
+ .header('Access-Control-Allow-Origin', '*')
275
+ .header('Content-Type', 'application/json')
276
+ .send(relayInfo);
277
+ });
278
+
279
+ return {
280
+ getEventCount: () => events.length,
281
+ getSubscriberCount: () => subscribers.size
282
+ };
283
+ }
package/src/server.js CHANGED
@@ -11,6 +11,7 @@ import { notificationsPlugin } from './notifications/index.js';
11
11
  import { idpPlugin } from './idp/index.js';
12
12
  import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js';
13
13
  import { AccessMode } from './wac/parser.js';
14
+ import { registerNostrRelay } from './nostr/relay.js';
14
15
 
15
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
17
 
@@ -27,6 +28,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
27
28
  * @param {boolean} options.subdomains - Enable subdomain-based pods for XSS protection (default false)
28
29
  * @param {string} options.baseDomain - Base domain for subdomain pods (e.g., "example.com")
29
30
  * @param {boolean} options.git - Enable Git HTTP backend for clone/push (default false)
31
+ * @param {boolean} options.nostr - Enable Nostr relay (default false)
32
+ * @param {string} options.nostrPath - Nostr relay WebSocket path (default '/relay')
33
+ * @param {number} options.nostrMaxEvents - Max events in relay memory (default 1000)
30
34
  */
31
35
  export function createServer(options = {}) {
32
36
  // Content negotiation is OFF by default - we're a JSON-LD native server
@@ -46,6 +50,10 @@ export function createServer(options = {}) {
46
50
  const mashlibVersion = options.mashlibVersion ?? '2.0.0';
47
51
  // Git HTTP backend is OFF by default - enables clone/push via git protocol
48
52
  const gitEnabled = options.git ?? false;
53
+ // Nostr relay is OFF by default
54
+ const nostrEnabled = options.nostr ?? false;
55
+ const nostrPath = options.nostrPath ?? '/relay';
56
+ const nostrMaxEvents = options.nostrMaxEvents ?? 1000;
49
57
  // Invite-only registration is OFF by default - open registration
50
58
  const inviteOnly = options.inviteOnly ?? false;
51
59
  // Default storage quota per pod (50MB default, 0 = unlimited)
@@ -134,6 +142,16 @@ export function createServer(options = {}) {
134
142
  fastify.register(idpPlugin, { issuer: idpIssuer, inviteOnly });
135
143
  }
136
144
 
145
+ // Register Nostr relay if enabled
146
+ if (nostrEnabled) {
147
+ fastify.register(async (instance) => {
148
+ await registerNostrRelay(instance, {
149
+ path: nostrPath,
150
+ maxEvents: nostrMaxEvents
151
+ });
152
+ });
153
+ }
154
+
137
155
  // Register rate limiting plugin
138
156
  // Protects against brute force attacks and resource exhaustion
139
157
  fastify.register(rateLimit, {
@@ -219,13 +237,14 @@ export function createServer(options = {}) {
219
237
  // Authorization hook - check WAC permissions
220
238
  // Skip for pod creation endpoint (needs special handling)
221
239
  fastify.addHook('preHandler', async (request, reply) => {
222
- // Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, notifications, and git
240
+ // Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, notifications, nostr, and git
223
241
  const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
224
242
  if (request.url === '/.pods' ||
225
243
  request.url === '/.notifications' ||
226
244
  request.method === 'OPTIONS' ||
227
245
  request.url.startsWith('/idp/') ||
228
246
  request.url.startsWith('/.well-known/') ||
247
+ (nostrEnabled && request.url.startsWith(nostrPath)) ||
229
248
  (gitEnabled && isGitRequest(request.url)) ||
230
249
  mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
231
250
  return;
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Tests for did:nostr to WebID resolution
3
+ */
4
+
5
+ import { describe, it, before, after, mock } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
8
+ import {
9
+ startTestServer,
10
+ stopTestServer,
11
+ request,
12
+ createTestPod,
13
+ getBaseUrl,
14
+ assertStatus
15
+ } from './helpers.js';
16
+
17
+ // Import the module under test
18
+ import { resolveDidNostrToWebId, clearCache } from '../src/auth/did-nostr.js';
19
+
20
+ describe('DID:nostr Resolution', () => {
21
+ describe('Unit Tests', () => {
22
+ before(() => {
23
+ clearCache();
24
+ });
25
+
26
+ it('should return null for invalid pubkey', async () => {
27
+ const result = await resolveDidNostrToWebId('invalid');
28
+ assert.strictEqual(result, null);
29
+ });
30
+
31
+ it('should return null for empty pubkey', async () => {
32
+ const result = await resolveDidNostrToWebId('');
33
+ assert.strictEqual(result, null);
34
+ });
35
+
36
+ it('should return null for null pubkey', async () => {
37
+ const result = await resolveDidNostrToWebId(null);
38
+ assert.strictEqual(result, null);
39
+ });
40
+
41
+ it('should return null for pubkey with wrong length', async () => {
42
+ const result = await resolveDidNostrToWebId('abcd1234');
43
+ assert.strictEqual(result, null);
44
+ });
45
+
46
+ it('should handle non-existent DID gracefully', async () => {
47
+ // Use a random pubkey that won't exist
48
+ const sk = generateSecretKey();
49
+ const pubkey = getPublicKey(sk);
50
+
51
+ // This will hit nostr.social and get 404
52
+ const result = await resolveDidNostrToWebId(pubkey);
53
+ assert.strictEqual(result, null);
54
+ });
55
+ });
56
+
57
+ describe('checkSameAsLink Function', () => {
58
+ // We need to test the internal checkSameAsLink function
59
+ // Since it's not exported, we test it indirectly through WebID verification
60
+
61
+ it('should recognize owl:sameAs string value', async () => {
62
+ // This test verifies the format we expect in WebID profiles
63
+ const profile = {
64
+ '@id': '#me',
65
+ 'owl:sameAs': 'did:nostr:abcd1234'
66
+ };
67
+
68
+ // The profile should have the correct structure
69
+ assert.strictEqual(profile['owl:sameAs'], 'did:nostr:abcd1234');
70
+ });
71
+
72
+ it('should recognize sameAs as @id object', async () => {
73
+ const profile = {
74
+ '@id': '#me',
75
+ 'owl:sameAs': { '@id': 'did:nostr:abcd1234' }
76
+ };
77
+
78
+ assert.strictEqual(profile['owl:sameAs']['@id'], 'did:nostr:abcd1234');
79
+ });
80
+ });
81
+
82
+ describe('Nostr Auth with DID Resolution', () => {
83
+ before(async () => {
84
+ await startTestServer();
85
+ });
86
+
87
+ after(async () => {
88
+ await stopTestServer();
89
+ clearCache();
90
+ });
91
+
92
+ it('should create a pod for DID testing', async () => {
93
+ const result = await createTestPod('nostrtest');
94
+ assert.ok(result.webId, 'Should have webId');
95
+ assert.ok(result.token, 'Should have token');
96
+ });
97
+
98
+ it('should accept valid NIP-98 auth header', async () => {
99
+ // Generate a Nostr keypair
100
+ const sk = generateSecretKey();
101
+ const pubkey = getPublicKey(sk);
102
+
103
+ // Create the pod for this pubkey
104
+ const podName = pubkey.substring(0, 16);
105
+ await createTestPod(podName);
106
+
107
+ // Create a NIP-98 event
108
+ const baseUrl = getBaseUrl();
109
+ const event = finalizeEvent({
110
+ kind: 27235,
111
+ created_at: Math.floor(Date.now() / 1000),
112
+ tags: [
113
+ ['u', `${baseUrl}/${podName}/public/`],
114
+ ['method', 'GET']
115
+ ],
116
+ content: ''
117
+ }, sk);
118
+
119
+ // Encode as base64
120
+ const token = Buffer.from(JSON.stringify(event)).toString('base64');
121
+
122
+ // Make request with Nostr auth
123
+ const res = await fetch(`${baseUrl}/${podName}/public/`, {
124
+ headers: {
125
+ 'Authorization': `Nostr ${token}`
126
+ }
127
+ });
128
+
129
+ // Should succeed (200) - the Nostr auth should work
130
+ // Even without DID resolution, did:nostr:<pubkey> is accepted
131
+ assertStatus(res, 200);
132
+ });
133
+
134
+ it('should return did:nostr when no WebID linked', async () => {
135
+ const sk = generateSecretKey();
136
+ const pubkey = getPublicKey(sk);
137
+
138
+ // Try to resolve - should return null since no alsoKnownAs
139
+ const result = await resolveDidNostrToWebId(pubkey);
140
+ assert.strictEqual(result, null, 'Should return null when no WebID linked');
141
+ });
142
+ });
143
+
144
+ describe('Real DID Document Fetch', () => {
145
+ before(() => {
146
+ clearCache();
147
+ });
148
+
149
+ it('should fetch DID document from nostr.social', async () => {
150
+ // Use a known pubkey that exists on nostr.social
151
+ // fiatjaf's pubkey
152
+ const pubkey = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d';
153
+
154
+ // This should not throw, just return null if no WebID linked
155
+ const result = await resolveDidNostrToWebId(pubkey);
156
+
157
+ // fiatjaf likely doesn't have a WebID linked, so expect null
158
+ // But the fetch itself should work without error
159
+ assert.strictEqual(result, null, 'Should return null when no bidirectional link');
160
+ });
161
+
162
+ it('should cache DID resolution results', async () => {
163
+ const pubkey = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d';
164
+
165
+ // First call
166
+ const start1 = Date.now();
167
+ await resolveDidNostrToWebId(pubkey);
168
+ const time1 = Date.now() - start1;
169
+
170
+ // Second call should be cached (much faster)
171
+ const start2 = Date.now();
172
+ await resolveDidNostrToWebId(pubkey);
173
+ const time2 = Date.now() - start2;
174
+
175
+ // Cached call should be < 5ms typically
176
+ assert.ok(time2 < time1 || time2 < 10, `Cached call should be fast. First: ${time1}ms, Second: ${time2}ms`);
177
+ });
178
+ });
179
+ });