javascript-solid-server 0.0.154 → 0.0.156

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.
@@ -357,7 +357,8 @@
357
357
  "Bash(cat LICENSE.full)",
358
358
  "Bash(rm LICENSE.full)",
359
359
  "Bash(awk -F: '{print $1,$2}')",
360
- "Bash(awk -F: '{print $1}')"
360
+ "Bash(awk -F: '{print $1}')",
361
+ "WebFetch(domain:www.gitfork.app)"
361
362
  ]
362
363
  }
363
364
  }
package/bin/jss.js CHANGED
@@ -84,6 +84,7 @@ program
84
84
  .option('--no-invite-only', 'Allow open registration')
85
85
  .option('--single-user', 'Single-user mode (creates pod on startup, disables registration)')
86
86
  .option('--single-user-name <name>', 'Username for single-user mode (default: me)')
87
+ .option('--single-user-password <pw>', 'Initial IDP password to seed when creating the single-user pod (or set JSS_SINGLE_USER_PASSWORD)')
87
88
  .option('--webid-tls', 'Enable WebID-TLS client certificate authentication')
88
89
  .option('--no-webid-tls', 'Disable WebID-TLS authentication')
89
90
  .option('--public', 'Allow unauthenticated access (skip WAC, open read/write)')
@@ -164,6 +165,7 @@ program
164
165
  webidTls: config.webidTls,
165
166
  singleUser: config.singleUser,
166
167
  singleUserName: config.singleUserName,
168
+ singleUserPassword: config.singleUserPassword,
167
169
  public: config.public,
168
170
  readOnly: config.readOnly,
169
171
  liveReload: config.liveReload,
@@ -201,6 +201,9 @@ export JSS_ACTIVITYPUB=true
201
201
  export JSS_AP_USERNAME=alice
202
202
  export JSS_PUBLIC=true
203
203
  export JSS_READ_ONLY=true
204
+ export JSS_SINGLE_USER=true
205
+ export JSS_SINGLE_USER_NAME=me
206
+ export JSS_SINGLE_USER_PASSWORD=choose-a-good-one # seeds IDP account on first start
204
207
  export JSS_LIVE_RELOAD=true
205
208
  export JSS_SOLIDOS_UI=true
206
209
  export JSS_PAY=true
@@ -256,8 +259,13 @@ For personal pod servers where only one user needs access:
256
259
 
