javascript-solid-server 0.0.75 → 0.0.77

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/src/idp/views.js CHANGED
@@ -105,6 +105,38 @@ const styles = `
105
105
  .btn-secondary:hover {
106
106
  background: #e0e0e0;
107
107
  }
108
+ .btn-passkey {
109
+ background: #1a73e8;
110
+ color: white;
111
+ width: 100%;
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+ gap: 8px;
116
+ }
117
+ .btn-passkey:hover {
118
+ background: #1557b0;
119
+ }
120
+ .btn-passkey svg {
121
+ width: 20px;
122
+ height: 20px;
123
+ }
124
+ .divider {
125
+ display: flex;
126
+ align-items: center;
127
+ margin: 20px 0;
128
+ color: #666;
129
+ font-size: 14px;
130
+ }
131
+ .divider::before,
132
+ .divider::after {
133
+ content: '';
134
+ flex: 1;
135
+ border-bottom: 1px solid #ddd;
136
+ }
137
+ .divider span {
138
+ padding: 0 12px;
139
+ }
108
140
  .scopes {
109
141
  margin: 20px 0;
110
142
  }
@@ -149,6 +181,12 @@ const solidLogo = `
149
181
  </svg>
150
182
  `;
151
183
 
184
+ const passkeyIcon = `
185
+ <svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
186
+ <path d="M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>
187
+ </svg>
188
+ `;
189
+
152
190
  const scopeDescriptions = {
153
191
  openid: 'Access your identity',
154
192
  webid: 'Access your WebID',
@@ -157,11 +195,125 @@ const scopeDescriptions = {
157
195
  offline_access: 'Stay logged in',
158
196
  };
159
197
 
198
+ /**
199
+ * Escape string for safe use in JavaScript
200
+ */
201
+ function escapeJs(text) {
202
+ if (!text) return '';
203
+ return String(text)
204
+ .replace(/\\/g, '\\\\')
205
+ .replace(/'/g, "\\'")
206
+ .replace(/"/g, '\\"')
207
+ .replace(/</g, '\\x3c')
208
+ .replace(/>/g, '\\x3e')
209
+ .replace(/\n/g, '\\n')
210
+ .replace(/\r/g, '\\r');
211
+ }
212
+
160
213
  /**
161
214
  * Login page HTML
162
215
  */
163
- export function loginPage(uid, clientId, error = null) {
216
+ export function loginPage(uid, clientId, error = null, passkeyEnabled = true) {
164
217
  const appName = clientId || 'An application';
218
+ const safeUid = escapeJs(uid);
219
+
220
+ const passkeySection = passkeyEnabled ? `
221
+ <button type="button" class="btn btn-passkey" onclick="loginWithPasskey()">
222
+ ${passkeyIcon}
223
+ Sign in with Passkey
224
+ </button>
225
+
226
+ <div class="divider"><span>or</span></div>
227
+ ` : '';
228
+
229
+ const passkeyScript = passkeyEnabled ? `
230
+ <script>
231
+ var INTERACTION_UID = '${safeUid}';
232
+
233
+ async function loginWithPasskey() {
234
+ try {
235
+ // Get authentication options
236
+ const optionsRes = await fetch('/idp/passkey/login/options', {
237
+ method: 'POST',
238
+ headers: { 'Content-Type': 'application/json' },
239
+ body: JSON.stringify({ visitorId: crypto.randomUUID() })
240
+ });
241
+ const options = await optionsRes.json();
242
+ if (options.error) {
243
+ alert('Error: ' + options.error);
244
+ return;
245
+ }
246
+
247
+ // Convert base64url to ArrayBuffer
248
+ options.challenge = base64urlToBuffer(options.challenge);
249
+ if (options.allowCredentials) {
250
+ options.allowCredentials = options.allowCredentials.map(c => ({
251
+ ...c,
252
+ id: base64urlToBuffer(c.id)
253
+ }));
254
+ }
255
+
256
+ // Prompt user for passkey
257
+ const credential = await navigator.credentials.get({ publicKey: options });
258
+
259
+ // Send response to server
260
+ const verifyRes = await fetch('/idp/passkey/login/verify', {
261
+ method: 'POST',
262
+ headers: { 'Content-Type': 'application/json' },
263
+ body: JSON.stringify({
264
+ challengeKey: options.challengeKey,
265
+ credential: {
266
+ id: credential.id,
267
+ rawId: bufferToBase64url(credential.rawId),
268
+ type: credential.type,
269
+ response: {
270
+ clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
271
+ authenticatorData: bufferToBase64url(credential.response.authenticatorData),
272
+ signature: bufferToBase64url(credential.response.signature),
273
+ userHandle: credential.response.userHandle
274
+ ? bufferToBase64url(credential.response.userHandle)
275
+ : null
276
+ }
277
+ }
278
+ })
279
+ });
280
+
281
+ const result = await verifyRes.json();
282
+ if (result.success) {
283
+ // Complete the OIDC interaction - build URL safely
284
+ const redirectUrl = '/idp/interaction/' + encodeURIComponent(INTERACTION_UID) + '/passkey-complete?accountId=' + encodeURIComponent(result.accountId);
285
+ window.location.href = redirectUrl;
286
+ } else {
287
+ alert('Passkey authentication failed: ' + (result.error || 'Unknown error'));
288
+ }
289
+ } catch (err) {
290
+ if (err.name === 'NotAllowedError') {
291
+ // User cancelled - do nothing
292
+ } else {
293
+ console.error('Passkey error:', err);
294
+ alert('Passkey authentication failed: ' + err.message);
295
+ }
296
+ }
297
+ }
298
+
299
+ function base64urlToBuffer(base64url) {
300
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
301
+ const padLen = (4 - base64.length % 4) % 4;
302
+ const padded = base64 + '='.repeat(padLen);
303
+ const binary = atob(padded);
304
+ const bytes = new Uint8Array(binary.length);
305
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
306
+ return bytes.buffer;
307
+ }
308
+
309
+ function bufferToBase64url(buffer) {
310
+ const bytes = new Uint8Array(buffer);
311
+ let binary = '';
312
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
313
+ return btoa(binary).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=/g, '');
314
+ }
315
+ </script>
316
+ ` : '';
165
317
 
166
318
  return `
