javascript-solid-server 0.0.55 → 0.0.56

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.56)
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,7 @@ 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
32
33
  - **Security** - Blocks access to dotfiles (`.git/`, `.env`, etc.) except Solid-specific ones
33
34
 
34
35
  ### HTTP Methods
@@ -76,6 +77,7 @@ jss start --port 8443 --ssl-key ./key.pem --ssl-cert ./cert.pem
76
77
  ```bash
77
78
  jss start [options] # Start the server
78
79
  jss init [options] # Initialize configuration
80
+ jss invite <cmd> # Manage invite codes (create, list, revoke)
79
81
  jss --help # Show help
80
82
  ```
81
83
 
@@ -99,6 +101,7 @@ jss --help # Show help
99
101
  | `--mashlib-cdn` | Enable Mashlib (CDN mode) | false |
100
102
  | `--mashlib-version <ver>` | Mashlib CDN version | 2.0.0 |
101
103
  | `--git` | Enable Git HTTP backend | false |
104
+ | `--invite-only` | Require invite code for registration | false |
102
105
  | `-q, --quiet` | Suppress logs | false |
103
106
 
104
107
  ### Environment Variables
@@ -113,6 +116,7 @@ export JSS_CONNEG=true
113
116
  export JSS_SUBDOMAINS=true
114
117
  export JSS_BASE_DOMAIN=example.com
115
118
  export JSS_MASHLIB=true
119
+ export JSS_INVITE_ONLY=true
116
120
  jss start
