javascript-solid-server 0.0.153 → 0.0.155

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.153",
3
+ "version": "0.0.155",
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'}`);
package/src/rdf/turtle.js CHANGED
@@ -189,10 +189,26 @@ function jsonLdToQuads(jsonLd, baseUri) {
189
189
 
190
190
  const context = mergedContext;
191
191
 
192
- for (const node of nodes) {
192
+ // BFS over nodes so that nested node objects (e.g. CID `service[]` entries
193
+ // with their own @id/@type/properties) are emitted as their own subjects
194
+ // rather than collapsed to a bare URI reference.
195
+ //
196
+ // Two notes on the traversal shape:
197
+ // - Index-based iteration avoids O(n) array.shift() per step.
198
+ // - We deliberately do NOT skip re-emission when the same @id appears
199
+ // twice. Duplicate triples are harmless in RDF, and documents built
200
+ // from PATCH merges or multi-doc inputs can legitimately carry
201
+ // multiple objects for the same subject. The `enqueuedNested` set
202
+ // (by object identity) is used only to prevent the same nested
203
+ // object from being enqueued twice — i.e. cycle protection, not
204
+ // emission deduplication.
205
+ const enqueuedNested = new WeakSet();
206
+ const queue = [...nodes];
207
+ for (let i = 0; i < queue.length; i++) {
208
+ const node = queue[i];
193
209
  if (!node['@id']) continue;
194
-
195
210
  const subjectUri = resolveUri(node['@id'], baseUri);
211
+
196
212
  const subject = subjectUri.startsWith('_:')
197
213
  ? blankNode(subjectUri.slice(2))
198
214
  : namedNode(subjectUri);
@@ -227,6 +243,20 @@ function jsonLdToQuads(jsonLd, baseUri) {
227
243
  if (object) {
228
244
  quads.push(quad(subject, predicate, object));
229
245
  }
246
+ // If v is a nested node (object with @id and at least one non-@value
247
+ // own property beyond @id), enqueue it so its triples are also
248
+ // emitted. Object-identity tracking (WeakSet) prevents the same
249
+ // nested object from being enqueued twice, which would otherwise
250
+ // loop for graphs that reuse an object reference (cycles).
251
+ if (v && typeof v === 'object' && !Array.isArray(v) &&
252
+ v['@id'] && v['@value'] === undefined &&
253
+ !enqueuedNested.has(v)) {
254
+ const hasOwnClaims = Object.keys(v).some(k => k !== '@id');
255
+ if (hasOwnClaims) {
256
+ enqueuedNested.add(v);
257
+ queue.push(v);
258
+ }
259
+ }
230
260
  }
231
261
  }
232
262
  }
@@ -378,9 +408,14 @@ function resolveUri(uri, baseUri) {
378
408
  }
379
409
 
380
410
  /**
381
- * Expand prefixed URI using context
411
+ * Expand prefixed URI using context.
412
+ *
413
+ * The `seen` parameter guards against cycles in user-supplied contexts
414
+ * (e.g., `foo -> bar -> foo`). Without this a request carrying a malicious
415
+ * JSON-LD context could cause unbounded recursion / stack overflow on the
416
+ * server during conneg conversion — a remote DoS.
382
417
  */
383
- function expandUri(uri, context) {
418
+ function expandUri(uri, context, seen) {
384
419
  if (uri.includes('://')) {
385
420
  return uri;
386
421
  }
@@ -388,19 +423,29 @@ function expandUri(uri, context) {
388
423
  if (uri.includes(':')) {
389
424
  const [prefix, local] = uri.split(':', 2);
390
425
  const ns = context[prefix] || COMMON_PREFIXES[prefix];
391
- if (ns) {
426
+ // Only concat when the prefix maps to a string namespace. A user-supplied
427
+ // context can legally define a prefix-looking key as a term-definition
428
+ // object; string-concatenating that would produce "[object Object]…".
429
+ if (typeof ns === 'string') {
392
430
  return ns + local;
393
431
  }
394
432
  }
395
433
 
396
- // Check if it's a term in context
434
+ // Check if it's a term in context. A context value can itself be a
435
+ // CURIE (`cid:service`) that still needs prefix expansion, so recurse —
436
+ // but only when we haven't already followed this term on the current
437
+ // expansion chain.
397
438
  if (context[uri]) {
439
+ const chain = seen || new Set();
440
+ if (chain.has(uri)) return uri;
441
+ chain.add(uri);
398
442
  const expansion = context[uri];
399
443
  if (typeof expansion === 'string') {
400
- return expansion;
444
+ return expansion === uri ? uri : expandUri(expansion, context, chain);
401
445
  }
402
446
  if (expansion['@id']) {
403
- return expansion['@id'];
447
+ const id = expansion['@id'];
448
+ return id === uri ? uri : expandUri(id, context, chain);
404
449
  }
405
450
  }
406
451
 
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
 
@@ -12,6 +12,8 @@ const SOLID = 'http://www.w3.org/ns/solid/terms#';
12
12
  const SCHEMA = 'http://schema.org/';
13
13
  const LDP = 'http://www.w3.org/ns/ldp#';
14
14
  const PIM = 'http://www.w3.org/ns/pim/space#';
15
+ const CID = 'https://www.w3.org/ns/cid/v1#';
16
+ const LWS = 'https://www.w3.org/ns/lws#';
15
17
 
16
18
  /**
17
19
  * Generate JSON-LD data for a WebID profile
@@ -24,6 +26,9 @@ const PIM = 'http://www.w3.org/ns/pim/space#';
24
26
  */
25
27
  export function generateProfileJsonLd({ webId, name, podUri, issuer }) {
26
28
  const pod = podUri.endsWith('/') ? podUri : podUri + '/';
29
+ // Document URL is the WebID without its fragment; service entries use
30
+ // fragment ids resolved against it.
31
+ const docUrl = webId.split('#')[0];
27
32
 
28
33
  return {
29
34
  '@context': {
@@ -32,6 +37,8 @@ export function generateProfileJsonLd({ webId, name, podUri, issuer }) {
32
37
  'schema': SCHEMA,
33
38
  'pim': PIM,
34
39
  'ldp': LDP,
40
+ 'cid': CID,
41
+ 'lws': LWS,
35
42
  'inbox': { '@id': 'ldp:inbox', '@type': '@id' },
36
43
  'storage': { '@id': 'pim:storage', '@type': '@id' },
37
44
  'oidcIssuer': { '@id': 'solid:oidcIssuer', '@type': '@id' },
@@ -39,7 +46,9 @@ export function generateProfileJsonLd({ webId, name, podUri, issuer }) {
39
46
  'publicTypeIndex': { '@id': 'solid:publicTypeIndex', '@type': '@id' },
40
47
  'privateTypeIndex': { '@id': 'solid:privateTypeIndex', '@type': '@id' },
41
48
  'isPrimaryTopicOf': { '@id': 'foaf:isPrimaryTopicOf', '@type': '@id' },
42
- 'mainEntityOfPage': { '@id': 'schema:mainEntityOfPage', '@type': '@id' }
49
+ 'mainEntityOfPage': { '@id': 'schema:mainEntityOfPage', '@type': '@id' },
50
+ 'service': { '@id': 'cid:service', '@container': '@set' },
51
+ 'serviceEndpoint': { '@id': 'cid:serviceEndpoint', '@type': '@id' }
43
52
  },
44
53
  '@id': webId,
45
54
  '@type': ['foaf:Person', 'schema:Person'],
@@ -51,7 +60,17 @@ export function generateProfileJsonLd({ webId, name, podUri, issuer }) {
51
60
  'oidcIssuer': issuer,
52
61
  'preferencesFile': `${pod}settings/prefs.jsonld`,
53
62
  'publicTypeIndex': `${pod}settings/publicTypeIndex.jsonld`,
54
- 'privateTypeIndex': `${pod}settings/privateTypeIndex.jsonld`
63
+ 'privateTypeIndex': `${pod}settings/privateTypeIndex.jsonld`,
64
+ // LWS 1.0 Controlled Identifier service entry — mirrors `oidcIssuer` so
65
+ // LWS-aware verifiers can establish trust. Additive; the legacy
66
+ // `solid:oidcIssuer` predicate stays for existing Solid clients.
67
+ 'service': [
68
+ {
69
+ '@id': `${docUrl}#oidc`,
70
+ '@type': 'lws:OpenIdProvider',
71
+ 'serviceEndpoint': issuer
72
+ }
73
+ ]
55
74
  };
56
75
  }
57
76
 
@@ -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
+ });
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
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Direct unit tests for the JSON-LD → Turtle converter.
3
+ *
4
+ * The focus is on regression coverage for properties that would otherwise
5
+ * be easy to regress silently:
6
+ * - cycle-safety in expandUri (DoS guard — a malicious context must not
7
+ * cause unbounded recursion / stack overflow)
8
+ * - duplicate @id across top-level docs must NOT suppress emission
9
+ * (the visited-set refactor previously dropped data)
10
+ * - cyclical nested node references must not hang the BFS
11
+ */
12
+
13
+ import { describe, it } from 'node:test';
14
+ import assert from 'node:assert';
15
+ import { fromJsonLd } from '../src/rdf/conneg.js';
16
+
17
+ describe('turtle converter — unit (#320 follow-ups)', () => {
18
+ it('expandUri does not recurse forever on a cyclic context (a → b → a)', async () => {
19
+ const doc = {
20
+ '@context': {
21
+ // Pathological: each term points at another term via CURIE, forming a loop.
22
+ 'a': { '@id': 'b:x' },
23
+ 'b': { '@id': 'a:y' }
24
+ },
25
+ '@id': 'https://example.test/s',
26
+ 'a': 'hello'
27
+ };
28
+ // The converter should finish — not stack-overflow — regardless of what
29
+ // the output happens to look like. We only assert it completes with a
30
+ // string result.
31
+ const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true);
32
+ assert.ok(typeof content === 'string');
33
+ });
34
+
35
+ it('expandUri does not recurse forever on a self-loop (a → a)', async () => {
36
+ const doc = {
37
+ '@context': {
38
+ 'selfy': 'selfy'
39
+ },
40
+ '@id': 'https://example.test/s',
41
+ 'selfy': 'hello'
42
+ };
43
+ const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true);
44
+ assert.ok(typeof content === 'string');
45
+ });
46
+
47
+ it('duplicate top-level @id is not silently dropped', async () => {
48
+ // Two docs describing the same subject — both claims must survive.
49
+ // (Previously the visited-set in the BFS skipped the second pass.)
50
+ const docs = [
51
+ {
52
+ '@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' },
53
+ '@id': 'https://example.test/alice',
54
+ 'foaf:name': 'Alice'
55
+ },
56
+ {
57
+ '@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' },
58
+ '@id': 'https://example.test/alice',
59
+ 'foaf:age': 30
60
+ }
61
+ ];
62
+ const { content } = await fromJsonLd(docs, 'text/turtle', 'https://example.test/', true);
63
+ assert.ok(content.includes('Alice'), `Turtle should contain the name claim, got:\n${content}`);
64
+ assert.ok(/30|"30"/.test(content), `Turtle should contain the age claim, got:\n${content}`);
65
+ });
66
+
67
+ it('prefix-looking context key defined as an object is not string-concatenated', async () => {
68
+ // A user-supplied context can legally define a prefix-looking key as a
69
+ // term-definition object (not a namespace string). The converter must
70
+ // not treat it as a namespace — string-concatenating the object would
71
+ // produce invalid IRIs like "[object Object]foo".
72
+ const doc = {
73
+ '@context': {
74
+ // `bogus` is defined as a term object, not a namespace string.
75
+ 'bogus': { '@id': 'https://example.test/ns#bogus' }
76
+ },
77
+ '@id': 'https://example.test/s',
78
+ // This looks like a CURIE `bogus:foo` but `bogus` is not a valid
79
+ // namespace — the converter should leave it alone.
80
+ 'bogus:foo': 'hello'
81
+ };
82
+ const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true);
83
+ assert.ok(typeof content === 'string');
84
+ assert.ok(!content.includes('[object Object]'),
85
+ `Turtle output must not contain object-stringification, got:\n${content}`);
86
+ });
87
+
88
+ it('cyclical nested node reference does not hang', async () => {
89
+ // Two nested nodes reference each other. BFS must not loop.
90
+ const a = { '@id': 'https://example.test/a', 'ex:knows': null };
91
+ const b = { '@id': 'https://example.test/b', 'ex:knows': a };
92
+ a['ex:knows'] = b;
93
+
94
+ const doc = {
95
+ '@context': { 'ex': 'https://example.test/ns#' },
96
+ '@id': 'https://example.test/root',
97
+ 'ex:knows': a
98
+ };
99
+ const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true);
100
+ assert.ok(typeof content === 'string');
101
+ assert.ok(content.includes('https://example.test/a'), 'node a should appear');
102
+ assert.ok(content.includes('https://example.test/b'), 'node b should appear');
103
+ });
104
+ });
@@ -98,6 +98,41 @@ describe('WebID Profile', () => {
98
98
  // Empty string is a relative URI reference to the document itself (JSON-LD)
99
99
  assert.strictEqual(jsonLd['isPrimaryTopicOf'], '', 'isPrimaryTopicOf should be "" (self)');
100
100
  });
