javascript-solid-server 0.0.94 → 0.0.95

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.
@@ -141,7 +141,6 @@
141
141
  "Bash(DATA_ROOT=/tmp/jss-git-test/data node:*)",
142
142
  "Bash(git remote set-url:*)",
143
143
  "Bash(for:*)",
144
- "Bash(^/**\" | head -1 | sed ''''s/.*\\* //'''')\")",
145
144
  "Bash(if [ ! -d \"node-solid-server\" ])",
146
145
  "Bash(then git clone --depth 1 https://github.com/nodeSolidServer/node-solid-server.git)",
147
146
  "Bash(node test-local-nss2.js:*)",
@@ -325,7 +324,10 @@
325
324
  "Bash(npm link:*)",
326
325
  "Bash(git push)",
327
326
  "Bash(ulimit:*)",
328
- "Bash(gh label:*)"
327
+ "Bash(gh label:*)",
328
+ "Bash(mongosh --eval \"db.runCommand\\({ ping: 1 }\\)\" 2>&1 | head -5)",
329
+ "Bash(which jss && jss --version 2>&1)",
330
+ "Bash(jss start --help 2>&1 | grep -i mongo)"
329
331
  ]
330
332
  }
331
333
  }
package/README.md CHANGED
@@ -148,6 +148,9 @@ jss --help # Show help
148
148
  | `--public` | Allow unauthenticated access (skip WAC) | false |
149
149
  | `--read-only` | Disable PUT/DELETE/PATCH methods | false |
150
150
  | `--live-reload` | Auto-refresh browser on file changes | false |
151
+ | `--mongo` | Enable MongoDB-backed /db/ route | false |
152
+ | `--mongo-url <url>` | MongoDB connection URL | mongodb://localhost:27017 |
153
+ | `--mongo-database <name>` | MongoDB database name | solid |
151
154
  | `-q, --quiet` | Suppress logs | false |
152
155
 
153
156
  ### Environment Variables
@@ -173,6 +176,9 @@ export JSS_PUBLIC=true
173
176
  export JSS_READ_ONLY=true
174
177
  export JSS_LIVE_RELOAD=true
175
178
  export JSS_SOLIDOS_UI=true
179
+ export JSS_MONGO=true
180
+ export JSS_MONGO_URL=mongodb://localhost:27017
181
+ export JSS_MONGO_DATABASE=solid
176
182
  jss start
177
183
  ```
178
184
 
@@ -647,6 +653,49 @@ jss quota show alice
647
653
  jss quota reconcile alice
648
654
  ```
649
655
 
656
+ ## MongoDB Storage (`/db/` Route)
657
+
658
+ Optional MongoDB-backed route for JSON-LD documents that need scale (social feeds, posts, follows). All other routes continue using the filesystem unchanged.
659
+
660
+ ```bash
661
+ # Install the optional MongoDB driver
662
+ npm install mongodb
663
+
664
+ # Start with MongoDB enabled
665
+ jss start --mongo --mongo-url mongodb://localhost:27017 --mongo-database solid
666
+ ```
667
+
668
+ ### Operations
669
+
670
+ ```bash
671
+ # Store a document
672
+ curl -X PUT http://localhost:3000/db/alice/notes/1 \
673
+ -H "Content-Type: application/ld+json" \
674
+ -H "Authorization: Bearer <token>" \
675
+ -d '{"@context": "https://schema.org/", "@type": "Note", "text": "Hello"}'
676
+
677
+ # Read it back
678
+ curl http://localhost:3000/db/alice/notes/1
679
+
680
+ # List container (derived from URI prefixes)
681
+ curl http://localhost:3000/db/alice/
682
+
683
+ # Delete
684
+ curl -X DELETE http://localhost:3000/db/alice/notes/1 \
685
+ -H "Authorization: Bearer <token>"
686
+ ```
687
+
688
+ ### How It Works
689
+
690
+ - `GET /db/:path` — retrieve a document by URI, or list a virtual container
691
+ - `PUT /db/:path` — create or update (upsert) a JSON-LD document
692
+ - `DELETE /db/:path` — remove a document
693
+ - Returns standard LDP headers (Link, ETag, WAC-Allow, CORS)
694
+ - Supports conditional requests (If-Match, If-None-Match)
695
+ - Container listings are computed from URI prefix queries — no directory management needed
696
+ - Auth: pod owner can write (`/db/{podName}/...`), reads are public
697
+ - MongoDB is an optional dependency — the server runs without it
698
+
650
699
  ### How It Works
