javascript-solid-server 0.0.163 → 0.0.165

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.
@@ -361,7 +361,24 @@
361
361
  "WebFetch(domain:www.gitfork.app)",
362
362
  "Bash(JSS_SINGLE_USER_PASSWORD=hunter2 timeout 3 node bin/jss.js start --port 4581 --root /tmp/jss-103 --single-user --single-user-name alice --idp)",
363
363
  "Bash(JSS_SINGLE_USER_PASSWORD=hunter2 timeout 2 node bin/jss.js start --port 4581 --root /tmp/jss-103 --single-user-name alice --idp)",
364
- "Bash(JSS_SINGLE_USER_PASSWORD=hunter2 timeout 3 node bin/jss.js start --port 4581 --root /tmp/jss-103-sanity --single-user-name alice --single-user --idp)"
364
+ "Bash(JSS_SINGLE_USER_PASSWORD=hunter2 timeout 3 node bin/jss.js start --port 4581 --root /tmp/jss-103-sanity --single-user-name alice --single-user --idp)",
365
+ "Bash(pm2 jlist *)",
366
+ "Bash(jss --version)",
367
+ "Bash(cp -a ~/main/me ~/main/me.backup-pre-348)",
368
+ "Bash(cp -a ~/main/.idp/accounts ~/main/.idp/accounts.backup-pre-348)",
369
+ "Bash(mv ~/main/me.backup-pre-348 ~/main.backup-me-pre-348)",
370
+ "Bash(mv ~/main/.idp/accounts.backup-pre-348 ~/main.backup-accounts-pre-348)",
371
+ "Bash(shopt -s dotglob)",
372
+ "Bash(mv ~/main/me/* ~/main/)",
373
+ "Bash(shopt -u dotglob)",
374
+ "Bash(rmdir ~/main/me)",
375
+ "Bash(rm /tmp/dup-issue.md)",
376
+ "Bash(rm -rf /tmp/losos-fix)",
377
+ "Bash(terser losos/shell.js -m -c)",
378
+ "Bash(terser losos/html.js -m -c)",
379
+ "Bash(terser losos/store.js -m -c)",
380
+ "Bash(terser losos/registry.js -m -c)",
381
+ "Bash(terser losos/losos.js -m -c)"
365
382
  ]
366
383
  }
367
384
  }
package/bin/jss.js CHANGED
@@ -126,7 +126,7 @@ program
126
126
  .option('--invite-only', 'Require invite code for registration')
127
127
  .option('--no-invite-only', 'Allow open registration')
128
128
  .option('--single-user', 'Single-user mode (creates pod on startup, disables registration)')
129
- .option('--single-user-name <name>', 'Username for single-user mode (default: me)')
129
+ .option('--single-user-name <name>', 'Mount the pod at /<name>/ instead of at the server root (default: root pod at /)')
130
130
  .option('--single-user-password <pw>', 'Initial IDP password to seed when creating the single-user pod (or set JSS_SINGLE_USER_PASSWORD)')
131
131
  .option('--webid-tls', 'Enable WebID-TLS client certificate authentication')
132
132
  .option('--no-webid-tls', 'Disable WebID-TLS authentication')
@@ -258,19 +258,21 @@ Response:
258
258
  For personal pod servers where only one user needs access:
259
259
 
