Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bin/jss.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ program
.option('--no-invite-only', 'Allow open registration')
.option('--single-user', 'Single-user mode (creates pod on startup, disables registration)')
.option('--single-user-name <name>', 'Username for single-user mode (default: me)')
.option('--single-user-password <pw>', 'Initial IDP password to seed when creating the single-user pod (or set JSS_SINGLE_USER_PASSWORD)')
.option('--webid-tls', 'Enable WebID-TLS client certificate authentication')
.option('--no-webid-tls', 'Disable WebID-TLS authentication')
.option('--public', 'Allow unauthenticated access (skip WAC, open read/write)')
Expand Down Expand Up @@ -164,6 +165,7 @@ program
webidTls: config.webidTls,
singleUser: config.singleUser,
singleUserName: config.singleUserName,
singleUserPassword: config.singleUserPassword,
public: config.public,
readOnly: config.readOnly,
liveReload: config.liveReload,
Expand Down
19 changes: 18 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@ export JSS_ACTIVITYPUB=true
export JSS_AP_USERNAME=alice
export JSS_PUBLIC=true
export JSS_READ_ONLY=true
export JSS_SINGLE_USER=true
export JSS_SINGLE_USER_NAME=me
export JSS_SINGLE_USER_PASSWORD=choose-a-good-one # seeds IDP account on first start
export JSS_LIVE_RELOAD=true
export JSS_SOLIDOS_UI=true
export JSS_PAY=true
Expand Down Expand Up @@ -256,8 +259,13 @@ For personal pod servers where only one user needs access:

```bash
# Basic single-user mode (creates pod at /me/)
# On first run JSS will prompt for an initial password (TTY only).
jss start --single-user --idp

# Provide the initial IDP password non-interactively (systemd, containers, CI):
jss start --single-user --idp --single-user-password 'choose-a-good-one'
JSS_SINGLE_USER_PASSWORD='choose-a-good-one' jss start --single-user --idp

# Custom username
jss start --single-user --single-user-name alice --idp

Expand All @@ -270,10 +278,19 @@ JSS_SINGLE_USER=true jss start --idp

**Features:**
- Pod auto-created on first startup with full structure (inbox, public, private, profile)
- IDP account auto-seeded so the operator can log in immediately
- Registration endpoint disabled (returns 403)
- Login still works for the single user
- Login works for the single user via password (`POST /idp/credentials`) or any other configured method
- Proper ACLs generated automatically

**Initial password sources, in priority order:**
1. `--single-user-password <pw>` CLI flag
2. `JSS_SINGLE_USER_PASSWORD` env var
3. Interactive no-echo prompt (TTY only)
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.)

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`).


## Invite-Only Registration

