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
53 changes: 40 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ jspod --multiuser
```
Options:
-p, --port <number> Port to listen on (default: 5444)
-h, --host <address> Host to bind to (default: 0.0.0.0)
-h, --host <address> Host to bind to (default: 127.0.0.1)
-r, --root <path> Data directory (default: ./pod-data)
--multiuser Enable multi-user mode
--no-auth Disable authentication
Expand All @@ -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 <root>/.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"
Expand All @@ -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 `<root>/.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)"
```
Expand Down Expand Up @@ -198,10 +199,12 @@ 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)
TOKEN_SECRET: (per-pod) // Auto-generated on first run and persisted at
// <root>/.token-secret (mode 0600). Override via
// the TOKEN_SECRET env var for operator control.
}
Comment on lines 199 to 208
```

Expand All @@ -216,15 +219,39 @@ 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**: 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 3**: Sign in (rung 1 of the auth ladder)

**Step 4**: Start using your pod!
- Upload files, create resources
- Use Solid apps to connect to your pod
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

Comment on lines +222 to +236
| 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 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`
Expand Down
210 changes: 201 additions & 9 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,19 @@ 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, chmodSync, statSync } from 'fs';
import { randomBytes } from 'crypto';

const __dirname = dirname(fileURLToPath(import.meta.url));
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}`;
Expand All @@ -31,22 +36,79 @@ 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).
// 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 = 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. 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;
}
Comment on lines +64 to +77

for (let i = 0; i < args.length; i++) {
const arg = args[i];

if (arg === '--port' || arg === '-p') {
options.port = parseInt(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') {
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.
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') {
Comment on lines +95 to 112
options.multiuser = true;
} else if (arg === '--no-auth') {
Expand All @@ -66,7 +128,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('<number>') + chalk.dim(' Port to listen on (default: 5444)'));
console.log(chalk.green(' -h, --host ') + chalk.yellow('<address>') + chalk.dim(' Host to bind to (default: 0.0.0.0)'));
console.log(chalk.green(' -h, --host ') + chalk.yellow('<address>') + chalk.dim(' Host to bind to (default: 127.0.0.1)'));
console.log(chalk.green(' -r, --root ') + chalk.yellow('<path>') + 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'));
Expand Down Expand Up @@ -99,6 +161,61 @@ 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 <root>/.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)) {
// 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
// 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.`
));
}
Comment on lines +173 to +191
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;
Comment on lines +173 to +197
}

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
console.log(chalk.cyan(`
╔═══════════════════════════════════════════════════════════════════╗
Expand All @@ -125,6 +242,55 @@ 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) {
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));
// 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'));
Comment on lines +245 to +261

// 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.
// (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';
if (!isLoopback) {
Comment on lines +271 to +275
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.'));
}
}
Comment on lines +271 to +291
}

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('✗')));
Expand All @@ -150,8 +316,34 @@ 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 {
Comment on lines +319 to +323
// 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');
// 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);
}
}
Comment on lines +327 to +339
}

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
Expand All @@ -160,7 +352,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'
}
});
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down