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.
- package/.claude/settings.local.json +2 -1
- package/bin/jss.js +2 -0
- package/docs/configuration.md +18 -1
- package/package.json +1 -1
- package/src/config.js +69 -3
- package/src/rdf/turtle.js +53 -8
- package/src/server.js +129 -5
- package/src/webid/profile.js +21 -2
- package/test/config.test.js +66 -0
- package/test/idp.test.js +202 -0
- package/test/turtle.test.js +104 -0
- package/test/webid.test.js +81 -0
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,
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
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
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
|
package/src/webid/profile.js
CHANGED
|
@@ -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
|
+
});
|
package/test/webid.test.js
CHANGED
|
@@ -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
|
+
});
|