257
260
  ```bash
258
261
  # Basic single-user mode (creates pod at /me/)
262
+ # On first run JSS will prompt for an initial password (TTY only).
259
263
  jss start --single-user --idp
260
264
 
265
+ # Provide the initial IDP password non-interactively (systemd, containers, CI):
266
+ jss start --single-user --idp --single-user-password 'choose-a-good-one'
267
+ JSS_SINGLE_USER_PASSWORD='choose-a-good-one' jss start --single-user --idp
268
+
261
269
  # Custom username
262
270
  jss start --single-user --single-user-name alice --idp
263
271
 
@@ -270,10 +278,19 @@ JSS_SINGLE_USER=true jss start --idp
270
278
 
271
279
  **Features:**
272
280
  - Pod auto-created on first startup with full structure (inbox, public, private, profile)
281
+ - IDP account auto-seeded so the operator can log in immediately
273
282
  - Registration endpoint disabled (returns 403)
274
- - Login still works for the single user
283
+ - Login works for the single user via password (`POST /idp/credentials`) or any other configured method
275
284
  - Proper ACLs generated automatically
276
285
 
286
+ **Initial password sources, in priority order:**
287
+ 1. `--single-user-password <pw>` CLI flag
288
+ 2. `JSS_SINGLE_USER_PASSWORD` env var
289
+ 3. Interactive no-echo prompt (TTY only)
290
+ 4. None — the server starts and warns; the pod is created but isn't loggable until you restart with `--single-user-password <pw>`, set `JSS_SINGLE_USER_PASSWORD`, or run on a TTY to be prompted. (`jss passwd <user>` does not work here — it returns "User not found" until an account exists.)
291
+
292
+ The password is only consulted on the first start — once an account exists, subsequent restarts skip the seed step and never overwrite it. The password is never written to the saved config file (`.jss/config`).
293
+
277
294
 
278
295
  ## Invite-Only Registration
279
296
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.154",
3
+ "version": "0.0.156",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/config.js CHANGED
@@ -75,6 +75,11 @@ export const defaults = {
75
75
  // Single-user mode (personal pod server)
76
76
  singleUser: false,
77
77
  singleUserName: 'me',
78
+ // Initial IDP password seeded on first single-user pod creation. If
79
+ // unset and --idp is enabled, the server prompts on a TTY or logs a
80
+ // warning and continues startup on non-TTY (so the pod is created but
81
+ // is not yet loggable until a password is set).
82
+ singleUserPassword: null,
78
83
 
79
84
  // WebID-TLS client certificate authentication
80
85
  webidTls: false,
@@ -154,6 +159,7 @@ const envMap = {
154
159
  JSS_INVITE_ONLY: 'inviteOnly',
155
160
  JSS_SINGLE_USER: 'singleUser',
156
161
  JSS_SINGLE_USER_NAME: 'singleUserName',
162
+ JSS_SINGLE_USER_PASSWORD: 'singleUserPassword',
157
163
  JSS_WEBID_TLS: 'webidTls',
158
164
  JSS_DEFAULT_QUOTA: 'defaultQuota',
159
165
  JSS_PUBLIC: 'public',
@@ -184,15 +190,53 @@ export function parseSize(str) {
184
190
  return Math.floor(num * (multipliers[unit] || 1));
185
191
  }
186
192
 
193
+ /**
194
+ * Config keys whose values are genuinely boolean. Only these get the
195
+ * "true"/"false" string coercion below — otherwise a user-supplied
196
+ * password (or any other string-valued option) like "true"/"false"
197
+ * would silently turn into a boolean and break downstream code (e.g.
198
+ * bcrypt hashing).
199
+ */
200
+ const BOOLEAN_KEYS = new Set([
201
+ 'ssl',
202
+ 'conneg',
203
+ 'subdomains',
204
+ 'mashlib',
205
+ 'mashlibCdn',
206
+ 'git',
207
+ 'nostr',
208
+ 'webrtc',
209
+ 'terminal',
210
+ 'tunnel',
211
+ 'activitypub',
212
+ 'inviteOnly',
213
+ 'multiuser',
214
+ 'singleUser',
215
+ 'webidTls',
216
+ 'public',
217
+ 'readOnly',
218
+ 'liveReload',
219
+ 'pay',
220
+ 'mongo',
221
+ 'idp',
222
+ 'notifications',
223
+ 'logger',
224
+ 'quiet'
225
+ ]);
226
+
187
227
  /**
188
228
  * Parse a value from environment variable string
189
229
  */
190
230
  function parseEnvValue(value, key) {
191
231
  if (value === undefined) return undefined;
192
232
 
193
- // Boolean values
194
- if (value.toLowerCase() === 'true') return true;
195
- if (value.toLowerCase() === 'false') return false;
233
+ // Boolean values — only for known boolean keys; everything else
234
+ // stays a string so passwords / tokens / arbitrary text aren't
235
+ // silently coerced to booleans.
236
+ if (BOOLEAN_KEYS.has(key)) {
237
+ if (value.toLowerCase() === 'true') return true;
238
+ if (value.toLowerCase() === 'false') return false;
239
+ }
196
240
 
197
241
  // Numeric values for known numeric keys
198
242
  if ((key === 'port' || key === 'nostrMaxEvents' || key === 'payCost' || key === 'payRate') && !isNaN(value)) {
@@ -311,6 +355,10 @@ export async function saveConfig(config, configFile) {
311
355
  // Remove derived/runtime values
312
356
  delete toSave.ssl;
313
357
  delete toSave.logger;
358
+ // Never persist secrets to a static config file. The password is
359
+ // expected to come from --single-user-password or
360
+ // JSS_SINGLE_USER_PASSWORD at runtime, not be written into .jss/config.
361
+ delete toSave.singleUserPassword;
314
362
 
315
363
  await fs.ensureDir(path.dirname(configFile));
316
364
  await fs.writeFile(configFile, JSON.stringify(toSave, null, 2));
@@ -327,6 +375,24 @@ export function printConfig(config) {
327
375
  console.log(` Root: ${path.resolve(config.root)}`);
328
376
  console.log(` SSL: ${config.ssl ? 'enabled' : 'disabled'}`);
329
377
  console.log(` Multi-user: ${config.multiuser}`);
378
+ if (config.singleUser) {
379
+ let details = `${config.singleUserName}`;
380
+ // Password seeding only runs when --idp is on AND the pod isn't the
381
+ // root-level case ('/'). Reflect both gates in the printed line so
382
+ // operators don't see a misleading "missing — login disabled" when
383
+ // login isn't governed by an IDP password at all.
384
+ if (config.idp) {
385
+ if (config.singleUserName === '/' || !config.singleUserName) {
386
+ details += ' (root pod; password not seeded)';
387
+ } else {
388
+ const pwSource = config.singleUserPassword
389
+ ? 'provided'
390
+ : (process.stdin.isTTY ? 'will prompt at startup' : 'missing — login disabled');
391
+ details += ` (password: ${pwSource})`;
392
+ }
393
+ }
394
+ console.log(` Single-user: ${details}`);
395
+ }
330
396
  console.log(` Conneg: ${config.conneg}`);
331
397
  console.log(` Notifications: ${config.notifications}`);
332
398
  console.log(` IdP: ${config.idp ? (config.idpIssuer || 'enabled') : 'disabled'}`);
@@ -149,17 +149,18 @@ export async function handleGet(request, reply) {
149
149
  const content = await storage.read(indexPath);
150
150
  const indexStats = await storage.stat(indexPath);
151
151
 
152
- // Check if RDF format requested via content negotiation
152
+ // Pick the negotiated RDF type using q-aware Accept parsing. The
153
+ // naive `acceptHeader.includes('text/turtle')` we used to do here
154
+ // ignored q-weights — `Accept: application/ld+json, text/turtle;q=0.1`
155
+ // would still pick Turtle even though JSON-LD was preferred (#325).
153
156
  const acceptHeader = request.headers.accept || '';
154
- const wantsTurtle = connegEnabled && (
155
- acceptHeader.includes('text/turtle') ||
156
- acceptHeader.includes('text/n3') ||
157
- acceptHeader.includes('application/n-triples')
158
- );
159
- const wantsJsonLd = connegEnabled && (
160
- acceptHeader.includes('application/ld+json') ||
161
- acceptHeader.includes('application/json')
162
- );
157
+ const negotiated = connegEnabled
158
+ ? selectContentType(acceptHeader, true)
159
+ : null;
160
+ const wantsTurtle = negotiated === RDF_TYPES.TURTLE
161
+ || negotiated === RDF_TYPES.N3
162
+ || negotiated === 'application/n-triples';
163
+ const wantsJsonLd = negotiated === RDF_TYPES.JSON_LD;
163
164
 
164
165
  if (wantsTurtle || wantsJsonLd) {
165
166
  // Extract JSON-LD from HTML data island
@@ -257,13 +258,14 @@ export async function handleGet(request, reply) {
257
258
  return reply.type('text/html').send(html);
258
259
  }
259
260
 
260
- // Check if Turtle/N3 format is requested via content negotiation
261
+ // Pick the negotiated RDF type using q-aware Accept parsing (#325).
261
262
  const acceptHeader = request.headers.accept || '';
262
- const wantsTurtle = connegEnabled && (
263
- acceptHeader.includes('text/turtle') ||
264
- acceptHeader.includes('text/n3') ||
265
- acceptHeader.includes('application/n-triples')
266
- );
263
+ const negotiated = connegEnabled
264
+ ? selectContentType(acceptHeader, true)
265
+ : null;
266
+ const wantsTurtle = negotiated === RDF_TYPES.TURTLE
267
+ || negotiated === RDF_TYPES.N3
268
+ || negotiated === 'application/n-triples';
267
269
 
268
270
  if (wantsTurtle) {
269
271
  // Convert container JSON-LD to Turtle
@@ -383,11 +385,14 @@ export async function handleGet(request, reply) {
383
385
  if (connegEnabled) {
384
386
  const contentStr = content.toString();
385
387
  const acceptHeader = request.headers.accept || '';
386
- // Serve Turtle if: URL ends with .ttl OR Accept header requests it
387
- const wantsTurtle = urlPath.endsWith('.ttl') ||
388
- acceptHeader.includes('text/turtle') ||
389
- acceptHeader.includes('text/n3') ||
390
- acceptHeader.includes('application/n-triples');
388
+ // Serve Turtle if: URL ends with .ttl OR Accept's q-weighted top
389
+ // RDF type is Turtle/N3 (#325 — naive substring matching ignored
390
+ // q-weights and would pick Turtle whenever it appeared in Accept).
391
+ const negotiated = selectContentType(acceptHeader, true);
392
+ const wantsTurtle = urlPath.endsWith('.ttl')
393
+ || negotiated === RDF_TYPES.TURTLE
394
+ || negotiated === RDF_TYPES.N3
395
+ || negotiated === 'application/n-triples';
391
396
 
392
397
  // Check if this is HTML with JSON-LD data island
393
398
  const isHtmlWithDataIsland = contentStr.trimStart().startsWith('<!DOCTYPE') ||
@@ -505,24 +510,31 @@ export async function handleHead(request, reply) {
505
510
  let contentType;
506
511
 
507
512
  if (stats.isDirectory) {
508
- // For directories with index.html, determine content type based on Accept header
509
513
  const indexPath = storagePath.endsWith('/') ? `${storagePath}index.html` : `${storagePath}/index.html`;
510
514
  const indexExists = await storage.exists(indexPath);
515
+ const acceptHeader = request.headers.accept || '';
511
516
 
512
- if (indexExists && connegEnabled) {
513
- const acceptHeader = request.headers.accept || '';
514
- const wantsTurtle = acceptHeader.includes('text/turtle') ||
515
- acceptHeader.includes('text/n3') ||
516
- acceptHeader.includes('application/n-triples');
517
- const wantsJsonLd = acceptHeader.includes('application/ld+json') ||
518
- acceptHeader.includes('application/json');
517
+ if (connegEnabled) {
518
+ // HEAD must mirror what GET would emit; otherwise client caches and
519
+ // RDF-aware tooling key off a content-type that doesn't match the
520
+ // body they'll see on the next GET (#325). Use q-aware Accept
521
+ // parsing for both the index.html and listing branches.
522
+ const negotiated = selectContentType(acceptHeader, true);
523
+ const wantsTurtle = negotiated === RDF_TYPES.TURTLE
524
+ || negotiated === RDF_TYPES.N3
525
+ || negotiated === 'application/n-triples';
526
+ const wantsJsonLd = negotiated === RDF_TYPES.JSON_LD;
519
527
 
520
528
  if (wantsTurtle) {
521
529
  contentType = 'text/turtle';
522
530
  } else if (wantsJsonLd) {
523
- contentType = 'application/ld+json';
531
+ // For an index.html container, only override to JSON-LD if the
532
+ // Accept header explicitly asked for JSON; otherwise fall back
533
+ // to text/html so HEAD matches the index.html that GET serves.
534
+ const explicitJson = /\b(application\/ld\+json|application\/json)\b/i.test(acceptHeader);
535
+ contentType = (indexExists && !explicitJson) ? 'text/html' : 'application/ld+json';
524
536
  } else {
525
- contentType = 'text/html';
537
+ contentType = indexExists ? 'text/html' : 'application/ld+json';
526
538
  }
527
539
  } else if (indexExists) {
528
540
  contentType = 'text/html';
package/src/server.js CHANGED
@@ -94,6 +94,7 @@ export function createServer(options = {}) {
94
94
  // Single-user mode - creates pod on startup, disables registration
95
95
  const singleUser = options.singleUser ?? false;
96
96
  const singleUserName = options.singleUserName ?? 'me';
97
+ const singleUserPassword = options.singleUserPassword ?? null;
97
98
  // Default storage quota per pod (50MB default, 0 = unlimited)
98
99
  const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024;
99
100
  // WebID-TLS client certificate authentication is OFF by default
@@ -561,15 +562,21 @@ export function createServer(options = {}) {
561
562
  const isRootPod = !singleUserName || singleUserName === '/';
562
563
  const podPath = isRootPod ? '/' : `/${singleUserName}/`;
563
564
  const podUri = isRootPod ? `${baseUrl}/` : `${baseUrl}/${singleUserName}/`;
564
- const webId = `${podUri}profile/card.jsonld#me`;
565
565
  const displayName = isRootPod ? 'me' : singleUserName;
