javascript-solid-server 0.0.164 → 0.0.165

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.
@@ -361,7 +361,24 @@
361
361
  "WebFetch(domain:www.gitfork.app)",
362
362
  "Bash(JSS_SINGLE_USER_PASSWORD=hunter2 timeout 3 node bin/jss.js start --port 4581 --root /tmp/jss-103 --single-user --single-user-name alice --idp)",
363
363
  "Bash(JSS_SINGLE_USER_PASSWORD=hunter2 timeout 2 node bin/jss.js start --port 4581 --root /tmp/jss-103 --single-user-name alice --idp)",
364
- "Bash(JSS_SINGLE_USER_PASSWORD=hunter2 timeout 3 node bin/jss.js start --port 4581 --root /tmp/jss-103-sanity --single-user-name alice --single-user --idp)"
364
+ "Bash(JSS_SINGLE_USER_PASSWORD=hunter2 timeout 3 node bin/jss.js start --port 4581 --root /tmp/jss-103-sanity --single-user-name alice --single-user --idp)",
365
+ "Bash(pm2 jlist *)",
366
+ "Bash(jss --version)",
367
+ "Bash(cp -a ~/main/me ~/main/me.backup-pre-348)",
368
+ "Bash(cp -a ~/main/.idp/accounts ~/main/.idp/accounts.backup-pre-348)",
369
+ "Bash(mv ~/main/me.backup-pre-348 ~/main.backup-me-pre-348)",
370
+ "Bash(mv ~/main/.idp/accounts.backup-pre-348 ~/main.backup-accounts-pre-348)",
371
+ "Bash(shopt -s dotglob)",
372
+ "Bash(mv ~/main/me/* ~/main/)",
373
+ "Bash(shopt -u dotglob)",
374
+ "Bash(rmdir ~/main/me)",
375
+ "Bash(rm /tmp/dup-issue.md)",
376
+ "Bash(rm -rf /tmp/losos-fix)",
377
+ "Bash(terser losos/shell.js -m -c)",
378
+ "Bash(terser losos/html.js -m -c)",
379
+ "Bash(terser losos/store.js -m -c)",
380
+ "Bash(terser losos/registry.js -m -c)",
381
+ "Bash(terser losos/losos.js -m -c)"
365
382
  ]
366
383
  }
