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.
- package/.claude/settings.local.json +6 -1
- package/README.md +51 -2
- package/bin/jss.js +103 -0
- package/package.json +1 -1
- package/src/config.js +8 -0
- package/src/idp/index.js +3 -3
- package/src/idp/interactions.js +22 -14
- package/src/idp/invites.js +181 -0
- package/src/idp/views.js +11 -3
- package/src/server.js +3 -1
|
@@ -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.
|
|
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
|
-
│
|
|
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
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}`);
|
package/src/idp/interactions.js
CHANGED
|
@@ -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
|