javascript-solid-server 0.0.58 → 0.0.60
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/.claude/settings.local.json +5 -1
- package/README.md +23 -1
- package/bin/jss.js +8 -0
- package/package.json +1 -1
- package/src/auth/nostr.js +15 -4
- package/src/config.js +9 -1
- package/src/handlers/git.js +3 -2
- package/src/idp/accounts.js +7 -1
- package/src/nostr/relay.js +283 -0
- package/src/server.js +20 -1
- package/test/did-nostr.test.js +179 -0
|
@@ -209,7 +209,11 @@
|
|
|
209
209
|
"WebFetch(domain:css-tricks.com)",
|
|
210
210
|
"Bash(node bin/jss.js:*)",
|
|
211
211
|
"WebFetch(domain:nostr.social)",
|
|
212
|
-
"Bash(xargs curl -s)"
|
|
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''\\)\\)\\)\":*)"
|
|
213
217
|
]
|
|
214
218
|
}
|
|
215
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.
|
|
9
|
+
### Implemented (v0.0.60)
|
|
10
10
|
|
|
11
11
|
- **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
|
|
12
12
|
- **N3 Patch** - Solid's native patch format for RDF updates
|
|
@@ -29,6 +29,7 @@ A minimal, fast, JSON-LD native Solid server.
|
|
|
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
|
|
@@ -51,6 +52,23 @@ A minimal, fast, JSON-LD native Solid server.
|
|
|
51
52
|
|
|
52
53
|
- Node.js 18+
|
|
53
54
|
|
|
55
|
+
### Android/Termux
|
|
56
|
+
|
|
57
|
+
JSS runs on Android via Termux with automatic `bcryptjs` fallback:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pkg install nodejs git
|
|
61
|
+
npm install -g javascript-solid-server
|
|
62
|
+
jss start --port 8080 --nostr --git
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Use PM2 for persistence:
|
|
66
|
+
```bash
|
|
67
|
+
npm install -g pm2
|
|
68
|
+
pm2 start jss -- start --port 8080 --nostr --git
|
|
69
|
+
pm2 save
|
|
70
|
+
```
|
|
71
|
+
|
|
54
72
|
### Installation
|
|
55
73
|
|
|
56
74
|
```bash
|
|
@@ -103,6 +121,9 @@ jss --help # Show help
|
|
|
103
121
|
| `--mashlib-cdn` | Enable Mashlib (CDN mode) | false |
|
|
104
122
|
| `--mashlib-version <ver>` | Mashlib CDN version | 2.0.0 |
|
|
105
123
|
| `--git` | Enable Git HTTP backend | false |
|
|
124
|
+
| `--nostr` | Enable Nostr relay | false |
|
|
125
|
+
| `--nostr-path <path>` | Nostr relay WebSocket path | /relay |
|
|
126
|
+
| `--nostr-max-events <n>` | Max events in relay memory | 1000 |
|
|
106
127
|
| `--invite-only` | Require invite code for registration | false |
|
|
107
128
|
| `--default-quota <size>` | Default storage quota per pod (e.g., 50MB) | 50MB |
|
|
108
129
|
| `-q, --quiet` | Suppress logs | false |
|
|
@@ -119,6 +140,7 @@ export JSS_CONNEG=true
|
|
|
119
140
|
export JSS_SUBDOMAINS=true
|
|
120
141
|
export JSS_BASE_DOMAIN=example.com
|
|
121
142
|
export JSS_MASHLIB=true
|
|
143
|
+
export JSS_NOSTR=true
|
|
122
144
|
export JSS_INVITE_ONLY=true
|
|
123
145
|
export JSS_DEFAULT_QUOTA=100MB
|
|
124
146
|
jss start
|
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
package/src/auth/nostr.js
CHANGED
|
@@ -187,11 +187,9 @@ export async function verifyNostrAuth(request) {
|
|
|
187
187
|
|
|
188
188
|
// Validate method tag matches request method
|
|
189
189
|
// For git clients: allow '*' as wildcard method
|
|
190
|
+
// If method tag is missing, infer from HTTP request (lenient mode)
|
|
190
191
|
const eventMethod = getTagValue(event, 'method');
|
|
191
|
-
if (
|
|
192
|
-
return { webId: null, error: 'Missing method tag in event' };
|
|
193
|
-
}
|
|
194
|
-
if (eventMethod !== '*' && eventMethod.toUpperCase() !== request.method.toUpperCase()) {
|
|
192
|
+
if (eventMethod && eventMethod !== '*' && eventMethod.toUpperCase() !== request.method.toUpperCase()) {
|
|
195
193
|
return { webId: null, error: `Method mismatch: expected ${request.method}, got ${eventMethod}` };
|
|
196
194
|
}
|
|
197
195
|
|
|
@@ -218,6 +216,19 @@ export async function verifyNostrAuth(request) {
|
|
|
218
216
|
return { webId: null, error: 'Invalid or missing pubkey' };
|
|
219
217
|
}
|
|
220
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
|
+
|
|
221
232
|
// Verify Schnorr signature
|
|
222
233
|
const isValid = verifyEvent(event);
|
|
223
234
|
if (!isValid) {
|
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
|
|
package/src/handlers/git.js
CHANGED
|
@@ -34,8 +34,9 @@ function extractRepoPath(urlPath) {
|
|
|
34
34
|
.replace(/\/git-upload-pack$/, '')
|
|
35
35
|
.replace(/\/git-receive-pack$/, '');
|
|
36
36
|
|
|
37
|
-
// Remove leading slash
|
|
38
|
-
|
|
37
|
+
// Remove leading slash, use '.' for root
|
|
38
|
+
const result = cleanPath.replace(/^\//, '');
|
|
39
|
+
return result === '' ? '.' : result;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
/**
|
package/src/idp/accounts.js
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
* Email is optional - internally uses username@jss if not provided
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// Try native bcrypt, fall back to pure JS bcryptjs (for Android/Termux)
|
|
8
|
+
let bcrypt;
|
|
9
|
+
try {
|
|
10
|
+
bcrypt = await import('bcrypt').then(m => m.default);
|
|
11
|
+
} catch {
|
|
12
|
+
bcrypt = await import('bcryptjs').then(m => m.default);
|
|
13
|
+
}
|
|
8
14
|
import crypto from 'crypto';
|
|
9
15
|
import fs from 'fs-extra';
|
|
10
16
|
import path from 'path';
|
|
@@ -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
|
+
});
|