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.
- 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/handlers/resource.js +43 -31
- package/src/server.js +129 -5
- package/test/config.test.js +66 -0
- package/test/conneg.test.js +92 -0
- package/test/idp.test.js +202 -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/handlers/resource.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
155
|
-
acceptHeader
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
//
|
|
261
|
+
// Pick the negotiated RDF type using q-aware Accept parsing (#325).
|
|
261
262
|
const acceptHeader = request.headers.accept || '';
|
|
262
|
-
const
|
|
263
|
-
acceptHeader
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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 (
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
const
|
|
518
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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/conneg.test.js
CHANGED
|
@@ -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
|
+
});
|