javascript-solid-server 0.0.55 → 0.0.57

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.
@@ -202,7 +202,12 @@
202
202
  "Bash(webledgers show:*)",
203
203
  "Bash(webledgers set-balance:*)",
204
204
  "Bash(ssh melvincarvalho.com \"pm2 list | grep jss\")",
205
- "Bash(ssh melvincarvalho.com \"cd /home/ubuntu/jss && git pull && pm2 restart jss\")"
205
+ "Bash(ssh melvincarvalho.com \"cd /home/ubuntu/jss && git pull && pm2 restart jss\")",
206
+ "WebFetch(domain:registry.npmjs.org)",
207
+ "WebFetch(domain:solid-chat.com)",
208
+ "WebFetch(domain:developer.chrome.com)",
209
+ "WebFetch(domain:css-tricks.com)",
210
+ "Bash(node bin/jss.js:*)"
206
211
  ]
207
212
  }
208
213
  }
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.42)
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
@@ -29,6 +29,8 @@ A minimal, fast, JSON-LD native Solid server.
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
+ - **Invite-Only Registration** - CLI-managed invite codes for controlled signups
33
+ - **Storage Quotas** - Per-user storage limits with CLI management
32
34
  - **Security** - Blocks access to dotfiles (`.git/`, `.env`, etc.) except Solid-specific ones
33
35
 
34
36
  ### HTTP Methods
@@ -76,6 +78,8 @@ jss start --port 8443 --ssl-key ./key.pem --ssl-cert ./cert.pem
76
78
  ```bash
77
79
  jss start [options] # Start the server
78
80
  jss init [options] # Initialize configuration
81
+ jss invite <cmd> # Manage invite codes (create, list, revoke)
82
+ jss quota <cmd> # Manage storage quotas (set, show, reconcile)
79
83
  jss --help # Show help
80
84
  ```
81
85
 
@@ -99,6 +103,8 @@ jss --help # Show help
99
103
  | `--mashlib-cdn` | Enable Mashlib (CDN mode) | false |
100
104
  | `--mashlib-version <ver>` | Mashlib CDN version | 2.0.0 |
101
105
  | `--git` | Enable Git HTTP backend | false |
106
+ | `--invite-only` | Require invite code for registration | false |
107
+ | `--default-quota <size>` | Default storage quota per pod (e.g., 50MB) | 50MB |
102
108
  | `-q, --quiet` | Suppress logs | false |
103
109
 
104
110
  ### Environment Variables
@@ -113,6 +119,8 @@ export JSS_CONNEG=true
113
119
  export JSS_SUBDOMAINS=true
114
120
  export JSS_BASE_DOMAIN=example.com
115
121
  export JSS_MASHLIB=true
122
+ export JSS_INVITE_ONLY=true
123
+ export JSS_DEFAULT_QUOTA=100MB
116
124
  jss start
