javascript-solid-server 0.0.117 → 0.0.119

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,6 +24,8 @@ A minimal, fast, JSON-LD native Solid server.
24
24
  - **MongoDB Storage** — Optional `/db/` route for JSON-LD at scale
25
25
  - **WebRTC Signaling** — Peer-to-peer connections via WebID-authenticated signaling
26
26
  - **Tunnel Proxy** — Decentralized ngrok through your pod
27
+ - **Terminal** — WebSocket shell access via `--terminal`
28
+ - **Password CLI** — `jss passwd` for user password management
27
29
  - **HTTP 402 Payments** — Monetize endpoints with per-request sat payments
28
30
  - **Mashlib / SolidOS UI** — Optional data browser (CDN, local, or ES module)
29
31
  - **Storage Quotas** — Per-user limits with CLI management
@@ -79,9 +81,10 @@ jss start [options] # Start the server
79
81
  jss init [options] # Initialize configuration
80
82
  jss invite <cmd> # Manage invite codes
81
83
  jss quota <cmd> # Manage storage quotas
84
+ jss passwd <username> # Manage user passwords
82
85
  ```
83
86
 
84
- Key options: `--port`, `--idp`, `--conneg`, `--mashlib`, `--git`, `--nostr`, `--activitypub`, `--webrtc`, `--tunnel`, `--mongo`, `--pay`, `--public`, `--single-user`
87
+ Key options: `--port`, `--idp`, `--conneg`, `--mashlib`, `--git`, `--nostr`, `--activitypub`, `--webrtc`, `--tunnel`, `--terminal`, `--mongo`, `--pay`, `--public`, `--single-user`
85
88
 
86
89
  Full options: [docs/configuration.md](docs/configuration.md)
87
90
 
@@ -98,6 +101,7 @@ Full options: [docs/configuration.md](docs/configuration.md)
98
101
  | ActivityPub & Mastodon API | [docs/activitypub.md](docs/activitypub.md) |
99
102
  | remoteStorage | [docs/remotestorage.md](docs/remotestorage.md) |
100
103
  | WebRTC & Tunnel | [docs/webrtc.md](docs/webrtc.md) |
104
+ | Terminal & Password CLI | [docs/terminal.md](docs/terminal.md) |
101
105
  | MongoDB `/db/` Route | [docs/mongodb.md](docs/mongodb.md) |
102
106
  | HTTP 402 Payments | [docs/payments.md](docs/payments.md) |
103
107
  | Storage Quotas | [docs/quotas.md](docs/quotas.md) |
package/bin/jss.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * Usage:
7
7
  * jss start [options] Start the server
8
8
  * jss init Initialize configuration
9
+ * jss passwd <username> Change user password
9
10
  */
10
11
 
11
12
  import { Command } from 'commander';
@@ -14,6 +15,7 @@ import { loadConfig, saveConfig, printConfig, defaults } from '../src/config.js'
14
15
  import { createInvite, listInvites, revokeInvite } from '../src/idp/invites.js';
15
16
  import { setQuotaLimit, getQuotaInfo, reconcileQuota, formatBytes } from '../src/storage/quota.js';
16
17
  import { parseSize } from '../src/config.js';
18
+ import crypto from 'crypto';
17
19
  import fs from 'fs-extra';
18
20
  import path from 'path';
19
21
  import { fileURLToPath } from 'url';
@@ -66,6 +68,8 @@ program
66
68
  .option('--webrtc', 'Enable WebRTC signaling server')
67
69
  .option('--no-webrtc', 'Disable WebRTC signaling server')
68
70
  .option('--webrtc-path <path>', 'WebRTC signaling WebSocket path (default: /.webrtc)')
71
+ .option('--terminal', 'Enable WebSocket terminal (shell access)')
72
+ .option('--no-terminal', 'Disable WebSocket terminal')
69
73
  .option('--tunnel', 'Enable tunnel proxy (decentralized ngrok)')
70
74
  .option('--no-tunnel', 'Disable tunnel proxy')
71
75
  .option('--tunnel-path <path>', 'Tunnel WebSocket path (default: /.tunnel)')
@@ -147,6 +151,7 @@ program
147
151
  nostrMaxEvents: config.nostrMaxEvents,
148
152
  webrtc: config.webrtc,
149
153
  webrtcPath: config.webrtcPath,
154
+ terminal: config.terminal,
150
155
  tunnel: config.tunnel,
151
156
  tunnelPath: config.tunnelPath,
152
157
  activitypub: config.activitypub,
@@ -193,6 +198,7 @@ program
193
198
  if (config.git) console.log(' Git: enabled (clone/push support)');
194
199
  if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
195
200
  if (config.webrtc) console.log(` WebRTC: enabled (${config.webrtcPath || '/.webrtc'})`);
201
+ if (config.terminal) console.log(' Terminal: enabled (/.terminal)');
196
202
  if (config.tunnel) console.log(` Tunnel: enabled (${config.tunnelPath || '/.tunnel'})`);
197
203
  if (config.activitypub) console.log(` ActivityPub: enabled (@${config.apUsername || 'me'})`);
198
204
  if (config.singleUser) console.log(` Single-user: ${config.singleUserName || 'me'} (registration disabled)`);
@@ -616,6 +622,134 @@ tokenCmd
616
622
  }
617
623
  });
618
624
 
625
+ /**
626
+ * Passwd command - change a user's password
627
+ */
628
+ program
629
+ .command('passwd <username>')
630
+ .description('Change password for a user account')
631
+ .option('-p, --password <password>', 'New password (non-interactive)')
632
+ .option('-g, --generate', 'Generate a random password')
633
+ .option('-r, --root <path>', 'Data directory')
634
+ .action(async (username, options) => {
635
+ try {
636
+ if (options.root) {
637
+ process.env.DATA_ROOT = path.resolve(options.root);
638
+ }
639
+
640
+ // Load account by username
641
+ const dataRoot = process.env.DATA_ROOT || './data';
642
+ const accountsDir = path.join(dataRoot, '.idp', 'accounts');
643
+ const indexPath = path.join(accountsDir, '_username_index.json');
644
+
645
+ let usernameIndex;
646
+ try {
647
+ usernameIndex = await fs.readJson(indexPath);
648
+ } catch (err) {
649
+ if (err.code === 'ENOENT') {
650
+ console.error(`Error: No accounts found (missing ${indexPath})`);
651
+ process.exit(1);
652
+ }
653
+ throw err;
654
+ }
655
+
656
+ const normalizedUsername = username.toLowerCase().trim();
657
+ const accountId = usernameIndex[normalizedUsername];
658
+ if (!accountId) {
659
+ console.error(`Error: User not found: ${username}`);
660
+ process.exit(1);
661
+ }
662
+
663
+ const accountPath = path.join(accountsDir, `${accountId}.json`);
664
+ const account = await fs.readJson(accountPath);
665
+
666
+ // Determine new password
667
+ let newPassword;
668
+
669
+ if (options.generate) {
670
+ newPassword = crypto.randomBytes(16).toString('base64url');
671
+ } else if (options.password) {
672
+ newPassword = options.password;
673
+ } else {
674
+ // Interactive prompt
675
+ newPassword = await promptPassword('New password: ');
676
+ const confirm = await promptPassword('Confirm password: ');
677
+ if (newPassword !== confirm) {
678
+ console.error('Error: Passwords do not match');
679
+ process.exit(1);
680
+ }
681
+ }
682
+
683
+ if (!newPassword) {
684
+ console.error('Error: Password cannot be empty');
685
+ process.exit(1);
686
+ }
687
+
688
+ // Hash and save
689
+ const bcrypt = await import('bcryptjs').then(m => m.default);
690
+ account.passwordHash = await bcrypt.hash(newPassword, 10);
691
+ account.passwordChangedAt = new Date().toISOString();
692
+ await fs.writeJson(accountPath, account, { spaces: 2 });
693
+
694
+ if (options.generate) {
695
+ console.log(`\nPassword updated for ${normalizedUsername}`);
696
+ console.log(`Generated password: ${newPassword}\n`);
697
+ } else {
698
+ console.log(`\nPassword updated for ${normalizedUsername}\n`);
699
+ }
700
+ } catch (err) {
701
+ console.error(`Error: ${err.message}`);
702
+ process.exit(1);
703
+ }
704
+ });
705
+
706
+ /**
707
+ * Helper: Prompt for a password (hidden input)
708
+ */
709
+ async function promptPassword(question) {
710
+ const rl = readline.createInterface({
711
+ input: process.stdin,
712
+ output: process.stdout
713
+ });
714
+
715
+ return new Promise((resolve) => {
716
+ // Disable echo for password input
717
+ if (process.stdin.isTTY) {
718
+ process.stdout.write(` ${question}`);
719
+ const stdin = process.openStdin();
720
+ process.stdin.setRawMode(true);
721
+ let password = '';
722
+ const onData = (ch) => {
723
+ const c = ch.toString('utf8');
724
+ if (c === '\n' || c === '\r' || c === '\u0004') {
725
+ process.stdin.setRawMode(false);
726
+ process.stdin.removeListener('data', onData);
727
+ process.stdout.write('\n');
728
+ rl.close();
729
+ resolve(password);
730
+ } else if (c === '\u0003') {
731
+ // Ctrl+C
732
+ process.exit(0);
733
+ } else if (c === '\u007f' || c === '\b') {
734
+ // Backspace
735
+ if (password.length > 0) {
736
+ password = password.slice(0, -1);
737
+ }
738
+ } else {
739
+ password += c;
740
+ }
741
+ };
742
+ process.stdin.on('data', onData);
743
+ } else {
744
+ // Non-TTY: read line normally (piped input)
745
+ rl.question(` ${question}`, (answer) => {
746
+ rl.close();
747
+ resolve(answer.trim());
748
+ });
749
+ }
750
+ });
751
+ }
752
+
619
753
  /**
620
754
  * Helper: Prompt for input
621
755
  */
@@ -177,6 +177,7 @@ Server: pub http://localhost:3000/alice/public/data.json (on change)
177
177
  | `--webrtc-path <path>` | WebRTC signaling WebSocket path | /.webrtc |
178
178
  | `--tunnel` | Enable tunnel proxy (decentralized ngrok) | false |
179
179
  | `--tunnel-path <path>` | Tunnel WebSocket path | /.tunnel |
180
+ | `--terminal` | Enable WebSocket shell at `/.terminal` | false |
180
181
  | `-q, --quiet` | Suppress logs | false |
181
182
 
182
183
  ### Environment Variables
@@ -0,0 +1,75 @@
1
+ # Terminal & Password CLI
2
+
3
+ ## WebSocket Terminal (`--terminal`)
4
+
5
+ Enable a WebSocket shell endpoint at `/.terminal` for browser-based terminal access.
6
+
7
+ ```bash
8
+ jss start --terminal
9
+ ```
10
+
11
+ ### How it works
12
+
13
+ - Spawns `/bin/sh` per authenticated WebSocket connection
14
+ - Pipes stdin/stdout/stderr between WebSocket and shell
15
+ - Requires authentication (only authenticated users can connect)
16
+ - Disabled by default, opt-in via `--terminal`
17
+
18
+ ### Environment variable
19
+
20
+ ```bash
21
+ JSS_TERMINAL=true jss start
22
+ ```
23
+
24
+ ### Browser integration
25
+
26
+ Connect with [xterm.js](https://xtermjs.org/) or any WebSocket terminal client:
27
+
28
+ ```javascript
29
+ const ws = new WebSocket('wss://your.pod/.terminal')
30
+ ws.onmessage = (e) => terminal.write(e.data)
31
+ terminal.onData((data) => ws.send(data))
32
+ ```
33
+
34
+ ### Security
35
+
36
+ - Authentication required — unauthenticated connections are rejected
37
+ - Experimental — not recommended for production multi-user servers without additional access controls
38
+ - Consider using `--single-user` mode for personal pod servers
39
+
40
+ ## Password Management (`jss passwd`)
41
+
42
+ Manage IdP user passwords from the command line.
43
+
44
+ ```bash
45
+ # Set password interactively
46
+ jss passwd <username>
47
+
48
+ # Set password directly
49
+ jss passwd <username> --password <newpassword>
50
+
51
+ # Generate and print a random password
52
+ jss passwd <username> --generate
53
+ ```
54
+
55
+ ### Options
56
+
57
+ | Flag | Description |
58
+ |------|-------------|
59
+ | `--password <pw>` | Set password non-interactively |
60
+ | `--generate` | Generate random password and print it |
61
+ | `-r, --root <path>` | Data directory (default: `./data`) |
62
+
63
+ ### Examples
64
+
65
+ ```bash
66
+ # Reset a user's password
67
+ jss passwd alice --password newsecretpass
68
+
69
+ # Generate a random password for a user
70
+ jss passwd bob --generate
71
+ # Output: New password for bob: a7Bx9kQ2mP...
72
+
73
+ # Interactive (prompts for password)
74
+ jss passwd alice
75
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.117",
3
+ "version": "0.0.119",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/config.js CHANGED
@@ -55,6 +55,9 @@ export const defaults = {
55
55
  webrtc: false,
56
56
  webrtcPath: '/.webrtc',
57
57
 
58
+ // Terminal (WebSocket shell access)
59
+ terminal: false,
60
+
58
61
  // Tunnel (decentralized ngrok)
59
62
  tunnel: false,
60
63
  tunnelPath: '/.tunnel',
@@ -140,6 +143,7 @@ const envMap = {
140
143
  JSS_NOSTR_MAX_EVENTS: 'nostrMaxEvents',
141
144
  JSS_WEBRTC: 'webrtc',
142
145
  JSS_WEBRTC_PATH: 'webrtcPath',
146
+ JSS_TERMINAL: 'terminal',
143
147
  JSS_TUNNEL: 'tunnel',
144
148
  JSS_TUNNEL_PATH: 'tunnelPath',
145
149
  JSS_ACTIVITYPUB: 'activitypub',
package/src/server.js CHANGED
@@ -20,6 +20,7 @@ import { remoteStoragePlugin } from './remotestorage.js';
20
20
  import { dbPlugin } from './db/index.js';
21
21
  import { webrtcPlugin } from './webrtc/index.js';
22
22
  import { tunnelPlugin } from './tunnel/index.js';
23
+ import { terminalPlugin } from './terminal/index.js';
23
24
 
24
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
25
26
 
@@ -76,6 +77,8 @@ export function createServer(options = {}) {
76
77
  // WebRTC signaling is OFF by default
77
78
  const webrtcEnabled = options.webrtc ?? false;
78
79
  const webrtcPath = options.webrtcPath ?? '/.webrtc';
80
+ // Terminal (WebSocket shell) is OFF by default
81
+ const terminalEnabled = options.terminal ?? false;
79
82
  // Tunnel proxy is OFF by default
80
83
  const tunnelEnabled = options.tunnel ?? false;
81
84
  const tunnelPath = options.tunnelPath ?? '/.tunnel';
@@ -248,6 +251,11 @@ export function createServer(options = {}) {
248
251
  fastify.register(webrtcPlugin, { path: webrtcPath });
249
252
  }
250
253
 
254
+ // Register terminal (WebSocket shell) if enabled
255
+ if (terminalEnabled) {
256
+ fastify.register(terminalPlugin, { path: '/.terminal', public: options.public || false });
257
+ }
258
+
251
259
  // Register tunnel proxy if enabled
252
260
  if (tunnelEnabled) {
253
261
  fastify.register(tunnelPlugin, { path: tunnelPath });
@@ -352,6 +360,9 @@ export function createServer(options = {}) {
352
360
  if (webrtcEnabled && urlNoQuery === webrtcPath) {
353
361
  return;
354
362
  }
363
+ if (terminalEnabled && urlNoQuery === '/.terminal') {
364
+ return;
365
+ }
355
366
 
356
367
  const segments = request.url.split('/').map(s => s.split('?')[0]); // Remove query strings
357
368
  const hasForbiddenDotfile = segments.some(seg =>
@@ -427,6 +438,7 @@ export function createServer(options = {}) {
427
438
  (payEnabled && isPayRequest(request.url)) ||
428
439
  (mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
429
440
  (webrtcEnabled && (request.url === webrtcPath || request.url.startsWith(webrtcPath + '?'))) ||
441
+ (terminalEnabled && (request.url === '/.terminal' || request.url.startsWith('/.terminal?'))) ||
430
442
  (tunnelEnabled && (request.url === tunnelPath || request.url.startsWith(tunnelPath + '?') || request.url.startsWith('/tunnel/'))) ||
431
443
  mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
432
444
  return;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Terminal Plugin — WebSocket Shell Access
3
+ *
4
+ * Provides a remote shell over WebSocket for authenticated pod owners.
5
+ * Spawns /bin/sh on connection and pipes stdin/stdout/stderr between
6
+ * the WebSocket and the shell process.
7
+ *
8
+ * SECURITY: Requires authentication. The connecting user's webId must
9
+ * be present (verified via token). This is a privileged endpoint.
10
+ *
11
+ * Usage: jss start --terminal
12
+ * Endpoint: wss://your.pod/.terminal
13
+ *
14
+ * Protocol (binary/text over WebSocket):
15
+ * -> (text/binary) stdin data sent to shell
16
+ * <- (text/binary) stdout/stderr data from shell
17
+ * <- JSON { type: "exit", code: <n> } shell exited
18
+ * <- JSON { type: "error", message: "..." }
19
+ */
20
+
21
+ import websocket from '@fastify/websocket';
22
+ import { getWebIdFromRequestAsync } from '../auth/token.js';
23
+ import { spawn } from 'child_process';
24
+
25
+ /**
26
+ * Register terminal WebSocket route on Fastify instance
27
+ *
28
+ * @param {object} fastify - Fastify instance
29
+ * @param {object} options - Options
30
+ * @param {string} options.path - WebSocket path (default: '/.terminal')
31
+ */
32
+ export async function terminalPlugin(fastify, options = {}) {
33
+ const wsPath = options.path || '/.terminal';
34
+
35
+ // Track active shell processes for cleanup
36
+ const shells = new Set();
37
+
38
+ if (!fastify.websocketServer) {
39
+ await fastify.register(websocket);
40
+ }
41
+
42
+ // Clean up all shells on server close
43
+ fastify.addHook('onClose', async () => {
44
+ for (const proc of shells) {
45
+ try { proc.kill(); } catch { /* already dead */ }
46
+ }
47
+ shells.clear();
48
+ });
49
+
50
+ fastify.get(wsPath, { websocket: true }, async (connection, request) => {
51
+ const socket = connection.socket || connection;
52
+
53
+ // Authenticate — query param token support for browser WebSocket
54
+ const queryToken = request.query?.token;
55
+ if (queryToken && !request.headers.authorization) {
56
+ request.headers.authorization = `Bearer ${queryToken}`;
57
+ }
58
+ const { webId } = await getWebIdFromRequestAsync(request);
59
+
60
+ if (!webId && !options.public) {
61
+ socket.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
62
+ socket.close();
63
+ return;
64
+ }
65
+
66
+ // Spawn shell
67
+ const shellCommand = process.env.SHELL || 'bash';
68
+ const shell = spawn(shellCommand, ['-i'], {
69
+ stdio: ['pipe', 'pipe', 'pipe'],
70
+ env: { ...process.env, TERM: 'xterm-256color' },
71
+ });
72
+
73
+ shells.add(shell);
74
+
75
+ // Pipe shell stdout to WebSocket
76
+ shell.stdout.on('data', (data) => {
77
+ if (socket.readyState === 1) {
78
+ try { socket.send(data.toString().replace(/\r?\n/g, '\r\n')); } catch { /* socket closed */ }
79
+ }
80
+ });
81
+
82
+ // Pipe shell stderr to WebSocket
83
+ shell.stderr.on('data', (data) => {
84
+ if (socket.readyState === 1) {
85
+ try { socket.send(data.toString().replace(/\r?\n/g, '\r\n')); } catch { /* socket closed */ }
86
+ }
87
+ });
88
+
89
+ // Shell exited
90
+ shell.on('close', (code) => {
91
+ shells.delete(shell);
92
+ if (socket.readyState === 1) {
93
+ try {
94
+ socket.send(JSON.stringify({ type: 'exit', code: code ?? 1 }));
95
+ socket.close();
96
+ } catch { /* socket already closed */ }
97
+ }
98
+ });
99
+
100
+ shell.on('error', (err) => {
101
+ shells.delete(shell);
102
+ if (socket.readyState === 1) {
103
+ try {
104
+ socket.send(JSON.stringify({ type: 'error', message: err.message }));
105
+ socket.close();
106
+ } catch { /* socket already closed */ }
107
+ }
108
+ });
109
+
110
+ // Pipe WebSocket messages to shell stdin
111
+ socket.on('message', (data) => {
112
+ if (shell.stdin.writable) {
113
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
114
+ try { shell.stdin.write(buf); } catch { /* stdin closed */ }
115
+ }
116
+ });
117
+
118
+ // WebSocket closed — kill the shell
119
+ socket.on('close', () => {
120
+ shells.delete(shell);
121
+ try { shell.kill(); } catch { /* already dead */ }
122
+ });
123
+
124
+ socket.on('error', () => {});
125
+ });
126
+ }
127
+
128
+ export default terminalPlugin;