javascript-solid-server 0.0.73 → 0.0.76

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.
@@ -15,7 +15,7 @@ import {
15
15
  } from '../rdf/conneg.js';
16
16
  import { emitChange } from '../notifications/events.js';
17
17
  import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
18
- import { generateDatabrowserHtml, shouldServeMashlib } from '../mashlib/index.js';
18
+ import { generateDatabrowserHtml, generateSolidosUiHtml, shouldServeMashlib } from '../mashlib/index.js';
19
19
 
20
20
  /**
21
21
  * Get the storage path and resource URL for a request
@@ -30,6 +30,64 @@ function getRequestPaths(request) {
30
30
  return { urlPath, storagePath, resourceUrl };
31
31
  }
32
32
 
33
+ /**
34
+ * Parse HTTP Range header
35
+ * @param {string} rangeHeader - The Range header value (e.g., "bytes=0-1023")
36
+ * @param {number} fileSize - Total file size in bytes
37
+ * @returns {{ start: number, end: number } | null}
38
+ */
39
+ function parseRangeHeader(rangeHeader, fileSize) {
40
+ if (!rangeHeader || !rangeHeader.startsWith('bytes=')) {
41
+ return null;
42
+ }
43
+
44
+ const range = rangeHeader.slice(6); // Remove 'bytes='
45
+
46
+ // Multi-range requests (e.g., "0-100,200-300") are not supported
47
+ // Per RFC 7233, ignore Range header and serve full content instead of 416
48
+ if (range.includes(',')) {
49
+ return null;
50
+ }
51
+
52
+ const parts = range.split('-');
53
+
54
+ if (parts.length !== 2) {
55
+ return null;
56
+ }
57
+
58
+ let start, end;
59
+
60
+ if (parts[0] === '') {
61
+ // Suffix range: bytes=-500 (last 500 bytes)
62
+ const suffix = parseInt(parts[1], 10);
63
+ if (isNaN(suffix) || suffix <= 0) return null;
64
+ start = Math.max(0, fileSize - suffix);
65
+ end = fileSize - 1;
66
+ } else if (parts[1] === '') {
67
+ // Open-ended range: bytes=1024- (from 1024 to end)
68
+ start = parseInt(parts[0], 10);
69
+ if (isNaN(start) || start < 0) return null;
70
+ end = fileSize - 1;
71
+ } else {
72
+ // Normal range: bytes=0-1023
73
+ start = parseInt(parts[0], 10);
74
+ end = parseInt(parts[1], 10);
75
+ if (isNaN(start) || isNaN(end) || start < 0 || end < start) return null;
76
+ }
77
+
78
+ // Clamp end to file size
79
+ if (end >= fileSize) {
80
+ end = fileSize - 1;
81
+ }
82
+
83
+ // Check if range is satisfiable
84
+ if (start > end || start >= fileSize) {
85
+ return null;
86
+ }
87
+
88
+ return { start, end };
89
+ }
90
+
33
91
  /**
34
92
  * Handle GET request
35
93
  */
