javascript-solid-server 0.0.139 → 0.0.140

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.139",
3
+ "version": "0.0.140",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/idp/index.js CHANGED
@@ -296,7 +296,7 @@ export async function idpPlugin(fastify, options) {
296
296
  });
297
297
  } else {
298
298
  fastify.get('/idp/register', async (request, reply) => {
299
- return handleRegisterGet(request, reply, inviteOnly);
299
+ return handleRegisterGet(request, reply, issuer, inviteOnly);
300
300
  });
301
301
 
302
302
  // Registration - rate limited to prevent spam accounts
@@ -329,9 +329,21 @@ export async function handleAbort(request, reply, provider) {
329
329
  * Handle GET /idp/register
330
330
  * Shows registration page
331
331
  */
332
- export async function handleRegisterGet(request, reply, inviteOnly = false) {
332
+ export async function handleRegisterGet(request, reply, issuer, inviteOnly = false) {
333
333
  const uid = request.query.uid || null;
334
- return reply.type('text/html').send(registerPage(uid, null, null, inviteOnly));
334
+ const ctx = previewContext(request, issuer);
335
+ return reply.type('text/html').send(registerPage(uid, null, null, inviteOnly, ctx));
336
+ }
337
+
338
+ // Live-preview context for the register page: lets the client-side script
339
+ // build the WebID + storage URL the user is about to claim, before submit.
340
+ function previewContext(request, issuer) {
341
+ const baseUri = (issuer || `${request.protocol}://${request.hostname}`).replace(/\/$/, '');
342
+ return {
343
+ baseUri,
344
+ subdomainsEnabled: !!request.subdomainsEnabled,
345
+ baseDomain: request.baseDomain || null,
346
+ };
335
347
  }
336
348
 
337
349
  /**
@@ -340,6 +352,7 @@ export async function handleRegisterGet(request, reply, inviteOnly = false) {
340
352
  */
341
353
  export async function handleRegisterPost(request, reply, issuer, inviteOnly = false) {
342
354
  const uid = request.query.uid || null;
355
+ const ctx = previewContext(request, issuer);
343
356
 
344
357
  // Parse body
345
358
  let parsedBody = request.body || {};
@@ -348,7 +361,7 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
348
361
  if (Buffer.isBuffer(parsedBody)) {
349
362
  // Security: check body size
350
363
  if (parsedBody.length > MAX_BODY_SIZE) {
351
- return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly));
364
+ return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly, ctx));
352
365
  }
353
366
  const bodyStr = parsedBody.toString();
354
367
  if (contentType.includes('application/json')) {
@@ -364,7 +377,7 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
364
377
  } else if (typeof parsedBody === 'string') {
365
378
  // Security: check body size
366
379
  if (parsedBody.length > MAX_BODY_SIZE) {
367
- return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly));
380
+ return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly, ctx));
368
381
  }
369
382
  const params = new URLSearchParams(parsedBody);
370
383
  parsedBody = Object.fromEntries(params.entries());
@@ -376,32 +389,50 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
376
389
  if (inviteOnly) {
377
390
  const inviteResult = await validateInvite(invite);
378
391
  if (!inviteResult.valid) {
379
- return reply.code(403).type('text/html').send(registerPage(uid, inviteResult.error, null, inviteOnly));
392
+ return reply.code(403).type('text/html').send(registerPage(uid, inviteResult.error, null, inviteOnly, ctx));
380
393
  }
381
394
  }
382
395
 
383
396
  // Validate input
384
397
  if (!username || !password) {
385
- return reply.type('text/html').send(registerPage(uid, 'Username and password are required', null, inviteOnly));
398
+ return reply.type('text/html').send(registerPage(uid, 'Username and password are required', null, inviteOnly, ctx));
386
399
  }
387
400
 
