javascript-solid-server 0.0.56 → 0.0.58

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.
@@ -207,7 +207,9 @@
207
207
  "WebFetch(domain:solid-chat.com)",
208
208
  "WebFetch(domain:developer.chrome.com)",
209
209
  "WebFetch(domain:css-tricks.com)",
210
- "Bash(node bin/jss.js:*)"
210
+ "Bash(node bin/jss.js:*)",
211
+ "WebFetch(domain:nostr.social)",
212
+ "Bash(xargs curl -s)"
211
213
  ]
212
214
  }
213
215
  }
package/README.md CHANGED
@@ -6,7 +6,7 @@ A minimal, fast, JSON-LD native Solid server.
6
6
 
7
7
  ## Features
8
8
 
9
- ### Implemented (v0.0.56)
9
+ ### Implemented (v0.0.57)
10
10
 
11
11
  - **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
12
12
  - **N3 Patch** - Solid's native patch format for RDF updates
@@ -24,12 +24,13 @@ A minimal, fast, JSON-LD native Solid server.
24
24
  - **Solid-OIDC Identity Provider** - Built-in IdP with DPoP, RS256/ES256, dynamic registration
25
25
  - **Solid-OIDC Resource Server** - Accept DPoP-bound access tokens from external IdPs
26
26
  - **NSS-style Registration** - Username/password auth compatible with Solid apps
27
- - **Nostr Authentication** - NIP-98 HTTP Auth with Schnorr signatures
27
+ - **Nostr Authentication** - NIP-98 HTTP Auth with Schnorr signatures, did:nostr → WebID resolution
28
28
  - **Simple Auth Tokens** - Built-in token authentication for development
29
29
  - **Content Negotiation** - Turtle <-> JSON-LD conversion, including HTML data islands
30
30
  - **CORS Support** - Full cross-origin resource sharing
31
31
  - **Git HTTP Backend** - Clone and push to containers via `git` protocol
32
32
  - **Invite-Only Registration** - CLI-managed invite codes for controlled signups
33
+ - **Storage Quotas** - Per-user storage limits with CLI management
33
34
  - **Security** - Blocks access to dotfiles (`.git/`, `.env`, etc.) except Solid-specific ones
34
35
 
35
36
  ### HTTP Methods
@@ -78,6 +79,7 @@ jss start --port 8443 --ssl-key ./key.pem --ssl-cert ./cert.pem
78
79
  jss start [options] # Start the server
79
80
  jss init [options] # Initialize configuration
80
81
  jss invite <cmd> # Manage invite codes (create, list, revoke)
82
+ jss quota <cmd> # Manage storage quotas (set, show, reconcile)
81
83
  jss --help # Show help
82
84
  ```
83
85
 
@@ -102,6 +104,7 @@ jss --help # Show help
102
104
  | `--mashlib-version <ver>` | Mashlib CDN version | 2.0.0 |
103
105
  | `--git` | Enable Git HTTP backend | false |
104
106
  | `--invite-only` | Require invite code for registration | false |
107
+ | `--default-quota <size>` | Default storage quota per pod (e.g., 50MB) | 50MB |
105
108
  | `-q, --quiet` | Suppress logs | false |
106
109
 
107
110
  ### Environment Variables
@@ -117,6 +120,7 @@ export JSS_SUBDOMAINS=true
117
120
  export JSS_BASE_DOMAIN=example.com
118
121
  export JSS_MASHLIB=true
119
122
  export JSS_INVITE_ONLY=true
123
+ export JSS_DEFAULT_QUOTA=100MB
120
124
  jss start
121
125
  ```
122
126
 
@@ -383,6 +387,35 @@ git add .acl && git commit -m "Add ACL"
383
387
 
