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: 1 addition & 1 deletion bin/jss.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ program
.option('--invite-only', 'Require invite code for registration')
.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-name <name>', 'Mount the pod at /<name>/ instead of at the server root (default: root pod at /)')
.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')
Expand Down
24 changes: 19 additions & 5 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,19 +258,21 @@ Response:
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).
# Default: pod served at server root (#348). WebID is
# /profile/card.jsonld#me; the IDP login username is "me". On first
# run JSS will prompt for an initial password (TTY only).
Comment on lines +261 to +263
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
# Mount the pod at a named path instead of the origin. WebID becomes
# /alice/profile/card.jsonld#me; login as "alice".
jss start --single-user --single-user-name alice --idp

# Root-level pod (pod at /, WebID at /profile/card#me)
jss start --single-user --single-user-name '' --idp
# Legacy /me/ pod — same as the old default before #348.
jss start --single-user --single-user-name me --idp
Comment on lines +261 to +275

# Via environment
JSS_SINGLE_USER=true jss start --idp
Expand All @@ -283,6 +285,18 @@ JSS_SINGLE_USER=true jss start --idp
- Login works for the single user via password (`POST /idp/credentials`) or any other configured method
- Proper ACLs generated automatically

**Upgrading from a pre-#348 install:** if your existing pod was created with the old default (data lives under `<root>/me/`), JSS no longer auto-detects it — restarting plain `jss start --single-user` will start seeding a fresh empty root pod alongside your legacy `/me/` data, and your existing IDP account will keep authenticating against `/me/`. Pick one path on the next restart:
- **Keep the legacy layout:** add `--single-user-name me` to your launch command. No data movement needed.
- **Migrate to root pod:** move the *entire* contents of `<root>/me/` (including dotfiles like `.acl`, `.meta`, `.quota.json` — a plain `mv <root>/me/* <root>/` skips them) to `<root>/`, delete the IDP account for `me` (so the new root pod's `me` account can be seeded), then restart without the name flag. Use one of:
Comment on lines +288 to +290

```bash
# Option A: rsync handles dotfiles correctly with the trailing slash.
rsync -a <root>/me/ <root>/ && rm -rf <root>/me

# Option B: bash with dotglob enabled so * matches dotfiles too.
shopt -s dotglob && mv <root>/me/* <root>/ && rmdir <root>/me
```

**Initial password sources, in priority order:**
1. `--single-user-password <pw>` CLI flag
2. `JSS_SINGLE_USER_PASSWORD` env var
Expand Down
29 changes: 15 additions & 14 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ export const defaults = {

// Single-user mode (personal pod server)
singleUser: false,
singleUserName: 'me',
// null = root pod (mounted at server origin, WebID at
// /profile/card.jsonld#me). A string mounts the pod at /<name>/ —
// useful when more than one Solid identity coexists on the same
// origin, or when the operator wants the pre-#348 /me/ shape.
singleUserName: null,
Comment on lines +77 to +81
// 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
Expand Down Expand Up @@ -399,20 +403,17 @@ export function printConfig(config) {
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.
const isRootPod = config.singleUserName === '/' || !config.singleUserName;
let details = isRootPod ? '/ (root pod)' : config.singleUserName;
// The "login as me" hint and password line only make sense when
// the built-in IdP is on. With --no-idp / external issuer there's
// no built-in login form, so don't imply one exists.
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})`;
}
if (isRootPod) details += ', login as "me"';
const pwSource = config.singleUserPassword
? 'provided'
: (process.stdin.isTTY ? 'will prompt at startup' : 'missing — login disabled');
Comment on lines +413 to +415
details += ` (password: ${pwSource})`;
}
console.log(` Single-user: ${details}`);
}
Expand Down
39 changes: 33 additions & 6 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,22 @@ export function createServer(options = {}) {
const inviteOnly = options.inviteOnly ?? false;
// Single-user mode - creates pod on startup, disables registration
const singleUser = options.singleUser ?? false;
const singleUserName = options.singleUserName ?? 'me';
// Default null = root pod (#348). Pass an explicit singleUserName
// to mount the pod at /<name>/ instead. Normalize the
// historical `'/'` / `''` forms to null up front so downstream
// code (remoteStoragePlugin, decorators, etc.) doesn't have to
// re-check for the same three shapes.
//
// Pre-#348 installs (default 'me') that upgrade in place will see
// a fresh empty root pod alongside their /me/ data. The fix is to
// pass `--single-user-name me` on restart (or move data/me/* out
// to the data root). At v0.0.x we accept that one-time
// intervention rather than carrying detection magic in the code.
Comment on lines +102 to +106
const rawSingleUserName = options.singleUserName ?? null;
const singleUserName =
(rawSingleUserName === '/' || rawSingleUserName === '')
? null
Comment on lines +107 to +110
: rawSingleUserName;
const singleUserPassword = options.singleUserPassword ?? null;
// Default storage quota per pod (50MB default, 0 = unlimited)
const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024;
Expand Down Expand Up @@ -558,8 +573,10 @@ export function createServer(options = {}) {
const baseUrl = idpIssuer?.replace(/\/$/, '') || `${protocol}://${host}:${port}`;
const issuer = idpIssuer || `${baseUrl}/`;

// Root-level pod (empty or '/' name) vs named pod
const isRootPod = !singleUserName || singleUserName === '/';
// Root pod (no name) vs named pod. After the singleUserName
// normalization at the top of createServer(), null is the only
// root-pod shape we need to recognize here.
const isRootPod = !singleUserName;
const podPath = isRootPod ? '/' : `/${singleUserName}/`;
const podUri = isRootPod ? `${baseUrl}/` : `${baseUrl}/${singleUserName}/`;
const displayName = isRootPod ? 'me' : singleUserName;
Expand Down Expand Up @@ -595,12 +612,22 @@ export function createServer(options = {}) {
// 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) {
//
// Root pods (#348) need this too: the pod has no name, but the IDP
// still needs *some* username for the login form. Default to 'me'
// — matches the WebID fragment, fits the historical convention.
if (idpEnabled) {
// The IDP also persists `podName` and surfaces it as the
// `name` claim under the OIDC `profile` scope (see
// src/idp/accounts.js). For root pods we use 'me' here too —
// a null podName would leak through as a null/missing
// profile.name on every login, which OIDC clients expect to
// be a non-empty human-readable string.
await seedSingleUserIdpAccount({
fastify,
username: singleUserName,
username: isRootPod ? 'me' : singleUserName,
webId,
podName: singleUserName,
podName: isRootPod ? 'me' : singleUserName,
Comment on lines 626 to +630
Comment on lines 626 to +630
providedPassword: singleUserPassword
});
}
Expand Down
32 changes: 32 additions & 0 deletions test/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,35 @@ describe('config — --single-user implies --idp (#331)', () => {
'--no-idp without --single-user should not trigger the #331 warning');
});
});