388
- // Validate username format
389
- const usernameRegex = /^[a-z0-9]+$/;
401
+ // Validate username format. Must start and end alphanumeric; the middle
402
+ // can contain dot, dash, underscore — covers `alice-smith`, `alice.smith`,
403
+ // `alice_work`, and so on. No leading/trailing separators (avoids the
404
+ // `.hidden` / trailing-dot footguns), no `..` (path traversal hygiene
405
+ // even though storage already guards against it).
406
+ //
407
+ // In subdomain mode the username becomes a single-level subdomain — DNS
408
+ // hostnames don't allow `.` or `_`, and `server.js` already refuses to
409
+ // route multi-level subdomains as pods. So we restrict to alphanumeric +
410
+ // hyphen there to keep the username and the pod actually addressable.
411
+ const subdomainMode = !!(request.subdomainsEnabled && request.baseDomain);
412
+ const usernameRegex = subdomainMode
413
+ ? /^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])?$/
414
+ : /^[a-z0-9]([a-z0-9._-]{1,30}[a-z0-9])?$/;
390
415
  if (!usernameRegex.test(username)) {
391
- return reply.type('text/html').send(registerPage(uid, 'Username must contain only lowercase letters and numbers', null, inviteOnly));
416
+ const msg = subdomainMode
417
+ ? 'Username must be lowercase letters, numbers, or - (subdomain mode disallows . and _)'
418
+ : 'Username must be lowercase letters, numbers, or . _ - (start and end alphanumeric)';
419
+ return reply.type('text/html').send(registerPage(uid, msg, null, inviteOnly, ctx));
420
+ }
421
+ if (username.includes('..')) {
422
+ return reply.type('text/html').send(registerPage(uid, 'Username cannot contain ".."', null, inviteOnly, ctx));
392
423
  }
393
424
 
394
425
  if (username.length < 3) {
395
- return reply.type('text/html').send(registerPage(uid, 'Username must be at least 3 characters', null, inviteOnly));
426
+ return reply.type('text/html').send(registerPage(uid, 'Username must be at least 3 characters', null, inviteOnly, ctx));
396
427
  }
397
428
 
398
429
  // Password strength validation
399
430
  if (password.length < 8) {
400
- return reply.type('text/html').send(registerPage(uid, 'Password must be at least 8 characters', null, inviteOnly));
431
+ return reply.type('text/html').send(registerPage(uid, 'Password must be at least 8 characters', null, inviteOnly, ctx));
401
432
  }
402
433
 
403
434
  if (password !== confirmPassword) {
404
- return reply.type('text/html').send(registerPage(uid, 'Passwords do not match', null, inviteOnly));
435
+ return reply.type('text/html').send(registerPage(uid, 'Passwords do not match', null, inviteOnly, ctx));
405
436
  }
406
437
 
407
438
  try {
@@ -425,7 +456,7 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
425
456
  const podPath = `${username}/`;
426
457
  const podExists = await storage.exists(podPath);
427
458
  if (podExists) {
428
- return reply.type('text/html').send(registerPage(uid, 'Username is already taken', null, inviteOnly));
459
+ return reply.type('text/html').send(registerPage(uid, 'Username is already taken', null, inviteOnly, ctx));
429
460
  }
430
461
 
431
462
  // Create pod structure
@@ -445,11 +476,11 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
445
476
  if (uid) {
446
477
  return reply.redirect(`/idp/interaction/${uid}`);
447
478
  } else {
448
- return reply.type('text/html').send(registerPage(null, null, `Account created! You can now sign in as "${username}".`, inviteOnly));
479
+ return reply.type('text/html').send(registerPage(null, null, `Account created! You can now sign in as "${username}".`, inviteOnly, ctx));
449
480
  }
450
481
  } catch (err) {
451
482
  request.log.error(err, 'Registration error');
452
- return reply.type('text/html').send(registerPage(uid, err.message, null, inviteOnly));
483
+ return reply.type('text/html').send(registerPage(uid, err.message, null, inviteOnly, ctx));
453
484
  }
454
485
  }
455
486
 