167
319
  <!DOCTYPE html>
@@ -185,6 +337,8 @@ export function loginPage(uid, clientId, error = null) {
185
337
 
186
338
  ${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
187
339
 
340
+ ${passkeySection}
341
+
188
342
  <form method="POST" action="/idp/interaction/${uid}/login">
189
343
  <label for="username">Username</label>
190
344
  <input type="text" id="username" name="username" required autofocus placeholder="Your username">
@@ -203,6 +357,7 @@ export function loginPage(uid, clientId, error = null) {
203
357
  Don't have an account? <a href="/idp/register?uid=${uid}" style="color: #0066cc;">Register</a>
204
358
  </p>
205
359
  </div>
360
+ ${passkeyScript}
206
361
  </body>
207
362
  </html>
208
363
  `;
@@ -349,6 +504,162 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl
349
504
  `;
350
505
  }
351
506
 
507
+ /**
508
+ * Passkey prompt page - shown after password login to encourage passkey setup
509
+ */
510
+ export function passkeyPromptPage(uid, accountId) {
511
+ const safeUid = escapeJs(uid);
512
+ const safeAccountId = escapeJs(accountId);
513
+ // Pre-escape the SVG for innerHTML assignment (no user data, just static SVG)
514
+ const passkeyIconEscaped = passkeyIcon.replace(/'/g, "\\'").replace(/\n/g, '');
515
+
516
+ return `
517
+ <!DOCTYPE html>
518
+ <html lang="en">
519
+ <head>
520
+ <meta charset="UTF-8">
521
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
522
+ <title>Add a Passkey - Solid IdP</title>
523
+ <style>${styles}</style>
524
+ </head>
525
+ <body>
526
+ <div class="container">
527
+ <div class="logo">${solidLogo}</div>
528
+ <h1>Add a Passkey?</h1>
529
+ <p class="subtitle">Sign in faster next time</p>
530
+
531
+ <div class="client-info">
532
+ <div class="client-name">Passkeys are more secure</div>
533
+ <div class="client-uri">Use Touch ID, Face ID, or a security key instead of your password</div>
534
+ </div>
535
+
536
+ <button type="button" class="btn btn-passkey" onclick="registerPasskey()" id="addBtn">
537
+ ${passkeyIcon}
538
+ Add Passkey
539
+ </button>
540
+
541
+ <form method="GET" action="/idp/interaction/${escapeHtml(uid)}/passkey-skip">
542
+ <button type="submit" class="btn btn-secondary">Skip for now</button>
543
+ </form>
544
+ </div>
545
+
546
+ <script>
547
+ var INTERACTION_UID = '${safeUid}';
548
+ var ACCOUNT_ID = '${safeAccountId}';
549
+ var PASSKEY_ICON = '${passkeyIconEscaped}';
550
+
551
+ async function registerPasskey() {
552
+ const btn = document.getElementById('addBtn');
553
+ btn.disabled = true;
554
+ btn.textContent = 'Setting up...';
555
+
556
+ try {
557
+ // Get registration options
558
+ const optionsRes = await fetch('/idp/passkey/register/options', {
559
+ method: 'POST',
560
+ headers: { 'Content-Type': 'application/json' },
561
+ body: JSON.stringify({ accountId: ACCOUNT_ID })
562
+ });
563
+ const options = await optionsRes.json();
564
+ if (options.error) {
565
+ alert('Error: ' + options.error);
566
+ btn.disabled = false;
567
+ btn.innerHTML = PASSKEY_ICON + ' Add Passkey';
568
+ return;
569
+ }
570
+
571
+ // Save challengeKey for verification
572
+ const challengeKey = options.challengeKey;
573
+
574
+ // Convert base64url to ArrayBuffer
575
+ options.challenge = base64urlToBuffer(options.challenge);
576
+ options.user.id = base64urlToBuffer(options.user.id);
577
+ if (options.excludeCredentials) {
578
+ options.excludeCredentials = options.excludeCredentials.map(c => ({
579
+ ...c,
580
+ id: base64urlToBuffer(c.id)
581
+ }));
582
+ }
583
+
584
+ // Prompt user to create passkey
585
+ const credential = await navigator.credentials.create({ publicKey: options });
586
+
587
+ // Send response to server
588
+ const verifyRes = await fetch('/idp/passkey/register/verify', {
589
+ method: 'POST',
590
+ headers: { 'Content-Type': 'application/json' },
591
+ body: JSON.stringify({
592
+ accountId: ACCOUNT_ID,
593
+ challengeKey: challengeKey,
594
+ credential: {
595
+ id: credential.id,
596
+ rawId: bufferToBase64url(credential.rawId),
597
+ type: credential.type,
598
+ response: {
599
+ clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
600
+ attestationObject: bufferToBase64url(credential.response.attestationObject),
601
+ transports: credential.response.getTransports ? credential.response.getTransports() : []
602
+ }
603
+ },
604
+ name: detectDeviceName()
605
+ })
606
+ });
607
+
608
+ const result = await verifyRes.json();
609
+ if (result.success) {
610
+ // Passkey added, continue to app - build URL safely
611
+ const redirectUrl = '/idp/interaction/' + encodeURIComponent(INTERACTION_UID) + '/passkey-complete?accountId=' + encodeURIComponent(ACCOUNT_ID);
612
+ window.location.href = redirectUrl;
613
+ } else {
614
+ alert('Failed to add passkey: ' + (result.error || 'Unknown error'));
615
+ btn.disabled = false;
616
+ btn.innerHTML = PASSKEY_ICON + ' Add Passkey';
617
+ }
618
+ } catch (err) {
619
+ if (err.name === 'NotAllowedError') {
620
+ // User cancelled
621
+ } else {
622
+ console.error('Passkey error:', err);
623
+ alert('Failed to add passkey: ' + err.message);
624
+ }
625
+ btn.disabled = false;
626
+ btn.innerHTML = PASSKEY_ICON + ' Add Passkey';
627
+ }
628
+ }
629
+
630
+ function detectDeviceName() {
631
+ const ua = navigator.userAgent;
632
+ if (/iPhone/.test(ua)) return 'iPhone';
633
+ if (/iPad/.test(ua)) return 'iPad';
634
+ if (/Mac/.test(ua)) return 'Mac';
635
+ if (/Android/.test(ua)) return 'Android';
636
+ if (/Windows/.test(ua)) return 'Windows';
637
+ if (/Linux/.test(ua)) return 'Linux';
638
+ return 'Security Key';
639
+ }
640
+
641
+ function base64urlToBuffer(base64url) {
642
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
643
+ const padLen = (4 - base64.length % 4) % 4;
644
+ const padded = base64 + '='.repeat(padLen);
645
+ const binary = atob(padded);
646
+ const bytes = new Uint8Array(binary.length);
647
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
648
+ return bytes.buffer;
649
+ }
650
+
651
+ function bufferToBase64url(buffer) {
652
+ const bytes = new Uint8Array(buffer);
653
+ let binary = '';
654
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
655
+ return btoa(binary).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=/g, '');
656
+ }
657
+ </script>
658
+ </body>
659
+ </html>
660
+ `;
661
+ }
662
+
352
663
  /**
353
664
  * Escape HTML to prevent XSS
354
665
  */
@@ -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
  */
package/src/server.js CHANGED
@@ -55,6 +55,8 @@ export function createServer(options = {}) {
55
55
  const mashlibEnabled = options.mashlib ?? false;
56
56
  const mashlibCdn = options.mashlibCdn ?? false;
57
57
  const mashlibVersion = options.mashlibVersion ?? '2.0.0';
58
+ // SolidOS UI (modern Nextcloud-style interface) - requires mashlib
59
+ const solidosUiEnabled = options.solidosUi ?? false;
58
60
  // Git HTTP backend is OFF by default - enables clone/push via git protocol
59
61
  const gitEnabled = options.git ?? false;
60
62
  // Nostr relay is OFF by default
@@ -127,6 +129,7 @@ export function createServer(options = {}) {
127
129
  fastify.decorateRequest('mashlibEnabled', null);
128
130
  fastify.decorateRequest('mashlibCdn', null);
129
131
  fastify.decorateRequest('mashlibVersion', null);
132
+ fastify.decorateRequest('solidosUiEnabled', null);
130
133
  fastify.decorateRequest('defaultQuota', null);
131
134
  fastify.addHook('onRequest', async (request) => {
132
135
  request.connegEnabled = connegEnabled;
@@ -137,6 +140,7 @@ export function createServer(options = {}) {
137
140
  request.mashlibEnabled = mashlibEnabled;
138
141
  request.mashlibCdn = mashlibCdn;
139
142
  request.mashlibVersion = mashlibVersion;
143
+ request.solidosUiEnabled = solidosUiEnabled;
140
144
  request.defaultQuota = defaultQuota;
141
145
 
142
146
  // Extract pod name from subdomain if enabled
@@ -296,7 +300,7 @@ export function createServer(options = {}) {
296
300
  // Authorization hook - check WAC permissions
297
301
  // Skip for pod creation endpoint (needs special handling)
298
302
  fastify.addHook('preHandler', async (request, reply) => {
299
- // 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
300
304
  const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
301
305
  const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following'];
302
306
  // Check if request wants ActivityPub content for profile
@@ -308,6 +312,7 @@ export function createServer(options = {}) {
308
312
  request.method === 'OPTIONS' ||
309
313
  request.url.startsWith('/idp/') ||
310
314
  request.url.startsWith('/.well-known/') ||
315
+ request.url.startsWith('/solidos-ui/') ||
311
316
  (nostrEnabled && request.url.startsWith(nostrPath)) ||
312
317
  (gitEnabled && isGitRequest(request.url)) ||
313
318
  (activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
@@ -381,6 +386,37 @@ export function createServer(options = {}) {
381
386
  }
382
387
  }
383
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
+
384
420
  // Rate limit configuration for write operations
385
421
  // Protects against resource exhaustion and abuse
386
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