384
388
  See [git-credential-nostr](https://github.com/JavaScriptSolidServer/git-credential-nostr) for more details.
385
389
 
390
+ ### Linking Nostr to WebID (did:nostr)
391
+
392
+ Bridge your Nostr identity to a Solid WebID for seamless authentication:
393
+
394
+ **Step 1:** Add your WebID to your Nostr profile (kind 0 event):
395
+ ```json
396
+ {
397
+ "name": "alice",
398
+ "alsoKnownAs": ["https://solid.social/alice/profile/card#me"]
399
+ }
400
+ ```
401
+
402
+ **Step 2:** Add the did:nostr link to your WebID profile:
403
+ ```json
404
+ {
405
+ "@id": "#me",
406
+ "owl:sameAs": "did:nostr:<your-64-char-hex-pubkey>"
407
+ }
408
+ ```
409
+
410
+ **How it works:**
411
+ 1. NIP-98 signature is verified (existing flow)
412
+ 2. DID document is fetched from `nostr.social/.well-known/did/nostr/<pubkey>.json`
413
+ 3. `alsoKnownAs` is checked for a WebID URL
414
+ 4. WebID profile is fetched and `owl:sameAs` verified
415
+ 5. If bidirectional link exists → authenticated as WebID
416
+
417
+ This enables Nostr users to access their Solid pods using existing NIP-07 browser extensions.
418
+
386
419
  ## Invite-Only Registration
387
420
 
388
421
  Control who can create accounts by requiring invite codes:
@@ -427,6 +460,43 @@ When `--invite-only` is enabled:
427
460
 
428
461
  Invite codes are stored in `.server/invites.json` in your data directory.
429
462
 
463
+ ## Storage Quotas
464
+
465
+ Limit storage per pod to prevent abuse and manage resources:
466
+
467
+ ```bash
468
+ jss start --default-quota 50MB
469
+ ```
470
+
471
+ ### Managing Quotas
472
+
473
+ ```bash
474
+ # Set quota for a user (overrides default)
475
+ jss quota set alice 100MB
476
+
477
+ # Show quota info
478
+ jss quota show alice
479
+ # alice:
480
+ # Used: 12.5 MB
481
+ # Limit: 100 MB
482
+ # Free: 87.5 MB
483
+ # Usage: 12%
484
+
485
+ # Recalculate from actual disk usage
486
+ jss quota reconcile alice
487
+ ```
488
+
489
+ ### How It Works
490
+
491
+ - Quotas are tracked incrementally on PUT, POST, and DELETE operations
492
+ - When quota is exceeded, the server returns HTTP 507 Insufficient Storage
493
+ - Each pod stores its quota in `/{pod}/.quota.json`
494
+ - Use `reconcile` to fix quota drift from manual file changes
495
+
496
+ ### Size Formats
497
+
498
+ Supported formats: `50MB`, `1GB`, `500KB`, `1TB`
499
+
430
500
  ## Authentication
431
501
 
432
502
  ### Simple Tokens (Development)
@@ -688,12 +758,14 @@ src/
688
758
  │ ├── container.js # POST, pod creation
689
759
  │ └── git.js # Git HTTP backend
690
760
  ├── storage/
691
- └── filesystem.js # File operations
761
+ ├── filesystem.js # File operations
762
+ │ └── quota.js # Storage quota management
692
763
  ├── auth/
693
764
  │ ├── middleware.js # Auth hook
694
765
  │ ├── token.js # Simple token auth
695
766
  │ ├── solid-oidc.js # DPoP verification
696
- └── nostr.js # NIP-98 Nostr authentication
767
+ ├── nostr.js # NIP-98 Nostr authentication
768
+ │ └── did-nostr.js # did:nostr → WebID resolution
697
769
  ├── wac/
698
770
  │ ├── parser.js # ACL parsing
699
771
  │ └── checker.js # Permission checking
package/bin/jss.js CHANGED
@@ -12,6 +12,8 @@ import { Command } from 'commander';
12
12
  import { createServer } from '../src/server.js';
13
13
  import { loadConfig, saveConfig, printConfig, defaults } from '../src/config.js';
14
14
  import { createInvite, listInvites, revokeInvite } from '../src/idp/invites.js';
15
+ import { setQuotaLimit, getQuotaInfo, reconcileQuota, formatBytes } from '../src/storage/quota.js';
16
+ import { parseSize } from '../src/config.js';
15
17
  import fs from 'fs-extra';
16
18
  import path from 'path';
17
19
  import { fileURLToPath } from 'url';
@@ -307,6 +309,92 @@ inviteCmd
307
309
  }
308
310
  });
309
311
 
