javascript-solid-server 0.0.15 → 0.0.16

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.
@@ -51,7 +51,8 @@
51
51
  "Bash(ACCESS_TOKEN=\"eyJhbGciOiJFUzI1NiIsImtpZCI6IjQwY2U0YzIzLWY2OWQtNDU4NS05ODg2LTE4MDQzZWIyZjU2ZCJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQwMDAvIiwic3ViIjoiYjhlZjY5YWUtODc0ZS00MDg5LThiMDktOGQwY2QyM2VlZWY3IiwiYXVkIjoic29saWQiLCJ3ZWJpZCI6Imh0dHA6Ly9sb2NhbGhvc3Q0MDAwL2FsaWNlLyNtZSIsImlhdCI6MTc2NjgyOTQ5MiwiZXhwIjoxNzY2ODMzMDkyLCJqdGkiOiIwMWY4ODVlZS05ZjY2LTQ3M2MtYmZkNC05MWM4ZGU3NGJhZjYiLCJjbGllbnRfaWQiOiJjcmVkZW50aWFsc19jbGllbnQiLCJzY29wZSI6Im9wZW5pZCB3ZWJpZCJ9.DYTlSRkORyDN28XtXk-zbR7xNLViD97KkPqUKb6chV860BaIgwa1suif4TxHQDnK_ejvbvmZ46_n5WwwRnf_Zw\" curl -sI -X PUT http://localhost:4000/alice/cth-test/ -H \"Content-Type: text/turtle\" -H \"Authorization: Bearer $ACCESS_TOKEN\")",
52
52
  "Bash(timeout 60 docker run:*)",
53
53
  "Bash(rm:*)",
54
- "Bash(mkdir:*)"
54
+ "Bash(mkdir:*)",
55
+ "WebFetch(domain:communitysolidserver.github.io)"
55
56
  ]
56
57
  }
57
58
  }
package/README.md CHANGED
@@ -54,7 +54,7 @@ npm run benchmark
54
54
 
55
55
  ## Features
56
56
 
57
- ### Implemented (v0.0.15)
57
+ ### Implemented (v0.0.16)
58
58
 
59
59
  - **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
60
60
  - **N3 Patch** - Solid's native patch format for RDF updates
@@ -64,7 +64,8 @@ npm run benchmark
64
64
  - **SSL/TLS** - HTTPS support with certificate configuration
65
65
  - **WebSocket Notifications** - Real-time updates via solid-0.1 protocol (SolidOS compatible)
66
66
  - **Container Management** - Create, list, and manage containers
67
- - **Multi-user Pods** - Create pods at `/<username>/`
67
+ - **Multi-user Pods** - Path-based (`/alice/`) or subdomain-based (`alice.example.com`)
68
+ - **Subdomain Mode** - XSS protection via origin isolation
68
69
  - **WebID Profiles** - JSON-LD structured data in HTML at pod root
69
70
  - **Web Access Control (WAC)** - `.acl` file-based authorization
70
71
  - **Solid-OIDC Identity Provider** - Built-in IdP with DPoP, dynamic registration
@@ -135,6 +136,8 @@ jss --help # Show help
135
136
  | `--notifications` | Enable WebSocket | false |
136
137
  | `--idp` | Enable built-in IdP | false |
137
138
  | `--idp-issuer <url>` | IdP issuer URL | (auto) |
139
+ | `--subdomains` | Enable subdomain-based pods | false |
140
+ | `--base-domain <domain>` | Base domain for subdomains | - |
138
141
  | `-q, --quiet` | Suppress logs | false |
139
142
 
140
143
  ### Environment Variables
@@ -146,6 +149,8 @@ export JSS_PORT=8443
146
149
  export JSS_SSL_KEY=/path/to/key.pem
147
150
  export JSS_SSL_CERT=/path/to/cert.pem
148
151
  export JSS_CONNEG=true
152
+ export JSS_SUBDOMAINS=true
153
+ export JSS_BASE_DOMAIN=example.com
149
154
  jss start
150
155
  ```
151
156
 
@@ -338,13 +343,66 @@ curl -H "Authorization: DPoP ACCESS_TOKEN" \
338
343
  http://localhost:3000/alice/private/
339
344
  ```
340
345
 