package/src/idp/views.js CHANGED
@@ -553,13 +553,36 @@ export function errorPage(title, message) {
553
553
  /**
554
554
  * Registration page HTML
555
555
  */
556
- export function registerPage(uid = null, error = null, success = null, inviteOnly = false) {
556
+ export function registerPage(uid = null, error = null, success = null, inviteOnly = false, ctx = {}) {
557
557
  const inviteField = inviteOnly ? `
558
558
  <label for="invite">Invite Code</label>
559
559
  <input type="text" id="invite" name="invite" required
560
560
  placeholder="Enter your invite code" style="text-transform: uppercase;">
561
561
  ` : '';
562
562
 
563
+ // Embed the values the live preview needs. Escape characters that are
564
+ // unsafe in inline <script> contexts so values like "</script>" or
565
+ // U+2028 / U+2029 line separators can't terminate the script tag or
566
+ // confuse the parser when template-substituted.
567
+ const previewConfig = JSON.stringify({
568
+ baseUri: ctx.baseUri || '',
569
+ subdomainsEnabled: !!ctx.subdomainsEnabled,
570
+ baseDomain: ctx.baseDomain || '',
571
+ })
572
+ .replace(/</g, '\\u003c')
573
+ .replace(/\u2028/g, '\\u2028')
574
+ .replace(/\u2029/g, '\\u2029');
575
+
576
+ // Server validates more strictly than the HTML pattern can express; mirror
577
+ // as much as possible client-side so the browser catches obvious mistakes
578
+ // before submit. Subdomain mode drops dot/underscore (DNS hostname rules).
579
+ const usernamePattern = (ctx.subdomainsEnabled && ctx.baseDomain)
580
+ ? '[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?'
581
+ : '(?!.*\\.\\.)[a-z0-9](?:[a-z0-9._-]{1,30}[a-z0-9])?';
582
+ const usernameTitle = (ctx.subdomainsEnabled && ctx.baseDomain)
583
+ ? 'Lowercase letters, numbers, or - (start and end alphanumeric, 3–32 chars). Subdomain mode disallows . and _.'
584
+ : 'Lowercase letters, numbers, or . _ - (start and end alphanumeric, 3–32 chars, no consecutive dots)';
585
+
563
586
  return `
564
587
  <!DOCTYPE html>
565
588
  <html lang="en">
@@ -567,13 +590,38 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl
567
590
  <meta charset="UTF-8">
568
591
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
569
592
  <title>Register - Solid IdP</title>
570
- <style>${styles}</style>
593
+ <style>${styles}
594
+ /* registerPage local polish (#284) */
595
+ .container.register { padding-top: 32px; }
596
+ .register-header {
597
+ margin: -40px -40px 24px;
598
+ padding: 28px 40px 22px;
599
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
600
+ color: #fff;
601
+ border-radius: 12px 12px 0 0;
602
+ }
603
+ .register-header h1 { color: #fff; margin: 0 0 4px; font-size: 22px; }
604
+ .register-header .subtitle { color: rgba(255,255,255,.85); margin: 0; font-size: 13px; }
605
+ .preview {
606
+ margin: 4px 0 18px;
607
+ padding: 12px 14px;
608
+ background: #f8fafc;
609
+ border: 1px solid #e2e8f0;
610
+ border-radius: 8px;
611
+ font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
612
+ color: #475569;
613
+ word-break: break-all;
614
+ }
615
+ .preview .label { color: #64748b; font-weight: 600; margin-right: 6px; }
616
+ .preview .placeholder { color: #94a3b8; font-style: italic; }
617
+ </style>
571
618
  </head>
572
619
  <body>
573
- <div class="container">
574
- <div class="logo">${solidLogo}</div>
575
- <h1>Create Account</h1>
576
- <p class="subtitle">Register for a new Solid Pod${inviteOnly ? ' (invite required)' : ''}</p>
620
+ <div class="container register">
621
+ <div class="register-header">
622
+ <h1>Create Account</h1>
623
+ <p class="subtitle">Register for a new Solid Pod${inviteOnly ? ' (invite required)' : ''}</p>
624
+ </div>
577
625
 
578
626
  ${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
579
627
  ${success ? `<div class="error" style="background: #efe; border-color: #cfc; color: #060;">${escapeHtml(success)}</div>` : ''}
@@ -583,8 +631,14 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl
583
631
 
584
632
  <label for="username">Username</label>
585
633
  <input type="text" id="username" name="username" required ${!inviteOnly ? 'autofocus' : ''}
586
- placeholder="Choose a username" pattern="[a-z0-9]+"
587
- title="Lowercase letters and numbers only">
634
+ placeholder="Choose a username" minlength="3" maxlength="32"
635
+ pattern="${usernamePattern}"
636
+ title="${usernameTitle}">
637
+
638
+ <div class="preview" id="preview" aria-live="polite">
639
+ <div><span class="label">WebID</span><span id="preview-webid" class="placeholder">choose a username to preview</span></div>
640
+ <div style="margin-top: 4px;"><span class="label">Storage</span><span id="preview-storage" class="placeholder">—</span></div>
641
+ </div>
588
642
 
589
643
  <label for="password">Password</label>
590
644
  <input type="password" id="password" name="password" required
@@ -601,6 +655,46 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl
601
655
  Already have an account? <a href="${uid ? `/idp/interaction/${uid}` : '/idp/auth'}" style="color: #0066cc;">Sign In</a>
602
656
  </p>
603
657
  </div>
658
+
659
+ <script>
660
+ (function () {
661
+ var cfg = ${previewConfig};
662
+ var input = document.getElementById('username');
663
+ var webEl = document.getElementById('preview-webid');
664
+ var storEl = document.getElementById('preview-storage');
665
+ if (!input || !webEl || !storEl) return;
666
+
667
+ function render() {
668
+ // Server rejects uppercase outright, so normalise the field as the
669
+ // user types — keeps the preview honest and avoids a confusing
670
+ // post-submit error.
671
+ var normalised = (input.value || '').toLowerCase();
672
+ if (input.value !== normalised) input.value = normalised;
673
+ var u = normalised.trim();
674
+ if (!u) {
675
+ webEl.textContent = 'choose a username to preview';
676
+ webEl.className = 'placeholder';
677
+ storEl.textContent = '—';
678
+ storEl.className = 'placeholder';
679
+ return;
680
+ }
681
+ var pod, webid;
682
+ if (cfg.subdomainsEnabled && cfg.baseDomain) {
683
+ var origin = cfg.baseUri.split('://')[0] + '://';
684
+ pod = origin + u + '.' + cfg.baseDomain + '/';
685
+ } else {
686
+ pod = (cfg.baseUri || (location.protocol + '//' + location.host)) + '/' + u + '/';
687
+ }
688
+ webid = pod + 'profile/card.jsonld#me';
689
+ webEl.textContent = webid;
690
+ webEl.className = '';
691
+ storEl.textContent = pod;
692
+ storEl.className = '';
693
+ }
694
+ input.addEventListener('input', render);
695
+ render();
696
+ })();
697
+ </script>
604
698
  </body>
605
699
  </html>
606
700
  `;
package/test/idp.test.js CHANGED
@@ -181,6 +181,125 @@ describe('Identity Provider', () => {
181
181
  assert.ok(res.status >= 200 && res.status < 600, `got valid HTTP status ${res.status}`);
182
182
  });
183
183
  });
184
+
185
+ // Regression coverage for #284 — relaxed username regex + `..` rejection.
186
+ // Each register call below also exercises the .jsonld pod-creation flow
187
+ // from #283, since handleRegisterPost calls createPodStructure on success.
188
+ describe('Register username validation (path mode)', () => {
189
+ async function tryRegister(username) {
190
+ const res = await fetch(`${baseUrl}/idp/register`, {
191
+ method: 'POST',
192
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
193
+ body: new URLSearchParams({ username, password: 'secret-password', confirmPassword: 'secret-password' }),
194
+ });
195
+ const body = await res.text();
196
+ return { status: res.status, body };
197
+ }
198
+
199
+ it('accepts plain alphanumeric (alice)', async () => {
200
+ const r = await tryRegister('alice');
201
+ assert.match(r.body, /Account created/);
202
+ });
203
+
204
+ it('accepts dash (alice-smith)', async () => {
205
+ const r = await tryRegister('alice-smith');
206
+ assert.match(r.body, /Account created/);
207
+ });
208
+
209
+ it('accepts dot (alice.smith)', async () => {
210
+ const r = await tryRegister('alice.smith');
211
+ assert.match(r.body, /Account created/);
212
+ });
213
+
214
+ it('accepts underscore (alice_work)', async () => {
215
+ const r = await tryRegister('alice_work');
216
+ assert.match(r.body, /Account created/);
217
+ });
218
+
219
+ it('rejects leading separator (.alice)', async () => {
220
+ const r = await tryRegister('.alice');
221
+ assert.match(r.body, /lowercase letters, numbers/);
222
+ });
223
+
224
+ it('rejects trailing separator (alice-)', async () => {
225
+ const r = await tryRegister('alice-');
226
+ assert.match(r.body, /lowercase letters, numbers/);
227
+ });
228
+
229
+ it('rejects consecutive dots (alice..bob)', async () => {
230
+ const r = await tryRegister('alice..bob');
231
+ // Quotes are HTML-escaped (&quot;) in the rendered error banner.
232
+ assert.match(r.body, /cannot contain (?:"|&quot;)\.\.(?:"|&quot;)/);
233
+ });
234
+
235
+ it('rejects uppercase (Alice)', async () => {
236
+ const r = await tryRegister('Alice');
237
+ assert.match(r.body, /lowercase letters, numbers/);
238
+ });
239
+
240
+ it('rejects too short (ab)', async () => {
241
+ const r = await tryRegister('ab');
242
+ // Two-char names fail the regex (min 3 enforced by the pattern itself).
243
+ assert.match(r.body, /lowercase letters, numbers|at least 3/);
244
+ });
245
+ });
246
+ });
247
+
248
+ // Subdomain mode: usernames become hostname components, so `.` and `_` are
249
+ // not allowed (server.js refuses to route multi-level subdomains).
250
+ describe('Identity Provider - Subdomain mode register validation', () => {
251
+ let server;
252
+ let baseUrl;
253
+ const SUBDOMAIN_DATA_DIR = './test-data-idp-subdomain';
254
+
255
+ before(async () => {
256
+ await fs.remove(SUBDOMAIN_DATA_DIR);
257
+ await fs.ensureDir(SUBDOMAIN_DATA_DIR);
258
+
259
+ const port = await getAvailablePort();
260
+ baseUrl = `http://${TEST_HOST}:${port}`;
261
+
262
+ server = createServer({
263
+ logger: false,
264
+ root: SUBDOMAIN_DATA_DIR,
265
+ idp: true,
266
+ idpIssuer: baseUrl,
267
+ subdomains: true,
268
+ baseDomain: TEST_HOST,
269
+ forceCloseConnections: true,
270
+ });
271
+
272
+ await server.listen({ port, host: TEST_HOST });
273
+ });
274
+
275
+ after(async () => {
276
+ await server.close();
277
+ await fs.remove(SUBDOMAIN_DATA_DIR);
278
+ });
279
+
280
+ async function tryRegister(username) {
281
+ const res = await fetch(`${baseUrl}/idp/register`, {
282
+ method: 'POST',
283
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
284
+ body: new URLSearchParams({ username, password: 'secret-password', confirmPassword: 'secret-password' }),
285
+ });
286
+ return { status: res.status, body: await res.text() };
287
+ }
288
+
289
+ it('accepts dash (alice-smith)', async () => {
290
+ const r = await tryRegister('alice-smith');
291
+ assert.match(r.body, /Account created/);
292
+ });
293
+
294
+ it('rejects dot (alice.smith) — would not be a single-level subdomain', async () => {
295
+ const r = await tryRegister('alice.smith');
296
+ assert.match(r.body, /subdomain mode disallows/);
297
+ });
298
+
299
+ it('rejects underscore (alice_work) — invalid in DNS hostnames', async () => {
300
+ const r = await tryRegister('alice_work');
301
+ assert.match(r.body, /subdomain mode disallows/);
302
+ });
184
303
  });
185
304
 
186
305
  describe('Identity Provider - Accounts', () => {