Expand Down
72 changes: 69 additions & 3 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ export const defaults = {
// Single-user mode (personal pod server)
singleUser: false,
singleUserName: 'me',
// Initial IDP password seeded on first single-user pod creation. If
// unset and --idp is enabled, the server prompts on a TTY or logs a
// warning and continues startup on non-TTY (so the pod is created but
// is not yet loggable until a password is set).
singleUserPassword: null,

// WebID-TLS client certificate authentication
webidTls: false,
Expand Down Expand Up @@ -154,6 +159,7 @@ const envMap = {
JSS_INVITE_ONLY: 'inviteOnly',
JSS_SINGLE_USER: 'singleUser',
JSS_SINGLE_USER_NAME: 'singleUserName',
JSS_SINGLE_USER_PASSWORD: 'singleUserPassword',
JSS_WEBID_TLS: 'webidTls',
Comment on lines 159 to 163

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSS_SINGLE_USER_PASSWORD is parsed via parseEnvValue(), which currently coerces the literal strings "true"/"false" into booleans for all env keys. That means a user cannot set their password to "true" or "false" via env var (and a boolean may flow into bcrypt hashing). Consider changing env parsing to only coerce booleans for a known allowlist of boolean keys, or special-case singleUserPassword to always remain a string.

Copilot uses AI. Check for mistakes.
JSS_DEFAULT_QUOTA: 'defaultQuota',
JSS_PUBLIC: 'public',
Expand Down Expand Up @@ -184,15 +190,53 @@ export function parseSize(str) {
return Math.floor(num * (multipliers[unit] || 1));
}

/**
* Config keys whose values are genuinely boolean. Only these get the
* "true"/"false" string coercion below — otherwise a user-supplied
* password (or any other string-valued option) like "true"/"false"
* would silently turn into a boolean and break downstream code (e.g.
* bcrypt hashing).
*/
const BOOLEAN_KEYS = new Set([
'ssl',
'conneg',
'subdomains',
'mashlib',
'mashlibCdn',
'git',
'nostr',
'webrtc',
'terminal',
'tunnel',
'activitypub',
'inviteOnly',
'multiuser',
'singleUser',
'webidTls',
'public',
'readOnly',
'liveReload',
'pay',
'mongo',
'idp',
'notifications',
'logger',
'quiet'
]);
Comment on lines +200 to +225

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseEnvValue now only coerces "true"/"false" for keys in BOOLEAN_KEYS, but multiuser is missing from that set. This makes JSS_MULTIUSER=false (and =true) parse as the string "false"/"true" instead of a boolean, which will be truthy and can break feature gating. Add multiuser to BOOLEAN_KEYS (and consider a small regression test for it, similar to the JSS_IDP tests).

Copilot uses AI. Check for mistakes.

/**
* Parse a value from environment variable string
*/
function parseEnvValue(value, key) {
if (value === undefined) return undefined;

// Boolean values
if (value.toLowerCase() === 'true') return true;
if (value.toLowerCase() === 'false') return false;
// Boolean values — only for known boolean keys; everything else
// stays a string so passwords / tokens / arbitrary text aren't
// silently coerced to booleans.
if (BOOLEAN_KEYS.has(key)) {
if (value.toLowerCase() === 'true') return true;
if (value.toLowerCase() === 'false') return false;
}

// Numeric values for known numeric keys
if ((key === 'port' || key === 'nostrMaxEvents' || key === 'payCost' || key === 'payRate') && !isNaN(value)) {
Expand Down Expand Up @@ -311,6 +355,10 @@ export async function saveConfig(config, configFile) {
// Remove derived/runtime values
delete toSave.ssl;
delete toSave.logger;
// Never persist secrets to a static config file. The password is
// expected to come from --single-user-password or
// JSS_SINGLE_USER_PASSWORD at runtime, not be written into .jss/config.
delete toSave.singleUserPassword;

await fs.ensureDir(path.dirname(configFile));
await fs.writeFile(configFile, JSON.stringify(toSave, null, 2));
Expand All @@ -327,6 +375,24 @@ export function printConfig(config) {
console.log(` Root: ${path.resolve(config.root)}`);
console.log(` SSL: ${config.ssl ? 'enabled' : 'disabled'}`);
console.log(` Multi-user: ${config.multiuser}`);
if (config.singleUser) {
let details = `${config.singleUserName}`;
// Password seeding only runs when --idp is on AND the pod isn't the
// root-level case ('/'). Reflect both gates in the printed line so
// operators don't see a misleading "missing — login disabled" when
// login isn't governed by an IDP password at all.
if (config.idp) {
if (config.singleUserName === '/' || !config.singleUserName) {
details += ' (root pod; password not seeded)';
} else {
const pwSource = config.singleUserPassword
? 'provided'
: (process.stdin.isTTY ? 'will prompt at startup' : 'missing — login disabled');
details += ` (password: ${pwSource})`;
}
}
console.log(` Single-user: ${details}`);
}
console.log(` Conneg: ${config.conneg}`);
console.log(` Notifications: ${config.notifications}`);
console.log(` IdP: ${config.idp ? (config.idpIssuer || 'enabled') : 'disabled'}`);
Expand Down
134 changes: 129 additions & 5 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export function createServer(options = {}) {
// Single-user mode - creates pod on startup, disables registration
const singleUser = options.singleUser ?? false;
const singleUserName = options.singleUserName ?? 'me';
const singleUserPassword = options.singleUserPassword ?? null;
// Default storage quota per pod (50MB default, 0 = unlimited)
const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024;
// WebID-TLS client certificate authentication is OFF by default
Expand Down Expand Up @@ -561,15 +562,21 @@ export function createServer(options = {}) {
const isRootPod = !singleUserName || singleUserName === '/';
const podPath = isRootPod ? '/' : `/${singleUserName}/`;
const podUri = isRootPod ? `${baseUrl}/` : `${baseUrl}/${singleUserName}/`;
const webId = `${podUri}profile/card.jsonld#me`;
const displayName = isRootPod ? 'me' : singleUserName;

// Check if pod already exists. Accept either the new `card.jsonld`
// or legacy extensionless `card` layout so we don't re-seed a pod
// that was created by an older JSS version.
const profileExists =
await storage.exists(`${podPath}profile/card.jsonld`) ||
await storage.exists(`${podPath}profile/card`);
// that was created by an older JSS version. Compute the effective
// WebID against whichever profile file actually resolves — a
// legacy pod must keep its `/profile/card#me` WebID, otherwise the
// seeded IDP account would point at a non-existent document.
const hasJsonLd = await storage.exists(`${podPath}profile/card.jsonld`);
const hasLegacy = !hasJsonLd && await storage.exists(`${podPath}profile/card`);
const profileFile = hasJsonLd ? 'profile/card.jsonld'
: hasLegacy ? 'profile/card'
: 'profile/card.jsonld'; // fresh pod default
const webId = `${podUri}${profileFile}#me`;
const profileExists = hasJsonLd || hasLegacy;

if (!profileExists) {
fastify.log.info(`Creating single-user pod at ${podUri}...`);
Expand All @@ -583,6 +590,123 @@ export function createServer(options = {}) {
}
fastify.log.info(`Single-user pod created at ${podUri}`);
}

// Seed an IDP account so the operator can actually log in. Without
// this, single-user + --idp produces a pod but no credential, and
// registration is intentionally disabled in single-user mode — so
// the pod is unloggable until a password is set externally (#323).
if (idpEnabled && !isRootPod) {
await seedSingleUserIdpAccount({
fastify,
username: singleUserName,
webId,
podName: singleUserName,
providedPassword: singleUserPassword
});
Comment on lines +598 to +605

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the pod already exists in the legacy layout (/profile/card), webId is still hard-coded to .../profile/card.jsonld#me and then passed into seedSingleUserIdpAccount(). That can seed an account with a WebID that doesn’t actually exist/resolvably match the user’s profile. Consider detecting which profile file exists and constructing webId accordingly (or migrating by writing card.jsonld for legacy pods before seeding).

Copilot uses AI. Check for mistakes.
}
});
}

/**
* Seed an IDP account for the single-user pod owner if one doesn't
* already exist. Password sources, in priority order:
* 1. `--single-user-password` / `JSS_SINGLE_USER_PASSWORD`
* 2. interactive prompt (TTY only)
* 3. error — server stays up but logs that login won't work yet
*/
async function seedSingleUserIdpAccount({ fastify, username, webId, podName, providedPassword }) {
const { findByUsername, createAccount } = await import('./idp/accounts.js');
const existing = await findByUsername(username);
if (existing) return; // already seeded — idempotent

// Treat anything that isn't a non-empty string as "not provided" so
// a misconfigured env coercion or stray boolean can't reach bcrypt.
let password = (typeof providedPassword === 'string' && providedPassword.length > 0)
? providedPassword
: null;

if (!password) {
if (process.stdin.isTTY && process.stdout.isTTY) {
try {
password = await promptPasswordOnce(`[jss] Set initial IDP password for "${username}": `);
} catch (err) {
fastify.log.warn({ err }, `Password prompt failed for "${username}"`);
return;
}
} else {
fastify.log.warn(
`--single-user --idp: no password provided. Set --single-user-password or ` +
`JSS_SINGLE_USER_PASSWORD before starting (or run on a TTY to be prompted). ` +
`Login is currently not possible for "${username}".`
);
return;
}
}

if (typeof password !== 'string' || password.length === 0) {
fastify.log.warn(`Empty password — skipping IDP account creation for "${username}".`);
return;
}

try {
await createAccount({ username, password, webId, podName });
fastify.log.info(`IDP account seeded for single-user "${username}".`);
} catch (err) {
fastify.log.error({ err }, `Failed to seed IDP account for "${username}"`);
}
}

/**
* Read a password from stdin without echoing it. Uses the public
* `emitKeypressEvents` + raw-mode keypress API rather than overriding
* the underscored `_writeToOutput` on a `readline.Interface`, which is
* a private/unstable hook.
*/
async function promptPasswordOnce(prompt) {
const { emitKeypressEvents } = await import('node:readline');
const stdin = process.stdin;
const stdout = process.stdout;
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
throw new Error('Interactive password prompt requires a TTY');
}
return new Promise((resolve, reject) => {
let password = '';
const wasRaw = stdin.isRaw === true;
const onKeypress = (str, key = {}) => {
if (key.ctrl && key.name === 'c') {
cleanup();
reject(new Error('Password prompt cancelled'));
return;
}
if (key.name === 'return' || key.name === 'enter') {
cleanup();
resolve(password);
return;
}
if (key.name === 'backspace' || key.name === 'delete') {
password = password.slice(0, -1);
return;
}
// Only accept printable input — \P{C} excludes control codes,
// so escape sequences from arrow keys, function keys, etc. don't
// sneak invisible bytes into the password buffer.
if (!key.ctrl && !key.meta &&
typeof str === 'string' && str.length > 0 &&
/^\P{C}+$/u.test(str)) {
password += str;
}
};
const cleanup = () => {
stdin.removeListener('keypress', onKeypress);
if (!wasRaw) stdin.setRawMode(false);
stdout.write('\n');
stdin.pause();
};
emitKeypressEvents(stdin);
stdout.write(prompt);
if (!wasRaw) stdin.setRawMode(true);
stdin.resume();
stdin.on('keypress', onKeypress);
});
}

Expand Down
66 changes: 66 additions & 0 deletions test/config.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Config / env-var parsing tests.
*
* Regression coverage for the env-coercion fix in #323: only known
* boolean keys may have their string values coerced to booleans.
* Otherwise an env var like JSS_SINGLE_USER_PASSWORD="true" would silently
* become a real boolean and break downstream code (bcrypt, etc.).
*/

import { describe, it, before, after } from 'node:test';
import assert from 'node:assert';
import { loadConfig } from '../src/config.js';

describe('config — env var boolean coercion', () => {
// Save/restore the env vars we touch so this test is hermetic.
const KEYS = ['JSS_SINGLE_USER_PASSWORD', 'JSS_IDP', 'JSS_BASE_DOMAIN', 'JSS_MULTIUSER'];
const original = {};
before(() => { for (const k of KEYS) original[k] = process.env[k]; });
after(() => {
for (const k of KEYS) {
if (original[k] === undefined) delete process.env[k];
else process.env[k] = original[k];
}
});

it('preserves string-valued env vars when their value is "true"', async () => {
process.env.JSS_SINGLE_USER_PASSWORD = 'true';
const cfg = await loadConfig({}, null);
assert.strictEqual(cfg.singleUserPassword, 'true',
'password env var must remain a string, not be coerced to boolean true');
});

it('preserves string-valued env vars when their value is "false"', async () => {
process.env.JSS_SINGLE_USER_PASSWORD = 'false';
const cfg = await loadConfig({}, null);
assert.strictEqual(cfg.singleUserPassword, 'false');
});

it('preserves string-valued env vars when set to other strings', async () => {
process.env.JSS_BASE_DOMAIN = 'example.com';
const cfg = await loadConfig({}, null);
assert.strictEqual(cfg.baseDomain, 'example.com');
});

it('still coerces known boolean keys', async () => {
process.env.JSS_IDP = 'true';
const cfg = await loadConfig({}, null);
assert.strictEqual(cfg.idp, true, 'idp env var should be coerced to boolean');
});

it('still coerces known boolean keys when "false"', async () => {
process.env.JSS_IDP = 'false';
const cfg = await loadConfig({}, null);
assert.strictEqual(cfg.idp, false);
});

it('coerces JSS_MULTIUSER to a boolean (regression for missed entry)', async () => {
process.env.JSS_MULTIUSER = 'false';
const cfg = await loadConfig({}, null);
assert.strictEqual(cfg.multiuser, false,
'multiuser must coerce to boolean false, not the string "false" (truthy)');
process.env.JSS_MULTIUSER = 'true';
const cfg2 = await loadConfig({}, null);
assert.strictEqual(cfg2.multiuser, true);
});
});
Loading