260
260
  ```bash
261
- # Basic single-user mode (creates pod at /me/)
262
- # On first run JSS will prompt for an initial password (TTY only).
261
+ # Default: pod served at server root (#348). WebID is
262
+ # /profile/card.jsonld#me; the IDP login username is "me". On first
263
+ # run JSS will prompt for an initial password (TTY only).
263
264
  jss start --single-user --idp
264
265
 
265
266
  # Provide the initial IDP password non-interactively (systemd, containers, CI):
266
267
  jss start --single-user --idp --single-user-password 'choose-a-good-one'
267
268
  JSS_SINGLE_USER_PASSWORD='choose-a-good-one' jss start --single-user --idp
268
269
 
269
- # Custom username
270
+ # Mount the pod at a named path instead of the origin. WebID becomes
271
+ # /alice/profile/card.jsonld#me; login as "alice".
270
272
  jss start --single-user --single-user-name alice --idp
271
273
 
272
- # Root-level pod (pod at /, WebID at /profile/card#me)
273
- jss start --single-user --single-user-name '' --idp
274
+ # Legacy /me/ pod same as the old default before #348.
275
+ jss start --single-user --single-user-name me --idp
274
276
 
275
277
  # Via environment
276
278
  JSS_SINGLE_USER=true jss start --idp
@@ -283,6 +285,18 @@ JSS_SINGLE_USER=true jss start --idp
283
285
  - Login works for the single user via password (`POST /idp/credentials`) or any other configured method
284
286
  - Proper ACLs generated automatically
285
287
 
288
+ **Upgrading from a pre-#348 install:** if your existing pod was created with the old default (data lives under `<root>/me/`), JSS no longer auto-detects it — restarting plain `jss start --single-user` will start seeding a fresh empty root pod alongside your legacy `/me/` data, and your existing IDP account will keep authenticating against `/me/`. Pick one path on the next restart:
289
+ - **Keep the legacy layout:** add `--single-user-name me` to your launch command. No data movement needed.
290
+ - **Migrate to root pod:** move the *entire* contents of `<root>/me/` (including dotfiles like `.acl`, `.meta`, `.quota.json` — a plain `mv <root>/me/* <root>/` skips them) to `<root>/`, delete the IDP account for `me` (so the new root pod's `me` account can be seeded), then restart without the name flag. Use one of:
291
+
292
+ ```bash
293
+ # Option A: rsync handles dotfiles correctly with the trailing slash.
294
+ rsync -a <root>/me/ <root>/ && rm -rf <root>/me
295
+
296
+ # Option B: bash with dotglob enabled so * matches dotfiles too.
297
+ shopt -s dotglob && mv <root>/me/* <root>/ && rmdir <root>/me
298
+ ```
299
+
286
300
  **Initial password sources, in priority order:**
287
301
  1. `--single-user-password <pw>` CLI flag
288
302
  2. `JSS_SINGLE_USER_PASSWORD` env var
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.163",
3
+ "version": "0.0.165",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/config.js CHANGED
@@ -74,7 +74,11 @@ export const defaults = {
74
74
 
75
75
  // Single-user mode (personal pod server)
76
76
  singleUser: false,
77
- singleUserName: 'me',
77
+ // null = root pod (mounted at server origin, WebID at
78
+ // /profile/card.jsonld#me). A string mounts the pod at /<name>/ —
79
+ // useful when more than one Solid identity coexists on the same
80
+ // origin, or when the operator wants the pre-#348 /me/ shape.
81
+ singleUserName: null,
78
82
  // Initial IDP password seeded on first single-user pod creation. If
79
83
  // unset and --idp is enabled, the server prompts on a TTY or logs a
80
84
  // warning and continues startup on non-TTY (so the pod is created but
@@ -399,20 +403,17 @@ export function printConfig(config) {
399
403
  console.log(` SSL: ${config.ssl ? 'enabled' : 'disabled'}`);
400
404
  console.log(` Multi-user: ${config.multiuser}`);
401
405
  if (config.singleUser) {
402
- let details = `${config.singleUserName}`;
403
- // Password seeding only runs when --idp is on AND the pod isn't the
404
- // root-level case ('/'). Reflect both gates in the printed line so
405
- // operators don't see a misleading "missing login disabled" when
406
- // login isn't governed by an IDP password at all.
406
+ const isRootPod = config.singleUserName === '/' || !config.singleUserName;
407
+ let details = isRootPod ? '/ (root pod)' : config.singleUserName;
408
+ // The "login as me" hint and password line only make sense when
409
+ // the built-in IdP is on. With --no-idp / external issuer there's
410
+ // no built-in login form, so don't imply one exists.
407
411
  if (config.idp) {
408
- if (config.singleUserName === '/' || !config.singleUserName) {
409
- details += ' (root pod; password not seeded)';
410
- } else {
411
- const pwSource = config.singleUserPassword
412
- ? 'provided'
413
- : (process.stdin.isTTY ? 'will prompt at startup' : 'missing — login disabled');
414
- details += ` (password: ${pwSource})`;
415
- }
412
+ if (isRootPod) details += ', login as "me"';
413
+ const pwSource = config.singleUserPassword
414
+ ? 'provided'
415
+ : (process.stdin.isTTY ? 'will prompt at startup' : 'missing — login disabled');
416
+ details += ` (password: ${pwSource})`;
416
417
  }
417
418
  console.log(` Single-user: ${details}`);
418
419
  }
@@ -143,6 +143,19 @@ export async function createAccount({ username, password, webId, podName, email
143
143
  return safeAccount;
144
144
  }
145
145
 
146
+ /**
147
+ * Verify a password against an account's stored hash without side effects.
148
+ * Use this for re-auth proofs (e.g. password rotation) where stamping
149
+ * lastLogin would falsify the audit trail.
150
+ * @param {object} account - Account object with passwordHash
151
+ * @param {string} password - Plain text password
152
+ * @returns {Promise<boolean>}
153
+ */
154
+ export async function verifyPassword(account, password) {
155
+ if (!account?.passwordHash) return false;
156
+ return bcrypt.compare(password, account.passwordHash);
157
+ }
158
+
146
159
  /**
147
160
  * Authenticate a user with username/email and password
148
161
  * @param {string} identifier - Username or email
@@ -5,8 +5,9 @@
5
5
 
6
6
  import * as jose from 'jose';
7
7
  import crypto from 'crypto';
8
- import { authenticate } from './accounts.js';
8
+ import { authenticate, findByWebId, updatePassword, verifyPassword } from './accounts.js';
9
9
  import { getJwks } from './keys.js';
10
+ import { getWebIdFromRequestAsync } from '../auth/token.js';
10
11
 
11
12
  /**
12
13
  * Handle POST /idp/credentials
@@ -198,6 +199,79 @@ async function validateDpopProof(proof, method, url) {
198
199
  return thumbprint;
199
200
  }
200
201
 
202
+ /**
203
+ * Handle PUT /idp/credentials
204
+ * Authenticated owner rotates their own password.
205
+ *
206
+ * Auth: caller must be authenticated (Bearer/DPoP/Nostr-NIP-98).
207
+ * Body (JSON): { currentPassword, newPassword }
208
+ *
209
+ * Responses:
210
+ * 200 { ok: true, webid, passwordChangedAt }
211
+ * 400 missing fields
212
+ * 401 unauthenticated, or currentPassword wrong
213
+ * 403 caller's WebID does not match any account
214
+ */
215
+ export async function handleChangePassword(request, reply) {
216
+ // 1. Authenticate caller
217
+ const { webId, error: authError } = await getWebIdFromRequestAsync(request);
218
+ if (!webId) {
219
+ return reply.code(401).send({
220
+ error: 'invalid_token',
221
+ error_description: authError || 'Authentication required',
222
+ });
223
+ }
224
+
225
+ // 2. Parse body
226
+ let body = request.body;
227
+ if (Buffer.isBuffer(body)) body = body.toString('utf-8');
228
+ if (typeof body === 'string') {
229
+ try { body = JSON.parse(body); } catch { body = {}; }
230
+ }
231
+ const currentPassword = body?.currentPassword;
232
+ const newPassword = body?.newPassword;
233
+
234
+ if (typeof currentPassword !== 'string' || typeof newPassword !== 'string'
235
+ || !currentPassword || !newPassword) {
236
+ return reply.code(400).send({
237
+ error: 'invalid_request',
238
+ error_description: 'currentPassword and newPassword are required (strings)',
239
+ });
240
+ }
241
+
242
+ // 3. Resolve account from caller's WebID
243
+ const account = await findByWebId(webId);
244
+ if (!account) {
245
+ return reply.code(403).send({
246
+ error: 'forbidden',
247
+ error_description: 'No account found for authenticated WebID',
248
+ });
249
+ }
250
+
251
+ // 4. Verify currentPassword (re-auth proof). Side-effect-free — does NOT
252
+ // stamp lastLogin, since password rotation isn't a login event.
253
+ if (!(await verifyPassword(account, currentPassword))) {
254
+ return reply.code(401).send({
255
+ error: 'invalid_grant',
256
+ error_description: 'Current password is incorrect',
257
+ });
258
+ }
259
+
260
+ // 5. Rotate
261
+ await updatePassword(account.id, newPassword);
262
+
263
+ // Re-read to surface passwordChangedAt
264
+ const updated = await findByWebId(webId);
265
+
266
+ reply.header('Cache-Control', 'no-store');
267
+ reply.header('Pragma', 'no-cache');
268
+ return {
269
+ ok: true,
270
+ webid: account.webId,
271
+ passwordChangedAt: updated?.passwordChangedAt,
272
+ };
273
+ }
274
+
201
275
  /**
202
276
  * Handle GET /idp/credentials
203
277
  * Returns info about the credentials endpoint
package/src/idp/index.js CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  import {
22
22
  handleCredentials,
23
23
  handleCredentialsInfo,
24
+ handleChangePassword,
24
25
  } from './credentials.js';
25
26
  import * as passkey from './passkey.js';
26
27
  import { addTrustedIssuer } from '../auth/solid-oidc.js';
@@ -264,6 +265,19 @@ export async function idpPlugin(fastify, options) {
264
265
  return handleCredentials(request, reply, issuer);
265
266
  });
266
267
 
268
+ // PUT credentials - authenticated owner rotates their own password (#351)
269
+ fastify.put('/idp/credentials', {
270
+ config: {
271
+ rateLimit: {
272
+ max: 10,
273
+ timeWindow: '1 minute',
274
+ keyGenerator: (request) => request.ip
275
+ }
276
+ }
277
+ }, async (request, reply) => {
278
+ return handleChangePassword(request, reply);
279
+ });
280
+
267
281
  // Interaction routes (our custom login/consent UI)
268
282
  // These bypass oidc-provider and use our handlers
269
283
 
package/src/server.js CHANGED
@@ -93,7 +93,22 @@ export function createServer(options = {}) {
93
93
  const inviteOnly = options.inviteOnly ?? false;
94
94
  // Single-user mode - creates pod on startup, disables registration
95
95
  const singleUser = options.singleUser ?? false;
96
- const singleUserName = options.singleUserName ?? 'me';
96
+ // Default null = root pod (#348). Pass an explicit singleUserName
97
+ // to mount the pod at /<name>/ instead. Normalize the
98
+ // historical `'/'` / `''` forms to null up front so downstream
99
+ // code (remoteStoragePlugin, decorators, etc.) doesn't have to
100
+ // re-check for the same three shapes.
101
+ //
102
+ // Pre-#348 installs (default 'me') that upgrade in place will see
103
+ // a fresh empty root pod alongside their /me/ data. The fix is to
104
+ // pass `--single-user-name me` on restart (or move data/me/* out
105
+ // to the data root). At v0.0.x we accept that one-time
106
+ // intervention rather than carrying detection magic in the code.
107
+ const rawSingleUserName = options.singleUserName ?? null;
108
+ const singleUserName =
109
+ (rawSingleUserName === '/' || rawSingleUserName === '')
110
+ ? null
111
+ : rawSingleUserName;
97
112
  const singleUserPassword = options.singleUserPassword ?? null;
98
113
  // Default storage quota per pod (50MB default, 0 = unlimited)
99
114
  const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024;
@@ -558,8 +573,10 @@ export function createServer(options = {}) {
558
573
  const baseUrl = idpIssuer?.replace(/\/$/, '') || `${protocol}://${host}:${port}`;
559
574
  const issuer = idpIssuer || `${baseUrl}/`;
560
575
 
561
- // Root-level pod (empty or '/' name) vs named pod
562
- const isRootPod = !singleUserName || singleUserName === '/';
576
+ // Root pod (no name) vs named pod. After the singleUserName
577
+ // normalization at the top of createServer(), null is the only
578
+ // root-pod shape we need to recognize here.
579
+ const isRootPod = !singleUserName;
563
580
  const podPath = isRootPod ? '/' : `/${singleUserName}/`;
564
581
  const podUri = isRootPod ? `${baseUrl}/` : `${baseUrl}/${singleUserName}/`;
565
582
  const displayName = isRootPod ? 'me' : singleUserName;
@@ -595,12 +612,22 @@ export function createServer(options = {}) {
595
612
  // this, single-user + --idp produces a pod but no credential, and
596
613
  // registration is intentionally disabled in single-user mode — so
597
614
  // the pod is unloggable until a password is set externally (#323).
598
- if (idpEnabled && !isRootPod) {
615
+ //
616
+ // Root pods (#348) need this too: the pod has no name, but the IDP
617
+ // still needs *some* username for the login form. Default to 'me'
618
+ // — matches the WebID fragment, fits the historical convention.
619
+ if (idpEnabled) {
620
+ // The IDP also persists `podName` and surfaces it as the
621
+ // `name` claim under the OIDC `profile` scope (see
622
+ // src/idp/accounts.js). For root pods we use 'me' here too —
623
+ // a null podName would leak through as a null/missing
624
+ // profile.name on every login, which OIDC clients expect to
625
+ // be a non-empty human-readable string.
599
626
  await seedSingleUserIdpAccount({
600
627
  fastify,
601
- username: singleUserName,
628
+ username: isRootPod ? 'me' : singleUserName,
602
629
  webId,
603
- podName: singleUserName,
630
+ podName: isRootPod ? 'me' : singleUserName,
604
631
  providedPassword: singleUserPassword
605
632
  });
606
633
  }
@@ -158,3 +158,35 @@ describe('config — --single-user implies --idp (#331)', () => {
158
158
  '--no-idp without --single-user should not trigger the #331 warning');
159
159
  });
160
160
  });
161
+
162
+ // #348: the user-visible default change — `jss start --single-user`
163
+ // (no name flag) must produce a config where singleUserName is null,
164
+ // so createServer() takes the root-pod path. createServer() has its
165
+ // own tests but a future refactor of loadConfig() could silently
166
+ // restore the old `'me'` default and only the server-level tests
167
+ // would catch it via behaviour, not the config layer directly.
168
+ describe('config — singleUserName default (#348)', () => {
169
+ it('loadConfig() returns singleUserName=null when no flag/env is set', async () => {
170
+ delete process.env.JSS_SINGLE_USER_NAME;
171
+ const cfg = await loadConfig({}, null);
172
+ assert.strictEqual(cfg.singleUserName, null,
173
+ 'default must be null (= root pod), not the legacy "me"');
174
+ });
175
+
176
+ it('loadConfig() preserves an explicit singleUserName CLI arg', async () => {
177
+ delete process.env.JSS_SINGLE_USER_NAME;
178
+ const cfg = await loadConfig({ singleUserName: 'alice' }, null);
179
+ assert.strictEqual(cfg.singleUserName, 'alice');
180
+ });
181
+
182
+ it('loadConfig() respects JSS_SINGLE_USER_NAME from env', async () => {
183
+ process.env.JSS_SINGLE_USER_NAME = 'me';
184
+ try {
185
+ const cfg = await loadConfig({}, null);
186
+ assert.strictEqual(cfg.singleUserName, 'me',
187
+ 'env var should restore the legacy "me" pod path on demand');
188
+ } finally {
189
+ delete process.env.JSS_SINGLE_USER_NAME;
190
+ }
191
+ });
192
+ });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * PUT /idp/credentials — authenticated owner rotates their own password (#351)
3
+ */
4
+
5
+ import { describe, it, before, after } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { createServer } from '../src/server.js';
8
+ import fs from 'fs-extra';
9
+ import { createServer as createNetServer } from 'net';
10
+
11
+ const TEST_HOST = 'localhost';
12
+
13
+ function getAvailablePort() {
14
+ return new Promise((resolve, reject) => {
15
+ const srv = createNetServer();
16
+ srv.on('error', reject);
17
+ srv.listen(0, TEST_HOST, () => {
18
+ const port = srv.address().port;
19
+ srv.close(() => resolve(port));
20
+ });
21
+ });
22
+ }
23
+
24
+ async function createPod(baseUrl, name, email, password) {
25
+ const res = await fetch(`${baseUrl}/.pods`, {
26
+ method: 'POST',
27
+ headers: { 'Content-Type': 'application/json' },
28
+ body: JSON.stringify({ name, email, password }),
29
+ });
30
+ const body = await res.json().catch(() => ({}));
31
+ assert.strictEqual(res.status, 201, `pod create failed: ${JSON.stringify(body)}`);
32
+ return body;
33
+ }
34
+
35
+ async function loginToken(baseUrl, email, password) {
36
+ const res = await fetch(`${baseUrl}/idp/credentials`, {
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/json' },
39
+ body: JSON.stringify({ email, password }),
40
+ });
41
+ const body = await res.json().catch(() => ({}));
42
+ assert.strictEqual(res.status, 200, `login failed: ${JSON.stringify(body)}`);
43
+ return body.access_token;
44
+ }
45
+
46
+ describe('PUT /idp/credentials — change password', () => {
47
+ let server;
48
+ let baseUrl;
49
+ let originalDataRoot;
50
+ const DATA_DIR = './test-data-change-password';
51
+
52
+ before(async () => {
53
+ originalDataRoot = process.env.DATA_ROOT;
54
+ await fs.remove(DATA_DIR);
55
+ await fs.ensureDir(DATA_DIR);
56
+ const port = await getAvailablePort();
57
+ baseUrl = `http://${TEST_HOST}:${port}`;
58
+ server = createServer({
59
+ logger: false,
60
+ root: DATA_DIR,
61
+ idp: true,
62
+ idpIssuer: baseUrl,
63
+ forceCloseConnections: true,
64
+ });
65
+ await server.listen({ port, host: TEST_HOST });
66
+ });
67
+
68
+ after(async () => {
69
+ await server.close();
70
+ await fs.remove(DATA_DIR);
71
+ if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
72
+ else process.env.DATA_ROOT = originalDataRoot;
73
+ });
74
+
75
+ it('rejects unauthenticated request with 401', async () => {
76
+ const res = await fetch(`${baseUrl}/idp/credentials`, {
77
+ method: 'PUT',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ body: JSON.stringify({ currentPassword: 'a', newPassword: 'b' }),
80
+ });
81
+ assert.strictEqual(res.status, 401);
82
+ });
83
+
84
+ it('rejects missing fields with 400', async () => {
85
+ const id = `alice${Date.now()}`;
86
+ await createPod(baseUrl, id, `${id}@example.com`, 'oldpassword123');
87
+ const token = await loginToken(baseUrl, `${id}@example.com`, 'oldpassword123');
88
+
89
+ const res = await fetch(`${baseUrl}/idp/credentials`, {
90
+ method: 'PUT',
91
+ headers: {
92
+ 'Content-Type': 'application/json',
93
+ 'Authorization': `Bearer ${token}`,
94
+ },
95
+ body: JSON.stringify({ currentPassword: 'oldpassword123' }),
96
+ });
97
+ assert.strictEqual(res.status, 400);
98
+ });
99
+
100
+ it('rejects wrong current password with 401, hash unchanged', async () => {
101
+ const id = `bob${Date.now()}`;
102
+ await createPod(baseUrl, id, `${id}@example.com`, 'oldpassword123');
103
+ const token = await loginToken(baseUrl, `${id}@example.com`, 'oldpassword123');
104
+
105
+ const res = await fetch(`${baseUrl}/idp/credentials`, {
106
+ method: 'PUT',
107
+ headers: {
108
+ 'Content-Type': 'application/json',
109
+ 'Authorization': `Bearer ${token}`,
110
+ },
111
+ body: JSON.stringify({
112
+ currentPassword: 'wrongpassword',
113
+ newPassword: 'newpassword456',
114
+ }),
115
+ });
116
+ assert.strictEqual(res.status, 401);
117
+
118
+ // Original password still works
119
+ const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
120
+ method: 'POST',
121
+ headers: { 'Content-Type': 'application/json' },
122
+ body: JSON.stringify({ email: `${id}@example.com`, password: 'oldpassword123' }),
123
+ });
124
+ assert.strictEqual(reLogin.status, 200);
125
+ });
126
+
127
+ it('happy path: rotates password, old fails, new succeeds', async () => {
128
+ const id = `carol${Date.now()}`;
129
+ await createPod(baseUrl, id, `${id}@example.com`, 'oldpassword123');
130
+ const token = await loginToken(baseUrl, `${id}@example.com`, 'oldpassword123');
131
+
132
+ const res = await fetch(`${baseUrl}/idp/credentials`, {
133
+ method: 'PUT',
134
+ headers: {
135
+ 'Content-Type': 'application/json',
136
+ 'Authorization': `Bearer ${token}`,
137
+ },
138
+ body: JSON.stringify({
139
+ currentPassword: 'oldpassword123',
140
+ newPassword: 'newpassword456',
141
+ }),
142
+ });
143
+ assert.strictEqual(res.status, 200);
144
+ const body = await res.json();
145
+ assert.strictEqual(body.ok, true);
146
+ assert.ok(body.webid.includes(id), 'response carries webid');
147
+ assert.ok(body.passwordChangedAt, 'response carries passwordChangedAt');
148
+
149
+ // Old password rejected
150
+ const oldRes = await fetch(`${baseUrl}/idp/credentials`, {
151
+ method: 'POST',
152
+ headers: { 'Content-Type': 'application/json' },
153
+ body: JSON.stringify({ email: `${id}@example.com`, password: 'oldpassword123' }),
154
+ });
155
+ assert.strictEqual(oldRes.status, 401);
156
+
157
+ // New password accepted
158
+ const newRes = await fetch(`${baseUrl}/idp/credentials`, {
159
+ method: 'POST',
160
+ headers: { 'Content-Type': 'application/json' },
161
+ body: JSON.stringify({ email: `${id}@example.com`, password: 'newpassword456' }),
162
+ });
163
+ assert.strictEqual(newRes.status, 200);
164
+ });
165
+
166
+ it('cross-account write: A authenticated cannot rotate B by sending B\'s currentPassword', async () => {
167
+ const aId = `dave${Date.now()}`;
168
+ const bId = `eve${Date.now() + 1}`;
169
+ await createPod(baseUrl, aId, `${aId}@example.com`, 'apassword123');
170
+ await createPod(baseUrl, bId, `${bId}@example.com`, 'bpassword123');
171
+
172
+ const aToken = await loginToken(baseUrl, `${aId}@example.com`, 'apassword123');
173
+
174
+ // A sends B's currentPassword → server resolves account from A's WebID, so the
175
+ // currentPassword must match A's, not B's. With B's password it must fail 401
176
+ // (and crucially must NOT touch B's account).
177
+ const res = await fetch(`${baseUrl}/idp/credentials`, {
178
+ method: 'PUT',
179
+ headers: {
180
+ 'Content-Type': 'application/json',
181
+ 'Authorization': `Bearer ${aToken}`,
182
+ },
183
+ body: JSON.stringify({
184
+ currentPassword: 'bpassword123',
185
+ newPassword: 'hijack',
186
+ }),
187
+ });
188
+ assert.strictEqual(res.status, 401);
189
+
190
+ // B's password unchanged
191
+ const bLogin = await fetch(`${baseUrl}/idp/credentials`, {
192
+ method: 'POST',
193
+ headers: { 'Content-Type': 'application/json' },
194
+ body: JSON.stringify({ email: `${bId}@example.com`, password: 'bpassword123' }),
195
+ });
196
+ assert.strictEqual(bLogin.status, 200);
197
+
198
+ // A's password also unchanged
199
+ const aLogin = await fetch(`${baseUrl}/idp/credentials`, {
200
+ method: 'POST',
201
+ headers: { 'Content-Type': 'application/json' },
202
+ body: JSON.stringify({ email: `${aId}@example.com`, password: 'apassword123' }),
203
+ });
204
+ assert.strictEqual(aLogin.status, 200);
205
+ });
206
+ });
package/test/idp.test.js CHANGED
@@ -458,6 +458,84 @@ describe('Identity Provider - Root pod type index ACLs', () => {
458
458
  });