117
125
  ```
118
126
 
@@ -379,6 +387,87 @@ git add .acl && git commit -m "Add ACL"
379
387
 
380
388
  See [git-credential-nostr](https://github.com/JavaScriptSolidServer/git-credential-nostr) for more details.
381
389
 
390
+ ## Invite-Only Registration
391
+
392
+ Control who can create accounts by requiring invite codes:
393
+
394
+ ```bash
395
+ jss start --idp --invite-only
396
+ ```
397
+
398
+ ### Managing Invite Codes
399
+
400
+ ```bash
401
+ # Create a single-use invite
402
+ jss invite create
403
+ # Created invite code: ABCD1234
404
+
405
+ # Create multi-use invite with note
406
+ jss invite create -u 5 -n "For team members"
407
+
408
+ # List all active invites
409
+ jss invite list
410
+ # CODE USES CREATED NOTE
411
+ # -------------------------------------------------------
412
+ # ABCD1234 0/1 2026-01-03
413
+ # EFGH5678 2/5 2026-01-03 For team members
414
+
415
+ # Revoke an invite
416
+ jss invite revoke ABCD1234
417
+ ```
418
+
419
+ ### How It Works
420
+
421
+ | Mode | Registration | Pod Creation |
422
+ |------|--------------|--------------|
423
+ | Open (default) | Anyone can register | Anyone can create pods |
424
+ | Invite-only | Requires valid invite code | Via registration only |
425
+
426
+ When `--invite-only` is enabled:
427
+ - The registration page shows an "Invite Code" field
428
+ - Invalid or expired codes are rejected with an error
429
+ - Each use decrements the invite's remaining uses
430
+ - Depleted invites are automatically removed
431
+
432
+ Invite codes are stored in `.server/invites.json` in your data directory.
433
+
434
+ ## Storage Quotas
435
+
436
+ Limit storage per pod to prevent abuse and manage resources:
437
+
438
+ ```bash
439
+ jss start --default-quota 50MB
440
+ ```
441
+
442
+ ### Managing Quotas
443
+
444
+ ```bash
445
+ # Set quota for a user (overrides default)
446
+ jss quota set alice 100MB
447
+
448
+ # Show quota info
449
+ jss quota show alice
450
+ # alice:
451
+ # Used: 12.5 MB
452
+ # Limit: 100 MB
453
+ # Free: 87.5 MB
454
+ # Usage: 12%
455
+
456
+ # Recalculate from actual disk usage
457
+ jss quota reconcile alice
458
+ ```
459
+
460
+ ### How It Works
461
+
462
+ - Quotas are tracked incrementally on PUT, POST, and DELETE operations
463
+ - When quota is exceeded, the server returns HTTP 507 Insufficient Storage
464
+ - Each pod stores its quota in `/{pod}/.quota.json`
465
+ - Use `reconcile` to fix quota drift from manual file changes
466
+
467
+ ### Size Formats
468
+
469
+ Supported formats: `50MB`, `1GB`, `500KB`, `1TB`
470
+
382
471
  ## Authentication
383
472
 
384
473
  ### Simple Tokens (Development)
@@ -640,7 +729,8 @@ src/
640
729
  │ ├── container.js # POST, pod creation
641
730
  │ └── git.js # Git HTTP backend
642
731
  ├── storage/
643
- └── filesystem.js # File operations
732
+ ├── filesystem.js # File operations
733
+ │ └── quota.js # Storage quota management
644
734
  ├── auth/
645
735
  │ ├── middleware.js # Auth hook
646
736
  │ ├── token.js # Simple token auth
@@ -668,7 +758,8 @@ src/
668
758
  │ ├── accounts.js # User account management
669
759
  │ ├── keys.js # JWKS key management
670
760
  │ ├── interactions.js # Login/consent handlers
671
- └── views.js # HTML templates
761
+ ├── views.js # HTML templates
762
+ │ └── invites.js # Invite code management
672
763
  ├── rdf/
673
764
  │ ├── turtle.js # Turtle <-> JSON-LD
674
765
  │ └── conneg.js # Content negotiation
package/bin/jss.js CHANGED
@@ -11,6 +11,9 @@
11
11
  import { Command } from 'commander';
12
12
  import { createServer } from '../src/server.js';
13
13
  import { loadConfig, saveConfig, printConfig, defaults } from '../src/config.js';
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';
14
17
  import fs from 'fs-extra';
15
18
  import path from 'path';
16
19
  import { fileURLToPath } from 'url';
@@ -56,6 +59,8 @@ program
56
59
  .option('--mashlib-version <version>', 'Mashlib version for CDN mode (default: 2.0.0)')
57
60
  .option('--git', 'Enable Git HTTP backend (clone/push support)')
58
61
  .option('--no-git', 'Disable Git HTTP backend')
62
+ .option('--invite-only', 'Require invite code for registration')
63
+ .option('--no-invite-only', 'Allow open registration')
59
64
  .option('-q, --quiet', 'Suppress log output')
60
65
  .option('--print-config', 'Print configuration and exit')
61
66
  .action(async (options) => {
@@ -98,6 +103,7 @@ program
98
103
  mashlibCdn: config.mashlibCdn,
99
104
  mashlibVersion: config.mashlibVersion,
100
105
  git: config.git,
106
+ inviteOnly: config.inviteOnly,
101
107
  });
102
108
 
103
109
  await server.listen({ port: config.port, host: config.host });
@@ -117,6 +123,7 @@ program
117
123
  console.log(` Mashlib: local (data browser enabled)`);
118
124
  }
119
125
  if (config.git) console.log(' Git: enabled (clone/push support)');
126
+ if (config.inviteOnly) console.log(' Registration: invite-only');
120
127
  console.log('\n Press Ctrl+C to stop\n');
121
128
  }
122
129
 
@@ -204,6 +211,190 @@ program
204
211
  console.log('\nRun `jss start` to start the server.\n');
205
212
  });
206
213
 
214
+ /**
215
+ * Invite command - manage invite codes
216
+ */
217
+ const inviteCmd = program
218
+ .command('invite')
219
+ .description('Manage invite codes for registration');
220
+
221
+ inviteCmd
222
+ .command('create')
223
+ .description('Create a new invite code')
224
+ .option('-u, --uses <number>', 'Maximum uses (default: 1)', parseInt, 1)
225
+ .option('-n, --note <text>', 'Optional note/description')
226
+ .option('-r, --root <path>', 'Data directory')
227
+ .action(async (options) => {
228
+ try {
229
+ // Set DATA_ROOT if provided
230
+ if (options.root) {
231
+ process.env.DATA_ROOT = path.resolve(options.root);
232
+ }
233
+
234
+ const { code, invite } = await createInvite({
235
+ maxUses: options.uses,
236
+ note: options.note || ''
237
+ });
238
+
239
+ console.log(`\nCreated invite code: ${code}`);
240
+ if (invite.maxUses > 1) {
241
+ console.log(`Uses: 0/${invite.maxUses}`);
242
+ }
243
+ if (invite.note) {
244
+ console.log(`Note: ${invite.note}`);
245
+ }
246
+ console.log('');
247
+ } catch (err) {
248
+ console.error(`Error: ${err.message}`);
249
+ process.exit(1);
250
+ }
251
+ });
252
+
253
+ inviteCmd
254
+ .command('list')
255
+ .description('List all invite codes')
256
+ .option('-r, --root <path>', 'Data directory')
257
+ .action(async (options) => {
258
+ try {
259
+ // Set DATA_ROOT if provided
260
+ if (options.root) {
261
+ process.env.DATA_ROOT = path.resolve(options.root);
262
+ }
263
+
264
+ const invites = await listInvites();
265
+
266
+ if (invites.length === 0) {
267
+ console.log('\nNo invite codes found.\n');
268
+ return;
269
+ }
270
+
271
+ console.log('\n CODE USES CREATED NOTE');
272
+ console.log(' ' + '-'.repeat(55));
273
+
274
+ for (const invite of invites) {
275
+ const uses = `${invite.uses}/${invite.maxUses}`.padEnd(8);
276
+ const created = invite.created.split('T')[0];
277
+ const note = invite.note || '';
278
+ console.log(` ${invite.code} ${uses} ${created} ${note}`);
279
+ }
280
+ console.log('');
281
+ } catch (err) {
282
+ console.error(`Error: ${err.message}`);
283
+ process.exit(1);
284
+ }
285
+ });
286
+
287
+ inviteCmd
288
+ .command('revoke <code>')
289
+ .description('Revoke an invite code')
290
+ .option('-r, --root <path>', 'Data directory')
291
+ .action(async (code, options) => {
292
+ try {
293
+ // Set DATA_ROOT if provided
294
+ if (options.root) {
295
+ process.env.DATA_ROOT = path.resolve(options.root);
296
+ }
297
+
298
+ const success = await revokeInvite(code);
299
+
300
+ if (success) {
301
+ console.log(`\nRevoked invite code: ${code.toUpperCase()}\n`);
302
+ } else {
303
+ console.log(`\nInvite code not found: ${code.toUpperCase()}\n`);
304
+ process.exit(1);
305
+ }
306
+ } catch (err) {
307
+ console.error(`Error: ${err.message}`);
308
+ process.exit(1);
309
+ }
310
+ });
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
+
207
398
  /**
208
399
  * Helper: Prompt for input
209
400
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.55",
3
+ "version": "0.0.57",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/config.js CHANGED
@@ -42,6 +42,15 @@ export const defaults = {
42
42
  mashlibCdn: false,
43
43
  mashlibVersion: '2.0.0',
44
44
 
45
+ // Git HTTP backend
46
+ git: false,
47
+
48
+ // Invite-only registration
49
+ inviteOnly: false,
50
+
51
+ // Storage quota (bytes) - 50MB default
52
+ defaultQuota: 50 * 1024 * 1024,
53
+
45
54
  // Logging
46
55
  logger: true,
47
56
  quiet: false,
@@ -71,8 +80,24 @@ const envMap = {
71
80
  JSS_MASHLIB: 'mashlib',
72
81
  JSS_MASHLIB_CDN: 'mashlibCdn',
73
82
  JSS_MASHLIB_VERSION: 'mashlibVersion',
83
+ JSS_GIT: 'git',
84
+ JSS_INVITE_ONLY: 'inviteOnly',
85
+ JSS_DEFAULT_QUOTA: 'defaultQuota',
74
86
  };
75
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
+
76
101
  /**
77
102
  * Parse a value from environment variable string
78
103
  */
@@ -88,6 +113,11 @@ function parseEnvValue(value, key) {
88
113
  return parseInt(value, 10);
89
114
  }
90
115
 
116
+ // Size values (quota)
117
+ if (key === 'defaultQuota') {
118
+ return parseSize(value);
119
+ }
120
+
91
121
  return value;
92
122
  }
93
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/idp/index.js CHANGED
@@ -27,7 +27,7 @@ import { addTrustedIssuer } from '../auth/solid-oidc.js';
27
27
  * @param {string} options.issuer - The issuer URL
28
28
  */
29
29
  export async function idpPlugin(fastify, options) {
30
- const { issuer } = options;
30
+ const { issuer, inviteOnly = false } = options;
31
31
 
32
32
  if (!issuer) {
33
33
  throw new Error('IdP requires issuer URL');
@@ -274,7 +274,7 @@ export async function idpPlugin(fastify, options) {
274
274
 
275
275
  // Registration routes
276
276
  fastify.get('/idp/register', async (request, reply) => {
277
- return handleRegisterGet(request, reply);
277
+ return handleRegisterGet(request, reply, inviteOnly);
278
278
  });
279
279
 
280
280
  // Registration - rate limited to prevent spam accounts
@@ -287,7 +287,7 @@ export async function idpPlugin(fastify, options) {
287
287
  }
288
288
  }
289
289
  }, async (request, reply) => {
290
- return handleRegisterPost(request, reply, issuer);
290
+ return handleRegisterPost(request, reply, issuer, inviteOnly);
291
291
  });
292
292
 
293
293
  fastify.log.info(`IdP initialized with issuer: ${issuer}`);
@@ -7,6 +7,7 @@ import { authenticate, findById, createAccount } from './accounts.js';
7
7
  import { loginPage, consentPage, errorPage, registerPage } from './views.js';
8
8
  import * as storage from '../storage/filesystem.js';
9
9
  import { createPodStructure } from '../handlers/container.js';
10
+ import { validateInvite } from './invites.js';
10
11
 
11
12
  // Security: Maximum body size for IdP form submissions (1MB)
12
13
  const MAX_BODY_SIZE = 1024 * 1024;
@@ -309,16 +310,16 @@ export async function handleAbort(request, reply, provider) {
309
310
  * Handle GET /idp/register
310
311
  * Shows registration page
311
312
  */
312
- export async function handleRegisterGet(request, reply) {
313
+ export async function handleRegisterGet(request, reply, inviteOnly = false) {
313
314
  const uid = request.query.uid || null;
314
- return reply.type('text/html').send(registerPage(uid));
315
+ return reply.type('text/html').send(registerPage(uid, null, null, inviteOnly));
315
316
  }
316
317
 
317
318
  /**
318
319
  * Handle POST /idp/register
319
320
  * Creates account and pod
320
321
  */
321
- export async function handleRegisterPost(request, reply, issuer) {
322
+ export async function handleRegisterPost(request, reply, issuer, inviteOnly = false) {
322
323
  const uid = request.query.uid || null;
323
324
 
324
325
  // Parse body
@@ -328,7 +329,7 @@ export async function handleRegisterPost(request, reply, issuer) {
328
329
  if (Buffer.isBuffer(parsedBody)) {
329
330
  // Security: check body size
330
331
  if (parsedBody.length > MAX_BODY_SIZE) {
331
- return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.'));
332
+ return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly));
332
333
  }
333
334
  const bodyStr = parsedBody.toString();
334
335
  if (contentType.includes('application/json')) {
@@ -344,32 +345,39 @@ export async function handleRegisterPost(request, reply, issuer) {
344
345
  } else if (typeof parsedBody === 'string') {
345
346
  // Security: check body size
346
347
  if (parsedBody.length > MAX_BODY_SIZE) {
347
- return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.'));
348
+ return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly));
348
349
  }
349
350
  const params = new URLSearchParams(parsedBody);
350
351
  parsedBody = Object.fromEntries(params.entries());
351
352
  }
352
353
 
353
- const { username, password, confirmPassword } = parsedBody;
354
+ const { username, password, confirmPassword, invite } = parsedBody;
355
+
356
+ // Validate invite code if invite-only mode is enabled
357
+ if (inviteOnly) {
358
+ const inviteResult = await validateInvite(invite);
359
+ if (!inviteResult.valid) {
360
+ return reply.code(403).type('text/html').send(registerPage(uid, inviteResult.error, null, inviteOnly));
361
+ }
362
+ }
354
363
 
355
364
  // Validate input
356
365
  if (!username || !password) {
357
- return reply.type('text/html').send(registerPage(uid, 'Username and password are required'));
366
+ return reply.type('text/html').send(registerPage(uid, 'Username and password are required', null, inviteOnly));
358
367
  }
359
368
 
360
369
  // Validate username format
361
370
  const usernameRegex = /^[a-z0-9]+$/;
362
371
  if (!usernameRegex.test(username)) {
363
- return reply.type('text/html').send(registerPage(uid, 'Username must contain only lowercase letters and numbers'));
372
+ return reply.type('text/html').send(registerPage(uid, 'Username must contain only lowercase letters and numbers', null, inviteOnly));
364
373
  }
365
374
 
366
375
  if (username.length < 3) {
367
- return reply.type('text/html').send(registerPage(uid, 'Username must be at least 3 characters'));
376
+ return reply.type('text/html').send(registerPage(uid, 'Username must be at least 3 characters', null, inviteOnly));
368
377
  }
369
378
 
370
-
371
379
  if (password !== confirmPassword) {
372
- return reply.type('text/html').send(registerPage(uid, 'Passwords do not match'));
380
+ return reply.type('text/html').send(registerPage(uid, 'Passwords do not match', null, inviteOnly));
373
381
  }
374
382
 
375
383
  try {
@@ -393,7 +401,7 @@ export async function handleRegisterPost(request, reply, issuer) {
393
401
  const podPath = `${username}/`;
394
402
  const podExists = await storage.exists(podPath);
395
403
  if (podExists) {
396
- return reply.type('text/html').send(registerPage(uid, 'Username is already taken'));
404
+ return reply.type('text/html').send(registerPage(uid, 'Username is already taken', null, inviteOnly));
397
405
  }
398
406
 
399
407
  // Create pod structure
@@ -413,10 +421,10 @@ export async function handleRegisterPost(request, reply, issuer) {
413
421
  if (uid) {
414
422
  return reply.redirect(`/idp/interaction/${uid}`);
415
423
  } else {
416
- return reply.type('text/html').send(registerPage(null, null, `Account created! You can now sign in as "${username}".`));
424
+ return reply.type('text/html').send(registerPage(null, null, `Account created! You can now sign in as "${username}".`, inviteOnly));
417
425
  }
418
426
  } catch (err) {
419
427
  request.log.error(err, 'Registration error');
420
- return reply.type('text/html').send(registerPage(uid, err.message));
428
+ return reply.type('text/html').send(registerPage(uid, err.message, null, inviteOnly));
421
429
  }
422
430
  }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Invite code management for invite-only registration
3
+ * Stores codes in /data/.server/invites.json
4
+ */
5
+
6
+ import { promises as fs } from 'fs';
7
+ import { join } from 'path';
8
+ import crypto from 'crypto';
9
+ import { getDataRoot } from '../utils/url.js';
10
+
11
+ const INVITES_DIR = '.server';
12
+ const INVITES_FILE = 'invites.json';
13
+
14
+ /**
15
+ * Get path to invites file
16
+ */
17
+ function getInvitesPath() {
18
+ return join(getDataRoot(), INVITES_DIR, INVITES_FILE);
19
+ }
20
+
21
+ /**
22
+ * Ensure .server directory exists
23
+ */
24
+ async function ensureServerDir() {
25
+ const dirPath = join(getDataRoot(), INVITES_DIR);
26
+ try {
27
+ await fs.mkdir(dirPath, { recursive: true });
28
+ } catch (err) {
29
+ if (err.code !== 'EEXIST') throw err;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Load all invites from storage
35
+ * @returns {Promise<object>} Map of code -> invite data
36
+ */
37
+ export async function loadInvites() {
38
+ try {
39
+ const data = await fs.readFile(getInvitesPath(), 'utf-8');
40
+ return JSON.parse(data);
41
+ } catch (err) {
42
+ if (err.code === 'ENOENT') {
43
+ return {};
44
+ }
45
+ throw err;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Save invites to storage
51
+ * @param {object} invites - Map of code -> invite data
52
+ */
53
+ async function saveInvites(invites) {
54
+ await ensureServerDir();
55
+ await fs.writeFile(getInvitesPath(), JSON.stringify(invites, null, 2));
56
+ }
57
+
58
+ /**
59
+ * Generate a random invite code
60
+ * @returns {string} 8-character uppercase alphanumeric code
61
+ */
62
+ function generateCode() {
63
+ const bytes = crypto.randomBytes(6);
64
+ // Base64 encode and take first 8 chars, uppercase, remove ambiguous chars
65
+ return bytes.toString('base64')
66
+ .replace(/[+/=]/g, '')
67
+ .substring(0, 8)
68
+ .toUpperCase()
69
+ .replace(/O/g, '0')
70
+ .replace(/I/g, '1')
71
+ .replace(/L/g, '1');
72
+ }
73
+
74
+ /**
75
+ * Create a new invite code
76
+ * @param {object} options
77
+ * @param {number} options.maxUses - Maximum number of uses (default 1)
78
+ * @param {string} options.note - Optional note/description
79
+ * @returns {Promise<{code: string, invite: object}>}
80
+ */
81
+ export async function createInvite({ maxUses = 1, note = '' } = {}) {
82
+ const invites = await loadInvites();
83
+
84
+ // Generate unique code
85
+ let code;
86
+ do {
87
+ code = generateCode();
88
+ } while (invites[code]);
89
+
90
+ const invite = {
91
+ created: new Date().toISOString(),
92
+ maxUses,
93
+ uses: 0,
94
+ note
95
+ };
96
+
97
+ invites[code] = invite;
98
+ await saveInvites(invites);
99
+
100
+ return { code, invite };
101
+ }
102
+
103
+ /**
104
+ * List all invite codes
105
+ * @returns {Promise<Array<{code: string, ...invite}>>}
106
+ */
107
+ export async function listInvites() {
108
+ const invites = await loadInvites();
109
+ return Object.entries(invites).map(([code, invite]) => ({
110
+ code,
111
+ ...invite
112
+ }));
113
+ }
114
+
115
+ /**
116
+ * Revoke (delete) an invite code
117
+ * @param {string} code - The invite code to revoke
118
+ * @returns {Promise<boolean>} True if code existed and was deleted
119
+ */
120
+ export async function revokeInvite(code) {
121
+ const invites = await loadInvites();
122
+ const upperCode = code.toUpperCase();
123
+
124
+ if (!invites[upperCode]) {
125
+ return false;
126
+ }
127
+
128
+ delete invites[upperCode];
129
+ await saveInvites(invites);
130
+ return true;
131
+ }
132
+
133
+ /**
134
+ * Validate and consume an invite code
135
+ * @param {string} code - The invite code to validate
136
+ * @returns {Promise<{valid: boolean, error?: string}>}
137
+ */
138
+ export async function validateInvite(code) {
139
+ if (!code || typeof code !== 'string') {
140
+ return { valid: false, error: 'Invite code required' };
141
+ }
142
+
143
+ const invites = await loadInvites();
144
+ const upperCode = code.toUpperCase().trim();
145
+ const invite = invites[upperCode];
146
+
147
+ if (!invite) {
148
+ return { valid: false, error: 'Invalid invite code' };
149
+ }
150
+
151
+ if (invite.uses >= invite.maxUses) {
152
+ return { valid: false, error: 'Invite code has been fully used' };
153
+ }
154
+
155
+ // Consume one use
156
+ invite.uses += 1;
157
+ await saveInvites(invites);
158
+
159
+ return { valid: true };
160
+ }
161
+
162
+ /**
163
+ * Check if an invite code is valid without consuming it
164
+ * @param {string} code - The invite code to check
165
+ * @returns {Promise<boolean>}
166
+ */
167
+ export async function isValidInvite(code) {
168
+ if (!code || typeof code !== 'string') {
169
+ return false;
170
+ }
171
+
172
+ const invites = await loadInvites();
173
+ const upperCode = code.toUpperCase().trim();
174
+ const invite = invites[upperCode];
175
+
176
+ if (!invite) {
177
+ return false;
178
+ }
179
+
180
+ return invite.uses < invite.maxUses;
181
+ }
package/src/idp/views.js CHANGED
@@ -296,7 +296,13 @@ export function errorPage(title, message) {
296
296
  /**
297
297
  * Registration page HTML
298
298
  */
299
- export function registerPage(uid = null, error = null, success = null) {
299
+ export function registerPage(uid = null, error = null, success = null, inviteOnly = false) {
300
+ const inviteField = inviteOnly ? `
301
+ <label for="invite">Invite Code</label>
302
+ <input type="text" id="invite" name="invite" required
303
+ placeholder="Enter your invite code" style="text-transform: uppercase;">
304
+ ` : '';
305
+
300
306
  return `