566
566
 
567
567
  // Check if pod already exists. Accept either the new `card.jsonld`
568
568
  // or legacy extensionless `card` layout so we don't re-seed a pod
569
- // that was created by an older JSS version.
570
- const profileExists =
571
- await storage.exists(`${podPath}profile/card.jsonld`) ||
572
- await storage.exists(`${podPath}profile/card`);
569
+ // that was created by an older JSS version. Compute the effective
570
+ // WebID against whichever profile file actually resolves — a
571
+ // legacy pod must keep its `/profile/card#me` WebID, otherwise the
572
+ // seeded IDP account would point at a non-existent document.
573
+ const hasJsonLd = await storage.exists(`${podPath}profile/card.jsonld`);
574
+ const hasLegacy = !hasJsonLd && await storage.exists(`${podPath}profile/card`);
575
+ const profileFile = hasJsonLd ? 'profile/card.jsonld'
576
+ : hasLegacy ? 'profile/card'
577
+ : 'profile/card.jsonld'; // fresh pod default
578
+ const webId = `${podUri}${profileFile}#me`;
579
+ const profileExists = hasJsonLd || hasLegacy;
573
580
 
574
581
  if (!profileExists) {
575
582
  fastify.log.info(`Creating single-user pod at ${podUri}...`);
@@ -583,6 +590,123 @@ export function createServer(options = {}) {
583
590
  }
584
591
  fastify.log.info(`Single-user pod created at ${podUri}`);
585
592
  }