651
700
 
652
701
  - Quotas are tracked incrementally on PUT, POST, and DELETE operations
package/bin/jss.js CHANGED
@@ -80,6 +80,10 @@ program
80
80
  .option('--public', 'Allow unauthenticated access (skip WAC, open read/write)')
81
81
  .option('--read-only', 'Disable PUT/DELETE/PATCH methods (read-only mode)')
82
82
  .option('--live-reload', 'Inject live reload script into HTML (auto-refresh on changes)')
83
+ .option('--mongo', 'Enable MongoDB-backed /db/ route')
84
+ .option('--no-mongo', 'Disable MongoDB-backed /db/ route')
85
+ .option('--mongo-url <url>', 'MongoDB connection URL (default: mongodb://localhost:27017)')
86
+ .option('--mongo-database <name>', 'MongoDB database name (default: solid)')
83
87
  .option('-q, --quiet', 'Suppress log output')
84
88
  .option('--log-level <level>', 'Log level: error, warn, info, debug (default: info)')
85
89
  .option('--print-config', 'Print configuration and exit')
@@ -142,6 +146,9 @@ program
142
146
  public: config.public,
143
147
  readOnly: config.readOnly,
144
148
  liveReload: config.liveReload,
149
+ mongo: config.mongo,
150
+ mongoUrl: config.mongoUrl,
151
+ mongoDatabase: config.mongoDatabase,
145
152
  });
146
153
 
147
154
  await server.listen({ port: config.port, host: config.host });
@@ -177,6 +184,7 @@ program
177
184
  }
178
185
  console.log(' Do not expose to the internet!');
179
186
  }
187
+ if (config.mongo) console.log(` MongoDB: ${config.mongoUrl} (${config.mongoDatabase})`);
180
188
  if (config.readOnly) console.log(' Read-only: enabled (PUT/DELETE/PATCH disabled)');
181
189
  console.log('\n Press Ctrl+C to stop\n');
182
190
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.94",
3
+ "version": "0.0.95",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -51,6 +51,9 @@
51
51
  "nostr"
52
52
  ],
53
53
  "license": "AGPL-3.0-only",
54
+ "optionalDependencies": {
55
+ "mongodb": "^6.21.0"
56
+ },
54
57
  "devDependencies": {
55
58
  "autocannon": "^8.0.0"
56
59
  }