301
307
  <!DOCTYPE html>
302
308
  <html lang="en">
@@ -310,14 +316,16 @@ export function registerPage(uid = null, error = null, success = null) {
310
316
  <div class="container">
311
317
  <div class="logo">${solidLogo}</div>
312
318
  <h1>Create Account</h1>
313
- <p class="subtitle">Register for a new Solid Pod</p>
319
+ <p class="subtitle">Register for a new Solid Pod${inviteOnly ? ' (invite required)' : ''}</p>
314
320
 
315
321
  ${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
316
322
  ${success ? `<div class="error" style="background: #efe; border-color: #cfc; color: #060;">${escapeHtml(success)}</div>` : ''}
317
323
 
318
324
  <form method="POST" action="/idp/register${uid ? `?uid=${uid}` : ''}">
325
+ ${inviteField}
326
+
319
327
  <label for="username">Username</label>
320
- <input type="text" id="username" name="username" required autofocus
328
+ <input type="text" id="username" name="username" required ${!inviteOnly ? 'autofocus' : ''}
321
329
  placeholder="Choose a username" pattern="[a-z0-9]+"
322
330
  title="Lowercase letters and numbers only">
323
331
 
package/src/server.js CHANGED
@@ -46,6 +46,10 @@ export function createServer(options = {}) {
46
46
  const mashlibVersion = options.mashlibVersion ?? '2.0.0';
47
47
  // Git HTTP backend is OFF by default - enables clone/push via git protocol
48
48
  const gitEnabled = options.git ?? false;
49
+ // Invite-only registration is OFF by default - open registration
50
+ const inviteOnly = options.inviteOnly ?? false;
51
+ // Default storage quota per pod (50MB default, 0 = unlimited)
52
+ const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024;
49
53
 
50
54
  // Set data root via environment variable if provided
51
55
  if (options.root) {
@@ -93,6 +97,7 @@ export function createServer(options = {}) {
93
97
  fastify.decorateRequest('mashlibEnabled', null);
94
98
  fastify.decorateRequest('mashlibCdn', null);
95
99
  fastify.decorateRequest('mashlibVersion', null);
100
+ fastify.decorateRequest('defaultQuota', null);
96
101
  fastify.addHook('onRequest', async (request) => {
97
102
  request.connegEnabled = connegEnabled;
98
103
  request.notificationsEnabled = notificationsEnabled;
@@ -102,6 +107,7 @@ export function createServer(options = {}) {
102
107
  request.mashlibEnabled = mashlibEnabled;
103
108
  request.mashlibCdn = mashlibCdn;
104
109
  request.mashlibVersion = mashlibVersion;
110
+ request.defaultQuota = defaultQuota;
105
111
 
106
112
  // Extract pod name from subdomain if enabled
107
113
  if (subdomainsEnabled && baseDomain) {
@@ -125,7 +131,7 @@ export function createServer(options = {}) {
125
131
 
126
132
  // Register Identity Provider plugin if enabled
127
133
  if (idpEnabled) {
128
- fastify.register(idpPlugin, { issuer: idpIssuer });
134
+ fastify.register(idpPlugin, { issuer: idpIssuer, inviteOnly });
129
135
  }
130
136
 
131
137
  // Register rate limiting plugin
@@ -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