593
+
594
+ // Seed an IDP account so the operator can actually log in. Without
595
+ // this, single-user + --idp produces a pod but no credential, and
596
+ // registration is intentionally disabled in single-user mode — so
597
+ // the pod is unloggable until a password is set externally (#323).
598
+ if (idpEnabled && !isRootPod) {
599
+ await seedSingleUserIdpAccount({
600
+ fastify,
601
+ username: singleUserName,
602
+ webId,
603
+ podName: singleUserName,
604
+ providedPassword: singleUserPassword
605
+ });
606
+ }
607
+ });
608
+ }
609
+
610
+ /**
611
+ * Seed an IDP account for the single-user pod owner if one doesn't
612
+ * already exist. Password sources, in priority order:
613
+ * 1. `--single-user-password` / `JSS_SINGLE_USER_PASSWORD`
614
+ * 2. interactive prompt (TTY only)
615
+ * 3. error — server stays up but logs that login won't work yet
616
+ */
617
+ async function seedSingleUserIdpAccount({ fastify, username, webId, podName, providedPassword }) {
618
+ const { findByUsername, createAccount } = await import('./idp/accounts.js');
619
+ const existing = await findByUsername(username);
620
+ if (existing) return; // already seeded — idempotent
621
+
622
+ // Treat anything that isn't a non-empty string as "not provided" so
623
+ // a misconfigured env coercion or stray boolean can't reach bcrypt.
624
+ let password = (typeof providedPassword === 'string' && providedPassword.length > 0)
625
+ ? providedPassword
626
+ : null;
627
+
628
+ if (!password) {
629
+ if (process.stdin.isTTY && process.stdout.isTTY) {
630
+ try {
631
+ password = await promptPasswordOnce(`[jss] Set initial IDP password for "${username}": `);
632
+ } catch (err) {
633
+ fastify.log.warn({ err }, `Password prompt failed for "${username}"`);
634
+ return;
635
+ }
636
+ } else {
637
+ fastify.log.warn(
638
+ `--single-user --idp: no password provided. Set --single-user-password or ` +
639
+ `JSS_SINGLE_USER_PASSWORD before starting (or run on a TTY to be prompted). ` +
640
+ `Login is currently not possible for "${username}".`
641
+ );
642
+ return;
643
+ }
644
+ }
645
+
646
+ if (typeof password !== 'string' || password.length === 0) {
647
+ fastify.log.warn(`Empty password — skipping IDP account creation for "${username}".`);
648
+ return;
649
+ }
650
+
651
+ try {
652
+ await createAccount({ username, password, webId, podName });
653
+ fastify.log.info(`IDP account seeded for single-user "${username}".`);
654
+ } catch (err) {
655
+ fastify.log.error({ err }, `Failed to seed IDP account for "${username}"`);
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Read a password from stdin without echoing it. Uses the public
661
+ * `emitKeypressEvents` + raw-mode keypress API rather than overriding
662
+ * the underscored `_writeToOutput` on a `readline.Interface`, which is
663
+ * a private/unstable hook.
664
+ */
665
+ async function promptPasswordOnce(prompt) {
666
+ const { emitKeypressEvents } = await import('node:readline');
667
+ const stdin = process.stdin;
668
+ const stdout = process.stdout;
669
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
670
+ throw new Error('Interactive password prompt requires a TTY');
671
+ }
672
+ return new Promise((resolve, reject) => {
673
+ let password = '';
674
+ const wasRaw = stdin.isRaw === true;
675
+ const onKeypress = (str, key = {}) => {
676
+ if (key.ctrl && key.name === 'c') {
677
+ cleanup();
678
+ reject(new Error('Password prompt cancelled'));
679
+ return;
680
+ }
681
+ if (key.name === 'return' || key.name === 'enter') {
682
+ cleanup();
683
+ resolve(password);
684
+ return;
685
+ }
686
+ if (key.name === 'backspace' || key.name === 'delete') {
687
+ password = password.slice(0, -1);
688
+ return;
689
+ }
690
+ // Only accept printable input — \P{C} excludes control codes,
691
+ // so escape sequences from arrow keys, function keys, etc. don't
692
+ // sneak invisible bytes into the password buffer.
693
+ if (!key.ctrl && !key.meta &&
694
+ typeof str === 'string' && str.length > 0 &&
695
+ /^\P{C}+$/u.test(str)) {
696
+ password += str;
697
+ }
698
+ };
699
+ const cleanup = () => {
700
+ stdin.removeListener('keypress', onKeypress);
701
+ if (!wasRaw) stdin.setRawMode(false);
702
+ stdout.write('\n');
703
+ stdin.pause();
704
+ };
705
+ emitKeypressEvents(stdin);
706
+ stdout.write(prompt);
707
+ if (!wasRaw) stdin.setRawMode(true);
708
+ stdin.resume();
709
+ stdin.on('keypress', onKeypress);
586
710
  });
587
711
  }
588
712
 
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Config / env-var parsing tests.
3
+ *
4
+ * Regression coverage for the env-coercion fix in #323: only known
5
+ * boolean keys may have their string values coerced to booleans.
6
+ * Otherwise an env var like JSS_SINGLE_USER_PASSWORD="true" would silently
7
+ * become a real boolean and break downstream code (bcrypt, etc.).
8
+ */
9
+
10
+ import { describe, it, before, after } from 'node:test';
11
+ import assert from 'node:assert';
12
+ import { loadConfig } from '../src/config.js';
13
+
14
+ describe('config — env var boolean coercion', () => {
15
+ // Save/restore the env vars we touch so this test is hermetic.
16
+ const KEYS = ['JSS_SINGLE_USER_PASSWORD', 'JSS_IDP', 'JSS_BASE_DOMAIN', 'JSS_MULTIUSER'];
17
+ const original = {};
18
+ before(() => { for (const k of KEYS) original[k] = process.env[k]; });
19
+ after(() => {
20
+ for (const k of KEYS) {
21
+ if (original[k] === undefined) delete process.env[k];
22
+ else process.env[k] = original[k];
23
+ }
24
+ });
25
+
26
+ it('preserves string-valued env vars when their value is "true"', async () => {
27
+ process.env.JSS_SINGLE_USER_PASSWORD = 'true';
28
+ const cfg = await loadConfig({}, null);
29
+ assert.strictEqual(cfg.singleUserPassword, 'true',
30
+ 'password env var must remain a string, not be coerced to boolean true');
31
+ });
32
+
33
+ it('preserves string-valued env vars when their value is "false"', async () => {
34
+ process.env.JSS_SINGLE_USER_PASSWORD = 'false';
35
+ const cfg = await loadConfig({}, null);
36
+ assert.strictEqual(cfg.singleUserPassword, 'false');
37
+ });
38
+
39
+ it('preserves string-valued env vars when set to other strings', async () => {
40
+ process.env.JSS_BASE_DOMAIN = 'example.com';
41
+ const cfg = await loadConfig({}, null);
42
+ assert.strictEqual(cfg.baseDomain, 'example.com');
43
+ });
44
+
45
+ it('still coerces known boolean keys', async () => {
46
+ process.env.JSS_IDP = 'true';
47
+ const cfg = await loadConfig({}, null);
48
+ assert.strictEqual(cfg.idp, true, 'idp env var should be coerced to boolean');
49
+ });
50
+
51
+ it('still coerces known boolean keys when "false"', async () => {
52
+ process.env.JSS_IDP = 'false';
53
+ const cfg = await loadConfig({}, null);
54
+ assert.strictEqual(cfg.idp, false);
55
+ });
56
+
57
+ it('coerces JSS_MULTIUSER to a boolean (regression for missed entry)', async () => {
58
+ process.env.JSS_MULTIUSER = 'false';
59
+ const cfg = await loadConfig({}, null);
60
+ assert.strictEqual(cfg.multiuser, false,
61
+ 'multiuser must coerce to boolean false, not the string "false" (truthy)');
62
+ process.env.JSS_MULTIUSER = 'true';
63
+ const cfg2 = await loadConfig({}, null);
64
+ assert.strictEqual(cfg2.multiuser, true);
65
+ });
66
+ });
@@ -354,3 +354,95 @@ describe('Content Negotiation (conneg disabled - default)', () => {
354
354
  });
355
355
  });
