Repository navigation
Phase 2: enable IDP, seed me/me, localhost-only by default - #7
Conversation
The welcome page is HEAD-adaptive: it reveals "Sign in" or "Sign up" based on what the IDP advertises. With the IDP off (jspod's previous default), neither button rendered and the only visible CTA was a docs link going off-site. Turning the IDP on with single-user seeding flips that page from "read the docs" to "sign in here." - Flip default `--host` to 127.0.0.1 (was 0.0.0.0). Personal pods almost always want localhost; making this the default also lets us safely seed deliberately-weak rung-1 credentials. - Replace `--no-multiuser`-only single-user behaviour with full single-user mode: `--no-multiuser --single-user --idp --single-user-password me`. JSS hardcodes the username on root pods to 'me' (server.js:970), so credentials end up `me` / `me` — symmetric, memorable, clearly a placeholder. - Banner adds a "Sign In (rung 1 of the auth ladder)" section showing username, password, and the climb hint. References the ladder framing from issue #6. - Loud red warning if user passes `--host 0.0.0.0` (or any non-loopback host) telling them the well-known credentials are now reachable beyond localhost. - README: First Run Guide rewritten around the ladder. Rungs 0-4 table, climb instructions, and JSS_SINGLE_USER_PASSWORD escape hatch for users who want a custom rung-1 password. - Multi-user mode (`--multiuser`) still works: passes `--idp` so the IDP is available for registration, but no `--single-user-*` flags so registration stays open. - Help text: --host default updated to 127.0.0.1. - Bump jspod to 0.0.10. This addresses issue #1 phase 2, executed through the lens of issue #3 (single-user positioning) and issue #6 (auth ladder). Refs #1 #3 #6
There was a problem hiding this comment.
Pull request overview
This PR updates jspod’s first-run experience to land users in an IDP-backed single-user flow by default, binding to localhost for safety and documenting the “auth ladder” onboarding story.
Changes:
- Default host binding changed to
127.0.0.1(localhost-only by default). - Default single-user startup now enables IDP and seeds rung-1 credentials (
me/me), with a banner and LAN-exposure warning for non-loopback binds. - README first-run guide rewritten around the auth-ladder concept; package version bumped to
0.0.10.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| README.md | Updates defaults + adds auth-ladder / first-run sign-in guidance. |
| index.js | Changes default host, adds rung-1 credential banner + warning, and adjusts JSS spawn args for single-user vs multi-user. |
| package.json | Bumps version to 0.0.10. |
Comments suppressed due to low confidence (1)
README.md:252
- This section still instructs users to “Register with a passkey” immediately after the new default flow tells them to sign in with the seeded
me/meaccount. In single-user modeHEAD /idp/registeris expected to return 403 (Sign in only), so these registration instructions may not be actionable for the default path. Consider scoping this to multi-user mode (or explaining when registration is available).
**Step 3**: Register with a passkey
- Click "Register" or "Sign Up"
- Use your device's biometric auth (fingerprint, Face ID, etc.)
- Your WebID will be created automatically
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (options.multiuser) { | ||
| // Multi-user mode is an explicit opt-out from jspod's single-user | ||
| // positioning (#3). The IDP stays available so users can register. | ||
| if (options.auth) jssArgs.push('--idp'); | ||
| } else { |
| jssArgs.push('--no-multiuser', '--single-user'); | ||
| if (options.auth) { | ||
| jssArgs.push('--idp', '--single-user-password', RUNG_1_PASSWORD); | ||
| } |
| const isLoopback = | ||
| options.host === '127.0.0.1' || | ||
| options.host === 'localhost' || | ||
| options.host === '::1'; | ||
| if (!isLoopback) { |
| **Step 3**: Sign in (rung 1 of the auth ladder) | ||
|
|
||
| The first time you start jspod, an IDP account is seeded with deliberately weak default credentials: | ||
|
|
||
| | Field | Value | | ||
| | -------- | ----- | | ||
| | Username | `me` | | ||
| | Password | `me` | | ||
|
|
||
| Click **Sign in** on the welcome page, then point a Solid app (like [Pilot](https://solid-apps.github.io/pilot/)) at `http://localhost:5444` and sign in with `me` / `me`. | ||
|
|
||
| > **Why are the defaults so weak?** jspod ships you onto the first rung of the auth ladder in under a minute, then guides you up. Rung 1 is **only safe on localhost** — jspod binds to `127.0.0.1` by default for exactly this reason. Once you're in, change the password (rung 2) or add a passkey (rung 3) from your pod's account settings. See [issue #6](https://github.com/JavaScriptSolidServer/jspod/issues/6) for the ladder rationale. | ||
|
|
||
| **Step 4**: Climb the ladder | ||
|
|
Four legitimate catches from the review:
1. `--no-auth` was a no-op against JSS. Now forwards JSS's `--public`
flag so the pod actually skips WAC and accepts unauthenticated
reads/writes (verified: JSS prints "PUBLIC MODE ENABLED").
2. `JSS_SINGLE_USER_PASSWORD` env override was documented in the
README but not honoured — the CLI always passed `--single-user-
password me`, drowning out the env. Now the env value is read in
jspod, passed to JSS via the CLI flag, AND surfaced in the
banner ("Sign In (password from JSS_SINGLE_USER_PASSWORD)") so
the displayed credentials match reality.
3. Loopback detection was too strict — only 127.0.0.1 / localhost /
::1 were treated as safe. Expanded to the full 127.0.0.0/8 IPv4
range (covers Ubuntu's default 127.0.1.1 in /etc/hosts) and the
bracketed `[::1]` form a user might paste in by accident.
4. README First Run Guide had duplicated Step 3 / Step 4 left over
from the previous version. Renumbered and removed the obsolete
"Register with a passkey" step (registration is disabled in
single-user mode anyway).
Refs #1 #3 #6
|
Thanks @copilot-pull-request-reviewer — all four were legitimate. Pushed 89d9fe7:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
README.md:205
- The README claims
TOKEN_SECRETis “(auto)” and “auto-generated”, butindex.jscurrently sets a fixed default (jspod-default-secret-change-in-production) whenTOKEN_SECRETis not provided. This is both inaccurate documentation and potentially insecure if the server is ever bound beyond localhost; either generate a random secret per data directory (persist it) or update the README to reflect the actual behavior.
{
port: 5444, // Memorable, low collision with common dev servers
host: '127.0.0.1', // Localhost-only by default (rung-1 credentials)
root: './pod-data', // Local data directory
multiuser: false, // Single pod per server
TOKEN_SECRET: (auto) // JWT secret (auto-generated, change for production)
}
| console.log('\n' + chalk.bold.red('⚠ Warning: ') + chalk.yellow( | ||
| `--host ${options.host} exposes the well-known me/me credentials beyond localhost.` | ||
| )); | ||
| console.log(chalk.dim(' Set --single-user-password via env (JSS_SINGLE_USER_PASSWORD) or run on 127.0.0.1.')); |
| if (options.auth && !options.multiuser) { | ||
| const rungLabel = RUNG_1_PASSWORD_FROM_ENV | ||
| ? 'Sign In (password from JSS_SINGLE_USER_PASSWORD):' | ||
| : 'Sign In (rung 1 of the auth ladder):'; | ||
| console.log('\n' + chalk.bold.white(`🔑 ${rungLabel}\n`)); | ||
| console.log(chalk.cyan(' ├─ ') + chalk.white('Username: ') + chalk.bold.green(RUNG_1_USERNAME)); | ||
| console.log(chalk.cyan(' ├─ ') + chalk.white('Password: ') + chalk.bold.green(RUNG_1_PASSWORD)); | ||
| console.log(chalk.cyan(' └─ ') + chalk.dim('Climb: change the password or add a passkey from account settings')); |
| // Loopback covers the full 127.0.0.0/8 IPv4 range plus IPv6 ::1 | ||
| // (and its bracketed form, which a user might paste in by accident). | ||
| const isLoopback = | ||
| options.host === 'localhost' || | ||
| /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(options.host) || | ||
| options.host === '::1' || | ||
| options.host === '[::1]'; |
Four follow-ups from the second pass:
1. Banner no longer prints the env-provided password verbatim. When
the credential comes from JSS_SINGLE_USER_PASSWORD, the banner
confirms "(hidden — set via JSS_SINGLE_USER_PASSWORD)" instead of
echoing the value to stdout. Default rung-1 'me' still prints
(the user doesn't know it yet, and it has no secrecy property).
Avoids leaking real passwords into terminal scrollback, shell
history capture, CI logs, and shared sessions.
2. `--host [::1]` (bracketed IPv6) now parses cleanly. Brackets are
stripped at CLI parse time, so options.host stays canonical
('::1'), and formatUrl re-adds brackets only where URLs need
them. Previously the bracketed form would round-trip through
formatUrl as http://[[::1]]:PORT (invalid).
3. LAN-exposure warning no longer references a `--single-user-
password` jspod flag that doesn't exist. It now points users to
JSS_SINGLE_USER_PASSWORD=... or binding to 127.0.0.1.
4. README's "Default Configuration" code block previously claimed
TOKEN_SECRET was "(auto)" / "auto-generated". The code actually
uses a static fallback ('jspod-default-secret-change-in-
production'). Documentation now matches reality. A real
per-data-dir secret-generator can land in a follow-up PR.
Refs #1 #3 #6
|
Thanks @copilot-pull-request-reviewer — all four addressed in f6458f2:
|
| // Strip optional brackets from IPv6 literals so a user-friendly | ||
| // `--host [::1]` paste-in stays canonical. formatUrl re-adds the | ||
| // brackets where they belong in URLs; the raw host going to JSS | ||
| // and to comparisons remains the unbracketed literal. | ||
| options.host = args[++i].replace(/^\[|\]$/g, ''); |
| // Strip optional brackets from IPv6 literals so a user-friendly | ||
| // `--host [::1]` paste-in stays canonical. formatUrl re-adds the | ||
| // brackets where they belong in URLs; the raw host going to JSS | ||
| // and to comparisons remains the unbracketed literal. | ||
| options.host = args[++i].replace(/^\[|\]$/g, ''); | ||
| } else if (arg === '--root' || arg === '-r') { | ||
| options.root = args[++i]; | ||
| } else if (arg === '--multiuser') { |
| const isLoopback = | ||
| options.host === 'localhost' || | ||
| /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(options.host) || | ||
| options.host === '::1'; | ||
| if (!isLoopback) { | ||
| console.log('\n' + chalk.bold.red('⚠ Warning: ') + chalk.yellow( | ||
| `--host ${options.host} exposes the well-known me/me credentials beyond localhost.` | ||
| )); | ||
| console.log(chalk.dim(' Set JSS_SINGLE_USER_PASSWORD=... before running, or bind to 127.0.0.1.')); | ||
| } |
| ```javascript | ||
| { | ||
| port: 5444, // Memorable, low collision with common dev servers | ||
| host: '0.0.0.0', // Accept connections from anywhere | ||
| host: '127.0.0.1', // Localhost-only by default (rung-1 credentials) | ||
| root: './pod-data', // Local data directory | ||
| multiuser: false, // Single pod per server | ||
| TOKEN_SECRET: (auto) // JWT secret (auto-generated, change for production) | ||
| TOKEN_SECRET: 'jspod-default-secret-change-in-production' | ||
| // Static fallback. Override via env for any non-local use. | ||
| } |
Four follow-ups from the third pass:
1. Missing-value validation for flags that take an argument.
`jspod --host` (or --port, --root) used to read undefined off the
args array and crash with a cryptic TypeError on the next
operation. Now exits cleanly with "Missing value for --host" and a
pointer to --help.
2. IPv6 zone identifiers (e.g. fe80::1%lo0) are now rejected at CLI
parse time with a clear error. The previous %25-encoding attempt
was correct per RFC 6874 but Node's WHATWG URL parser doesn't
accept zone IDs regardless — any URL we built (banner, auto-open,
readiness probe) would be unparseable. Failing fast beats shipping
silently broken auto-open.
3. TOKEN_SECRET now auto-generates a 48-byte random secret on first
run and persists it at <root>/.token-secret with mode 0600.
Subsequent restarts read the same secret so sessions and refresh
tokens survive process bounces. Per-data-dir, so different pods on
the same machine get different secrets. The previous static
fallback ('jspod-default-secret-change-in-production') was
remotely exploitable on any non-loopback bind — anyone who knew
the string could forge JWTs. Env TOKEN_SECRET still wins when
set, for operator-managed deployments.
4. README: replaced the literal fallback secret string with accurate
docs about auto-generation, persistence path, file mode, and the
env override use-case (operator-managed deployments / rotation /
secret managers).
Refs #1 #3 #6
|
Thanks @copilot-pull-request-reviewer — all four addressed in 8de8cbd:
The static fallback string is gone from both the code and the docs — no more "publishing the password" footgun. |
| if (arg === '--port' || arg === '-p') { | ||
| options.port = parseInt(args[++i], 10); | ||
| options.port = parseInt(requireValue(arg, args[++i]), 10); |
| function resolveTokenSecret(rootDir) { | ||
| if (process.env.TOKEN_SECRET) return process.env.TOKEN_SECRET; | ||
| const secretFile = join(rootDir, '.token-secret'); | ||
| if (existsSync(secretFile)) { | ||
| return readFileSync(secretFile, 'utf8').trim(); | ||
| } |
| if (!isLoopback) { | ||
| console.log('\n' + chalk.bold.red('⚠ Warning: ') + chalk.yellow( | ||
| `--host ${options.host} exposes the well-known me/me credentials beyond localhost.` | ||
| )); | ||
| console.log(chalk.dim(' Set JSS_SINGLE_USER_PASSWORD=... before running, or bind to 127.0.0.1.')); |
| // every subsequent start is a no-op (JSS is idempotent on the seed). | ||
| jssArgs.push('--no-multiuser', '--single-user'); | ||
| if (options.auth) { | ||
| jssArgs.push('--idp', '--single-user-password', RUNG_1_PASSWORD); |
Four follow-ups from the fourth pass:
1. Validate --port: reject non-numeric, out-of-range (<1, >65535),
and trailing-garbage values at parse time. parseInt('abc') was
silently producing NaN, which then propagated to JSS as
`--port NaN` and crashed the server with a confusing error.
2. Validate the persisted JWT secret on read. If <root>/.token-secret
exists but is empty / whitespace-only / too short (<32 chars), warn
and regenerate rather than silently weakening signing. Empty file
could happen via a botched edit, a failed `cp`, or a partial write.
3. Differentiate the LAN-exposure warning based on whether the
password is env-supplied. Previously the warning always claimed
the server was exposing "well-known me/me credentials," even when
the user had supplied a strong password via env — a false statement
and a confusing one. Now: default rung-1 keeps the original
well-known-credentials wording; env-supplied gets a more neutral
"exposes single-user sign-in" warning that suggests strong-password
discipline and HTTPS for production.
4. Stop re-exposing the env-supplied password on the JSS subprocess
argv. `ps`, service-manager logs, and other local users could read
what we'd carefully hidden from the banner. The fix: pass
`--single-user-password` on argv only when it's the literal
rung-1 placeholder ('me' has no secrecy property). For env-
supplied passwords, JSS picks up JSS_SINGLE_USER_PASSWORD from
process.env directly (already inherited from jspod's env spread).
Verified via `ps` — JSS argv now: `--port ... --host ... --root
... --notifications --conneg --no-multiuser --single-user --idp`,
no password.
Refs #1 #3 #6
|
Thanks @copilot-pull-request-reviewer — all four addressed in 27e301f:
|
| function requireValue(flag, value) { | ||
| if (value === undefined) { | ||
| console.error(chalk.red(`✗ Missing value for ${flag}`)); | ||
| console.error(chalk.dim('Use --help for usage information')); | ||
| process.exit(1); | ||
| } | ||
| return value; | ||
| } |
| function resolveTokenSecret(rootDir) { | ||
| if (process.env.TOKEN_SECRET) return process.env.TOKEN_SECRET; | ||
| const secretFile = join(rootDir, '.token-secret'); | ||
| if (existsSync(secretFile)) { | ||
| const loaded = readFileSync(secretFile, 'utf8').trim(); | ||
| // Guard against a truncated / empty / accidentally-overwritten | ||
| // secret file. A short-or-empty secret would silently weaken JWT | ||
| // signing — regenerate and warn rather than ship the bad value. | ||
| if (loaded.length >= 32) return loaded; | ||
| console.warn(chalk.yellow( | ||
| `⚠ ${secretFile} is empty or too short (${loaded.length} chars); regenerating.` | ||
| )); | ||
| } | ||
| const secret = randomBytes(48).toString('base64'); | ||
| writeFileSync(secretFile, secret, { mode: 0o600 }); | ||
| return secret; |
| **⚠️ Important**: Before deploying to production: | ||
|
|
||
| 1. **Set TOKEN_SECRET** | ||
| 1. **TOKEN_SECRET** is auto-generated on first run and persisted at `<root>/.token-secret` (mode 0600). For operator-managed deployments — secret rotation, distributed setups, secret managers — set it explicitly via env: |
Three follow-ups from the fifth pass: 1. requireValue() now rejects values that look like another option. `jspod --host --no-auth` previously consumed `--no-auth` as the host string and silently dropped the intended flag, then bound the server to a literal '--no-auth' hostname. Now exits cleanly with "Got: --no-auth (looks like another option, not a value)." `--port -1` is caught here too (slightly less specific than the range-check error, but still rejected before propagating). 2. .token-secret file mode is now enforced on every write, not just on creation. writeFileSync's `mode` option is silently ignored when overwriting an existing file (regeneration path), so a previously-too-loose secret file could survive a regeneration with its old permissions. New ensureMode0600() helper stats the file, warns if the mode differs, and chmod's it to 0600. 3. .token-secret mode is also tightened on every read, not just on write. A file created (or touched) before jspod 0.0.10 — or by a different process — could be group/world-readable; jspod now stats it on each start, warns the operator about the previous mode, and chmod's it. README claim of "persisted with mode 0600" now matches reality without doc edits. ensureMode0600 swallows errors so platforms without unix mode semantics (Windows) or exotic filesystems don't crash startup. Refs #1 #3 #6
|
Thanks @copilot-pull-request-reviewer — all three addressed in fbafde7:
Errors from chmod/stat are swallowed so platforms without unix mode semantics (Windows) or exotic filesystems don't crash startup. Verified: 644 → 600 with warning on both read and regenerate paths. |
Summary
Phase 2 of #1, executed through the lens of #3 (single-user positioning) and #6 (auth ladder). The welcome page that previously had only a stale docs-link CTA now reveals a real Sign in button, and a fresh
npx jspodlands the user inside the IDP flow within seconds.Root cause of the previous "Welcome → docs link" page
JSS's server-root template at
/is already HEAD-adaptive: it probes./idp/registerand reveals Sign up / Sign in buttons based on the response (200 → both, 403 → Sign in only, 404 / error → neither). With the IDP disabled in jspod's previous default spawn, neither button rendered and the only visible CTA was the externaljss.live/docs/getting-started/first-runlink.What changes
--host 127.0.0.1is the new default (was0.0.0.0). Personal pods almost always want localhost-only, and this is what makes seeding rung-1 credentials safe.--no-multiuser --single-user --idp --single-user-password me.me/ password isme. JSS hardcodes the username for root pods (server.js:970:username: isRootPod ? 'me' : singleUserName); jspod seeds the password to match. Symmetric, memorable, obviously a placeholder.--host 0.0.0.0(or any non-loopback host) — the well-known credentials are then reachable beyond localhost and need to be changed.--no-authstill produces a no-auth server with no sign-in block and no IDP.--multiuserstill works: the IDP is enabled (so users can register) but no single-user seeding happens.JSS_SINGLE_USER_PASSWORDescape hatch).0.0.10.Acceptance criteria (from #1 phase 2, adapted via #6)
npx jspod+ browser visit tolocalhost:5444shows a working welcome page with Sign in revealed (HEAD probe returns 403 → "Sign in" branch)npx jspod --no-authproduces a no-auth server (current bare behaviour)--host 0.0.0.0still works, but prints a clear LAN-exposure warning--multiuserTest plan
HEAD /idp/registerreturns 403,GET /idpreturns 200 with Solid-app-friendly copy--host 0.0.0.0: loud red warning about exposed credentials--no-auth: no Sign In block,WebID Auth ✗in features, no warningnpx jspodfrom a real terminal, click Sign in on the welcome page, point a Solid app (e.g. Pilot at https://solid-apps.github.io/pilot/) at the server, sign in withme/me, confirm pod access.What this doesn't do (deferred to follow-up PRs)
/profile,/public/welcome,/inbox) — still a gap; targeted next.Cross-references