459
459
  });
460
460
 
461
+ // #348: --single-user with no name flag now defaults to a root pod
462
+ // (was '/me/' historically). The server-side seed must land the
463
+ // profile at /profile/card.jsonld, not /me/profile/card.jsonld.
464
+ describe('Single-user default — root pod (#348)', () => {
465
+ let server;
466
+ let baseUrl;
467
+ const DEFAULT_DATA_DIR = './test-data-348-default-root';
468
+ const ROOT_POD_PASSWORD = 'root-pod-test-pw';
469
+
470
+ before(async () => {
471
+ await fs.remove(DEFAULT_DATA_DIR);
472
+ await fs.ensureDir(DEFAULT_DATA_DIR);
473
+
474
+ const port = await getAvailablePort();
475
+ baseUrl = `http://${TEST_HOST}:${port}`;
476
+
477
+ server = createServer({
478
+ logger: false,
479
+ root: DEFAULT_DATA_DIR,
480
+ idp: true,
481
+ idpIssuer: baseUrl,
482
+ singleUser: true,
483
+ // singleUserName intentionally omitted — exercises the new default.
484
+ // Provide a password so the seeding path runs non-interactively.
485
+ singleUserPassword: ROOT_POD_PASSWORD,
486
+ forceCloseConnections: true,
487
+ });
488
+
489
+ await server.listen({ port, host: TEST_HOST });
490
+ });
491
+
492
+ after(async () => {
493
+ await server.close();
494
+ await fs.remove(DEFAULT_DATA_DIR);
495
+ });
496
+
497
+ it('seeds the profile at /profile/card.jsonld (not /me/profile/...)', async () => {
498
+ const root = await fetch(`${baseUrl}/profile/card.jsonld`);
499
+ assert.strictEqual(root.status, 200,
500
+ '--single-user with no name should default to a root pod');
501
+ // Check the filesystem directly — an HTTP-only check could pass
502
+ // on a 401 even if /me/ data was somehow seeded, which would
503
+ // hide the regression we care about (root vs /me/ pod).
504
+ assert.strictEqual(await fs.pathExists(path.join(DEFAULT_DATA_DIR, 'me/profile/card.jsonld')), false,
505
+ 'no /me/ pod files should be created when singleUserName is unset');
506
+ assert.strictEqual(await fs.pathExists(path.join(DEFAULT_DATA_DIR, 'me/profile/card')), false,
507
+ 'no legacy /me/ pod files should be created either');
508
+ });
509
+
510
+ it('WebID resolves at the server origin', async () => {
511
+ const res = await fetch(`${baseUrl}/profile/card.jsonld`);
512
+ const body = await res.json();
513
+ const webId = `${baseUrl}/profile/card.jsonld#me`;
514
+ const matches = Array.isArray(body)
515
+ ? body.some(n => n['@id'] === webId)
516
+ : body['@id'] === webId || (body['@graph'] || []).some(n => n['@id'] === webId);
517
+ assert.ok(matches, `profile should declare WebID ${webId}, got: ${JSON.stringify(body).slice(0, 200)}`);
518
+ });
519
+
520
+ it('seeds an IDP account for "me" so the root pod is loggable', async () => {
521
+ // Round-2 review of #348: a regression here would mean a fresh
522
+ // `jss start --single-user --idp` produces a pod nobody can log
523
+ // in to (registration is disabled in single-user mode, so there
524
+ // would be no recovery path other than out-of-band account
525
+ // creation). Use the credentials endpoint as a black-box login
526
+ // probe — if it issues a token, the seed worked.
527
+ const res = await fetch(`${baseUrl}/idp/credentials`, {
528
+ method: 'POST',
529
+ headers: { 'Content-Type': 'application/json' },
530
+ body: JSON.stringify({ username: 'me', password: ROOT_POD_PASSWORD }),
531
+ });
532
+ assert.strictEqual(res.status, 200,
533
+ `login as "me" should succeed for the default root pod (got ${res.status})`);
534
+ const body = await res.json();
535
+ assert.ok(body.access_token, 'response should carry an access token');
536
+ });
537
+ });
538
+
461
539
  describe('Identity Provider - Accounts', () => {
462
540
  let server;
463
541
  let accountsUrl;