@@ -149,8 +207,10 @@ export async function handleGet(request, reply) {
149
207
 
150
208
  // Check if we should serve Mashlib data browser for containers
151
209
  if (shouldServeMashlib(request, request.mashlibEnabled, 'application/ld+json')) {
152
- const cdnVersion = request.mashlibCdn ? request.mashlibVersion : null;
153
- const html = generateDatabrowserHtml(resourceUrl, cdnVersion);
210
+ // Use SolidOS UI if enabled, otherwise fallback to classic mashlib
211
+ const html = request.solidosUiEnabled
212
+ ? generateSolidosUiHtml()
213
+ : generateDatabrowserHtml(resourceUrl, request.mashlibCdn ? request.mashlibVersion : null);
154
214
  const headers = getAllHeaders({
155
215
  isContainer: true,
156
216
  etag: stats.etag,
@@ -224,9 +284,10 @@ export async function handleGet(request, reply) {
224
284
  // Check if we should serve Mashlib data browser
225
285
  // Only for RDF resources when Accept: text/html is requested
226
286
  if (shouldServeMashlib(request, request.mashlibEnabled, storedContentType)) {
227
- // Pass CDN version if using CDN mode, null for local mode
228
- const cdnVersion = request.mashlibCdn ? request.mashlibVersion : null;
229
- const html = generateDatabrowserHtml(resourceUrl, cdnVersion);
287
+ // Use SolidOS UI if enabled, otherwise fallback to classic mashlib
288
+ const html = request.solidosUiEnabled
289
+ ? generateSolidosUiHtml()
290
+ : generateDatabrowserHtml(resourceUrl, request.mashlibCdn ? request.mashlibVersion : null);
230
291
  const headers = getAllHeaders({
231
292
  isContainer: false,
232
293
  etag: stats.etag,
@@ -245,6 +306,43 @@ export async function handleGet(request, reply) {
245
306
  return reply.type('text/html').send(html);
246
307
  }
247
308
 
309
+ // Handle Range requests for media files (video, audio, etc.)
310
+ const rangeHeader = request.headers.range;
311
+ if (rangeHeader && !isRdfContentType(storedContentType)) {
312
+ const range = parseRangeHeader(rangeHeader, stats.size);
313
+
314
+ if (range) {
315
+ const { start, end } = range;
316
+ const chunkSize = end - start + 1;
317
+
318
+ const headers = getAllHeaders({
319
+ isContainer: false,
320
+ etag: stats.etag,
321
+ contentType: storedContentType,
322
+ origin,
323
+ resourceUrl,
324
+ connegEnabled
325
+ });
326
+ headers['Content-Range'] = `bytes ${start}-${end}/${stats.size}`;
327
+ headers['Content-Length'] = chunkSize;
328
+
329
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
330
+
331
+ const streamResult = storage.createReadStream(storagePath, { start, end });
332
+ if (!streamResult) {
333
+ return reply.code(500).send({ error: 'Stream error' });
334
+ }
335
+
336
+ // Handle stream errors that occur during response
337
+ streamResult.stream.on('error', (err) => {
338
+ console.error('Stream error during range response:', err.message);
339
+ });
340
+
341
+ return reply.code(206).send(streamResult.stream);
342
+ }
343
+ // If range is null (unsupported format or multi-range), fall through to serve full content
344
+ }
345
+
248
346
  const content = await storage.read(storagePath);
249
347
  if (content === null) {
250
348
  return reply.code(500).send({ error: 'Read error' });
@@ -56,6 +56,7 @@ export function getResponseHeaders({ isContainer = false, etag = null, contentTy
56
56
  const headers = {
57
57
  'Link': getLinkHeader(isContainer, aclUrl),
58
58
  'Accept-Patch': 'text/n3, application/sparql-update',
59
+ 'Accept-Ranges': isContainer ? 'none' : 'bytes',
59
60
  'Allow': 'GET, HEAD, PUT, DELETE, PATCH, OPTIONS' + (isContainer ? ', POST' : ''),
60
61
  'Vary': connegEnabled ? 'Accept, Authorization, Origin' : 'Authorization, Origin'
61
62
  };
@@ -94,8 +95,8 @@ export function getCorsHeaders(origin) {
94
95
  return {
95
96
  'Access-Control-Allow-Origin': origin || '*',
96
97
  'Access-Control-Allow-Methods': 'GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS',
97
- 'Access-Control-Allow-Headers': 'Accept, Authorization, Content-Type, DPoP, If-Match, If-None-Match, Link, Slug, Origin',
98
- 'Access-Control-Expose-Headers': 'Accept-Patch, Accept-Post, Allow, Content-Type, ETag, Link, Location, Updates-Via, WAC-Allow',
98
+ 'Access-Control-Allow-Headers': 'Accept, Authorization, Content-Type, DPoP, If-Match, If-None-Match, Link, Range, Slug, Origin',
99
+ 'Access-Control-Expose-Headers': 'Accept-Patch, Accept-Post, Accept-Ranges, Allow, Content-Length, Content-Range, Content-Type, ETag, Link, Location, Updates-Via, WAC-Allow',
99
100
  'Access-Control-Allow-Credentials': 'true',
100
101
  'Access-Control-Max-Age': '86400'
101
102
  };
@@ -94,6 +94,113 @@ export function shouldServeMashlib(request, mashlibEnabled, contentType) {
94
94
  return rdfTypes.includes(baseType);
95
95
  }
96
96
 
97
+ /**
98
+ * Generate SolidOS UI HTML (modern Nextcloud-style interface)
99
+ * Uses mashlib for data layer but solidos-ui for the UI shell
100
+ *
101
+ * @returns {string} HTML content
102
+ */
103
+ export function generateSolidosUiHtml() {
104
+ return `<!DOCTYPE html>
105
+ <html lang="en">
106
+ <head>
107
+ <meta charset="utf-8"/>
108
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
109
+ <title>SolidOS - Modern UI</title>
110
+ <!-- SolidOS UI Styles -->
111
+ <link rel="stylesheet" href="/solidos-ui/styles/variables.css">
112
+ <link rel="stylesheet" href="/solidos-ui/styles/shell.css">
113
+ <link rel="stylesheet" href="/solidos-ui/styles/components.css">
114
+ <link rel="stylesheet" href="/solidos-ui/styles/responsive.css">
115
+ <!-- View-specific styles -->
116
+ <link rel="stylesheet" href="/solidos-ui/views/profile/profile.css">
117
+ <link rel="stylesheet" href="/solidos-ui/views/contacts/contacts.css">
118
+ <link rel="stylesheet" href="/solidos-ui/views/sharing/sharing.css">
119
+ <link rel="stylesheet" href="/solidos-ui/views/settings/settings.css">
120
+ <!-- Bundled styles (contains all component styles) -->
121
+ <link rel="stylesheet" href="/solidos-ui/style.css">
122
+ <style>
123
+ * { margin: 0; padding: 0; box-sizing: border-box; }
124
+ html, body { height: 100%; }
125
+ #app { height: 100%; }
126
+ </style>
127
+ </head>
128
+ <body>
129
+ <div id="app"></div>
130
+
131
+ <script>
132
+ // Load mashlib first, then solidos-ui
133
+ (function() {
134
+ var mashScript = document.createElement('script');
135
+ mashScript.src = '/mashlib.min.js';
136
+ mashScript.onload = function() {
137
+ // Now load solidos-ui
138
+ import('/solidos-ui/solidos-ui.js').then(function(module) {
139
+ var initSolidOSSkin = module.initSolidOSSkin;
140
+ var SolidLogic = window.SolidLogic;
141
+ var panes = window.panes;
142
+ var store = SolidLogic.store;
143
+
144
+ initSolidOSSkin('#app', {
145
+ store: store,
146
+ fetcher: store.fetcher,
147
+ paneRegistry: panes,
148
+ authn: SolidLogic.authn,
149
+ logic: SolidLogic.solidLogicSingleton,
150
+ }, {
151
+ onNavigate: function(uri) {
152
+ if (uri) {
153
+ // Use path-based navigation - update URL to match resource
154
+ try {
155
+ var url = new URL(uri);
156
+ // Always use the path from the URI, regardless of origin
157
+ // (URIs may use internal hostname like jss:4000 vs localhost:4000)
158
+ var newPath = url.pathname;
159
+ if (newPath !== window.location.pathname) {
160
+ window.history.pushState({ uri: uri }, '', newPath);
161
+ }
162
+ } catch (e) {
163
+ console.warn('Invalid URI for navigation:', uri);
164
+ }
165
+ }
166
+ },
167
+ onLogout: function() {
168
+ window.location.reload();
169
+ },
170
+ }).then(function(skin) {
171
+ // Handle browser back/forward
172
+ window.addEventListener('popstate', function(event) {
173
+ // Use the current URL as the resource (not hash-based)
174
+ var resourceUrl = window.location.origin + window.location.pathname;
175
+ skin.goto(resourceUrl);
176
+ });
177
+
178
+ // Navigate to the current URL's resource
179
+ // The URL path IS the resource in JSS (not hash-based routing)
180
+ var currentPath = window.location.pathname;
181
+ if (currentPath && currentPath !== '/') {
182
+ var resourceUrl = window.location.origin + currentPath;
183
+ skin.goto(resourceUrl);
184
+ }
185
+
186
+ // Expose for debugging
187
+ window.solidosSkin = skin;
188
+ });
189
+ }).catch(function(err) {
190
+ console.error('Failed to load solidos-ui:', err);
191
+ document.body.innerHTML = '<p>Failed to load SolidOS UI</p>';
192
+ });
193
+ };
194
+ mashScript.onerror = function() {
195
+ document.body.innerHTML = '<p>Failed to load Mashlib</p>';
196
+ };
197
+ document.head.appendChild(mashScript);
198
+ })();
199
+ </script>
200
+ </body>
201
+ </html>`;
202
+ }
203
+
97
204
  /**
98
205
  * Escape HTML special characters
99
206
  */
@@ -18,6 +18,7 @@
18
18
 
19
19
  import websocket from '@fastify/websocket';
20
20
  import { handleWebSocket, getConnectionCount, getSubscriptionCount } from './websocket.js';
21
+ import { getWebIdFromRequestAsync } from '../auth/token.js';
21
22
  export { emitChange } from './events.js';
22
23
 
23
24
  /**
@@ -32,8 +33,10 @@ export async function notificationsPlugin(fastify, options) {
32
33
  // WebSocket route for notifications (dedicated path to avoid route conflicts)
33
34
  // Clients discover this via Updates-Via header
34
35
  // In @fastify/websocket v8, handler receives (connection, request) where connection.socket is the raw WebSocket
35
- fastify.get('/.notifications', { websocket: true }, (connection, request) => {
36
- handleWebSocket(connection.socket, request);
36
+ fastify.get('/.notifications', { websocket: true }, async (connection, request) => {
37
+ // Get WebID from auth token (if present) for ACL checking on subscriptions
38
+ const { webId } = await getWebIdFromRequestAsync(request);
39
+ handleWebSocket(connection.socket, request, webId);
37
40
  });
38
41
 
39
42
  // Optional: Status endpoint for monitoring
@@ -6,11 +6,19 @@
6
6
  * Protocol:
7
7
  * - Server sends: "protocol solid-0.1" on connect
8
8
  * - Client sends: "sub <uri>" to subscribe
9
- * - Server sends: "ack <uri>" to acknowledge
9
+ * - Server sends: "ack <uri>" to acknowledge (if authorized)
10
+ * - Server sends: "err <uri> forbidden" if not authorized
10
11
  * - Server sends: "pub <uri>" when resource changes
12
+ *
13
+ * Security:
14
+ * - ACL is checked on every subscription request
15
+ * - Only subscribers with read access receive notifications
11
16
  */
12
17
 
13
18
  import { resourceEvents } from './events.js';
19
+ import { checkAccess } from '../wac/checker.js';
20
+ import { AccessMode } from '../wac/parser.js';
21
+ import * as storage from '../storage/filesystem.js';
14
22
 
15
23
  // Security limits
16
24
  const MAX_SUBSCRIPTIONS_PER_CONNECTION = 100;
@@ -26,8 +34,13 @@ const subscribers = new Map();
26
34
  * Handle new WebSocket connection
27
35
  * @param {WebSocket} socket - The WebSocket connection
28
36
  * @param {Request} request - The HTTP request
37
+ * @param {string|null} webId - Authenticated WebID (null for anonymous)
29
38
  */
30
- export function handleWebSocket(socket, request) {
39
+ export function handleWebSocket(socket, request, webId = null) {
40
+ // Store webId and server info on socket for ACL checks
41
+ socket.webId = webId;
42
+ socket.serverOrigin = `${request.protocol}://${request.hostname}`;
43
+
31
44
  // Send protocol greeting
32
45
  socket.send('protocol solid-0.1');
33
46
 
@@ -35,7 +48,7 @@ export function handleWebSocket(socket, request) {
35
48
  subscriptions.set(socket, new Set());
36
49
 
37
50
  // Handle incoming messages
38
- socket.on('message', (message) => {
51
+ socket.on('message', async (message) => {
39
52
  const msg = message.toString().trim();
40
53
 
41
54
  // Handle subscription request
@@ -55,6 +68,13 @@ export function handleWebSocket(socket, request) {
55
68
  return;
56
69
  }
57
70
 
71
+ // Security: check ACL read permission before allowing subscription
72
+ const canSubscribe = await checkSubscriptionAccess(url, socket);
73
+ if (!canSubscribe) {
74
+ socket.send(`err ${url} forbidden`);
75
+ return;
76
+ }
77
+
58
78
  subscribe(socket, url);
59
79
  socket.send(`ack ${url}`);
60
80
  }
@@ -80,6 +100,46 @@ export function handleWebSocket(socket, request) {
80
100
  });
81
101
  }
82
102
 
103
+ /**
104
+ * Check if socket has read access to subscribe to a URL
105
+ * @param {string} url - The URL to subscribe to
106
+ * @param {WebSocket} socket - The WebSocket connection (with webId attached)
107
+ * @returns {Promise<boolean>} - true if subscription is allowed
108
+ */
109
+ async function checkSubscriptionAccess(url, socket) {
110
+ try {
111
+ // Parse the subscription URL
112
+ const parsedUrl = new URL(url);
113
+
114
+ // Security: Only allow subscriptions to URLs on this server
115
+ // This prevents using the server as a proxy to probe other servers
116
+ if (parsedUrl.origin !== socket.serverOrigin) {
117
+ return false;
118
+ }
119
+
120
+ const resourcePath = decodeURIComponent(parsedUrl.pathname);
121
+
122
+ // Check if resource exists and if it's a container
123
+ const stats = await storage.stat(resourcePath);
124
+ const isContainer = stats?.isDirectory || resourcePath.endsWith('/');
125
+
126
+ // Check WAC read permission
127
+ const { allowed } = await checkAccess({
128
+ resourceUrl: url,
129
+ resourcePath,
130
+ isContainer,
131
+ agentWebId: socket.webId,
132
+ requiredMode: AccessMode.READ
133
+ });
134
+
135
+ return allowed;
136
+ } catch (err) {
137
+ // On any error (invalid URL, storage error, etc.), deny subscription
138
+ // This prevents information leakage through error messages
139
+ return false;
140
+ }
141
+ }
142
+
83
143
  /**
84
144
  * Subscribe a socket to a resource URL
85
145
  */
package/src/server.js CHANGED
@@ -37,6 +37,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
37
37
  * @param {string} options.apDisplayName - ActivityPub display name
38
38
  * @param {string} options.apSummary - ActivityPub bio/summary
39
39
  * @param {string} options.apNostrPubkey - Nostr pubkey for identity linking
40
+ * @param {boolean} options.webidTls - Enable WebID-TLS client certificate auth (default false)
40
41
  */
41
42
  export function createServer(options = {}) {
42
43
  // Content negotiation is OFF by default - we're a JSON-LD native server
@@ -54,6 +55,8 @@ export function createServer(options = {}) {
54
55
  const mashlibEnabled = options.mashlib ?? false;
55
56
  const mashlibCdn = options.mashlibCdn ?? false;
56
57
  const mashlibVersion = options.mashlibVersion ?? '2.0.0';
58
+ // SolidOS UI (modern Nextcloud-style interface) - requires mashlib
59
+ const solidosUiEnabled = options.solidosUi ?? false;
57
60
  // Git HTTP backend is OFF by default - enables clone/push via git protocol
58
61
  const gitEnabled = options.git ?? false;
59
62
  // Nostr relay is OFF by default
@@ -70,6 +73,8 @@ export function createServer(options = {}) {
70
73
  const inviteOnly = options.inviteOnly ?? false;
71
74
  // Default storage quota per pod (50MB default, 0 = unlimited)
72
75
  const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024;
76
+ // WebID-TLS client certificate authentication is OFF by default
77
+ const webidTlsEnabled = options.webidTls ?? false;
73
78
 
74
79
  // Set data root via environment variable if provided
75
80
  if (options.root) {
@@ -90,6 +95,13 @@ export function createServer(options = {}) {
90
95
  key: options.ssl.key,
91
96
  cert: options.ssl.cert,
92
97
  };
98
+
99
+ // Enable client certificate request for WebID-TLS
100
+ if (webidTlsEnabled) {
101
+ fastifyOptions.https.requestCert = true;
102
+ // Don't reject unauthorized - we verify via WebID profile, not CA chain
103
+ fastifyOptions.https.rejectUnauthorized = false;
104
+ }
93
105
  }
94
106
 
95
107
  const fastify = Fastify(fastifyOptions);
@@ -117,6 +129,7 @@ export function createServer(options = {}) {
117
129
  fastify.decorateRequest('mashlibEnabled', null);
118
130
  fastify.decorateRequest('mashlibCdn', null);
119
131
  fastify.decorateRequest('mashlibVersion', null);
132
+ fastify.decorateRequest('solidosUiEnabled', null);
120
133
  fastify.decorateRequest('defaultQuota', null);
121
134
  fastify.addHook('onRequest', async (request) => {
122
135
  request.connegEnabled = connegEnabled;
@@ -127,6 +140,7 @@ export function createServer(options = {}) {
127
140
  request.mashlibEnabled = mashlibEnabled;
128
141
  request.mashlibCdn = mashlibCdn;
129
142
  request.mashlibVersion = mashlibVersion;
143
+ request.solidosUiEnabled = solidosUiEnabled;
130
144
  request.defaultQuota = defaultQuota;
131
145
 
132
146
  // Extract pod name from subdomain if enabled
@@ -286,7 +300,7 @@ export function createServer(options = {}) {
286
300
  // Authorization hook - check WAC permissions
287
301
  // Skip for pod creation endpoint (needs special handling)
288
302
  fastify.addHook('preHandler', async (request, reply) => {
289
- // Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, notifications, nostr, git, and AP
303
+ // Skip auth for pod creation, OPTIONS, IdP routes, mashlib, solidos-ui, well-known, notifications, nostr, git, and AP
290
304
  const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
291
305
  const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following'];
292
306
  // Check if request wants ActivityPub content for profile
@@ -298,6 +312,7 @@ export function createServer(options = {}) {
298
312
  request.method === 'OPTIONS' ||
299
313
  request.url.startsWith('/idp/') ||
300
314
  request.url.startsWith('/.well-known/') ||
315
+ request.url.startsWith('/solidos-ui/') ||
301
316
  (nostrEnabled && request.url.startsWith(nostrPath)) ||
302
317
  (gitEnabled && isGitRequest(request.url)) ||
303
318
  (activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
@@ -371,6 +386,37 @@ export function createServer(options = {}) {
371
386
  }
372
387
  }
373
388
 
389
+ // SolidOS UI static files (modern Nextcloud-style interface)
390
+ // Serves from /solidos-ui/* - requires mashlib to be enabled as well
391
+ if (solidosUiEnabled && mashlibEnabled) {
392
+ const solidosUiDir = join(__dirname, 'mashlib-local', 'dist', 'solidos-ui');
393
+
394
+ // Serve all files under /solidos-ui/* path
395
+ fastify.get('/solidos-ui/*', async (request, reply) => {
396
+ try {
397
+ // Get the path after /solidos-ui/
398
+ const filePath = request.url.replace('/solidos-ui/', '').split('?')[0];
399
+ const fullPath = join(solidosUiDir, filePath);
400
+
401
+ // Determine content type based on extension
402
+ const ext = filePath.split('.').pop()?.toLowerCase();
403
+ const contentTypes = {
404
+ 'js': 'application/javascript',
405
+ 'css': 'text/css',
406
+ 'map': 'application/json',
407
+ 'html': 'text/html'
408
+ };
409
+ const contentType = contentTypes[ext] || 'application/octet-stream';
410
+
411
+ const content = await readFile(fullPath);
412
+ return reply.type(contentType).send(content);
413
+ } catch (err) {
414
+ request.log.error(err, 'Failed to serve solidos-ui file');
415
+ return reply.code(404).send({ error: 'Not Found' });
416
+ }
417
+ });
418
+ }
419
+
374
420
  // Rate limit configuration for write operations
375
421
  // Protects against resource exhaustion and abuse
376
422
  const writeRateLimit = {
@@ -51,6 +51,28 @@ export async function read(urlPath) {
51
51
  }
52
52
  }
53
53
 
54
+ /**
55
+ * Create a readable stream for a resource (supports range requests)
56
+ * @param {string} urlPath
57
+ * @param {object} options - { start, end } byte range options
58
+ * @returns {{ stream: ReadStream, filePath: string } | null}
59
+ */
60
+ export function createReadStream(urlPath, options = {}) {
61
+ const filePath = urlToPath(urlPath);
62
+
63
+ // Check file exists before creating stream (createReadStream doesn't throw sync)
64
+ if (!fs.pathExistsSync(filePath)) {
65
+ return null;
66
+ }
67
+
68
+ try {
69
+ const stream = fs.createReadStream(filePath, options);
70
+ return { stream, filePath };
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
54
76
  /**
55
77
  * Write resource content
56
78
  * @param {string} urlPath
package/test/helpers.js CHANGED
@@ -39,9 +39,11 @@ export async function startTestServer(options = {}) {
39
39
  */
40
40
  export async function stopTestServer() {
41
41
  if (server) {
42
+ // Force close all connections to avoid hanging
42
43
  await server.close();
43
44
  server = null;
44
45
  }
46
+ baseUrl = null;
45
47
  // Clean up test data
46
48
  await fs.emptyDir(TEST_DATA_DIR);
47
49
  // Clear tokens
@@ -328,6 +328,96 @@ describe('WebSocket Notifications (notifications enabled)', () => {
328
328
  });
329
329
  });
330
330
 
331
+ describe('WebSocket ACL Enforcement', () => {
332
+ let wsUrl;
333
+
334
+ before(async () => {
335
+ await startTestServer({ notifications: true });
336
+ await createTestPod('aclnotify');
337
+ const res = await request('/aclnotify/', { method: 'OPTIONS' });
338
+ wsUrl = res.headers.get('Updates-Via');
339
+ });
340
+
341
+ after(async () => {
342
+ await stopTestServer();
343
+ });
344
+
345
+ it('should allow anonymous subscription to public resources', async () => {
346
+ const ws = new WebSocket(wsUrl);
347
+ const baseUrl = getBaseUrl();
348
+ const resourceUrl = `${baseUrl}/aclnotify/public/anon-allowed.json`;
349
+
350
+ const messages = [];
351
+
352
+ await new Promise((resolve, reject) => {
353
+ ws.on('open', () => {
354
+ ws.send(`sub ${resourceUrl}`);
355
+ });
356
+ ws.on('message', (data) => {
357
+ messages.push(data.toString());
358
+ if (messages.length >= 2) resolve();
359
+ });
360
+ ws.on('error', reject);
361
+ setTimeout(() => resolve(), 2000);
362
+ });
363
+
364
+ assert.ok(messages.includes('protocol solid-0.1'), 'Should receive protocol greeting');
365
+ assert.ok(messages.some(m => m === `ack ${resourceUrl}`), 'Should receive ack for public resource');
366
+ ws.close();
367
+ await new Promise(r => setTimeout(r, 50)); // Allow WebSocket to fully close
368
+ });
369
+
370
+ it('should deny anonymous subscription to private resources', async () => {
371
+ const ws = new WebSocket(wsUrl);
372
+ const baseUrl = getBaseUrl();
373
+ const resourceUrl = `${baseUrl}/aclnotify/private/secret.json`;
374
+
375
+ const messages = [];
376
+
377
+ await new Promise((resolve, reject) => {
378
+ ws.on('open', () => {
379
+ ws.send(`sub ${resourceUrl}`);
380
+ });
381
+ ws.on('message', (data) => {
382
+ messages.push(data.toString());
383
+ if (messages.some(m => m.startsWith('err '))) resolve();
384
+ });
385
+ ws.on('error', reject);
386
+ setTimeout(() => resolve(), 2000);
387
+ });
388
+
389
+ assert.ok(messages.includes('protocol solid-0.1'), 'Should receive protocol greeting');
390
+ assert.ok(messages.some(m => m === `err ${resourceUrl} forbidden`),
391
+ `Should receive err forbidden for private resource. Got: ${messages.join(', ')}`);
392
+ ws.close();
393
+ await new Promise(r => setTimeout(r, 50)); // Allow WebSocket to fully close
394
+ });
395
+
396
+ it('should deny subscription to resources on other servers', async () => {
397
+ const ws = new WebSocket(wsUrl);
398
+ const externalUrl = 'https://evil.example.com/steal/data.json';
399
+
400
+ const messages = [];
401
+
402
+ await new Promise((resolve, reject) => {
403
+ ws.on('open', () => {
404
+ ws.send(`sub ${externalUrl}`);
405
+ });
406
+ ws.on('message', (data) => {
407
+ messages.push(data.toString());
408
+ if (messages.some(m => m.startsWith('err '))) resolve();
409
+ });
410
+ ws.on('error', reject);
411
+ setTimeout(() => resolve(), 2000);
412
+ });
413
+
414
+ assert.ok(messages.some(m => m === `err ${externalUrl} forbidden`),
415
+ 'Should deny subscription to external URLs');
416
+ ws.close();
417
+ await new Promise(r => setTimeout(r, 50)); // Allow WebSocket to fully close
418
+ });
419
+ });
420
+
331
421
  describe('WebSocket Notifications (notifications disabled - default)', () => {
332
422
  before(async () => {
333
423
  // Start server with notifications DISABLED (default)