// #348: the user-visible default change — `jss start --single-user`
// (no name flag) must produce a config where singleUserName is null,
// so createServer() takes the root-pod path. createServer() has its
// own tests but a future refactor of loadConfig() could silently
// restore the old `'me'` default and only the server-level tests
// would catch it via behaviour, not the config layer directly.
describe('config — singleUserName default (#348)', () => {
it('loadConfig() returns singleUserName=null when no flag/env is set', async () => {
delete process.env.JSS_SINGLE_USER_NAME;
const cfg = await loadConfig({}, null);
assert.strictEqual(cfg.singleUserName, null,
'default must be null (= root pod), not the legacy "me"');
});

it('loadConfig() preserves an explicit singleUserName CLI arg', async () => {
delete process.env.JSS_SINGLE_USER_NAME;
const cfg = await loadConfig({ singleUserName: 'alice' }, null);
assert.strictEqual(cfg.singleUserName, 'alice');
});

it('loadConfig() respects JSS_SINGLE_USER_NAME from env', async () => {
process.env.JSS_SINGLE_USER_NAME = 'me';
try {
const cfg = await loadConfig({}, null);
assert.strictEqual(cfg.singleUserName, 'me',
'env var should restore the legacy "me" pod path on demand');
} finally {
delete process.env.JSS_SINGLE_USER_NAME;
}
Comment on lines +169 to +190
});
});
78 changes: 78 additions & 0 deletions test/idp.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,84 @@ describe('Identity Provider - Root pod type index ACLs', () => {
});
});

