From ec2789021cbe4a95064eae2f3eb4cf8e05861b8b Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Fri, 15 May 2026 19:13:24 +0200 Subject: [PATCH 1/6] feat: enable IDP, seed me/me, localhost-only by default (#1 phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 33 +++++++++++++++++++++++++++++++-- index.js | 47 +++++++++++++++++++++++++++++++++++++++++++---- package.json | 2 +- 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 83e1fa6..dc18d78 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ jspod --multiuser ``` Options: -p, --port Port to listen on (default: 5444) - -h, --host
Host to bind to (default: 0.0.0.0) + -h, --host
Host to bind to (default: 127.0.0.1) -r, --root Data directory (default: ./pod-data) --multiuser Enable multi-user mode --no-auth Disable authentication @@ -198,7 +198,7 @@ Under the hood, jspod runs JavaScriptSolidServer with these options: ```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) @@ -216,6 +216,35 @@ npx jspod > Running over SSH, in CI, or in a non-interactive terminal? jspod skips auto-open and prints the URL instead. You can also pass `--no-open` to disable it explicitly. +**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 + +| Rung | Auth | How | +| ---- | --------------------- | -------------------------------------------- | +| 0 | None | `npx jspod --no-auth` (demos / dev only) | +| 1 | `me` / `me` | **Default.** Localhost-only. | +| 2 | Your password | Change it from your pod's account settings | +| 3 | Passkey | Add a passkey from account settings | +| 4 | Hardware key / MFA | Power-user setup | + +Override the default password without going through the UI: + +```bash +JSS_SINGLE_USER_PASSWORD='your-password' npx jspod +``` + **Step 3**: Register with a passkey - Click "Register" or "Sign Up" - Use your device's biometric auth (fingerprint, Face ID, etc.) diff --git a/index.js b/index.js index 8368e44..a34d9b4 100755 --- a/index.js +++ b/index.js @@ -31,13 +31,20 @@ function formatUrl(host, port) { const args = process.argv.slice(2); const options = { port: 5444, - host: '0.0.0.0', + host: '127.0.0.1', root: './pod-data', multiuser: false, auth: true, open: true }; +// Auth-ladder rung-1 credentials. See issue #6: jspod ships a deliberately +// weak default sign-in so the new user is on a working pod within seconds, +// with a clearly-marked path to climb (change password / add a passkey). +// Safe because the default host is localhost-only (127.0.0.1). +const RUNG_1_USERNAME = 'me'; +const RUNG_1_PASSWORD = 'me'; + for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -66,7 +73,7 @@ for (let i = 0; i < args.length; i++) { console.log(chalk.yellow(' jspod') + chalk.dim(' [options]\n')); console.log(chalk.white('Options:')); console.log(chalk.green(' -p, --port ') + chalk.yellow('') + chalk.dim(' Port to listen on (default: 5444)')); - console.log(chalk.green(' -h, --host ') + chalk.yellow('
') + chalk.dim(' Host to bind to (default: 0.0.0.0)')); + console.log(chalk.green(' -h, --host ') + chalk.yellow('
') + chalk.dim(' Host to bind to (default: 127.0.0.1)')); console.log(chalk.green(' -r, --root ') + chalk.yellow('') + chalk.dim(' Data directory (default: ./pod-data)')); console.log(chalk.green(' --multiuser') + chalk.dim(' Enable multi-user mode')); console.log(chalk.green(' --no-auth') + chalk.dim(' Disable authentication')); @@ -125,6 +132,28 @@ console.log(chalk.cyan(' ├─ ') + chalk.white('Host: ') + chalk.yellow console.log(chalk.cyan(' ├─ ') + chalk.white('Pod Root: ') + chalk.yellow(options.root)); console.log(chalk.cyan(' └─ ') + chalk.white('Mode: ') + (options.multiuser ? chalk.yellow('Multi-user') : chalk.yellow('Single-user'))); +if (options.auth && !options.multiuser) { + console.log('\n' + chalk.bold.white('🔑 Sign In (rung 1 of the auth ladder):\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')); + + // Loud warning if the rung-1 known credentials are reachable beyond + // the local machine. See issue #6 ("auth ladder"): rung 1 is only + // safe when the host is loopback-only. Any other bind exposes the + // well-known me/me credentials to the LAN (or worse). + const isLoopback = + options.host === '127.0.0.1' || + options.host === 'localhost' || + 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 --single-user-password via env (JSS_SINGLE_USER_PASSWORD) or run on 127.0.0.1.')); + } +} + console.log('\n' + chalk.bold.white('✨ Features:\n')); console.log(chalk.cyan(' ├─ ') + chalk.green('Solid Protocol ') + chalk.bold.green('✓')); console.log(chalk.cyan(' ├─ ') + chalk.green('WebID Auth ') + (options.auth ? chalk.bold.green('✓') : chalk.dim('✗'))); @@ -150,8 +179,18 @@ const jssArgs = [ '--conneg' ]; -if (!options.multiuser) { - jssArgs.push('--no-multiuser'); +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 { + // Default: single-user personal pod with rung-1 credentials seeded. + // The pod, IDP, and known credentials are created on first start; + // 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); + } } // Start JSS with enhanced PATH to find the binary diff --git a/package.json b/package.json index 96aeebc..f0514ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jspod", - "version": "0.0.9", + "version": "0.0.10", "description": "JavaScript Solid Pod - Just works, batteries included", "type": "module", "main": "index.js", From 89d9fe72379cac3b1f27a279a2be11228c72ffdf Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Fri, 15 May 2026 19:24:43 +0200 Subject: [PATCH 2/6] fix: address Copilot review on PR #7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 11 +++-------- index.js | 26 ++++++++++++++++++++++---- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index dc18d78..bb24194 100644 --- a/README.md +++ b/README.md @@ -245,15 +245,10 @@ Override the default password without going through the UI: JSS_SINGLE_USER_PASSWORD='your-password' npx jspod ``` -**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 - -**Step 4**: Start using your pod! -- Upload files, create resources -- Use Solid apps to connect to your pod +**Step 5**: Start using your pod +- Upload files, create resources from a Solid app - Your data stays on your server +- Connect more apps — they all authenticate against your IDP **Troubleshooting**: - **Port in use?** Run `jspod --port 5445` diff --git a/index.js b/index.js index a34d9b4..3ecb1e8 100755 --- a/index.js +++ b/index.js @@ -42,8 +42,13 @@ const options = { // weak default sign-in so the new user is on a working pod within seconds, // with a clearly-marked path to climb (change password / add a passkey). // Safe because the default host is localhost-only (127.0.0.1). +// Username is fixed by JSS for root pods (server.js:970). Password defaults +// to 'me' but can be overridden via JSS_SINGLE_USER_PASSWORD so the env +// override documented in the README actually takes effect (and the banner +// shows the effective password, not a stale default). const RUNG_1_USERNAME = 'me'; -const RUNG_1_PASSWORD = 'me'; +const RUNG_1_PASSWORD = process.env.JSS_SINGLE_USER_PASSWORD || 'me'; +const RUNG_1_PASSWORD_FROM_ENV = !!process.env.JSS_SINGLE_USER_PASSWORD; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -133,7 +138,10 @@ console.log(chalk.cyan(' ├─ ') + chalk.white('Pod Root: ') + chalk.yellow console.log(chalk.cyan(' └─ ') + chalk.white('Mode: ') + (options.multiuser ? chalk.yellow('Multi-user') : chalk.yellow('Single-user'))); if (options.auth && !options.multiuser) { - console.log('\n' + chalk.bold.white('🔑 Sign In (rung 1 of the auth ladder):\n')); + 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')); @@ -142,10 +150,13 @@ if (options.auth && !options.multiuser) { // the local machine. See issue #6 ("auth ladder"): rung 1 is only // safe when the host is loopback-only. Any other bind exposes the // well-known me/me credentials to the LAN (or worse). + // 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 === '127.0.0.1' || options.host === 'localhost' || - options.host === '::1'; + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(options.host) || + options.host === '::1' || + 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.` @@ -193,6 +204,13 @@ if (options.multiuser) { } } +if (!options.auth) { + // JSS's `--public` is the real no-auth switch: skip WAC, open + // read/write. Without it, `--no-auth` would only mean "no IDP" + // — the pod would still be ACL-gated and unreachable. + jssArgs.push('--public'); +} + // Start JSS with enhanced PATH to find the binary const jss = spawn('jss', jssArgs, { stdio: 'inherit', From f6458f23877fcfb2bb35c211b92af1c3db4a6005 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Fri, 15 May 2026 23:34:44 +0200 Subject: [PATCH 3/6] fix: address Copilot review 2 on PR #7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 3 ++- index.js | 28 +++++++++++++++++++++------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index bb24194..2f02fa0 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,8 @@ Under the hood, jspod runs JavaScriptSolidServer with these options: 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. } ``` diff --git a/index.js b/index.js index 3ecb1e8..5611e25 100755 --- a/index.js +++ b/index.js @@ -56,7 +56,11 @@ for (let i = 0; i < args.length; i++) { if (arg === '--port' || arg === '-p') { options.port = parseInt(args[++i], 10); } else if (arg === '--host' || arg === '-h') { - options.host = args[++i]; + // 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') { @@ -143,25 +147,35 @@ if (options.auth && !options.multiuser) { : '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)); + // Only print the literal password when it's the rung-1 default. If + // the user set a real password via env, echoing it to stdout would + // leak into terminal scrollback, shell history capture, CI logs, and + // shared sessions. They already know the value they set; the banner + // just confirms it was picked up. + if (RUNG_1_PASSWORD_FROM_ENV) { + console.log(chalk.cyan(' ├─ ') + chalk.white('Password: ') + chalk.dim('(hidden — set via JSS_SINGLE_USER_PASSWORD)')); + } else { + 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')); // Loud warning if the rung-1 known credentials are reachable beyond // the local machine. See issue #6 ("auth ladder"): rung 1 is only // safe when the host is loopback-only. Any other bind exposes the // well-known me/me credentials to the LAN (or worse). - // 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). + // Loopback covers the full 127.0.0.0/8 IPv4 range plus IPv6 ::1. + // (Bracketed `[::1]` input is stripped to `::1` at CLI parse time + // — see options.host parsing — so it matches here without a + // bracketed branch.) const isLoopback = options.host === 'localhost' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(options.host) || - options.host === '::1' || - options.host === '[::1]'; + 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 --single-user-password via env (JSS_SINGLE_USER_PASSWORD) or run on 127.0.0.1.')); + console.log(chalk.dim(' Set JSS_SINGLE_USER_PASSWORD=... before running, or bind to 127.0.0.1.')); } } From 8de8cbd26fc95369bd72f4a56e050c79e9502608 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sat, 16 May 2026 00:24:33 +0200 Subject: [PATCH 4/6] fix: address Copilot review 3 on PR #7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /.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 --- README.md | 12 ++++++----- index.js | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2f02fa0..f6f2f50 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,9 @@ Options: ### Environment Variables ```bash -# Set JWT secret (recommended for production) -export TOKEN_SECRET="your-secret-key-here" +# JWT secret — auto-generated and persisted at /.token-secret on +# first run. Override here only for operator-managed deployments. +export TOKEN_SECRET="$(openssl rand -base64 32)" # Set environment export NODE_ENV="production" @@ -105,7 +106,7 @@ jspod **⚠️ Important**: Before deploying to production: -1. **Set TOKEN_SECRET** +1. **TOKEN_SECRET** is auto-generated on first run and persisted at `/.token-secret` (mode 0600). For operator-managed deployments — secret rotation, distributed setups, secret managers — set it explicitly via env: ```bash export TOKEN_SECRET="$(openssl rand -base64 32)" ``` @@ -201,8 +202,9 @@ Under the hood, jspod runs JavaScriptSolidServer with these options: 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: 'jspod-default-secret-change-in-production' - // Static fallback. Override via env for any non-local use. + TOKEN_SECRET: (per-pod) // Auto-generated on first run and persisted at + // /.token-secret (mode 0600). Override via + // the TOKEN_SECRET env var for operator control. } ``` diff --git a/index.js b/index.js index 5611e25..7c6130c 100755 --- a/index.js +++ b/index.js @@ -9,7 +9,8 @@ import { spawn } from 'child_process'; import { fileURLToPath } from 'url'; import { dirname, join, delimiter } from 'path'; import chalk from 'chalk'; -import { existsSync, mkdirSync, readFileSync } from 'fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { randomBytes } from 'crypto'; const __dirname = dirname(fileURLToPath(import.meta.url)); const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8')); @@ -17,6 +18,10 @@ const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8')); // Build a browser-friendly URL from a host/port pair. Normalizes wildcard // addresses (0.0.0.0, ::) to localhost and brackets IPv6 literals so the // result is always a valid URL the user (and the browser) can open. +// IPv6 zone identifiers (e.g. `fe80::1%lo0`) are rejected at CLI parse +// time — the WHATWG URL spec doesn't support them, so any URL we built +// with one would be unparseable by Node and by the browser regardless +// of `%` encoding. function formatUrl(host, port) { if (host === '0.0.0.0' || host === '::' || host === '*') { return `http://localhost:${port}`; @@ -50,19 +55,42 @@ const RUNG_1_USERNAME = 'me'; const RUNG_1_PASSWORD = process.env.JSS_SINGLE_USER_PASSWORD || 'me'; const RUNG_1_PASSWORD_FROM_ENV = !!process.env.JSS_SINGLE_USER_PASSWORD; +// Require a value after a value-taking flag. Without this guard, a stray +// `jspod --host` (no value) reads `undefined` from args[++i] and the next +// .replace() call throws a cryptic TypeError. Friendlier to error early +// and tell the user what's missing. +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; +} + for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--port' || arg === '-p') { - options.port = parseInt(args[++i], 10); + options.port = parseInt(requireValue(arg, args[++i]), 10); } else if (arg === '--host' || arg === '-h') { // 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, ''); + const rawHost = requireValue(arg, args[++i]).replace(/^\[|\]$/g, ''); + // Reject IPv6 zone identifiers — WHATWG URL spec doesn't support + // them, so any URL we built (banner, browser auto-open, readiness + // probe) would be unparseable. Better to fail fast with a clear + // message than to ship a broken auto-open silently. + if (rawHost.includes('%')) { + console.error(chalk.red(`✗ IPv6 zone identifiers are not supported: ${rawHost}`)); + console.error(chalk.dim('Bind to a non-zoned address (e.g. ::1, 127.0.0.1, or your LAN IP) instead.')); + process.exit(1); + } + options.host = rawHost; } else if (arg === '--root' || arg === '-r') { - options.root = args[++i]; + options.root = requireValue(arg, args[++i]); } else if (arg === '--multiuser') { options.multiuser = true; } else if (arg === '--no-auth') { @@ -115,6 +143,27 @@ if (!existsSync(options.root)) { mkdirSync(options.root, { recursive: true }); } +// Resolve the JWT signing secret. Priority: +// 1. TOKEN_SECRET env var (operator-controlled) +// 2. Persisted random secret at /.token-secret (generated on +// first run, mode 0600). Same data dir always produces the same +// effective secret across restarts — sessions and refresh tokens +// survive process bounces. +// Generating per-data-dir avoids the previous footgun of a hardcoded +// fallback string that anyone could use to forge JWTs against a +// non-loopback deployment. +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(); + } + const secret = randomBytes(48).toString('base64'); + writeFileSync(secretFile, secret, { mode: 0o600 }); + return secret; +} +const tokenSecret = resolveTokenSecret(options.root); + // Display startup banner console.log(chalk.cyan(` ╔═══════════════════════════════════════════════════════════════════╗ @@ -231,7 +280,7 @@ const jss = spawn('jss', jssArgs, { env: { ...process.env, PATH: `${join(__dirname, 'node_modules', '.bin')}${delimiter}${process.env.PATH}`, - TOKEN_SECRET: process.env.TOKEN_SECRET || 'jspod-default-secret-change-in-production', + TOKEN_SECRET: tokenSecret, NODE_ENV: process.env.NODE_ENV || 'development' } }); From 27e301f3177f267d361e0889709cd7b00e0e70ce Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sat, 16 May 2026 00:37:53 +0200 Subject: [PATCH 5/6] fix: address Copilot review 4 on PR #7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /.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 --- index.js | 51 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/index.js b/index.js index 7c6130c..0e32ea1 100755 --- a/index.js +++ b/index.js @@ -72,7 +72,17 @@ for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--port' || arg === '-p') { - options.port = parseInt(requireValue(arg, args[++i]), 10); + const raw = requireValue(arg, args[++i]); + const parsed = parseInt(raw, 10); + // Reject non-numeric / out-of-range / privileged ports. parseInt('abc') + // returns NaN, which would silently propagate to JSS as `--port NaN` + // and produce a confusing crash deep in the server. + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535 || String(parsed) !== raw.trim()) { + console.error(chalk.red(`✗ Invalid port: ${raw}`)); + console.error(chalk.dim('Port must be an integer in the range 1-65535.')); + process.exit(1); + } + options.port = parsed; } else if (arg === '--host' || arg === '-h') { // Strip optional brackets from IPv6 literals so a user-friendly // `--host [::1]` paste-in stays canonical. formatUrl re-adds the @@ -156,7 +166,14 @@ 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(); + 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 }); @@ -221,10 +238,21 @@ if (options.auth && !options.multiuser) { /^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.')); + if (RUNG_1_PASSWORD_FROM_ENV) { + // Custom password from env. Still worth warning the user that + // their sign-in is now reachable from anywhere this host + // answers, but no longer accurate to call the credentials + // "well-known." + console.log('\n' + chalk.bold.red('⚠ Warning: ') + chalk.yellow( + `--host ${options.host} exposes single-user sign-in beyond localhost.` + )); + console.log(chalk.dim(' Make sure your JSS_SINGLE_USER_PASSWORD is strong, and use HTTPS in production.')); + } else { + 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.')); + } } } @@ -263,7 +291,16 @@ if (options.multiuser) { // 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); + jssArgs.push('--idp'); + // Pass the rung-1 placeholder on argv (it has no secrecy property + // — anyone reading the docs already knows the literal 'me'). For + // an env-supplied password, *don't* re-expose it on argv where + // `ps`, service-manager logs, and other local users can read it. + // JSS reads JSS_SINGLE_USER_PASSWORD from env directly when no + // CLI flag is given, and we forward process.env to the child. + if (!RUNG_1_PASSWORD_FROM_ENV) { + jssArgs.push('--single-user-password', RUNG_1_PASSWORD); + } } } From fbafde72ed2280ba195804917b21fd4ab4c5a2d0 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sat, 16 May 2026 00:51:08 +0200 Subject: [PATCH 6/6] fix: address Copilot review 5 on PR #7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- index.js | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/index.js b/index.js index 0e32ea1..97f8982 100755 --- a/index.js +++ b/index.js @@ -9,7 +9,7 @@ import { spawn } from 'child_process'; import { fileURLToPath } from 'url'; import { dirname, join, delimiter } from 'path'; import chalk from 'chalk'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, statSync } from 'fs'; import { randomBytes } from 'crypto'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -57,14 +57,22 @@ const RUNG_1_PASSWORD_FROM_ENV = !!process.env.JSS_SINGLE_USER_PASSWORD; // Require a value after a value-taking flag. Without this guard, a stray // `jspod --host` (no value) reads `undefined` from args[++i] and the next -// .replace() call throws a cryptic TypeError. Friendlier to error early -// and tell the user what's missing. +// .replace() call throws a cryptic TypeError. We also reject values that +// look like another option (`-`-prefixed) — otherwise `jspod --host +// --no-auth` would silently consume `--no-auth` as the host value, drop +// the intended flag, and bind the server to a literal string '--no-auth'. 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); } + if (value.startsWith('-')) { + console.error(chalk.red(`✗ Missing value for ${flag}`)); + console.error(chalk.dim(` Got: ${value} (looks like another option, not a value)`)); + console.error(chalk.dim('Use --help for usage information')); + process.exit(1); + } return value; } @@ -166,6 +174,12 @@ function resolveTokenSecret(rootDir) { if (process.env.TOKEN_SECRET) return process.env.TOKEN_SECRET; const secretFile = join(rootDir, '.token-secret'); if (existsSync(secretFile)) { + // Tighten perms on every read: writeFileSync's `mode` option only + // applies to *creation*, so a regenerated file (overwritten in + // place) or a manually-touched file may have inherited broader + // permissions. Stat-then-chmod also warns the operator if the + // file was previously group/world-readable. + ensureMode0600(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 @@ -177,8 +191,29 @@ function resolveTokenSecret(rootDir) { } const secret = randomBytes(48).toString('base64'); writeFileSync(secretFile, secret, { mode: 0o600 }); + // Explicit chmod covers the overwrite case (mode option in + // writeFileSync is ignored when the file already exists). + ensureMode0600(secretFile); return secret; } + +function ensureMode0600(path) { + try { + const mode = statSync(path).mode & 0o777; + if (mode !== 0o600) { + // Surface the previous mode so operators can investigate how the + // file became group/world-readable (or just learn that jspod is + // tightening it for them). + console.warn(chalk.yellow( + `⚠ Tightening permissions on ${path} (was ${mode.toString(8).padStart(3, '0')}, now 600)` + )); + chmodSync(path, 0o600); + } + } catch { + // chmod is a no-op on Windows and may fail on exotic filesystems. + // Don't crash startup over it; the secret is still in use. + } +} const tokenSecret = resolveTokenSecret(options.root); // Display startup banner