346
+ ## Subdomain Mode (XSS Protection)
347
+
348
+ By default, JSS uses **path-based pods** (`/alice/`, `/bob/`). This is simple but has a security limitation: all pods share the same origin, making cross-site scripting (XSS) attacks possible between pods.
349
+
350
+ **Subdomain mode** provides **origin isolation** - each pod gets its own subdomain (`alice.example.com`, `bob.example.com`), preventing XSS attacks between pods.
351
+
352
+ ### Why Subdomain Mode?
353
+
354
+ | Mode | URL | Origin | XSS Risk |
355
+ |------|-----|--------|----------|
356
+ | Path-based | `example.com/alice/` | `example.com` | Shared origin - pods can XSS each other |
357
+ | Subdomain | `alice.example.com/` | `alice.example.com` | Isolated - browser's Same-Origin Policy protects |
358
+
359
+ ### Enabling Subdomain Mode
360
+
361
+ ```bash
362
+ jss start --subdomains --base-domain example.com
363
+ ```
364
+
365
+ Or via environment variables:
366
+
367
+ ```bash
368
+ export JSS_SUBDOMAINS=true
369
+ export JSS_BASE_DOMAIN=example.com
370
+ jss start
371
+ ```
372
+
373
+ ### DNS Configuration
374
+
375
+ You need a **wildcard DNS record** pointing to your server:
376
+
377
+ ```
378
+ *.example.com A <your-server-ip>
379
+ ```
380
+
381
+ ### Pod URLs in Subdomain Mode
382
+
383
+ | Path Mode | Subdomain Mode |
384
+ |-----------|----------------|
385
+ | `example.com/alice/` | `alice.example.com/` |
386
+ | `example.com/alice/public/file.txt` | `alice.example.com/public/file.txt` |
387
+ | `example.com/alice/#me` | `alice.example.com/#me` |
388
+
389
+ Pod creation still uses the main domain:
390
+
391
+ ```bash
392
+ curl -X POST https://example.com/.pods \
393
+ -H "Content-Type: application/json" \
394
+ -d '{"name": "alice"}'
395
+ ```
396
+
341
397
  ## Configuration
342
398
 
343
399
  ```javascript
344
400
  createServer({
345
401
  logger: true, // Enable Fastify logging (default: true)
346
402
  conneg: false, // Enable content negotiation (default: false)
347
- notifications: false // Enable WebSocket notifications (default: false)
403
+ notifications: false, // Enable WebSocket notifications (default: false)
404
+ subdomains: false, // Enable subdomain-based pods (default: false)
405
+ baseDomain: null, // Base domain for subdomains (e.g., "example.com")
348
406
  });
349
407
  ```
350
408
 
package/bin/jss.js CHANGED
@@ -47,6 +47,9 @@ program
47
47
  .option('--idp', 'Enable built-in Identity Provider')
48
48
  .option('--no-idp', 'Disable built-in Identity Provider')
49
49
  .option('--idp-issuer <url>', 'IdP issuer URL (defaults to server URL)')
50
+ .option('--subdomains', 'Enable subdomain-based pods (XSS protection)')
51
+ .option('--no-subdomains', 'Disable subdomain-based pods')
52
+ .option('--base-domain <domain>', 'Base domain for subdomain pods (e.g., "example.com")')
50
53
  .option('-q, --quiet', 'Suppress log output')
51
54
  .option('--print-config', 'Print configuration and exit')
52
55
  .action(async (options) => {
@@ -80,6 +83,8 @@ program
80
83
  cert: await fs.readFile(config.sslCert),
81
84
  } : null,
82
85
  root: config.root,
86
+ subdomains: config.subdomains,
87
+ baseDomain: config.baseDomain,
83
88
  });
84
89
 
85
90
  await server.listen({ port: config.port, host: config.host });
@@ -92,6 +97,7 @@ program
92
97
  if (config.conneg) console.log(' Conneg: enabled');
93
98
  if (config.notifications) console.log(' WebSocket: enabled');
94
99
  if (config.idp) console.log(` IdP: ${idpIssuer}`);
100
+ if (config.subdomains) console.log(` Subdomains: ${config.baseDomain} (XSS protection enabled)`);
95
101
  console.log('\n Press Ctrl+C to stop\n');
96
102
  }
97
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.15",
3
+ "version": "0.0.16",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -7,6 +7,7 @@
7
7
  import { getWebIdFromRequestAsync } from './token.js';
8
8
  import { checkAccess, getRequiredMode } from '../wac/checker.js';
9
9
  import * as storage from '../storage/filesystem.js';
10
+ import { getEffectiveUrlPath } from '../utils/url.js';
10
11
 