101
+
102
+ // LWS 1.0 Controlled Identifier alignment (#320).
103
+ // These assertions live alongside the WebID predicate assertions — both
104
+ // must continue to hold since the profile is dual-write.
105
+ it('should emit a CID service[] with an lws:OpenIdProvider entry', async () => {
106
+ const res = await request(profilePath);
107
+ const jsonLd = await res.json();
108
+ assert.ok(Array.isArray(jsonLd.service), 'profile should have a service array');
109
+ const oidc = jsonLd.service.find((s) => s['@type'] === 'lws:OpenIdProvider');
110
+ assert.ok(oidc, 'service[] must include an lws:OpenIdProvider entry');
111
+ });
112
+
113
+ it('lws:OpenIdProvider service.serviceEndpoint mirrors oidcIssuer', async () => {
114
+ const res = await request(profilePath);
115
+ const jsonLd = await res.json();
116
+ assert.ok(Array.isArray(jsonLd.service), 'profile should have a service array');
117
+ const oidc = jsonLd.service.find((s) => s['@type'] === 'lws:OpenIdProvider');
118
+ assert.ok(oidc, 'service[] must include an lws:OpenIdProvider entry');
119
+ assert.strictEqual(
120
+ oidc.serviceEndpoint,
121
+ jsonLd.oidcIssuer,
122
+ 'serviceEndpoint must equal the existing oidcIssuer value'
123
+ );
124
+ });
125
+
126
+ it('lws:OpenIdProvider service.id is a fragment on the profile document', async () => {
127
+ const res = await request(profilePath);
128
+ const jsonLd = await res.json();
129
+ assert.ok(Array.isArray(jsonLd.service), 'profile should have a service array');
130
+ const oidc = jsonLd.service.find((s) => s['@type'] === 'lws:OpenIdProvider');
131
+ assert.ok(oidc, 'service[] must include an lws:OpenIdProvider entry');
132
+ const docUrl = jsonLd['@id'].split('#')[0];
133
+ assert.strictEqual(oidc['@id'], `${docUrl}#oidc`,
134
+ 'service entry @id should be `<profile-doc>#oidc`');
135
+ });
101
136
  });