// #348: --single-user with no name flag now defaults to a root pod
// (was '/me/' historically). The server-side seed must land the
// profile at /profile/card.jsonld, not /me/profile/card.jsonld.
describe('Single-user default — root pod (#348)', () => {
let server;
let baseUrl;
const DEFAULT_DATA_DIR = './test-data-348-default-root';
const ROOT_POD_PASSWORD = 'root-pod-test-pw';

before(async () => {
await fs.remove(DEFAULT_DATA_DIR);
await fs.ensureDir(DEFAULT_DATA_DIR);

const port = await getAvailablePort();
baseUrl = `http://${TEST_HOST}:${port}`;

server = createServer({
logger: false,
root: DEFAULT_DATA_DIR,
idp: true,
idpIssuer: baseUrl,
singleUser: true,
// singleUserName intentionally omitted — exercises the new default.
// Provide a password so the seeding path runs non-interactively.
singleUserPassword: ROOT_POD_PASSWORD,
forceCloseConnections: true,
});

await server.listen({ port, host: TEST_HOST });
Comment on lines +477 to +489
});

after(async () => {
await server.close();
await fs.remove(DEFAULT_DATA_DIR);
});

it('seeds the profile at /profile/card.jsonld (not /me/profile/...)', async () => {
const root = await fetch(`${baseUrl}/profile/card.jsonld`);
assert.strictEqual(root.status, 200,
'--single-user with no name should default to a root pod');
// Check the filesystem directly — an HTTP-only check could pass
// on a 401 even if /me/ data was somehow seeded, which would
// hide the regression we care about (root vs /me/ pod).
assert.strictEqual(await fs.pathExists(path.join(DEFAULT_DATA_DIR, 'me/profile/card.jsonld')), false,
'no /me/ pod files should be created when singleUserName is unset');
assert.strictEqual(await fs.pathExists(path.join(DEFAULT_DATA_DIR, 'me/profile/card')), false,
'no legacy /me/ pod files should be created either');
});

it('WebID resolves at the server origin', async () => {
const res = await fetch(`${baseUrl}/profile/card.jsonld`);
const body = await res.json();
const webId = `${baseUrl}/profile/card.jsonld#me`;
const matches = Array.isArray(body)
? body.some(n => n['@id'] === webId)
: body['@id'] === webId || (body['@graph'] || []).some(n => n['@id'] === webId);
assert.ok(matches, `profile should declare WebID ${webId}, got: ${JSON.stringify(body).slice(0, 200)}`);
});

it('seeds an IDP account for "me" so the root pod is loggable', async () => {
// Round-2 review of #348: a regression here would mean a fresh
// `jss start --single-user --idp` produces a pod nobody can log
// in to (registration is disabled in single-user mode, so there
// would be no recovery path other than out-of-band account
// creation). Use the credentials endpoint as a black-box login
// probe — if it issues a token, the seed worked.
const res = await fetch(`${baseUrl}/idp/credentials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'me', password: ROOT_POD_PASSWORD }),
});
assert.strictEqual(res.status, 200,
`login as "me" should succeed for the default root pod (got ${res.status})`);
const body = await res.json();
assert.ok(body.access_token, 'response should carry an access token');
Comment on lines +464 to +535
});
});

describe('Identity Provider - Accounts', () => {
let server;
let accountsUrl;
Expand Down
8 changes: 8 additions & 0 deletions test/url.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ describe('getPodName', () => {
assert.strictEqual(getPodName(req), '.');
});

it("returns '.' for a root pod (singleUserName null — #348 default)", () => {
// server.js normalizes '/' and '' to null at the top of
// createServer, so most root-pod requests now reach getPodName
// with singleUserName === null. Pin that path explicitly.
const req = { singleUser: true, singleUserName: null, url: '/index.html' };
assert.strictEqual(getPodName(req), '.');
});

it('returns singleUserName for a named pod, regardless of URL', () => {
const req = { singleUser: true, singleUserName: 'me', url: '/index.html' };
assert.strictEqual(getPodName(req), 'me');
Expand Down