117
121
  ```
118
122
 
@@ -379,6 +383,50 @@ git add .acl && git commit -m "Add ACL"
379
383
 
380
384
  See [git-credential-nostr](https://github.com/JavaScriptSolidServer/git-credential-nostr) for more details.
381
385
 
386
+ ## Invite-Only Registration
387
+
388
+ Control who can create accounts by requiring invite codes:
389
+
390
+ ```bash
391
+ jss start --idp --invite-only
392
+ ```
393
+
394
+ ### Managing Invite Codes
395
+
396
+ ```bash
397
+ # Create a single-use invite
398
+ jss invite create
399
+ # Created invite code: ABCD1234
400
+
401
+ # Create multi-use invite with note
402
+ jss invite create -u 5 -n "For team members"
403
+
404
+ # List all active invites
405
+ jss invite list
406
+ # CODE USES CREATED NOTE
407
+ # -------------------------------------------------------
408
+ # ABCD1234 0/1 2026-01-03
409
+ # EFGH5678 2/5 2026-01-03 For team members
410
+
411
+ # Revoke an invite
412
+ jss invite revoke ABCD1234
413
+ ```
414
+
415
+ ### How It Works
416
+
417
+ | Mode | Registration | Pod Creation |
418
+ |------|--------------|--------------|
419
+ | Open (default) | Anyone can register | Anyone can create pods |
420
+ | Invite-only | Requires valid invite code | Via registration only |
421
+
422
+ When `--invite-only` is enabled:
423
+ - The registration page shows an "Invite Code" field
424
+ - Invalid or expired codes are rejected with an error
425
+ - Each use decrements the invite's remaining uses
426
+ - Depleted invites are automatically removed
427
+
428
+ Invite codes are stored in `.server/invites.json` in your data directory.
429
+
382
430
  ## Authentication
383
431
 
384
432
  ### Simple Tokens (Development)
@@ -668,7 +716,8 @@ src/
668
716
  │ ├── accounts.js # User account management
669
717
  │ ├── keys.js # JWKS key management
670
718
  │ ├── interactions.js # Login/consent handlers
671
- └── views.js # HTML templates
719
+ ├── views.js # HTML templates
720
+ │ └── invites.js # Invite code management
672
721
  ├── rdf/
673
722
  │ ├── turtle.js # Turtle <-> JSON-LD
674
723
  │ └── conneg.js # Content negotiation
package/bin/jss.js CHANGED
@@ -11,6 +11,7 @@
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';
14
15
  import fs from 'fs-extra';
15
16
  import path from 'path';
16
17
  import { fileURLToPath } from 'url';
@@ -56,6 +57,8 @@ program
56
57
  .option('--mashlib-version <version>', 'Mashlib version for CDN mode (default: 2.0.0)')
57
58
  .option('--git', 'Enable Git HTTP backend (clone/push support)')
58
59
  .option('--no-git', 'Disable Git HTTP backend')
60
+ .option('--invite-only', 'Require invite code for registration')
61
+ .option('--no-invite-only', 'Allow open registration')
59
62
  .option('-q, --quiet', 'Suppress log output')
60
63
  .option('--print-config', 'Print configuration and exit')
61
64
  .action(async (options) => {
@@ -98,6 +101,7 @@ program
98
101
  mashlibCdn: config.mashlibCdn,
99
102
  mashlibVersion: config.mashlibVersion,
100
103
  git: config.git,
104
+ inviteOnly: config.inviteOnly,
101
105
  });
102
106
 
103
107
  await server.listen({ port: config.port, host: config.host });
@@ -117,6 +121,7 @@ program
117
121
  console.log(` Mashlib: local (data browser enabled)`);
118
122
  }
119
123
  if (config.git) console.log(' Git: enabled (clone/push support)');
124
+ if (config.inviteOnly) console.log(' Registration: invite-only');
120
125
  console.log('\n Press Ctrl+C to stop\n');
121
126
  }
122
127
 
@@ -204,6 +209,104 @@ program
204
209
  console.log('\nRun `jss start` to start the server.\n');
205
210
  });
206
211
 
212
+ /**
213
+ * Invite command - manage invite codes
214
+ */
215
+ const inviteCmd = program
216
+ .command('invite')
217
+ .description('Manage invite codes for registration');
218
+
219
+ inviteCmd
220
+ .command('create')
221
+ .description('Create a new invite code')
222
+ .option('-u, --uses <number>', 'Maximum uses (default: 1)', parseInt, 1)
223
+ .option('-n, --note <text>', 'Optional note/description')
224
+ .option('-r, --root <path>', 'Data directory')
225
+ .action(async (options) => {
226
+ try {
227
+ // Set DATA_ROOT if provided
228
+ if (options.root) {
229
+ process.env.DATA_ROOT = path.resolve(options.root);
230
+ }
231
+
232
+ const { code, invite } = await createInvite({
233
+ maxUses: options.uses,
234
+ note: options.note || ''
235
+ });
236
+
237
+ console.log(`\nCreated invite code: ${code}`);
238
+ if (invite.maxUses > 1) {
239
+ console.log(`Uses: 0/${invite.maxUses}`);
240
+ }
241
+ if (invite.note) {
242
+ console.log(`Note: ${invite.note}`);
243
+ }
244
+ console.log('');
245
+ } catch (err) {
246
+ console.error(`Error: ${err.message}`);
247
+ process.exit(1);
248
+ }
249
+ });
250
+
251
+ inviteCmd
252
+ .command('list')
253
+ .description('List all invite codes')
254
+ .option('-r, --root <path>', 'Data directory')
255
+ .action(async (options) => {
256
+ try {
257
+ // Set DATA_ROOT if provided
258
+ if (options.root) {
259
+ process.env.DATA_ROOT = path.resolve(options.root);
260
+ }
261
+
262
+ const invites = await listInvites();
263
+
264
+ if (invites.length === 0) {
265
+ console.log('\nNo invite codes found.\n');
266
+ return;
267
+ }
268
+
269
+ console.log('\n CODE USES CREATED NOTE');
270
+ console.log(' ' + '-'.repeat(55));
271
+
272
+ for (const invite of invites) {
273
+ const uses = `${invite.uses}/${invite.maxUses}`.padEnd(8);
274
+ const created = invite.created.split('T')[0];
275
+ const note = invite.note || '';
276
+ console.log(` ${invite.code} ${uses} ${created} ${note}`);
277
+ }
278
+ console.log('');
279
+ } catch (err) {
280
+ console.error(`Error: ${err.message}`);
281
+ process.exit(1);
282
+ }
283
+ });
284
+
285
+ inviteCmd
286
+ .command('revoke <code>')
287
+ .description('Revoke an invite code')
288
+ .option('-r, --root <path>', 'Data directory')
289
+ .action(async (code, options) => {
290
+ try {
291
+ // Set DATA_ROOT if provided
292
+ if (options.root) {
293
+ process.env.DATA_ROOT = path.resolve(options.root);
294
+ }
295
+
296
+ const success = await revokeInvite(code);
297
+
298
+ if (success) {
299
+ console.log(`\nRevoked invite code: ${code.toUpperCase()}\n`);
300
+ } else {
301
+ console.log(`\nInvite code not found: ${code.toUpperCase()}\n`);
302
+ process.exit(1);
303
+ }
304
+ } catch (err) {
305
+ console.error(`Error: ${err.message}`);
306
+ process.exit(1);
307
+ }
308
+ });
309
+
207
310
  /**
208
311
  * Helper: Prompt for input
209
312
  */
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.56",
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,12 @@ 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
+
45
51
  // Logging
46
52
  logger: true,
47
53
  quiet: false,
@@ -71,6 +77,8 @@ const envMap = {
71
77
  JSS_MASHLIB: 'mashlib',
72
78
  JSS_MASHLIB_CDN: 'mashlibCdn',
73
79
  JSS_MASHLIB_VERSION: 'mashlibVersion',
80
+ JSS_GIT: 'git',
81
+ JSS_INVITE_ONLY: 'inviteOnly',
74
82
  };
75
83
 
76
84
  /**
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,8 @@ 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;
49
51
 
50
52
  // Set data root via environment variable if provided
51
53
  if (options.root) {
@@ -125,7 +127,7 @@ export function createServer(options = {}) {
125
127
 
126
128
  // Register Identity Provider plugin if enabled
127
129
  if (idpEnabled) {
128
- fastify.register(idpPlugin, { issuer: idpIssuer });
130
+ fastify.register(idpPlugin, { issuer: idpIssuer, inviteOnly });
129
131
  }
130
132
 
131
133
  // Register rate limiting plugin