367
384
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.164",
3
+ "version": "0.0.165",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -143,6 +143,19 @@ export async function createAccount({ username, password, webId, podName, email
143
143
  return safeAccount;
144
144
  }
145
145
 
146
+ /**
147
+ * Verify a password against an account's stored hash without side effects.
148
+ * Use this for re-auth proofs (e.g. password rotation) where stamping
149
+ * lastLogin would falsify the audit trail.
150
+ * @param {object} account - Account object with passwordHash
151
+ * @param {string} password - Plain text password
152
+ * @returns {Promise<boolean>}
153
+ */
154
+ export async function verifyPassword(account, password) {
155
+ if (!account?.passwordHash) return false;
156
+ return bcrypt.compare(password, account.passwordHash);
157
+ }
158
+
146
159
  /**
147
160
  * Authenticate a user with username/email and password
148
161
  * @param {string} identifier - Username or email
@@ -5,8 +5,9 @@
5
5
 
6
6
  import * as jose from 'jose';
7
7
  import crypto from 'crypto';
8
- import { authenticate } from './accounts.js';
8
+ import { authenticate, findByWebId, updatePassword, verifyPassword } from './accounts.js';
9
9
  import { getJwks } from './keys.js';
10
+ import { getWebIdFromRequestAsync } from '../auth/token.js';
10
11
 
11
12
  /**
12
13
  * Handle POST /idp/credentials
@@ -198,6 +199,79 @@ async function validateDpopProof(proof, method, url) {
198
199
  return thumbprint;
199
200
  }
200
201
 
202
+ /**
203
+ * Handle PUT /idp/credentials
204
+ * Authenticated owner rotates their own password.
205
+ *
206
+ * Auth: caller must be authenticated (Bearer/DPoP/Nostr-NIP-98).
207
+ * Body (JSON): { currentPassword, newPassword }
208
+ *
209
+ * Responses:
210
+ * 200 { ok: true, webid, passwordChangedAt }
211
+ * 400 missing fields
212
+ * 401 unauthenticated, or currentPassword wrong
213
+ * 403 caller's WebID does not match any account
214
+ */
215
+ export async function handleChangePassword(request, reply) {
216
+ // 1. Authenticate caller
217
+ const { webId, error: authError } = await getWebIdFromRequestAsync(request);
218
+ if (!webId) {
219
+ return reply.code(401).send({
220
+ error: 'invalid_token',
221
+ error_description: authError || 'Authentication required',
222
+ });
223
+ }
224
+
225
+ // 2. Parse body
226
+ let body = request.body;
227
+ if (Buffer.isBuffer(body)) body = body.toString('utf-8');
228
+ if (typeof body === 'string') {
229
+ try { body = JSON.parse(body); } catch { body = {}; }
230
+ }
231
+ const currentPassword = body?.currentPassword;
232
+ const newPassword = body?.newPassword;
233
+
234
+ if (typeof currentPassword !== 'string' || typeof newPassword !== 'string'
235
+ || !currentPassword || !newPassword) {
236
+ return reply.code(400).send({
237
+ error: 'invalid_request',
238
+ error_description: 'currentPassword and newPassword are required (strings)',
239
+ });
240
+ }
241
+
242
+ // 3. Resolve account from caller's WebID
243
+ const account = await findByWebId(webId);
244
+ if (!account) {
245
+ return reply.code(403).send({
246
+ error: 'forbidden',
247
+ error_description: 'No account found for authenticated WebID',
248
+ });
249
+ }
250
+
251
+ // 4. Verify currentPassword (re-auth proof). Side-effect-free — does NOT
252
+ // stamp lastLogin, since password rotation isn't a login event.
253
+ if (!(await verifyPassword(account, currentPassword))) {
254
+ return reply.code(401).send({
255
+ error: 'invalid_grant',
256
+ error_description: 'Current password is incorrect',
257
+ });
258
+ }
259
+
260
+ // 5. Rotate
261
+ await updatePassword(account.id, newPassword);
262
+
263
+ // Re-read to surface passwordChangedAt
264
+ const updated = await findByWebId(webId);
265
+
266
+ reply.header('Cache-Control', 'no-store');
267
+ reply.header('Pragma', 'no-cache');
268
+ return {
269
+ ok: true,
270
+ webid: account.webId,
271
+ passwordChangedAt: updated?.passwordChangedAt,
272
+ };
273
+ }
274
+
201
275
  /**
202
276
  * Handle GET /idp/credentials
203
277
  * Returns info about the credentials endpoint
package/src/idp/index.js CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  import {
22
22
  handleCredentials,
23
23
  handleCredentialsInfo,
24
+ handleChangePassword,
24
25
  } from './credentials.js';
25
26
  import * as passkey from './passkey.js';
26
27
  import { addTrustedIssuer } from '../auth/solid-oidc.js';
@@ -264,6 +265,19 @@ export async function idpPlugin(fastify, options) {
264
265
  return handleCredentials(request, reply, issuer);
265
266
  });
266
267
 
268
+ // PUT credentials - authenticated owner rotates their own password (#351)
269
+ fastify.put('/idp/credentials', {
270
+ config: {
271
+ rateLimit: {
272
+ max: 10,
273
+ timeWindow: '1 minute',
274
+ keyGenerator: (request) => request.ip
275
+ }
276
+ }
277
+ }, async (request, reply) => {
278
+ return handleChangePassword(request, reply);
279
+ });
280
+
267
281
  // Interaction routes (our custom login/consent UI)
268
282
  // These bypass oidc-provider and use our handlers
269
283
 
@@ -0,0 +1,206 @@
1
+ /**
2
+ * PUT /idp/credentials — authenticated owner rotates their own password (#351)
3
+ */
4
+
5
+ import { describe, it, before, after } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { createServer } from '../src/server.js';
8
+ import fs from 'fs-extra';
9
+ import { createServer as createNetServer } from 'net';
10
+
11
+ const TEST_HOST = 'localhost';
12
+
13
+ function getAvailablePort() {
14
+ return new Promise((resolve, reject) => {
15
+ const srv = createNetServer();
16
+ srv.on('error', reject);
17
+ srv.listen(0, TEST_HOST, () => {
18
+ const port = srv.address().port;
19
+ srv.close(() => resolve(port));
20
+ });
21
+ });
22
+ }
23
+
24
+ async function createPod(baseUrl, name, email, password) {
25
+ const res = await fetch(`${baseUrl}/.pods`, {
26
+ method: 'POST',
27
+ headers: { 'Content-Type': 'application/json' },
28
+ body: JSON.stringify({ name, email, password }),
29
+ });
30
+ const body = await res.json().catch(() => ({}));
31
+ assert.strictEqual(res.status, 201, `pod create failed: ${JSON.stringify(body)}`);
32
+ return body;
33
+ }
34
+
35
+ async function loginToken(baseUrl, email, password) {
36
+ const res = await fetch(`${baseUrl}/idp/credentials`, {
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/json' },
39
+ body: JSON.stringify({ email, password }),
40
+ });
41
+ const body = await res.json().catch(() => ({}));
42
+ assert.strictEqual(res.status, 200, `login failed: ${JSON.stringify(body)}`);
43
+ return body.access_token;
44
+ }
45
+
46
+ describe('PUT /idp/credentials — change password', () => {
47
+ let server;
48
+ let baseUrl;
49
+ let originalDataRoot;
50
+ const DATA_DIR = './test-data-change-password';
51
+
52
+ before(async () => {
53
+ originalDataRoot = process.env.DATA_ROOT;
54
+ await fs.remove(DATA_DIR);
55
+ await fs.ensureDir(DATA_DIR);
56
+ const port = await getAvailablePort();
57
+ baseUrl = `http://${TEST_HOST}:${port}`;
58
+ server = createServer({
59
+ logger: false,
60
+ root: DATA_DIR,
61
+ idp: true,
62
+ idpIssuer: baseUrl,
63
+ forceCloseConnections: true,
64
+ });
65
+ await server.listen({ port, host: TEST_HOST });
66
+ });
67
+
68
+ after(async () => {
69
+ await server.close();
70
+ await fs.remove(DATA_DIR);
71
+ if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
72
+ else process.env.DATA_ROOT = originalDataRoot;
73
+ });
74
+
75
+ it('rejects unauthenticated request with 401', async () => {
76
+ const res = await fetch(`${baseUrl}/idp/credentials`, {
77
+ method: 'PUT',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ body: JSON.stringify({ currentPassword: 'a', newPassword: 'b' }),
80
+ });
81
+ assert.strictEqual(res.status, 401);
82
+ });
83
+
84
+ it('rejects missing fields with 400', async () => {
85
+ const id = `alice${Date.now()}`;
86
+ await createPod(baseUrl, id, `${id}@example.com`, 'oldpassword123');
87
+ const token = await loginToken(baseUrl, `${id}@example.com`, 'oldpassword123');
88
+
89
+ const res = await fetch(`${baseUrl}/idp/credentials`, {
90
+ method: 'PUT',
91
+ headers: {
92
+ 'Content-Type': 'application/json',
93
+ 'Authorization': `Bearer ${token}`,
94
+ },
95
+ body: JSON.stringify({ currentPassword: 'oldpassword123' }),
96
+ });
97
+ assert.strictEqual(res.status, 400);
98
+ });
99
+
100
+ it('rejects wrong current password with 401, hash unchanged', async () => {
101
+ const id = `bob${Date.now()}`;
102
+ await createPod(baseUrl, id, `${id}@example.com`, 'oldpassword123');
103
+ const token = await loginToken(baseUrl, `${id}@example.com`, 'oldpassword123');
104
+
105
+ const res = await fetch(`${baseUrl}/idp/credentials`, {
106
+ method: 'PUT',
107
+ headers: {
108
+ 'Content-Type': 'application/json',
109
+ 'Authorization': `Bearer ${token}`,
110
+ },
111
+ body: JSON.stringify({
112
+ currentPassword: 'wrongpassword',
113
+ newPassword: 'newpassword456',
114
+ }),
115
+ });
116
+ assert.strictEqual(res.status, 401);
117
+
118
+ // Original password still works
119
+ const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
120
+ method: 'POST',
121
+ headers: { 'Content-Type': 'application/json' },
122
+ body: JSON.stringify({ email: `${id}@example.com`, password: 'oldpassword123' }),
123
+ });
124
+ assert.strictEqual(reLogin.status, 200);
125
+ });
126
+
127
+ it('happy path: rotates password, old fails, new succeeds', async () => {
128
+ const id = `carol${Date.now()}`;
129
+ await createPod(baseUrl, id, `${id}@example.com`, 'oldpassword123');
130
+ const token = await loginToken(baseUrl, `${id}@example.com`, 'oldpassword123');
131
+
132
+ const res = await fetch(`${baseUrl}/idp/credentials`, {
133
+ method: 'PUT',
134
+ headers: {
135
+ 'Content-Type': 'application/json',
136
+ 'Authorization': `Bearer ${token}`,
137
+ },
138
+ body: JSON.stringify({
139
+ currentPassword: 'oldpassword123',
140
+ newPassword: 'newpassword456',
141
+ }),
142
+ });
143
+ assert.strictEqual(res.status, 200);
144
+ const body = await res.json();
145
+ assert.strictEqual(body.ok, true);
146
+ assert.ok(body.webid.includes(id), 'response carries webid');
147
+ assert.ok(body.passwordChangedAt, 'response carries passwordChangedAt');
148
+
149
+ // Old password rejected
150
+ const oldRes = await fetch(`${baseUrl}/idp/credentials`, {
151
+ method: 'POST',
152
+ headers: { 'Content-Type': 'application/json' },
153
+ body: JSON.stringify({ email: `${id}@example.com`, password: 'oldpassword123' }),
154
+ });
155
+ assert.strictEqual(oldRes.status, 401);
156
+
157
+ // New password accepted
158
+ const newRes = await fetch(`${baseUrl}/idp/credentials`, {
159
+ method: 'POST',
160
+ headers: { 'Content-Type': 'application/json' },
161
+ body: JSON.stringify({ email: `${id}@example.com`, password: 'newpassword456' }),
162
+ });
163
+ assert.strictEqual(newRes.status, 200);
164
+ });
165
+
166
+ it('cross-account write: A authenticated cannot rotate B by sending B\'s currentPassword', async () => {
167
+ const aId = `dave${Date.now()}`;
168
+ const bId = `eve${Date.now() + 1}`;
169
+ await createPod(baseUrl, aId, `${aId}@example.com`, 'apassword123');
170
+ await createPod(baseUrl, bId, `${bId}@example.com`, 'bpassword123');
171
+
172
+ const aToken = await loginToken(baseUrl, `${aId}@example.com`, 'apassword123');
173
+
174
+ // A sends B's currentPassword → server resolves account from A's WebID, so the
175
+ // currentPassword must match A's, not B's. With B's password it must fail 401
176
+ // (and crucially must NOT touch B's account).
177
+ const res = await fetch(`${baseUrl}/idp/credentials`, {
178
+ method: 'PUT',
179
+ headers: {
180
+ 'Content-Type': 'application/json',
181
+ 'Authorization': `Bearer ${aToken}`,
182
+ },
183
+ body: JSON.stringify({
184
+ currentPassword: 'bpassword123',
185
+ newPassword: 'hijack',
186
+ }),
187
+ });
188
+ assert.strictEqual(res.status, 401);
189
+
190
+ // B's password unchanged
191
+ const bLogin = await fetch(`${baseUrl}/idp/credentials`, {
192
+ method: 'POST',
193
+ headers: { 'Content-Type': 'application/json' },
194
+ body: JSON.stringify({ email: `${bId}@example.com`, password: 'bpassword123' }),
195
+ });
196
+ assert.strictEqual(bLogin.status, 200);
197
+
198
+ // A's password also unchanged
199
+ const aLogin = await fetch(`${baseUrl}/idp/credentials`, {
200
+ method: 'POST',
201
+ headers: { 'Content-Type': 'application/json' },
202
+ body: JSON.stringify({ email: `${aId}@example.com`, password: 'apassword123' }),
203
+ });
204
+ assert.strictEqual(aLogin.status, 200);
205
+ });
206
+ });
package/jsserve/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2024 JavaScriptSolidServer Contributors
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
package/jsserve/README.md DELETED
@@ -1,194 +0,0 @@
1
- # servejss
2
-
3
- > Static file server with REST write support. A drop-in `npx serve` alternative.
4
-
5
- [![npm version](https://img.shields.io/npm/v/servejss.svg)](https://www.npmjs.com/package/servejss)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
-
8
- ## Why?
9
-
10
- `npx serve` is great for quickly serving static files, but it's **read-only**. Sometimes you need to:
11
-
12
- - Upload files during development
13
- - Test REST APIs locally
14
- - Sync files between devices on your LAN
15
- - Have a simple WebDAV-like server
16
-
17
- **servejss** is `serve` with superpowers: same simple interface, but you can write too.
18
-
19
- ## Install
20
-
21
- ```bash
22
- npm install -g servejss
23
- ```
24
-
25
- Or use directly with npx:
26
-
27
- ```bash
28
- npx servejss
29
- ```
30
-
31
- ## Usage
32
-
33
- ```bash
34
- # Serve current directory (read + write enabled)
35
- servejss
36
-
37
- # Serve specific directory
38
- servejss ./public
39
-
40
- # Custom port
41
- servejss -p 8080
42
-
43
- # Specify port and directory
44
- servejss -l 3000 ./dist
45
-
46
- # Read-only mode (exactly like npx serve)
47
- servejss --read-only
48
- ```
49
-
50
- ## Output
51
-
52
- ```
53
- servejss
54
-
55
- Directory: /home/user/project
56
-
57
- Local: http://localhost:3000
58
- Network: http://192.168.1.5:3000
59
-
60
- Mode: GET/PUT/DELETE enabled
61
-
62
- Press Ctrl+C to stop
63
- ```
64
-
65
- ## REST API
66
-
67
- ```bash
68
- # Read a file
69
- curl http://localhost:3000/file.txt
70
-
71
- # Create or update a file
72
- curl -X PUT -d "Hello, World!" http://localhost:3000/file.txt
73
-
74
- # Delete a file
75
- curl -X DELETE http://localhost:3000/file.txt
76
-
77
- # Conditional update (only if ETag matches)
78
- curl -X PUT -H 'If-Match: "abc123"' -d "Updated" http://localhost:3000/file.txt
79
-
80
- # Create only if doesn't exist
81
- curl -X PUT -H 'If-None-Match: *' -d "New file" http://localhost:3000/new.txt
82
- ```
83
-
84
- ## Options
85
-
86
- ```
87
- Usage: servejss [options] [directory]
88
-
89
- Options:
90
- -v, --version Output version number
91
- -l, --listen <uri> Specify a URI endpoint on which to listen
92
- -p, --port <port> Specify custom port (default: 3000)
93
- -H, --host <host> Host to bind to (default: 0.0.0.0)
94
- -s, --single Rewrite all not-found requests to index.html (SPA mode)
95
- -d, --debug Show debugging information
96
- -C, --cors Enable CORS (enabled by default)
97
- -L, --no-request-logging Do not log any request information
98
- --no-etag Disable ETag generation
99
- -S, --symlinks Resolve symlinks instead of showing 404
100
- --ssl-cert <path> Path to SSL certificate
101
- --ssl-key <path> Path to SSL private key
102
- --no-port-switching Do not open a different port if specified one is taken
103
- -r, --read-only Disable PUT/DELETE methods (like npx serve)
104
- --auth <credentials> Enable basic auth (user:pass)
105
- --solid Enable full Solid protocol features
106
- -q, --quiet Suppress all output
107
- -h, --help Display help
108
- ```
109
-
110
- ## Comparison with serve
111
-
112
- | Feature | serve | servejss |
113
- |---------|-------|---------|
114
- | Static file serving | ✅ | ✅ |
115
- | Directory listings | ✅ | ✅ |
116
- | CORS | ✅ | ✅ |
117
- | SPA mode | ✅ | ✅ |
118
- | Custom port | ✅ | ✅ |
119
- | Auto port switching | ✅ | ✅ |
120
- | SSL/TLS | ✅ | ✅ |
121
- | **PUT (create/update)** | ❌ | ✅ |
122
- | **DELETE** | ❌ | ✅ |
123
- | **ETags** | ❌ | ✅ |
124
- | **Conditional requests** | ❌ | ✅ |
125
- | **Upgrade to Solid** | ❌ | ✅ |
126
-
127
- ## Advanced Features
128
-
129
- ### Conditional Requests
130
-
131
- servejss supports ETags for efficient caching and safe concurrent updates:
132
-
133
- ```bash
134
- # Get a file with its ETag
135
- curl -i http://localhost:3000/file.txt
136
- # Returns: ETag: "a1b2c3"
137
-
138
- # Only fetch if changed
139
- curl -H 'If-None-Match: "a1b2c3"' http://localhost:3000/file.txt
140
- # Returns: 304 Not Modified (if unchanged)
141
-
142
- # Safe update (fails if file changed since you read it)
143
- curl -X PUT -H 'If-Match: "a1b2c3"' -d "new content" http://localhost:3000/file.txt
144
- ```
145
-
146
- ### Upgrade to Solid
147
-
148
- servejss is powered by [JSS (JavaScript Solid Server)](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer). Enable full Solid protocol support:
149
-
150
- ```bash
151
- servejss --solid
152
- ```
153
-
154
- This enables:
155
- - Solid-OIDC authentication
156
- - Web Access Control (WAC)
157
- - Linked Data support (Turtle, JSON-LD)
158
- - WebID profiles
159
-
160
- ## Use Cases
161
-
162
- ### Local Development Server
163
- ```bash
164
- # Serve your project with write support for uploads
165
- cd my-project
166
- servejss
167
- ```
168
-
169
- ### Quick File Sharing on LAN
170
- ```bash
171
- # Share files with devices on your network
172
- servejss --read-only ~/shared-files
173
- ```
174
-
175
- ### REST API Testing
176
- ```bash
177
- # Mock a simple REST backend
178
- servejss ./mock-data
179
- ```
180
-
181
- ### WebDAV Alternative
182
- ```bash
183
- # Lightweight file sync server
184
- servejss --auth user:pass ~/sync
185
- ```
186
-
187
- ## License
188
-
189
- MIT
190
-
191
- ## Related Projects
192
-
193
- - [JSS](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer) - Full Solid server
194
- - [serve](https://github.com/vercel/serve) - Static file serving (read-only)