356
356
  });
357
+
358
+ // Regression coverage for #325 — q-weighted Accept and HEAD/GET parity.
359
+ // Previously the conneg dispatcher used naive substring matching on the
360
+ // Accept header, so any Accept that mentioned text/turtle (even at q=0.1
361
+ // alongside q=1.0 application/ld+json) returned Turtle. Separately, HEAD
362
+ // on a container without an index.html hard-coded application/ld+json,
363
+ // so HEAD and GET disagreed on content-type for the same URL.
364
+ describe('Content Negotiation — q-weights and HEAD/GET parity (#325)', () => {
365
+ before(async () => {
366
+ await startTestServer({ conneg: true });
367
+ await createTestPod('qwtest');
368
+ });
369
+ after(async () => { await stopTestServer(); });
370
+
371
+ function ct(res) {
372
+ return (res.headers.get('content-type') || '').split(';')[0].trim();
373
+ }
374
+
375
+ describe('container — q-weight respected', () => {
376
+ it('Accept: jsonld q=1.0, turtle q=0.1 → JSON-LD', async () => {
377
+ const res = await request('/qwtest/', {
378
+ headers: { Accept: 'application/ld+json;q=1.0, text/turtle;q=0.1' }
379
+ });
380
+ assertStatus(res, 200);
381
+ assert.strictEqual(ct(res), 'application/ld+json');
382
+ const body = await res.text();
383
+ assert.ok(body.trimStart().startsWith('{'),
384
+ `body should be JSON, got: ${body.slice(0, 80)}`);
385
+ });
386
+
387
+ it('Accept: jsonld, turtle;q=0.5 → JSON-LD wins (downstream repro)', async () => {
388
+ const res = await request('/qwtest/', {
389
+ headers: { Accept: 'application/ld+json, text/turtle;q=0.5' }
390
+ });
391
+ assert.strictEqual(ct(res), 'application/ld+json');
392
+ const body = await res.text();
393
+ assert.ok(body.trimStart().startsWith('{'),
394
+ `body should be JSON, got: ${body.slice(0, 80)}`);
395
+ });
396
+
397
+ it('Accept: turtle (explicit) → Turtle', async () => {
398
+ const res = await request('/qwtest/', { headers: { Accept: 'text/turtle' } });
399
+ assert.strictEqual(ct(res), 'text/turtle');
400
+ const body = await res.text();
401
+ assert.ok(body.trimStart().startsWith('@prefix'),
402
+ `body should be Turtle, got: ${body.slice(0, 80)}`);
403
+ });
404
+
405
+ it('no Accept → JSON-LD (native default)', async () => {
406
+ const res = await request('/qwtest/');
407
+ assert.strictEqual(ct(res), 'application/ld+json');
408
+ });
409
+ });
410
+
411
+ describe('container — HEAD content-type matches GET', () => {
412
+ const cases = [
413
+ ['no Accept', {}],
414
+ ['jsonld preferred', { Accept: 'application/ld+json;q=1.0, text/turtle;q=0.1' }],
415
+ ['turtle preferred', { Accept: 'text/turtle' }],
416
+ ['mixed (q=0.5)', { Accept: 'application/ld+json, text/turtle;q=0.5' }]
417
+ ];
418
+ for (const [label, headers] of cases) {
419
+ it(`HEAD === GET content-type — ${label}`, async () => {
420
+ const get = await request('/qwtest/', { headers });
421
+ const head = await request('/qwtest/', { method: 'HEAD', headers });
422
+ assert.strictEqual(get.status, 200);
423
+ assert.strictEqual(head.status, 200);
424
+ assert.strictEqual(ct(head), ct(get),
425
+ `HEAD ct (${ct(head)}) must equal GET ct (${ct(get)}) for ${label}`);
426
+ });
427
+ }
428
+ });
429
+
430
+ describe('container — auth path matches anonymous', () => {
431
+ it('GET with auth returns same content-type as without auth (turtle case)', async () => {
432
+ const headers = { Accept: 'text/turtle' };
433
+ const anon = await request('/qwtest/', { headers });
434
+ const authed = await request('/qwtest/', { headers, auth: 'qwtest' });
435
+ assert.strictEqual(ct(anon), 'text/turtle');
436
+ assert.strictEqual(ct(authed), ct(anon),
437
+ 'authenticated GET must report the same content-type as anonymous');
438
+ });
439
+
440
+ it('GET with auth returns same content-type as without auth (jsonld case)', async () => {
441
+ const headers = { Accept: 'application/ld+json;q=1.0, text/turtle;q=0.1' };
442
+ const anon = await request('/qwtest/', { headers });
443
+ const authed = await request('/qwtest/', { headers, auth: 'qwtest' });
444
+ assert.strictEqual(ct(anon), 'application/ld+json');
445
+ assert.strictEqual(ct(authed), ct(anon));
446
+ });
447
+ });
448
+ });
package/test/idp.test.js CHANGED
@@ -713,3 +713,205 @@ describe('Identity Provider - Credentials Endpoint', () => {
713
713
  });