package/src/config.js CHANGED
@@ -83,6 +83,11 @@ export const defaults = {
83
83
  // Live reload - inject script to auto-refresh browser on file changes
84
84
  liveReload: false,
85
85
 
86
+ // MongoDB-backed /db/ route
87
+ mongo: false,
88
+ mongoUrl: 'mongodb://localhost:27017',
89
+ mongoDatabase: 'solid',
90
+
86
91
  // Logging
87
92
  logger: true,
88
93
  quiet: false,
@@ -133,6 +138,9 @@ const envMap = {
133
138
  JSS_PUBLIC: 'public',
134
139
  JSS_READ_ONLY: 'readOnly',
135
140
  JSS_LIVE_RELOAD: 'liveReload',
141
+ JSS_MONGO: 'mongo',
142
+ JSS_MONGO_URL: 'mongoUrl',
143
+ JSS_MONGO_DATABASE: 'mongoDatabase',
136
144
  };
137
145
 
138
146
  /**
@@ -297,5 +305,6 @@ export function printConfig(config) {
297
305
  console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`);
298
306
  console.log(` Mashlib: ${config.mashlibModule ? `module (${config.mashlibModule})` : config.mashlibCdn ? `CDN v${config.mashlibVersion}` : config.mashlib ? 'local' : 'disabled'}`);
299
307
  console.log(` SolidOS UI: ${config.solidosUi ? 'enabled' : 'disabled'}`);
308
+ if (config.mongo) console.log(` MongoDB: ${config.mongoUrl} (${config.mongoDatabase})`);
300
309
  console.log('─'.repeat(40));
301
310
  }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * MongoDB Database Route Plugin for JSS
3
+ *
4
+ * Adds /db/* routes backed by MongoDB.
5
+ * Documents are stored as JSON-LD and keyed by URI.
6
+ */
7
+
8
+ import { connect, disconnect, findOne, upsertOne, deleteOne, listByPrefix } from './store.js';
9
+ import { getAllHeaders, getNotFoundHeaders } from '../ldp/headers.js';
10
+ import { generateContainerJsonLd, serializeJsonLd } from '../ldp/container.js';
11
+ import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
12
+ import { emitChange } from '../notifications/events.js';
13
+ import { getWebIdFromRequestAsync } from '../auth/token.js';
14
+
15
+ /**
16
+ * Database route Fastify plugin
17
+ * @param {FastifyInstance} fastify
18
+ * @param {object} options
19
+ */
20
+ export async function dbPlugin(fastify, options) {
21
+ await connect({
22
+ url: options.mongoUrl,
23
+ database: options.mongoDatabase || 'solid'
24
+ });
25
+
26
+ fastify.addHook('onClose', async () => {
27
+ await disconnect();
28
+ });
29
+
30
+ // Auth hook for /db/* routes
31
+ // WAC doesn't apply here — uses WebID-based ownership
32
+ fastify.addHook('preHandler', async (request, reply) => {
33
+ if (request.method === 'OPTIONS') return;
34
+
35
+ // Public mode — skip auth
36
+ if (request.config?.public) {
37
+ request.webId = null;
38
+ return;
39
+ }
40
+
41
+ const { webId } = await getWebIdFromRequestAsync(request);
42
+ request.webId = webId;
43
+
44
+ // Read is public
45
+ if (request.method === 'GET' || request.method === 'HEAD') return;
46
+
47
+ // Write requires authentication
48
+ if (!webId) {
49
+ return reply.code(401).send({ error: 'Unauthorized', message: 'Authentication required' });
50
+ }
51
+
52
+ // Ownership check: only pod owner can write to /db/{podName}/...
53
+ const urlPath = request.url.split('?')[0];
54
+ const relative = urlPath.replace(/^\/db\//, '');
55
+ const podName = relative.split('/')[0];
56
+ if (podName) {
57
+ // Build expected WebID for both path and subdomain modes
58
+ const expectedWebId = request.subdomainsEnabled && request.baseDomain
59
+ ? `${request.protocol}://${podName}.${request.baseDomain}/profile/card#me`
60
+ : `${request.protocol}://${request.hostname}/${podName}/profile/card#me`;
61
+ if (webId !== expectedWebId) {
62
+ return reply.code(403).send({ error: 'Forbidden', message: 'You can only write to your own /db/ space' });
63
+ }
64
+ }
65
+ });
66
+
67
+ // Routes
68
+ fastify.get('/db', handleDbGet);
69
+ fastify.get('/db/*', handleDbGet);
70
+ fastify.head('/db', handleDbHead);
71
+ fastify.head('/db/*', handleDbHead);
72
+ fastify.put('/db/*', handleDbPut);
73
+ fastify.delete('/db/*', handleDbDelete);
74
+ fastify.options('/db', handleDbOptions);
75
+ fastify.options('/db/*', handleDbOptions);
76
+ }
77
+
78
+ /**
79
+ * Build the full resource URL for a /db/ request
80
+ */
81
+ function getResourceUrl(request) {
82
+ const urlPath = request.url.split('?')[0];
83
+ return `${request.protocol}://${request.hostname}${urlPath}`;
84
+ }
85
+
86
+ /**
87
+ * GET /db/* — read resource or container listing
88
+ */
89
+ async function handleDbGet(request, reply) {
90
+ const urlPath = request.url.split('?')[0];
91
+ const resourceUrl = getResourceUrl(request);
92
+ const origin = request.headers.origin;
93
+ const connegEnabled = request.connegEnabled || false;
94
+
95
+ // Container request (treat /db as root container)
96
+ if (urlPath === '/db' || urlPath.endsWith('/')) {
97
+ const entries = await listByPrefix(resourceUrl);
98
+ const jsonLd = generateContainerJsonLd(resourceUrl, entries);
99
+ const content = serializeJsonLd(jsonLd);
100
+
101
+ const etag = `"container-${entries.length}"`;
102
+
103
+ const ifNoneMatch = request.headers['if-none-match'];
104
+ if (ifNoneMatch) {
105
+ const check = checkIfNoneMatchForGet(ifNoneMatch, etag);
106
+ if (!check.ok && check.notModified) {
107
+ return reply.code(304).send();
108
+ }
109
+ }
110
+
111
+ const headers = getAllHeaders({
112
+ isContainer: true, etag,
113
+ contentType: 'application/ld+json',
114
+ origin, resourceUrl, connegEnabled
115
+ });
116
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
117
+ return reply.send(content);
118
+ }
119
+
120
+ // Resource request
121
+ const doc = await findOne(resourceUrl);
122
+ if (!doc) {
123
+ const headers = getNotFoundHeaders({ resourceUrl, origin, connegEnabled });
124
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
125
+ return reply.code(404).send({ error: 'Not Found' });
126
+ }
127
+
128
+ const ifNoneMatch = request.headers['if-none-match'];
129
+ if (ifNoneMatch) {
130
+ const check = checkIfNoneMatchForGet(ifNoneMatch, doc.etag);
131
+ if (!check.ok && check.notModified) {
132
+ return reply.code(304).send();
133
+ }
134
+ }
135
+
136
+ const headers = getAllHeaders({
137
+ isContainer: false, etag: doc.etag,
138
+ contentType: doc.contentType,
139
+ origin, resourceUrl, connegEnabled
140
+ });
141
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
142
+ return reply.send(JSON.stringify(doc.data, null, 2));
143
+ }
144
+
145
+ /**
146
+ * HEAD /db/* — same as GET but no body
147
+ */
148
+ async function handleDbHead(request, reply) {
149
+ const urlPath = request.url.split('?')[0];
150
+ const resourceUrl = getResourceUrl(request);
151
+ const origin = request.headers.origin;
152
+ const connegEnabled = request.connegEnabled || false;
153
+
154
+ if (urlPath === '/db' || urlPath.endsWith('/')) {
155
+ const entries = await listByPrefix(resourceUrl);
156
+ const etag = `"container-${entries.length}"`;
157
+ const headers = getAllHeaders({
158
+ isContainer: true, etag,
159
+ contentType: 'application/ld+json',
160
+ origin, resourceUrl, connegEnabled
161
+ });
162
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
163
+ return reply.code(200).send();
164
+ }
165
+
166
+ const doc = await findOne(resourceUrl);
167
+ if (!doc) {
168
+ const headers = getNotFoundHeaders({ resourceUrl, origin, connegEnabled });
169
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
170
+ return reply.code(404).send();
171
+ }
172
+
173
+ const headers = getAllHeaders({
174
+ isContainer: false, etag: doc.etag,
175
+ contentType: doc.contentType,
176
+ origin, resourceUrl, connegEnabled
177
+ });
178
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
179
+ return reply.code(200).send();
180
+ }
181
+
182
+ /**
183
+ * PUT /db/* — create or update resource
184
+ */
185
+ async function handleDbPut(request, reply) {
186
+ if (request.config?.readOnly) {
187
+ return reply.code(405).send({ error: 'Method Not Allowed', message: 'Server is in read-only mode' });
188
+ }
189
+
190
+ const urlPath = request.url.split('?')[0];
191
+ const resourceUrl = getResourceUrl(request);
192
+
193
+ if (urlPath.endsWith('/')) {
194
+ return reply.code(409).send({ error: 'Conflict', message: 'Cannot PUT to a container' });
195
+ }
196
+
197
+ // Only accept JSON content types — stored as JSON-LD
198
+ const incomingType = (request.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
199
+ if (incomingType && incomingType !== 'application/ld+json' && incomingType !== 'application/json') {
200
+ return reply.code(415).send({ error: 'Unsupported Media Type', message: 'Only application/ld+json and application/json are accepted' });
201
+ }
202
+
203
+ // Parse body
204
+ let data;
205
+ let body = request.body;
206
+ if (Buffer.isBuffer(body)) body = body.toString();
207
+ if (typeof body === 'string') {
208
+ try { data = JSON.parse(body); }
209
+ catch { return reply.code(400).send({ error: 'Bad Request', message: 'Invalid JSON' }); }
210
+ } else if (typeof body === 'object' && body !== null) {
211
+ data = body;
212
+ } else {
213
+ return reply.code(400).send({ error: 'Bad Request', message: 'Request body required' });
214
+ }
215
+
216
+ // Conditional headers
217
+ const existing = await findOne(resourceUrl);
218
+ const currentEtag = existing?.etag || null;
219
+
220
+ const ifMatch = request.headers['if-match'];
221
+ if (ifMatch) {
222
+ const check = checkIfMatch(ifMatch, currentEtag);
223
+ if (!check.ok) return reply.code(check.status).send({ error: check.error });
224
+ }
225
+
226
+ const ifNoneMatch = request.headers['if-none-match'];
227
+ if (ifNoneMatch) {
228
+ const check = checkIfNoneMatchForWrite(ifNoneMatch, currentEtag);
229
+ if (!check.ok) return reply.code(check.status).send({ error: check.error });
230
+ }
231
+
232
+ const { created, etag } = await upsertOne(resourceUrl, data, 'application/ld+json');
233
+
234
+ const origin = request.headers.origin;
235
+ const headers = getAllHeaders({
236
+ isContainer: false, etag,
237
+ origin, resourceUrl,
238
+ connegEnabled: request.connegEnabled || false
239
+ });
240
+ headers['Location'] = resourceUrl;
241
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
242
+
243
+ if (request.notificationsEnabled) {
244
+ emitChange(resourceUrl);
245
+ }
246
+
247
+ return reply.code(created ? 201 : 204).send();
248
+ }
249
+
250
+ /**
251
+ * DELETE /db/* — delete resource
252
+ */
253
+ async function handleDbDelete(request, reply) {
254
+ if (request.config?.readOnly) {
255
+ return reply.code(405).send({ error: 'Method Not Allowed', message: 'Server is in read-only mode' });
256
+ }
257
+
258
+ const resourceUrl = getResourceUrl(request);
259
+ const origin = request.headers.origin;
260
+
261
+ const existing = await findOne(resourceUrl);
262
+ if (!existing) {
263
+ const headers = getNotFoundHeaders({ resourceUrl, origin, connegEnabled: request.connegEnabled || false });
264
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
265
+ return reply.code(404).send({ error: 'Not Found' });
266
+ }
267
+
268
+ const ifMatch = request.headers['if-match'];
269
+ if (ifMatch) {
270
+ const check = checkIfMatch(ifMatch, existing.etag);
271
+ if (!check.ok) return reply.code(check.status).send({ error: check.error });
272
+ }
273
+
274
+ await deleteOne(resourceUrl);
275
+
276
+ const headers = getAllHeaders({
277
+ isContainer: false, origin, resourceUrl
278
+ });
279
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
280
+
281
+ if (request.notificationsEnabled) {
282
+ emitChange(resourceUrl);
283
+ }
284
+
285
+ return reply.code(204).send();
286
+ }
287
+
288
+ /**
289
+ * OPTIONS /db/* — return allowed methods
290
+ */
291
+ async function handleDbOptions(request, reply) {
292
+ const resourceUrl = getResourceUrl(request);
293
+ const origin = request.headers.origin;
294
+ const headers = getAllHeaders({
295
+ isContainer: request.url.split('?')[0] === '/db' || request.url.split('?')[0].endsWith('/'),
296
+ origin, resourceUrl,
297
+ connegEnabled: request.connegEnabled || false
298
+ });
299
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
300
+ return reply.code(204).send();
301
+ }
302
+
303
+ export default dbPlugin;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * MongoDB Storage Layer for /db/ route
3
+ *
4
+ * Optional dependency — dynamically imports 'mongodb'.
5
+ * Provides document-level CRUD keyed by URI.
6
+ */
7
+
8
+ import crypto from 'crypto';
9
+
10
+ let client = null;
11
+ let db = null;
12
+ let col = null;
13
+
14
+ /**
15
+ * Connect to MongoDB
16
+ * @param {object} options
17
+ * @param {string} options.url - MongoDB connection URL
18
+ * @param {string} options.database - Database name
19
+ * @returns {Promise<void>}
20
+ */
21
+ export async function connect({ url, database }) {
22
+ let MongoClient;
23
+ try {
24
+ ({ MongoClient } = await import('mongodb'));
25
+ } catch {
26
+ throw new Error(
27
+ 'MongoDB driver not installed. Install it with: npm install mongodb\n' +
28
+ 'The mongodb package is optional and only needed when using --mongo.'
29
+ );
30
+ }
31
+
32
+ client = new MongoClient(url);
33
+ await client.connect();
34
+ db = client.db(database);
35
+ col = db.collection('resources');
36
+
37
+ // Create unique index on URI for fast lookups
38
+ await col.createIndex({ uri: 1 }, { unique: true });
39
+ }
40
+
41
+ /**
42
+ * Disconnect from MongoDB
43
+ * @returns {Promise<void>}
44
+ */
45
+ export async function disconnect() {
46
+ if (client) {
47
+ await client.close();
48
+ client = null;
49
+ db = null;
50
+ col = null;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Generate ETag from data
56
+ */
57
+ function generateEtag(data) {
58
+ const hash = crypto.createHash('md5').update(JSON.stringify(data)).digest('hex');
59
+ return `"${hash}"`;
60
+ }
61
+
62
+ /**
63
+ * Find a single document by URI
64
+ * @param {string} uri - The resource URI
65
+ * @returns {Promise<{data: object, contentType: string, etag: string, modified: Date} | null>}
66
+ */
67
+ export async function findOne(uri) {
68
+ const doc = await col.findOne({ uri });
69
+ if (!doc) return null;
70
+ return {
71
+ data: doc.data,
72
+ contentType: doc.contentType,
73
+ etag: doc.etag,
74
+ modified: doc.modified
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Upsert a document by URI
80
+ * @param {string} uri - The resource URI
81
+ * @param {object} data - The document data (JSON-LD)
82
+ * @param {string} contentType - The content type
83
+ * @returns {Promise<{created: boolean, etag: string}>}
84
+ */
85
+ export async function upsertOne(uri, data, contentType) {
86
+ const etag = generateEtag(data);
87
+ const now = new Date();
88
+
89
+ const result = await col.updateOne(
90
+ { uri },
91
+ {
92
+ $set: { data, contentType, etag, modified: now },
93
+ $setOnInsert: { created: now }
94
+ },
95
+ { upsert: true }
96
+ );
97
+
98
+ return {
99
+ created: result.upsertedCount > 0,
100
+ etag
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Delete a document by URI
106
+ * @param {string} uri - The resource URI
107
+ * @returns {Promise<boolean>} - true if deleted, false if not found
108
+ */
109
+ export async function deleteOne(uri) {
110
+ const result = await col.deleteOne({ uri });
111
+ return result.deletedCount > 0;
112
+ }
113
+
114
+ /**
115
+ * Escape special regex characters in a string
116
+ */
117
+ function escapeRegex(str) {
118
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
119
+ }
120
+
121
+ /**
122
+ * List immediate children whose URI starts with a prefix (for container listings)
123
+ * @param {string} prefix - URI prefix (must end with '/')
124
+ * @returns {Promise<Array<{name: string, isDirectory: boolean}>>}
125
+ */
126
+ export async function listByPrefix(prefix) {
127
+ const regex = new RegExp('^' + escapeRegex(prefix));
128
+ const docs = await col.find({ uri: regex }, { projection: { uri: 1 } }).toArray();
129
+
130
+ const children = new Map();
131
+ for (const doc of docs) {
132
+ const remainder = doc.uri.slice(prefix.length);
133
+ if (!remainder) continue;
134
+ const slashIndex = remainder.indexOf('/');
135
+ if (slashIndex === -1) {
136
+ // Direct child resource
137
+ children.set(remainder, false);
138
+ } else {
139
+ // Nested under a sub-container
140
+ const containerName = remainder.slice(0, slashIndex);
141
+ children.set(containerName, true);
142
+ }
143
+ }
144
+
145
+ return Array.from(children.entries()).map(([name, isDirectory]) => ({ name, isDirectory }));
146
+ }
147
+
148
+ /**
149
+ * Check if MongoDB is connected
150
+ * @returns {boolean}
151
+ */
152
+ export function isConnected() {
153
+ return client !== null;
154
+ }
package/src/server.js CHANGED
@@ -15,6 +15,7 @@ import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js'
15
15
  import { AccessMode } from './wac/parser.js';
16
16
  import { registerNostrRelay } from './nostr/relay.js';
17
17
  import { activityPubPlugin, getActorHandler } from './ap/index.js';
18
+ import { dbPlugin } from './db/index.js';
18
19
 
19
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
21
 
@@ -84,6 +85,10 @@ export function createServer(options = {}) {
84
85
  const webidTlsEnabled = options.webidTls ?? false;
85
86
  // Live reload - injects script to auto-refresh browser on file changes
86
87
  const liveReloadEnabled = options.liveReload ?? false;
88
+ // MongoDB-backed /db/ route is OFF by default
89
+ const mongoEnabled = options.mongo ?? false;
90
+ const mongoUrl = options.mongoUrl ?? 'mongodb://localhost:27017';
91
+ const mongoDatabase = options.mongoDatabase ?? 'solid';
87
92
 
88
93
  // Set data root via environment variable if provided
89
94
  if (options.root) {
@@ -229,6 +234,11 @@ export function createServer(options = {}) {
229
234
  });
230
235
  }
231
236
 
237
+ // Register MongoDB /db/ route if enabled
238
+ if (mongoEnabled) {
239
+ fastify.register(dbPlugin, { mongoUrl, mongoDatabase });
240
+ }
241
+
232
242
  // Register rate limiting plugin
233
243
  // Protects against brute force attacks and resource exhaustion
234
244
  fastify.register(rateLimit, {
@@ -358,6 +368,7 @@ export function createServer(options = {}) {
358
368
  (gitEnabled && isGitRequest(request.url)) ||
359
369
  (activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
360
370
  isProfileAP ||
371
+ (mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
361
372
  mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
362
373
  return;
363
374
  }