102
137
 
103
138
  describe('WebID Resolution', () => {
@@ -119,3 +154,49 @@ describe('WebID Profile', () => {
119
154
  });
120
155
  });
121
156
  });
157
+
158
+ // With conneg enabled the profile is converted to Turtle on demand. The
159
+ // CID service[] must survive that conversion — LWS verifiers that ask for
160
+ // Turtle need to see the nested service node's type and serviceEndpoint,
161
+ // not just a bare URI reference to it.
162
+ describe('WebID Profile — Turtle conneg (#320)', () => {
163
+ before(async () => {
164
+ await startTestServer({ conneg: true });
165
+ await createTestPod('webidturtletest');
166
+ });
167
+
168
+ after(async () => {
169
+ await stopTestServer();
170
+ });
171
+
172
+ it('Turtle variant includes cid:service with lws:OpenIdProvider and serviceEndpoint', async () => {
173
+ const res = await request('/webidturtletest/profile/card.jsonld', {
174
+ headers: { Accept: 'text/turtle' }
175
+ });
176
+ assertStatus(res, 200);
177
+ assertHeaderContains(res, 'Content-Type', 'text/turtle');
178
+ const ttl = await res.text();
179
+ // Accept either prefixed (cid:service) or expanded full-URI form. The
180
+ // critical property is that the nested service node's data survived the
181
+ // JSON-LD → Turtle conversion — i.e. the type and endpoint are present
182
+ // as their own triples, not dropped.
183
+ assert.ok(
184
+ ttl.includes('cid:service') || ttl.includes('cid/v1#service'),
185
+ `Turtle should reference the CID service predicate, got:\n${ttl}`
186
+ );
187
+ assert.ok(
188
+ ttl.includes('OpenIdProvider'),
189
+ `Turtle should declare the lws:OpenIdProvider type, got:\n${ttl}`
190
+ );
191
+ assert.ok(
192
+ ttl.includes('cid:serviceEndpoint') || ttl.includes('cid/v1#serviceEndpoint'),
193
+ `Turtle should include the cid:serviceEndpoint predicate, got:\n${ttl}`
194
+ );
195
+ // The service entry URI appears as a subject (its own line), proving it
196
+ // was emitted as a first-class node rather than a bare URI reference.
197
+ assert.ok(
198
+ /#oidc>\s+(?:a|<[^>]*#type>)/.test(ttl),
199
+ `Turtle should emit the service entry as a subject, got:\n${ttl}`
200
+ );
201
+ });
202
+ });