714
714
  });
715
715
  });
716
+
717
+ // Single-user + --idp must seed an IDP account so the operator can log in.
718
+ // Without this, the pod is created but is unloggable: registration is
719
+ // disabled in single-user mode and there's no pre-existing account.
720
+ // Regression for #323.
721
+ describe('Identity Provider — single-user password seeding (#323)', () => {
722
+ // Save/restore DATA_ROOT and stdin.isTTY around this suite so we don't
723
+ // leak global state into other tests in the same `node --test` run.
724
+ // For isTTY we capture the *property descriptor* so we can correctly
725
+ // restore an inherited (prototype) accessor — Object.defineProperty
726
+ // would otherwise leave a shadowing own-property behind.
727
+ let originalDataRoot;
728
+ let originalIsTTYDescriptor;
729
+ let originalIsTTYWasOwn;
730
+ before(() => {
731
+ originalDataRoot = process.env.DATA_ROOT;
732
+ originalIsTTYWasOwn = Object.prototype.hasOwnProperty.call(process.stdin, 'isTTY');
733
+ originalIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
734
+ // Force non-TTY so the no-password test never blocks on an
735
+ // unanswerable prompt when the suite is run from an interactive shell.
736
+ Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
737
+ });
738
+ after(() => {
739
+ if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
740
+ else process.env.DATA_ROOT = originalDataRoot;
741
+ if (originalIsTTYWasOwn && originalIsTTYDescriptor) {
742
+ Object.defineProperty(process.stdin, 'isTTY', originalIsTTYDescriptor);
743
+ } else {
744
+ // Property was inherited; remove our shadowing own-property so
745
+ // the prototype's accessor is visible again.
746
+ delete process.stdin.isTTY;
747
+ }
748
+ });
749
+
750
+ it('seeds an IDP account when singleUserPassword is provided', async () => {
751
+ const dir = './test-data-su-pw-provided';
752
+ await fs.remove(dir);
753
+ await fs.ensureDir(dir);
754
+ const port = await getAvailablePort();
755
+ const baseUrl = `http://${TEST_HOST}:${port}`;
756
+ const server = createServer({
757
+ logger: false,
758
+ root: dir,
759
+ idp: true,
760
+ idpIssuer: baseUrl,
761
+ singleUser: true,
762
+ singleUserName: 'me',
763
+ singleUserPassword: 'hunter2-test',
764
+ forceCloseConnections: true,
765
+ });
766
+ try {
767
+ // createServer already sets DATA_ROOT when `root` is provided;
768
+ // import accounts.js after listen() so it picks up the right path.
769
+ await server.listen({ port, host: TEST_HOST });
770
+ const { findByUsername, authenticate } = await import('../src/idp/accounts.js');
771
+ const account = await findByUsername('me');
772
+ assert.ok(account, 'IDP account for single-user "me" should exist');
773
+ assert.strictEqual(account.username, 'me');
774
+ assert.ok(account.webId.includes('/me/profile/card.jsonld#me'));
775
+ const authed = await authenticate('me', 'hunter2-test');
776
+ assert.ok(authed, 'should authenticate with the seeded password');
777
+ } finally {
778
+ await server.close();
779
+ await fs.remove(dir);
780
+ }
781
+ });
782
+
783
+ it('skips seeding (no error) when no password and not on a TTY', async () => {
784
+ // The before() hook stubs stdin.isTTY=false so the seed step warns
785
+ // and skips rather than blocking on an unanswerable prompt.
786
+ const dir = './test-data-su-pw-missing';
787
+ await fs.remove(dir);
788
+ await fs.ensureDir(dir);
789
+ const port = await getAvailablePort();
790
+ const baseUrl = `http://${TEST_HOST}:${port}`;
791
+ const server = createServer({
792
+ logger: false,
793
+ root: dir,
794
+ idp: true,
795
+ idpIssuer: baseUrl,
796
+ singleUser: true,
797
+ singleUserName: 'me',
798
+ // singleUserPassword intentionally omitted
799
+ forceCloseConnections: true,
800
+ });
801
+ try {
802
+ await server.listen({ port, host: TEST_HOST });
803
+ const { findByUsername } = await import('../src/idp/accounts.js');
804
+ const account = await findByUsername('me');
805
+ assert.strictEqual(account, null, 'no account should be seeded without a password');
806
+ // Pod itself must still exist — server starts up regardless.
807
+ const profileExists = await fs.pathExists(path.join(dir, 'me/profile/card.jsonld'));
808
+ assert.ok(profileExists, 'pod should still be created');
809
+ } finally {
810
+ await server.close();
811
+ await fs.remove(dir);
812
+ }
813
+ });
814
+
815
+ it('is idempotent — restarting does not duplicate or error', async () => {
816
+ const dir = './test-data-su-pw-idempotent';
817
+ await fs.remove(dir);
818
+ await fs.ensureDir(dir);
819
+ const port = await getAvailablePort();
820
+ const baseUrl = `http://${TEST_HOST}:${port}`;
821
+ const startOnce = async () => {
822
+ const s = createServer({
823
+ logger: false,
824
+ root: dir,
825
+ idp: true,
826
+ idpIssuer: baseUrl,
827
+ singleUser: true,
828
+ singleUserName: 'me',
829
+ singleUserPassword: 'idem-pw',
830
+ forceCloseConnections: true,
831
+ });
832
+ await s.listen({ port, host: TEST_HOST });
833
+ return s;
834
+ };
835
+ let s1, s2;
836
+ try {
837
+ s1 = await startOnce();
838
+ await s1.close();
839
+ s2 = await startOnce();
840
+ const { findByUsername, authenticate } = await import('../src/idp/accounts.js');
841
+ const account = await findByUsername('me');
842
+ assert.ok(account, 'account from first run should still exist');
843
+ // Original password still valid (we didn't overwrite on the second run).
844
+ const authed = await authenticate('me', 'idem-pw');
845
+ assert.ok(authed);
846
+ } finally {
847
+ if (s2) await s2.close();
848
+ await fs.remove(dir);
849
+ }
850
+ });
851
+
852
+ it('seeds with the legacy WebID when /profile/card (no .jsonld) already exists', async () => {
853
+ // Older JSS versions used /profile/card without the extension. A
854
+ // legacy pod must keep that URL — seeding an account whose WebID
855
+ // points at /profile/card.jsonld#me would create a credential bound
856
+ // to a document the user doesn't actually have.
857
+ const dir = './test-data-su-pw-legacy';
858
+ await fs.remove(dir);
859
+ await fs.ensureDir(dir);
860
+ // Pre-seed a legacy-layout pod so the server treats it as already
861
+ // existing on startup (no fresh creation).
862
+ const legacyProfileDir = path.join(dir, 'me/profile');
863
+ await fs.ensureDir(legacyProfileDir);
864
+ await fs.writeFile(path.join(legacyProfileDir, 'card'), '<html></html>');
865
+
866
+ const port = await getAvailablePort();
867
+ const baseUrl = `http://${TEST_HOST}:${port}`;
868
+ const server = createServer({
869
+ logger: false,
870
+ root: dir,
871
+ idp: true,
872
+ idpIssuer: baseUrl,
873
+ singleUser: true,
874
+ singleUserName: 'me',
875
+ singleUserPassword: 'legacy-pw',
876
+ forceCloseConnections: true,
877
+ });
878
+ try {
879
+ await server.listen({ port, host: TEST_HOST });
880
+ const { findByUsername } = await import('../src/idp/accounts.js');
881
+ const account = await findByUsername('me');
882
+ assert.ok(account, 'account should be seeded against the legacy pod');
883
+ assert.ok(
884
+ account.webId.endsWith('/me/profile/card#me'),
885
+ `legacy pod must keep /profile/card#me WebID, got ${account.webId}`
886
+ );
887
+ } finally {
888
+ await server.close();
889
+ await fs.remove(dir);
890
+ }
891
+ });
892
+
893
+ it('does not seed when --idp is off', async () => {
894
+ const dir = './test-data-su-pw-no-idp';
895
+ await fs.remove(dir);
896
+ await fs.ensureDir(dir);
897
+ const port = await getAvailablePort();
898
+ const server = createServer({
899
+ logger: false,
900
+ root: dir,
901
+ idp: false,
902
+ singleUser: true,
903
+ singleUserName: 'me',
904
+ singleUserPassword: 'should-be-ignored',
905
+ forceCloseConnections: true,
906
+ });
907
+ try {
908
+ await server.listen({ port, host: TEST_HOST });
909
+ // No .idp directory should exist when idp is disabled.
910
+ const idpDirExists = await fs.pathExists(path.join(dir, '.idp'));
911
+ assert.strictEqual(idpDirExists, false, 'no .idp directory when --idp off');
912
+ } finally {
913
+ await server.close();
914
+ await fs.remove(dir);
915
+ }
916
+ });
917
+ });