312
+ /**
313
+ * Quota command - manage storage quotas
314
+ */
315
+ const quotaCmd = program
316
+ .command('quota')
317
+ .description('Manage storage quotas for pods');
318
+
319
+ quotaCmd
320
+ .command('set <username> <size>')
321
+ .description('Set quota limit for a user (e.g., 50MB, 1GB)')
322
+ .option('-r, --root <path>', 'Data directory')
323
+ .action(async (username, size, options) => {
324
+ try {
325
+ if (options.root) {
326
+ process.env.DATA_ROOT = path.resolve(options.root);
327
+ }
328
+
329
+ const bytes = parseSize(size);
330
+ if (bytes === 0) {
331
+ console.error('Invalid size format. Use e.g., 50MB, 1GB');
332
+ process.exit(1);
333
+ }
334
+
335
+ const quota = await setQuotaLimit(username, bytes);
336
+ console.log(`\nQuota set for ${username}: ${formatBytes(quota.limit)}`);
337
+ console.log(`Current usage: ${formatBytes(quota.used)} (${Math.round(quota.used / quota.limit * 100)}%)\n`);
338
+ } catch (err) {
339
+ console.error(`Error: ${err.message}`);
340
+ process.exit(1);
341
+ }
342
+ });
343
+
344
+ quotaCmd
345
+ .command('show <username>')
346
+ .description('Show quota info for a user')
347
+ .option('-r, --root <path>', 'Data directory')
348
+ .action(async (username, options) => {
349
+ try {
350
+ if (options.root) {
351
+ process.env.DATA_ROOT = path.resolve(options.root);
352
+ }
353
+
354
+ const quota = await getQuotaInfo(username);
355
+
356
+ if (quota.limit === 0) {
357
+ console.log(`\n${username}: No quota set (unlimited)\n`);
358
+ } else {
359
+ console.log(`\n${username}:`);
360
+ console.log(` Used: ${formatBytes(quota.used)}`);
361
+ console.log(` Limit: ${formatBytes(quota.limit)}`);
362
+ console.log(` Free: ${formatBytes(quota.limit - quota.used)}`);
363
+ console.log(` Usage: ${quota.percent}%\n`);
364
+ }
365
+ } catch (err) {
366
+ console.error(`Error: ${err.message}`);
367
+ process.exit(1);
368
+ }
369
+ });
370
+
371
+ quotaCmd
372
+ .command('reconcile <username>')
373
+ .description('Recalculate quota usage from actual disk usage')
374
+ .option('-r, --root <path>', 'Data directory')
375
+ .action(async (username, options) => {
376
+ try {
377
+ if (options.root) {
378
+ process.env.DATA_ROOT = path.resolve(options.root);
379
+ }
380
+
381
+ console.log(`Calculating actual disk usage for ${username}...`);
382
+ const quota = await reconcileQuota(username);
383
+
384
+ if (quota.limit === 0) {
385
+ console.log(`\n${username}: No quota configured\n`);
386
+ } else {
387
+ console.log(`\nReconciled ${username}:`);
388
+ console.log(` Used: ${formatBytes(quota.used)}`);
389
+ console.log(` Limit: ${formatBytes(quota.limit)}`);
390
+ console.log(` Usage: ${Math.round(quota.used / quota.limit * 100)}%\n`);
391
+ }
392
+ } catch (err) {
393
+ console.error(`Error: ${err.message}`);
394
+ process.exit(1);
395
+ }
396
+ });
397
+
310
398
  /**
311
399
  * Helper: Prompt for input
312
400
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.56",
3
+ "version": "0.0.58",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,205 @@
1
+ /**
2
+ * DID:nostr Resolution
3
+ *
4
+ * Resolves did:nostr:<pubkey> to a Solid WebID by:
5
+ * 1. Fetching DID document from nostr.social
6
+ * 2. Extracting alsoKnownAs WebID
7
+ * 3. Verifying bidirectional link (WebID links back to did:nostr)
8
+ */
9
+
10
+ // Default DID resolver endpoint
11
+ const DEFAULT_DID_RESOLVER = 'https://nostr.social/.well-known/did/nostr';
12
+
13
+ // Cache for resolved DIDs (pubkey -> webId or null)
14
+ const cache = new Map();
15
+ const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
16
+
17
+ /**
18
+ * Fetch with timeout
19
+ */
20
+ async function fetchWithTimeout(url, options = {}, timeout = 5000) {
21
+ const controller = new AbortController();
22
+ const id = setTimeout(() => controller.abort(), timeout);
23
+ try {
24
+ const response = await fetch(url, { ...options, signal: controller.signal });
25
+ clearTimeout(id);
26
+ return response;
27
+ } catch (err) {
28
+ clearTimeout(id);
29
+ throw err;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Resolve did:nostr pubkey to WebID via DID document
35
+ * @param {string} pubkey - 64-char hex Nostr pubkey
36
+ * @param {string} resolverUrl - DID resolver base URL
37
+ * @returns {Promise<string|null>} WebID URL or null
38
+ */
39
+ export async function resolveDidNostrToWebId(pubkey, resolverUrl = DEFAULT_DID_RESOLVER) {
40
+ if (!pubkey || pubkey.length !== 64) {
41
+ return null;
42
+ }
43
+
44
+ // Check cache
45
+ const cacheKey = pubkey.toLowerCase();
46
+ const cached = cache.get(cacheKey);
47
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
48
+ return cached.webId;
49
+ }
50
+
51
+ try {
52
+ // Fetch DID document
53
+ const didUrl = `${resolverUrl}/${pubkey}.json`;
54
+ const didRes = await fetchWithTimeout(didUrl, {
55
+ headers: { 'Accept': 'application/did+json, application/json' }
56
+ });
57
+
58
+ if (!didRes.ok) {
59
+ cache.set(cacheKey, { webId: null, timestamp: Date.now() });
60
+ return null;
61
+ }
62
+
63
+ const didDoc = await didRes.json();
64
+
65
+ // Extract WebID from alsoKnownAs (array) or profile.webid or profile.sameAs
66
+ let webId = null;
67
+
68
+ if (Array.isArray(didDoc.alsoKnownAs) && didDoc.alsoKnownAs.length > 0) {
69
+ // Find first HTTP(S) URL that looks like a WebID
70
+ webId = didDoc.alsoKnownAs.find(aka =>
71
+ typeof aka === 'string' && aka.startsWith('https://'));
72
+ }
73
+
74
+ // Fallback to profile fields
75
+ if (!webId && didDoc.profile) {
76
+ webId = didDoc.profile.webid || didDoc.profile.sameAs;
77
+ }
78
+
79
+ if (!webId) {
80
+ cache.set(cacheKey, { webId: null, timestamp: Date.now() });
81
+ return null;
82
+ }
83
+
84
+ // Verify bidirectional link - WebID must link back to did:nostr
85
+ const verified = await verifyWebIdBacklink(webId, pubkey);
86
+
87
+ if (verified) {
88
+ cache.set(cacheKey, { webId, timestamp: Date.now() });
89
+ return webId;
90
+ }
91
+
92
+ cache.set(cacheKey, { webId: null, timestamp: Date.now() });
93
+ return null;
94
+
95
+ } catch (err) {
96
+ // Network error or timeout - don't cache failures
97
+ console.error(`DID resolution error for ${pubkey}:`, err.message);
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Verify WebID profile links back to did:nostr
104
+ * @param {string} webId - WebID URL
105
+ * @param {string} pubkey - Nostr pubkey
106
+ * @returns {Promise<boolean>}
107
+ */
108
+ async function verifyWebIdBacklink(webId, pubkey) {
109
+ try {
110
+ const expectedDid = `did:nostr:${pubkey.toLowerCase()}`;
111
+
112
+ // Fetch WebID profile
113
+ const res = await fetchWithTimeout(webId, {
114
+ headers: { 'Accept': 'application/ld+json, application/json, text/html' }
115
+ });
116
+
117
+ if (!res.ok) {
118
+ return false;
119
+ }
120
+
121
+ const contentType = res.headers.get('content-type') || '';
122
+ const text = await res.text();
123
+
124
+ // Handle HTML with JSON-LD data island
125
+ if (contentType.includes('text/html')) {
126
+ const jsonLdMatch = text.match(/<script\s+type=["']application\/ld\+json["']\s*>([\s\S]*?)<\/script>/i);
127
+ if (jsonLdMatch) {
128
+ try {
129
+ const jsonLd = JSON.parse(jsonLdMatch[1]);
130
+ return checkSameAsLink(jsonLd, expectedDid);
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+ return false;
136
+ }
137
+
138
+ // Handle JSON-LD directly
139
+ if (contentType.includes('json')) {
140
+ try {
141
+ const jsonLd = JSON.parse(text);
142
+ return checkSameAsLink(jsonLd, expectedDid);
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ return false;
149
+
150
+ } catch (err) {
151
+ console.error(`WebID backlink verification error for ${webId}:`, err.message);
152
+ return false;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Check if JSON-LD contains sameAs/owl:sameAs link to expected DID
158
+ * @param {object} jsonLd - Parsed JSON-LD
159
+ * @param {string} expectedDid - Expected did:nostr:pubkey
160
+ * @returns {boolean}
161
+ */
162
+ function checkSameAsLink(jsonLd, expectedDid) {
163
+ // Check various sameAs fields
164
+ const sameAsFields = [
165
+ jsonLd['owl:sameAs'],
166
+ jsonLd['sameAs'],
167
+ jsonLd['schema:sameAs'],
168
+ jsonLd['http://www.w3.org/2002/07/owl#sameAs']
169
+ ];
170
+
171
+ for (const field of sameAsFields) {
172
+ if (!field) continue;
173
+
174
+ // Handle string value
175
+ if (typeof field === 'string' && field.toLowerCase() === expectedDid) {
176
+ return true;
177
+ }
178
+
179
+ // Handle object with @id
180
+ if (field && typeof field === 'object' && field['@id']?.toLowerCase() === expectedDid) {
181
+ return true;
182
+ }
183
+
184
+ // Handle array
185
+ if (Array.isArray(field)) {
186
+ for (const item of field) {
187
+ if (typeof item === 'string' && item.toLowerCase() === expectedDid) {
188
+ return true;
189
+ }
190
+ if (item && typeof item === 'object' && item['@id']?.toLowerCase() === expectedDid) {
191
+ return true;
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ return false;
198
+ }
199
+
200
+ /**
201
+ * Clear the resolution cache (for testing)
202
+ */
203
+ export function clearCache() {
204
+ cache.clear();
205
+ }
package/src/auth/nostr.js CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { verifyEvent } from 'nostr-tools';
15
15
  import crypto from 'crypto';
16
+ import { resolveDidNostrToWebId } from './did-nostr.js';
16
17
 
17
18
  // NIP-98 event kind (references RFC 7235)
18
19
  const HTTP_AUTH_KIND = 27235;
@@ -223,7 +224,15 @@ export async function verifyNostrAuth(request) {
223
224
  return { webId: null, error: 'Invalid Schnorr signature' };
224
225
  }
225
226
 
226
- // Return did:nostr as the agent identifier
227
+ // Try to resolve did:nostr to a linked WebID
228
+ // This checks if the pubkey has an alsoKnownAs pointing to a WebID
229
+ // and verifies the WebID links back to did:nostr (bidirectional)
230
+ const resolvedWebId = await resolveDidNostrToWebId(event.pubkey);
231
+ if (resolvedWebId) {
232
+ return { webId: resolvedWebId, error: null };
233
+ }
234
+
235
+ // Fall back to did:nostr as the agent identifier
227
236
  const didNostr = pubkeyToDidNostr(event.pubkey);
228
237
 
229
238
  return { webId: didNostr, error: null };
package/src/config.js CHANGED
@@ -48,6 +48,9 @@ export const defaults = {
48
48
  // Invite-only registration
49
49
  inviteOnly: false,
50
50
 
51
+ // Storage quota (bytes) - 50MB default
52
+ defaultQuota: 50 * 1024 * 1024,
53
+
51
54
  // Logging
52
55
  logger: true,
53
56
  quiet: false,
@@ -79,8 +82,22 @@ const envMap = {
79
82
  JSS_MASHLIB_VERSION: 'mashlibVersion',
80
83
  JSS_GIT: 'git',
81
84
  JSS_INVITE_ONLY: 'inviteOnly',
85
+ JSS_DEFAULT_QUOTA: 'defaultQuota',
82
86
  };
83
87
 
88
+ /**
89
+ * Parse a size string like "50MB" or "1GB" to bytes
90
+ */
91
+ export function parseSize(str) {
92
+ const match = str.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?$/i);
93
+ if (!match) return parseInt(str, 10) || 0;
94
+
95
+ const num = parseFloat(match[1]);
96
+ const unit = (match[2] || 'B').toUpperCase();
97
+ const multipliers = { B: 1, KB: 1024, MB: 1024**2, GB: 1024**3, TB: 1024**4 };
98
+ return Math.floor(num * (multipliers[unit] || 1));
99
+ }
100
+
84
101
  /**
85
102
  * Parse a value from environment variable string
86
103
  */
@@ -96,6 +113,11 @@ function parseEnvValue(value, key) {
96
113
  return parseInt(value, 10);
97
114
  }
98
115
 
116
+ // Size values (quota)
117
+ if (key === 'defaultQuota') {
118
+ return parseSize(value);
119
+ }
120
+
99
121
  return value;
100
122
  }
101
123
 
@@ -1,6 +1,7 @@
1
1
  import * as storage from '../storage/filesystem.js';
2
+ import { initializeQuota, checkQuota, updateQuotaUsage } from '../storage/quota.js';
2
3
  import { getAllHeaders } from '../ldp/headers.js';
3
- import { isContainer, getEffectiveUrlPath } from '../utils/url.js';
4
+ import { isContainer, getEffectiveUrlPath, getPodName } from '../utils/url.js';
4
5
  import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js';
5
6
  import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl } from '../wac/parser.js';
6
7
  import { createToken } from '../auth/token.js';
@@ -106,7 +107,21 @@ export async function handlePost(request, reply) {
106
107
  }
107
108
  }
108
109
 
110
+ // Check storage quota before writing
111
+ const podName = getPodName(request);
112
+ if (podName) {
113
+ const { allowed, error } = await checkQuota(podName, content.length, request.defaultQuota || 0);
114
+ if (!allowed) {
115
+ return reply.code(507).send({ error: 'Insufficient Storage', message: error });
116
+ }
117
+ }
118
+
109
119
  success = await storage.write(newStoragePath, content);
120
+
121
+ // Update quota usage after successful write
122
+ if (success && podName) {
123
+ await updateQuotaUsage(podName, content.length);
124
+ }
110
125
  }
111
126
 
112
127
  if (!success) {
@@ -139,8 +154,9 @@ export async function handlePost(request, reply) {
139
154
  * @param {string} webId - User's WebID URI
140
155
  * @param {string} podUri - Pod root URI (e.g., https://alice.example.com/ or https://example.com/alice/)
141
156
  * @param {string} issuer - OIDC issuer URI
157
+ * @param {number} defaultQuota - Default storage quota in bytes (optional)
142
158
  */
143
- export async function createPodStructure(name, webId, podUri, issuer) {
159
+ export async function createPodStructure(name, webId, podUri, issuer, defaultQuota = 0) {
144
160
  const podPath = `/${name}/`;
145
161
 
146
162
  // Create pod directory structure
@@ -193,6 +209,11 @@ export async function createPodStructure(name, webId, podUri, issuer) {
193
209
  const profileAcl = generatePublicFolderAcl(`${podUri}profile/`, webId);
194
210
  await storage.write(`${podPath}profile/.acl`, serializeAcl(profileAcl));
195
211
 
212
+ // Initialize storage quota if configured
213
+ if (defaultQuota > 0) {
214
+ await initializeQuota(name, defaultQuota);
215
+ }
216
+
196
217
  return { podPath, podUri };
197
218
  }
198
219
 
@@ -1,7 +1,8 @@
1
1
  import * as storage from '../storage/filesystem.js';
2
+ import { checkQuota, updateQuotaUsage } from '../storage/quota.js';
2
3
  import { getAllHeaders, getNotFoundHeaders } from '../ldp/headers.js';
3
4
  import { generateContainerJsonLd, serializeJsonLd } from '../ldp/container.js';
4
- import { isContainer, getContentType, isRdfContentType, getEffectiveUrlPath, safeJsonParse } from '../utils/url.js';
5
+ import { isContainer, getContentType, isRdfContentType, getEffectiveUrlPath, safeJsonParse, getPodName } from '../utils/url.js';
5
6
  import { parseN3Patch, applyN3Patch, validatePatch } from '../patch/n3-patch.js';
6
7
  import { parseSparqlUpdate, applySparqlUpdate } from '../patch/sparql-update.js';
7
8
  import {
@@ -504,11 +505,28 @@ export async function handlePut(request, reply) {
504
505
  }
505
506
  }
506
507
 
508
+ // Check storage quota before writing
509
+ const podName = getPodName(request);
510
+ const oldSize = stats?.size || 0;
511
+ const sizeDelta = content.length - oldSize;
512
+
513
+ if (podName && sizeDelta > 0) {
514
+ const { allowed, error } = await checkQuota(podName, sizeDelta, request.defaultQuota || 0);
515
+ if (!allowed) {
516
+ return reply.code(507).send({ error: 'Insufficient Storage', message: error });
517
+ }
518
+ }
519
+
507
520
  const success = await storage.write(storagePath, content);
508
521
  if (!success) {
509
522
  return reply.code(500).send({ error: 'Write failed' });
510
523
  }
511
524
 
525
+ // Update quota usage after successful write
526
+ if (podName && sizeDelta !== 0) {
527
+ await updateQuotaUsage(podName, sizeDelta);
528
+ }
529
+
512
530
  const origin = request.headers.origin;
513
531
  const headers = getAllHeaders({ isContainer: false, origin, resourceUrl, connegEnabled });
514
532
  headers['Location'] = resourceUrl;
@@ -549,11 +567,20 @@ export async function handleDelete(request, reply) {
549
567
  }
550
568
  }
551
569
 
570
+ // Get file size before deletion for quota update
571
+ const fileSize = stats.size || 0;
572
+
552
573
  const success = await storage.remove(storagePath);
553
574
  if (!success) {
554
575
  return reply.code(500).send({ error: 'Delete failed' });
555
576
  }
556
577
 
578
+ // Update quota usage (subtract deleted file size)
579
+ const podName = getPodName(request);
580
+ if (podName && fileSize > 0) {
581
+ await updateQuotaUsage(podName, -fileSize);
582
+ }
583
+
557
584
  const origin = request.headers.origin;
558
585
  const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
559
586
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
package/src/server.js CHANGED
@@ -48,6 +48,8 @@ export function createServer(options = {}) {
48
48
  const gitEnabled = options.git ?? false;
49
49
  // Invite-only registration is OFF by default - open registration
50
50
  const inviteOnly = options.inviteOnly ?? false;
51
+ // Default storage quota per pod (50MB default, 0 = unlimited)
52
+ const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024;
51
53
 
52
54
  // Set data root via environment variable if provided
53
55
  if (options.root) {
@@ -95,6 +97,7 @@ export function createServer(options = {}) {
95
97
  fastify.decorateRequest('mashlibEnabled', null);
96
98
  fastify.decorateRequest('mashlibCdn', null);
97
99
  fastify.decorateRequest('mashlibVersion', null);
100
+ fastify.decorateRequest('defaultQuota', null);
98
101
  fastify.addHook('onRequest', async (request) => {
99
102
  request.connegEnabled = connegEnabled;
100
103
  request.notificationsEnabled = notificationsEnabled;
@@ -104,6 +107,7 @@ export function createServer(options = {}) {
104
107
  request.mashlibEnabled = mashlibEnabled;
105
108
  request.mashlibCdn = mashlibCdn;
106
109
  request.mashlibVersion = mashlibVersion;
110
+ request.defaultQuota = defaultQuota;
107
111
 
108
112
  // Extract pod name from subdomain if enabled
109
113
  if (subdomainsEnabled && baseDomain) {
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Storage quota management
3
+ * Tracks and enforces per-pod storage limits
4
+ */
5
+
6
+ import { promises as fs } from 'fs';
7
+ import { join } from 'path';
8
+ import { getDataRoot } from '../utils/url.js';
9
+
10
+ const QUOTA_FILE = '.quota.json';
11
+
12
+ /**
13
+ * Get quota file path for a pod
14
+ */
15
+ function getQuotaPath(podName) {
16
+ return join(getDataRoot(), podName, QUOTA_FILE);
17
+ }
18
+
19
+ /**
20
+ * Load quota data for a pod
21
+ * @param {string} podName - The pod name
22
+ * @returns {Promise<{limit: number, used: number}>}
23
+ */
24
+ export async function loadQuota(podName) {
25
+ try {
26
+ const data = await fs.readFile(getQuotaPath(podName), 'utf-8');
27
+ return JSON.parse(data);
28
+ } catch (err) {
29
+ if (err.code === 'ENOENT') {
30
+ // No quota file - return defaults (will be initialized on first write)
31
+ return { limit: 0, used: 0 };
32
+ }
33
+ throw err;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Save quota data for a pod
39
+ * @param {string} podName - The pod name
40
+ * @param {object} quota - Quota data
41
+ */
42
+ export async function saveQuota(podName, quota) {
43
+ await fs.writeFile(getQuotaPath(podName), JSON.stringify(quota, null, 2));
44
+ }
45
+
46
+ /**
47
+ * Initialize quota for a new pod
48
+ * @param {string} podName - The pod name
49
+ * @param {number} limit - Quota limit in bytes
50
+ */
51
+ export async function initializeQuota(podName, limit) {
52
+ const quota = { limit, used: 0 };
53
+ await saveQuota(podName, quota);
54
+ return quota;
55
+ }
56
+
57
+ /**
58
+ * Calculate actual disk usage for a pod (for reconciliation)
59
+ * @param {string} podName - The pod name
60
+ * @returns {Promise<number>} Total bytes used
61
+ */
62
+ export async function calculatePodSize(podName) {
63
+ const podPath = join(getDataRoot(), podName);
64
+ return calculateDirSize(podPath);
65
+ }
66
+
67
+ /**
68
+ * Recursively calculate directory size
69
+ */
70
+ async function calculateDirSize(dirPath) {
71
+ let total = 0;
72
+
73
+ try {
74
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
75
+
76
+ for (const entry of entries) {
77
+ const fullPath = join(dirPath, entry.name);
78
+
79
+ // Skip quota file itself
80
+ if (entry.name === QUOTA_FILE) continue;
81
+
82
+ if (entry.isDirectory()) {
83
+ total += await calculateDirSize(fullPath);
84
+ } else if (entry.isFile()) {
85
+ const stat = await fs.stat(fullPath);
86
+ total += stat.size;
87
+ }
88
+ }
89
+ } catch (err) {
90
+ // Directory might not exist or be inaccessible
91
+ if (err.code !== 'ENOENT') {
92
+ throw err;
93
+ }
94
+ }
95
+
96
+ return total;
97
+ }
98
+
99
+ /**
100
+ * Check if a write operation would exceed quota
101
+ * @param {string} podName - The pod name
102
+ * @param {number} additionalBytes - Bytes to be added
103
+ * @param {number} defaultQuota - Default quota limit
104
+ * @returns {Promise<{allowed: boolean, quota: object, error?: string}>}
105
+ */
106
+ export async function checkQuota(podName, additionalBytes, defaultQuota) {
107
+ let quota = await loadQuota(podName);
108
+
109
+ // Initialize if no quota set
110
+ if (quota.limit === 0 && defaultQuota > 0) {
111
+ quota = await initializeQuota(podName, defaultQuota);
112
+ }
113
+
114
+ // No quota enforcement if limit is 0
115
+ if (quota.limit === 0) {
116
+ return { allowed: true, quota };
117
+ }
118
+
119
+ const projectedUsage = quota.used + additionalBytes;
120
+
121
+ if (projectedUsage > quota.limit) {
122
+ const usedMB = (quota.used / (1024 * 1024)).toFixed(2);
123
+ const limitMB = (quota.limit / (1024 * 1024)).toFixed(2);
124
+ return {
125
+ allowed: false,
126
+ quota,
127
+ error: `Storage quota exceeded. Used: ${usedMB}MB / ${limitMB}MB`
128
+ };
129
+ }
130
+
131
+ return { allowed: true, quota };
132
+ }
133
+
134
+ /**
135
+ * Update quota usage after a write
136
+ * @param {string} podName - The pod name
137
+ * @param {number} bytesChange - Bytes added (positive) or removed (negative)
138
+ */
139
+ export async function updateQuotaUsage(podName, bytesChange) {
140
+ const quota = await loadQuota(podName);
141
+
142
+ // Skip if no quota initialized
143
+ if (quota.limit === 0) return quota;
144
+
145
+ quota.used = Math.max(0, quota.used + bytesChange);
146
+ await saveQuota(podName, quota);
147
+ return quota;
148
+ }
149
+
150
+ /**
151
+ * Set quota limit for a pod
152
+ * @param {string} podName - The pod name
153
+ * @param {number} limit - New limit in bytes
154
+ */
155
+ export async function setQuotaLimit(podName, limit) {
156
+ let quota = await loadQuota(podName);
157
+
158
+ // If no quota exists, calculate current usage
159
+ if (quota.limit === 0) {
160
+ quota.used = await calculatePodSize(podName);
161
+ }
162
+
163
+ quota.limit = limit;
164
+ await saveQuota(podName, quota);
165
+ return quota;
166
+ }
167
+
168
+ /**
169
+ * Get quota info for a pod
170
+ * @param {string} podName - The pod name
171
+ * @returns {Promise<{limit: number, used: number, percent: number}>}
172
+ */
173
+ export async function getQuotaInfo(podName) {
174
+ const quota = await loadQuota(podName);
175
+ const percent = quota.limit > 0 ? Math.round((quota.used / quota.limit) * 100) : 0;
176
+ return { ...quota, percent };
177
+ }
178
+
179
+ /**
180
+ * Reconcile quota with actual disk usage
181
+ * @param {string} podName - The pod name
182
+ */
183
+ export async function reconcileQuota(podName) {
184
+ const quota = await loadQuota(podName);
185
+ if (quota.limit === 0) return quota;
186
+
187
+ const actualUsed = await calculatePodSize(podName);
188
+ quota.used = actualUsed;
189
+ await saveQuota(podName, quota);
190
+ return quota;
191
+ }
192
+
193
+ /**
194
+ * Format bytes as human-readable string
195
+ */
196
+ export function formatBytes(bytes) {
197
+ if (bytes === 0) return '0 B';
198
+ const k = 1024;
199
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
200
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
201
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
202
+ }
package/src/utils/url.js CHANGED
@@ -143,6 +143,43 @@ export function getResourceName(urlPath) {
143
143
  return parts[parts.length - 1];
144
144
  }
145
145
 
146
+ /**
147
+ * Extract pod name from URL path or request
148
+ * @param {string|object} pathOrRequest - URL path string or Fastify request object
149
+ * @returns {string|null} - Pod name or null if not found
150
+ */
151
+ export function getPodName(pathOrRequest) {
152
+ // If it's a request object
153
+ if (typeof pathOrRequest === 'object') {
154
+ // Subdomain mode: pod name from hostname
155
+ if (pathOrRequest.subdomainsEnabled && pathOrRequest.podName) {
156
+ return pathOrRequest.podName;
157
+ }
158
+ // Path mode: extract from URL
159
+ const urlPath = pathOrRequest.url?.split('?')[0] || '';
160
+ return getPodNameFromPath(urlPath);
161
+ }
162
+
163
+ // If it's a string path
164
+ return getPodNameFromPath(pathOrRequest);
165
+ }
166
+
167
+ /**
168
+ * Extract pod name from URL path
169
+ * @param {string} urlPath - URL path (e.g., /alice/public/file.txt)
170
+ * @returns {string|null} - Pod name or null
171
+ */
172
+ function getPodNameFromPath(urlPath) {
173
+ const parts = urlPath.split('/').filter(Boolean);
174
+ if (parts.length === 0) return null;
175
+
176
+ // First segment is the pod name (skip system paths)
177
+ const firstPart = parts[0];
178
+ if (firstPart.startsWith('.')) return null; // .well-known, .acl, etc.
179
+
180
+ return firstPart;
181
+ }
182
+
146
183
  /**
147
184
  * Determine content type from file extension
148
185
  * @param {string} filePath