11
12
  /**
12
13
  * Check if request is authorized
@@ -27,27 +28,32 @@ export async function authorize(request, reply) {
27
28
  // Get WebID from token (supports both simple and Solid-OIDC tokens)
28
29
  const { webId, error: authError } = await getWebIdFromRequestAsync(request);
29
30
 
31
+ // Get effective storage path (includes pod name in subdomain mode)
32
+ const storagePath = getEffectiveUrlPath(request);
33
+
30
34
  // Get resource info
31
- const stats = await storage.stat(urlPath);
35
+ const stats = await storage.stat(storagePath);
32
36
  const resourceExists = stats !== null;
33
37
  const isContainer = stats?.isDirectory || urlPath.endsWith('/');
34
38
 
35
- // Build resource URL
39
+ // Build resource URL (uses actual request hostname which may be subdomain)
36
40
  const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
37
41
 
38
42
  // Get required access mode for this method
39
43
  const requiredMode = getRequiredMode(method);
40
44
 
41
45
  // For write operations on non-existent resources, check parent container
42
- let checkPath = urlPath;
46
+ let checkPath = storagePath;
43
47
  let checkUrl = resourceUrl;
44
48
  let checkIsContainer = isContainer;
45
49
 
46
50
  if (!resourceExists && (method === 'PUT' || method === 'POST' || method === 'PATCH')) {
47
51
  // Check write permission on parent container
48
- const parentPath = getParentPath(urlPath);
52
+ const parentPath = getParentPath(storagePath);
49
53
  checkPath = parentPath;
50
- checkUrl = `${request.protocol}://${request.hostname}${parentPath}`;
54
+ // For URL, also need to get parent
55
+ const parentUrlPath = getParentPath(urlPath);
56
+ checkUrl = `${request.protocol}://${request.hostname}${parentUrlPath}`;
51
57
  checkIsContainer = true;
52
58
  }
53
59
 
package/src/config.js CHANGED
@@ -33,6 +33,10 @@ export const defaults = {
33
33
  idp: false,
34
34
  idpIssuer: null,
35
35
 
36
+ // Subdomain mode (XSS protection)
37
+ subdomains: false,
38
+ baseDomain: null,
39
+
36
40
  // Logging
37
41
  logger: true,
38
42
  quiet: false,
@@ -57,6 +61,8 @@ const envMap = {
57
61
  JSS_CONFIG_PATH: 'configPath',
58
62
  JSS_IDP: 'idp',
59
63
  JSS_IDP_ISSUER: 'idpIssuer',
64
+ JSS_SUBDOMAINS: 'subdomains',
65
+ JSS_BASE_DOMAIN: 'baseDomain',
60
66
  };
61
67
 
62
68
  /**
@@ -188,5 +194,6 @@ export function printConfig(config) {
188
194
  console.log(` Conneg: ${config.conneg}`);
189
195
  console.log(` Notifications: ${config.notifications}`);
190
196
  console.log(` IdP: ${config.idp ? (config.idpIssuer || 'enabled') : 'disabled'}`);
197
+ console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`);
191
198
  console.log('─'.repeat(40));
192
199
  }
@@ -1,17 +1,30 @@
1
1
  import * as storage from '../storage/filesystem.js';
2
2
  import { getAllHeaders } from '../ldp/headers.js';
3
- import { isContainer } from '../utils/url.js';
3
+ import { isContainer, getEffectiveUrlPath } from '../utils/url.js';
4
4
  import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js';
5
5
  import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl } from '../wac/parser.js';
6
6
  import { createToken } from '../auth/token.js';
7
7
  import { canAcceptInput, toJsonLd, getVaryHeader, RDF_TYPES } from '../rdf/conneg.js';
8
8
  import { emitChange } from '../notifications/events.js';
9
9
 
10
+ /**
11
+ * Get the storage path and resource URL for a request
12
+ * In subdomain mode, storage path includes pod name, URL uses subdomain
13
+ */
14
+ function getRequestPaths(request) {
15
+ const urlPath = request.url.split('?')[0];
16
+ // Storage path - includes pod name in subdomain mode
17
+ const storagePath = getEffectiveUrlPath(request);
18
+ // Resource URL - uses the actual request hostname (subdomain in subdomain mode)
19
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
20
+ return { urlPath, storagePath, resourceUrl };
21
+ }
22
+
10
23
  /**
11
24
  * Handle POST request to container (create new resource)
12
25
  */
13
26
  export async function handlePost(request, reply) {
14
- const urlPath = request.url.split('?')[0];
27
+ const { urlPath, storagePath } = getRequestPaths(request);
15
28
 
16
29
  // Ensure target is a container
17
30
  if (!isContainer(urlPath)) {
@@ -32,10 +45,10 @@ export async function handlePost(request, reply) {
32
45
  }
33
46
 
34
47
  // Check container exists
35
- const stats = await storage.stat(urlPath);
48
+ const stats = await storage.stat(storagePath);
36
49
  if (!stats || !stats.isDirectory) {
37
50
  // Create container if it doesn't exist
38
- await storage.createContainer(urlPath);
51
+ await storage.createContainer(storagePath);
39
52
  }
40
53
 
41
54
  // Get slug from header or generate UUID
@@ -46,13 +59,14 @@ export async function handlePost(request, reply) {
46
59
  const isCreatingContainer = linkHeader.includes('Container') || linkHeader.includes('BasicContainer');
47
60
 
48
61
  // Generate unique filename
49
- const filename = await storage.generateUniqueFilename(urlPath, slug, isCreatingContainer);
50
- const newPath = urlPath + filename + (isCreatingContainer ? '/' : '');
51
- const resourceUrl = `${request.protocol}://${request.hostname}${newPath}`;
62
+ const filename = await storage.generateUniqueFilename(storagePath, slug, isCreatingContainer);
63
+ const newUrlPath = urlPath + filename + (isCreatingContainer ? '/' : '');
64
+ const newStoragePath = storagePath + filename + (isCreatingContainer ? '/' : '');
65
+ const resourceUrl = `${request.protocol}://${request.hostname}${newUrlPath}`;
52
66
 
53
67
  let success;
54
68
  if (isCreatingContainer) {
55
- success = await storage.createContainer(newPath);
69
+ success = await storage.createContainer(newStoragePath);
56
70
  } else {
57
71
  // Get content from request body
58
72
  let content = request.body;
@@ -80,7 +94,7 @@ export async function handlePost(request, reply) {
80
94
  }
81
95
  }
82
96
 
83
- success = await storage.write(newPath, content);
97
+ success = await storage.write(newStoragePath, content);
84
98
  }
85
99
 
86
100
  if (!success) {
@@ -153,10 +167,24 @@ export async function handleCreatePod(request, reply) {
153
167
  }
154
168
 
155
169
  // Build URIs
156
- // WebID is at pod root: /alice/#me
157
- const baseUri = `${request.protocol}://${request.hostname}`;
158
- const podUri = `${baseUri}${podPath}`;
159
- const webId = `${podUri}#me`;
170
+ // WebID is at pod root: /alice/#me (path mode) or alice.example.com/#me (subdomain mode)
171
+ const subdomainsEnabled = request.subdomainsEnabled;
172
+ const baseDomain = request.baseDomain;
173
+
174
+ let baseUri, podUri, webId;
175
+ if (subdomainsEnabled && baseDomain) {
176
+ // Subdomain mode: alice.example.com/
177
+ const podHost = `${name}.${baseDomain}`;
178
+ baseUri = `${request.protocol}://${baseDomain}`;
179
+ podUri = `${request.protocol}://${podHost}/`;
180
+ webId = `${podUri}#me`;
181
+ } else {
182
+ // Path mode: example.com/alice/
183
+ baseUri = `${request.protocol}://${request.hostname}`;
184
+ podUri = `${baseUri}${podPath}`;
185
+ webId = `${podUri}#me`;
186
+ }
187
+
160
188
  // Issuer needs trailing slash for CTH compatibility
161
189
  const issuer = baseUri + '/';
162
190
 
@@ -1,7 +1,7 @@
1
1
  import * as storage from '../storage/filesystem.js';
2
2
  import { getAllHeaders } from '../ldp/headers.js';
3
3
  import { generateContainerJsonLd, serializeJsonLd } from '../ldp/container.js';
4
- import { isContainer, getContentType, isRdfContentType } from '../utils/url.js';
4
+ import { isContainer, getContentType, isRdfContentType, getEffectiveUrlPath } from '../utils/url.js';
5
5
  import { parseN3Patch, applyN3Patch, validatePatch } from '../patch/n3-patch.js';
6
6
  import { parseSparqlUpdate, applySparqlUpdate } from '../patch/sparql-update.js';
7
7
  import {
@@ -15,12 +15,25 @@ import {
15
15
  import { emitChange } from '../notifications/events.js';
16
16
  import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
17
17
 
18
+ /**
19
+ * Get the storage path and resource URL for a request
20
+ * In subdomain mode, storage path includes pod name, URL uses subdomain
21
+ */
22
+ function getRequestPaths(request) {
23
+ const urlPath = request.url.split('?')[0];
24
+ // Storage path - includes pod name in subdomain mode
25
+ const storagePath = getEffectiveUrlPath(request);
26
+ // Resource URL - uses the actual request hostname (subdomain in subdomain mode)
27
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
28
+ return { urlPath, storagePath, resourceUrl };
29
+ }
30
+
18
31
  /**
19
32
  * Handle GET request
20
33
  */
21
34
  export async function handleGet(request, reply) {
22
- const urlPath = request.url.split('?')[0]; // Remove query string
23
- const stats = await storage.stat(urlPath);
35
+ const { urlPath, storagePath, resourceUrl } = getRequestPaths(request);
36
+ const stats = await storage.stat(storagePath);
24
37
 
25
38
  if (!stats) {
26
39
  return reply.code(404).send({ error: 'Not Found' });
@@ -36,14 +49,13 @@ export async function handleGet(request, reply) {
36
49
  }
37
50
 
38
51
  const origin = request.headers.origin;
39
- const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
40
52
 
41
53
  // Handle container
42
54
  if (stats.isDirectory) {
43
55
  const connegEnabled = request.connegEnabled || false;
44
56
 
45
57
  // Check for index.html (serves as both profile and container representation)
46
- const indexPath = urlPath.endsWith('/') ? `${urlPath}index.html` : `${urlPath}/index.html`;
58
+ const indexPath = storagePath.endsWith('/') ? `${storagePath}index.html` : `${storagePath}/index.html`;
47
59
  const indexExists = await storage.exists(indexPath);
48
60
 
49
61
  if (indexExists) {
@@ -105,7 +117,7 @@ export async function handleGet(request, reply) {
105
117
  }
106
118
 
107
119
  // No index.html, return JSON-LD container listing
108
- const entries = await storage.listContainer(urlPath);
120
+ const entries = await storage.listContainer(storagePath);
109
121
  const jsonLd = generateContainerJsonLd(resourceUrl, entries || []);
110
122
 
111
123
  const headers = getAllHeaders({
@@ -122,12 +134,12 @@ export async function handleGet(request, reply) {
122
134
  }
123
135
 
124
136
  // Handle resource
125
- const content = await storage.read(urlPath);
137
+ const content = await storage.read(storagePath);
126
138
  if (content === null) {
127
139
  return reply.code(500).send({ error: 'Read error' });
128
140
  }
129
141
 
130
- const storedContentType = getContentType(urlPath);
142
+ const storedContentType = getContentType(storagePath);
131
143
  const connegEnabled = request.connegEnabled || false;
132
144
 
133
145
  // Content negotiation for RDF resources
@@ -184,16 +196,15 @@ export async function handleGet(request, reply) {
184
196
  * Handle HEAD request
185
197
  */
186
198
  export async function handleHead(request, reply) {
187
- const urlPath = request.url.split('?')[0];
188
- const stats = await storage.stat(urlPath);
199
+ const { storagePath, resourceUrl } = getRequestPaths(request);
200
+ const stats = await storage.stat(storagePath);
189
201
 
190
202
  if (!stats) {
191
203
  return reply.code(404).send();
192
204
  }
193
205
 
194
206
  const origin = request.headers.origin;
195
- const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
196
- const contentType = stats.isDirectory ? 'application/ld+json' : getContentType(urlPath);
207
+ const contentType = stats.isDirectory ? 'application/ld+json' : getContentType(storagePath);
197
208
 
198
209
  const headers = getAllHeaders({
199
210
  isContainer: stats.isDirectory,
@@ -215,20 +226,19 @@ export async function handleHead(request, reply) {
215
226
  * Handle PUT request
216
227
  */
217
228
  export async function handlePut(request, reply) {
218
- const urlPath = request.url.split('?')[0];
229
+ const { urlPath, storagePath, resourceUrl } = getRequestPaths(request);
219
230
  const connegEnabled = request.connegEnabled || false;
220
- const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
221
231
 
222
232
  // Handle container creation via PUT
223
233
  if (isContainer(urlPath)) {
224
- const stats = await storage.stat(urlPath);
234
+ const stats = await storage.stat(storagePath);
225
235
  if (stats?.isDirectory) {
226
236
  // Container already exists - don't allow PUT to modify
227
237
  return reply.code(409).send({ error: 'Cannot PUT to existing container' });
228
238
  }
229
239
 
230
240
  // Create the container (and any intermediate containers)
231
- const success = await storage.createContainer(urlPath);
241
+ const success = await storage.createContainer(storagePath);
232
242
  if (!success) {
233
243
  return reply.code(500).send({ error: 'Failed to create container' });
234
244
  }
@@ -258,7 +268,7 @@ export async function handlePut(request, reply) {
258
268
  }
259
269
 
260
270
  // Check if resource already exists and get current ETag
261
- const stats = await storage.stat(urlPath);
271
+ const stats = await storage.stat(storagePath);
262
272
  const existed = stats !== null;
263
273
  const currentEtag = stats?.etag || null;
264
274
 
@@ -308,7 +318,7 @@ export async function handlePut(request, reply) {
308
318
  }
309
319
  }
310
320
 
311
- const success = await storage.write(urlPath, content);
321
+ const success = await storage.write(storagePath, content);
312
322
  if (!success) {
313
323
  return reply.code(500).send({ error: 'Write failed' });
314
324
  }
@@ -332,10 +342,10 @@ export async function handlePut(request, reply) {
332
342
  * Handle DELETE request
333
343
  */
334
344
  export async function handleDelete(request, reply) {
335
- const urlPath = request.url.split('?')[0];
345
+ const { storagePath, resourceUrl } = getRequestPaths(request);
336
346
 
337
347
  // Check if resource exists and get current ETag
338
- const stats = await storage.stat(urlPath);
348
+ const stats = await storage.stat(storagePath);
339
349
  if (!stats) {
340
350
  return reply.code(404).send({ error: 'Not Found' });
341
351
  }
@@ -349,13 +359,12 @@ export async function handleDelete(request, reply) {
349
359
  }
350
360
  }
351
361
 
352
- const success = await storage.remove(urlPath);
362
+ const success = await storage.remove(storagePath);
353
363
  if (!success) {
354
364
  return reply.code(500).send({ error: 'Delete failed' });
355
365
  }
356
366
 
357
367
  const origin = request.headers.origin;
358
- const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
359
368
  const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
360
369
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
361
370
 
@@ -371,11 +380,10 @@ export async function handleDelete(request, reply) {
371
380
  * Handle OPTIONS request
372
381
  */
373
382
  export async function handleOptions(request, reply) {
374
- const urlPath = request.url.split('?')[0];
375
- const stats = await storage.stat(urlPath);
383
+ const { urlPath, storagePath, resourceUrl } = getRequestPaths(request);
384
+ const stats = await storage.stat(storagePath);
376
385
 
377
386
  const origin = request.headers.origin;
378
- const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
379
387
  const connegEnabled = request.connegEnabled || false;
380
388
  const headers = getAllHeaders({
381
389
  isContainer: stats?.isDirectory || isContainer(urlPath),
@@ -393,7 +401,7 @@ export async function handleOptions(request, reply) {
393
401
  * Supports N3 Patch format (text/n3) and SPARQL Update for updating RDF resources
394
402
  */
395
403
  export async function handlePatch(request, reply) {
396
- const urlPath = request.url.split('?')[0];
404
+ const { urlPath, storagePath, resourceUrl } = getRequestPaths(request);
397
405
 
398
406
  // Don't allow PATCH to containers
399
407
  if (isContainer(urlPath)) {
@@ -413,7 +421,7 @@ export async function handlePatch(request, reply) {
413
421
  }
414
422
 
415
423
  // Check if resource exists
416
- const stats = await storage.stat(urlPath);
424
+ const stats = await storage.stat(storagePath);
417
425
  if (!stats) {
418
426
  return reply.code(404).send({ error: 'Not Found' });
419
427
  }
@@ -428,7 +436,7 @@ export async function handlePatch(request, reply) {
428
436
  }
429
437
 
430
438
  // Read existing content
431
- const existingContent = await storage.read(urlPath);
439
+ const existingContent = await storage.read(storagePath);
432
440
  if (existingContent === null) {
433
441
  return reply.code(500).send({ error: 'Read error' });
434
442
  }
@@ -449,8 +457,6 @@ export async function handlePatch(request, reply) {
449
457
  ? request.body.toString()
450
458
  : request.body;
451
459
 
452
- const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
453
-
454
460
  let updatedDocument;
455
461
 
456
462
  if (isSparqlUpdate) {
@@ -497,7 +503,7 @@ export async function handlePatch(request, reply) {
497
503
 
498
504
  // Write updated document
499
505
  const updatedContent = JSON.stringify(updatedDocument, null, 2);
500
- const success = await storage.write(urlPath, Buffer.from(updatedContent));
506
+ const success = await storage.write(storagePath, Buffer.from(updatedContent));
501
507
 
502
508
  if (!success) {
503
509
  return reply.code(500).send({ error: 'Write failed' });
package/src/server.js CHANGED
@@ -16,6 +16,8 @@ import { idpPlugin } from './idp/index.js';
16
16
  * @param {string} options.idpIssuer - IdP issuer URL (default: server URL)
17
17
  * @param {object} options.ssl - SSL configuration { key, cert } (default null)
18
18
  * @param {string} options.root - Data directory path (default from env or ./data)
19
+ * @param {boolean} options.subdomains - Enable subdomain-based pods for XSS protection (default false)
20
+ * @param {string} options.baseDomain - Base domain for subdomain pods (e.g., "example.com")
19
21
  */
20
22
  export function createServer(options = {}) {
21
23
  // Content negotiation is OFF by default - we're a JSON-LD native server
@@ -25,6 +27,9 @@ export function createServer(options = {}) {
25
27
  // Identity Provider is OFF by default
26
28
  const idpEnabled = options.idp ?? false;
27
29
  const idpIssuer = options.idpIssuer;
30
+ // Subdomain mode is OFF by default - use path-based pods
31
+ const subdomainsEnabled = options.subdomains ?? false;
32
+ const baseDomain = options.baseDomain || null;
28
33
 
29
34
  // Set data root via environment variable if provided
30
35
  if (options.root) {
@@ -58,10 +63,29 @@ export function createServer(options = {}) {
58
63
  fastify.decorateRequest('connegEnabled', null);
59
64
  fastify.decorateRequest('notificationsEnabled', null);
60
65
  fastify.decorateRequest('idpEnabled', null);
66
+ fastify.decorateRequest('subdomainsEnabled', null);
67
+ fastify.decorateRequest('baseDomain', null);
68
+ fastify.decorateRequest('podName', null);
61
69
  fastify.addHook('onRequest', async (request) => {
62
70
  request.connegEnabled = connegEnabled;
63
71
  request.notificationsEnabled = notificationsEnabled;
64
72
  request.idpEnabled = idpEnabled;
73
+ request.subdomainsEnabled = subdomainsEnabled;
74
+ request.baseDomain = baseDomain;
75
+
76
+ // Extract pod name from subdomain if enabled
77
+ if (subdomainsEnabled && baseDomain) {
78
+ const host = request.hostname;
79
+ // Check if host is a subdomain of baseDomain
80
+ if (host !== baseDomain && host.endsWith('.' + baseDomain)) {
81
+ // Extract subdomain (e.g., "alice.example.com" -> "alice")
82
+ const subdomain = host.slice(0, -(baseDomain.length + 1));
83
+ // Only single-level subdomains (no dots)
84
+ if (!subdomain.includes('.')) {
85
+ request.podName = subdomain;
86
+ }
87
+ }
88
+ }
65
89
  });
66
90
 
67
91
  // Register WebSocket notifications plugin if enabled
package/src/utils/url.js CHANGED
@@ -19,6 +19,58 @@ export function urlToPath(urlPath) {
19
19
  return path.join(DATA_ROOT, normalized);
20
20
  }
21
21
 
22
+ /**
23
+ * Convert URL path to filesystem path in subdomain mode
24
+ * In subdomain mode, the pod is determined by the hostname, not the path
25
+ * @param {string} urlPath - The URL path (e.g., /public/file.txt)
26
+ * @param {string} podName - The pod name from subdomain (e.g., "alice")
27
+ * @returns {string} - Filesystem path (e.g., DATA_ROOT/alice/public/file.txt)
28
+ */
29
+ export function urlToPathWithPod(urlPath, podName) {
30
+ // Normalize: remove leading slash, decode URI
31
+ let normalized = urlPath.startsWith('/') ? urlPath.slice(1) : urlPath;
32
+ normalized = decodeURIComponent(normalized);
33
+
34
+ // Security: prevent path traversal
35
+ normalized = normalized.replace(/\.\./g, '');
36
+
37
+ // Prepend pod name to path
38
+ return path.join(DATA_ROOT, podName, normalized);
39
+ }
40
+
41
+ /**
42
+ * Get the effective path for a request (subdomain-aware)
43
+ * @param {object} request - Fastify request object
44
+ * @returns {string} - Filesystem path
45
+ */
46
+ export function getPathFromRequest(request) {
47
+ const urlPath = request.url.split('?')[0];
48
+
49
+ // In subdomain mode with a recognized pod subdomain
50
+ if (request.subdomainsEnabled && request.podName) {
51
+ return urlToPathWithPod(urlPath, request.podName);
52
+ }
53
+
54
+ // Path-based mode (default)
55
+ return urlToPath(urlPath);
56
+ }
57
+
58
+ /**
59
+ * Get the effective URL path for a request (with pod prefix in subdomain mode)
60
+ * @param {object} request - Fastify request object
61
+ * @returns {string} - URL path with pod prefix if needed
62
+ */
63
+ export function getEffectiveUrlPath(request) {
64
+ const urlPath = request.url.split('?')[0];
65
+
66
+ // In subdomain mode with a recognized pod subdomain, prepend pod name
67
+ if (request.subdomainsEnabled && request.podName) {
68
+ return '/' + request.podName + urlPath;
69
+ }
70
+
71
+ return urlPath;
72
+ }
73
+
22
74
  /**
23
75
  * Check if URL path represents a container (ends with /)
24
76
  * @param {string} urlPath
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "3c1cd503-1d7f-4ba0-a3af-ebedf519594d",
3
+ "email": "credtest@example.com",
4
+ "passwordHash": "$2b$10$h3cRwsgAo/4wEOyip6ckyOf6J.reHOzJzyZrM.LDfQdIWa3e/MkAu",
5
+ "webId": "http://localhost:3101/credtest/#me",
6
+ "podName": "credtest",
7
+ "createdAt": "2025-12-27T12:48:00.226Z",
8
+ "lastLogin": "2025-12-27T12:48:00.567Z"
9
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "credtest@example.com": "292738d6-3363-4f40-9a6b-884bfd17830a"
2
+ "credtest@example.com": "3c1cd503-1d7f-4ba0-a3af-ebedf519594d"
3
3
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "http://localhost:3101/credtest/#me": "292738d6-3363-4f40-9a6b-884bfd17830a"
2
+ "http://localhost:3101/credtest/#me": "3c1cd503-1d7f-4ba0-a3af-ebedf519594d"
3
3
  }
@@ -3,20 +3,20 @@
3
3
  "keys": [
4
4
  {
5
5
  "kty": "EC",
6
- "x": "1gMgS0xMseqjfq5fA_aYkkq7CMqr6OOQ5ZS4D3MqG6g",
7
- "y": "rtkAdN0tManytaX1QDFRBRE6GXoOlxqj_d3Yt5mpViA",
6
+ "x": "NzQ-skj7cwbmI4Q_-vlQzoPYoQLWq_u34ln95VMcpnU",
7
+ "y": "H6gMaardV77boCWD4OTix1DsxdY-clIBw7I5xvvDDe8",
8
8
  "crv": "P-256",
9
- "d": "GqEv1nO1PRgrKE7n18iDNow-haou-7B6_dlMqo-ftLQ",
10
- "kid": "102e3c82-7dda-4a6f-a296-d47d9b2e0b59",
9
+ "d": "WrlpdJo_JidHFldBjU4Q6Wv_ULhAk1j-DTU6YlHxDAQ",
10
+ "kid": "4e655460-7c94-479c-9ed8-923aa8bfd77f",
11
11
  "use": "sig",
12
12
  "alg": "ES256",
13
- "iat": 1766838193
13
+ "iat": 1766839680
14
14
  }
15
15
  ]
16
16
  },
17
17
  "cookieKeys": [
18
- "PQdsUKa6PcaWNBEUm9G3IZumxoXHnd93rUcyf9VYc0w",
19
- "T4X4hUYp3dE9LakGJX9U5fRux5pyldcrpg_t8AA4FYg"
18
+ "CQXN03oU_rUqugGhwZgoiI7eZMgKJaPE_kyrT9lmTu4",
19
+ "jsJGLtKzYy-RJPZaAMZDpUcfY4-EtdqNOFS-Uiowk9w"
20
20
  ],
21
- "createdAt": "2025-12-27T12:23:13.203Z"
21
+ "createdAt": "2025-12-27T12:48:00.136Z"
22
22
  }
@@ -1,9 +0,0 @@
1
- {
2
- "id": "292738d6-3363-4f40-9a6b-884bfd17830a",
3
- "email": "credtest@example.com",
4
- "passwordHash": "$2b$10$tvcMaMvecS7noqe/T/A5Q.VojfNu1FEPAzWhl/.3v7WXrVIH38iYC",
5
- "webId": "http://localhost:3101/credtest/#me",
6
- "podName": "credtest",
7
- "createdAt": "2025-12-27T12:23:13.338Z",
8
- "lastLogin": "2025-12-27T12:23:13.871Z"
9
- }