javascript-solid-server 0.0.173 → 0.0.175

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/bin/jss.js CHANGED
@@ -773,11 +773,15 @@ accountCmd
773
773
 
774
774
  if (options.purge) {
775
775
  const dataRoot = process.env.DATA_ROOT || './data';
776
- const podPath = path.join(dataRoot, account.username);
776
+ // Use podName, not username — createAccount lowercases the
777
+ // username but pod directories on disk preserve the original
778
+ // case. On case-sensitive filesystems they can differ.
779
+ const podPath = path.join(dataRoot, account.podName || account.username);
777
780
  await fs.remove(podPath);
778
781
  console.log(`\nDeleted account ${account.username}. Pod data removed from ${podPath}.\n`);
779
782
  } else {
780
- console.log(`\nDeleted account ${account.username}. Pod data preserved at <dataRoot>/${account.username}/ (use --purge to remove).\n`);
783
+ const podDir = account.podName || account.username;
784
+ console.log(`\nDeleted account ${account.username}. Pod data preserved at <dataRoot>/${podDir}/ (use --purge to remove).\n`);
781
785
  }
782
786
  } catch (err) {
783
787
  console.error(`Error: ${err.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.173",
3
+ "version": "0.0.175",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -5,7 +5,9 @@
5
5
 
6
6
  import * as jose from 'jose';
7
7
  import crypto from 'crypto';
8
- import { authenticate, findByWebId, updatePassword, verifyPassword } from './accounts.js';
8
+ import fs from 'fs-extra';
9
+ import path from 'path';
10
+ import { authenticate, findByWebId, updatePassword, verifyPassword, deleteAccount } from './accounts.js';
9
11
  import { getJwks } from './keys.js';
10
12
  import { getWebIdFromRequestAsync } from '../auth/token.js';
11
13
 
@@ -272,6 +274,148 @@ export async function handleChangePassword(request, reply) {
272
274
  };
273
275
  }
274
276
 
277
+ /**
278
+ * Handle DELETE /idp/account (#352)
279
+ *
280
+ * Owner-initiated account deletion. Authenticated caller proves
281
+ * possession via re-entering currentPassword (matches the
282
+ * password-rotation pattern in #351). Optional `purgeData: true` also
283
+ * removes the pod's filesystem tree at `<dataRoot>/<podName>/` (falling
284
+ * back to `<username>` only if podName is absent on the account record).
285
+ *
286
+ * Failure modes:
287
+ * 401 — unauthenticated, or wrong currentPassword
288
+ * 400 — invalid request body / missing password
289
+ * 403 — single-user mode (deletion would brick the server until
290
+ * re-seed; operator should use the CLI), or no account for the
291
+ * caller's WebID. The "no account" case lands here rather than
292
+ * 404 because the caller had a valid token — they're proving
293
+ * identity, just not for an account this server holds.
294
+ *
295
+ * Out of scope: invalidating in-flight access tokens. Tokens reference
296
+ * the WebID; once the account record is gone, follow-up auth attempts
297
+ * fail at findByWebId(). Existing bearer tokens that don't round-trip
298
+ * through findByWebId() will appear valid until they expire — same
299
+ * shape as the password-change endpoint.
300
+ *
301
+ * @param {object} request - Fastify request
302
+ * @param {object} reply - Fastify reply
303
+ * @param {object} options
304
+ * @param {boolean} [options.singleUser] - When true, the endpoint
305
+ * refuses (deletion would leave the server with no IDP account).
306
+ */
307
+ export async function handleDeleteAccount(request, reply, options = {}) {
308
+ // Single-user mode: deletion via HTTP is blocked. The single-user
309
+ // pod has exactly one account; deleting it bricks the server until
310
+ // re-seed. The CLI (`jss account delete`) stays available for the
311
+ // operator who has filesystem access.
312
+ if (options.singleUser) {
313
+ return reply.code(403).send({
314
+ error: 'forbidden',
315
+ error_description: 'Account deletion via HTTP is disabled in single-user mode. Use the `jss account delete` CLI on the server.',
316
+ });
317
+ }
318
+
319
+ // 1. Authenticate caller
320
+ const { webId, error: authError } = await getWebIdFromRequestAsync(request);
321
+ if (!webId) {
322
+ return reply.code(401).send({
323
+ error: 'invalid_token',
324
+ error_description: authError || 'Authentication required',
325
+ });
326
+ }
327
+
328
+ // 2. Parse body — same flexible shape as handleChangePassword
329
+ let body = request.body;
330
+ if (Buffer.isBuffer(body)) body = body.toString('utf-8');
331
+ if (typeof body === 'string') {
332
+ try { body = JSON.parse(body); } catch { body = {}; }
333
+ }
334
+ const currentPassword = body?.currentPassword;
335
+ const purgeData = body?.purgeData === true;
336
+
337
+ if (typeof currentPassword !== 'string' || !currentPassword) {
338
+ return reply.code(400).send({
339
+ error: 'invalid_request',
340
+ error_description: 'currentPassword is required (string)',
341
+ });
342
+ }
343
+
344
+ // 3. Resolve account from caller's WebID
345
+ const account = await findByWebId(webId);
346
+ if (!account) {
347
+ return reply.code(403).send({
348
+ error: 'forbidden',
349
+ error_description: 'No account found for authenticated WebID',
350
+ });
351
+ }
352
+
353
+ // 4. Verify currentPassword (re-auth proof)
354
+ if (!(await verifyPassword(account, currentPassword))) {
355
+ return reply.code(401).send({
356
+ error: 'invalid_grant',
357
+ error_description: 'Current password is incorrect',
358
+ });
359
+ }
360
+
361
+ // 5. Delete the account record + indexes
362
+ await deleteAccount(account.id);
363
+
364
+ // 6. Optionally purge the pod's filesystem data. Mirrors the CLI
365
+ // `--purge` semantics. The path is `<dataRoot>/<podName>/`.
366
+ //
367
+ // Use account.podName, NOT account.username: createAccount normalizes
368
+ // username to lowercase (`username.toLowerCase().trim()`) but the pod
369
+ // directory on disk is created with the original case (per the input
370
+ // to handleCreatePod). On case-sensitive filesystems, deriving the
371
+ // purge path from username would either no-op (path doesn't exist)
372
+ // or hit a different directory if one exists at the lowercased name.
373
+ // Pod-name validation regex is /^[a-zA-Z0-9_-]+$/ (alphanum + dash +
374
+ // underscore; no dots, no traversal sequences) so podName is safe to
375
+ // join — defensive normalize stays as belt-and-suspenders.
376
+ //
377
+ // Best-effort: if fs.remove throws (permissions, transient FS error,
378
+ // race with another consumer), the account is already deleted and we
379
+ // shouldn't 500 over the leftover files. Log server-side and return
380
+ // purged: false so the caller knows pod data may still exist; an
381
+ // operator can finish the cleanup with a follow-up `rm -rf` or
382
+ // CLI `--purge` against the now-orphaned directory.
383
+ let purged = false;
384
+ if (purgeData) {
385
+ const dataRoot = process.env.DATA_ROOT || './data';
386
+ const candidate = path.resolve(dataRoot, account.podName || account.username);
387
+ const root = path.resolve(dataRoot);
388
+ // Belt-and-suspenders: refuse to remove anything that isn't a
389
+ // proper child of the data root. Won't trigger on registered pod
390
+ // names; protects against config drift / future bugs. Use
391
+ // path.relative so the check works when dataRoot is a filesystem
392
+ // root like `/` (where startsWith(root + path.sep) would compare
393
+ // against `//`, false-negative all valid children).
394
+ const rel = path.relative(root, candidate);
395
+ const isProperChild = rel && rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
396
+ if (isProperChild) {
397
+ try {
398
+ await fs.remove(candidate);
399
+ purged = true;
400
+ } catch (err) {
401
+ request.log.error({ err, path: candidate, username: account.username },
402
+ 'Pod data purge failed after account deletion');
403
+ // Don't surface the raw error to the user (file paths,
404
+ // permission detail leak); response.purged signals the
405
+ // outcome.
406
+ }
407
+ }
408
+ }
409
+
410
+ reply.header('Cache-Control', 'no-store');
411
+ reply.header('Pragma', 'no-cache');
412
+ return {
413
+ ok: true,
414
+ webid: account.webId,
415
+ purged,
416
+ };
417
+ }
418
+
275
419
  /**
276
420
  * Handle GET /idp/credentials
277
421
  * Returns info about the credentials endpoint
package/src/idp/index.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  handleCredentials,
24
24
  handleCredentialsInfo,
25
25
  handleChangePassword,
26
+ handleDeleteAccount,
26
27
  } from './credentials.js';
27
28
  import * as passkey from './passkey.js';
28
29
  import { addTrustedIssuer } from '../auth/solid-oidc.js';
@@ -279,6 +280,21 @@ export async function idpPlugin(fastify, options) {
279
280
  return handleChangePassword(request, reply);
280
281
  });
281
282
 
283
+ // DELETE account - authenticated owner deletes their own account (#352).
284
+ // Single-user mode is rejected at the handler (deletion would leave the
285
+ // server with no IDP account until re-seed; CLI is the operator path).
286
+ fastify.delete('/idp/account', {
287
+ config: {
288
+ rateLimit: {
289
+ max: 5,
290
+ timeWindow: '1 minute',
291
+ keyGenerator: (request) => request.ip
292
+ }
293
+ }
294
+ }, async (request, reply) => {
295
+ return handleDeleteAccount(request, reply, { singleUser });
296
+ });
297
+
282
298
  // Interaction routes (our custom login/consent UI)
283
299
  // These bypass oidc-provider and use our handlers
284
300
 
@@ -31,6 +31,12 @@ export function generateProfileJsonLd({ webId, name, podUri, issuer }) {
31
31
  const docUrl = webId.split('#')[0];
32
32
 
33
33
  return {
34
+ // CID v1 vocabulary is declared inline (rather than via an imported
35
+ // context URL) so JSS's JSON-LD → Turtle conneg layer can expand
36
+ // every term without fetching external contexts. Semantically
37
+ // equivalent to importing https://www.w3.org/ns/cid/v1: the IRIs
38
+ // each term expands to are the same. This keeps the profile a valid
39
+ // W3C Controlled Identifier document per LWS 1.0 (#386 Phase A).
34
40
  '@context': {
35
41
  'foaf': FOAF,
36
42
  'solid': SOLID,
@@ -48,13 +54,39 @@ export function generateProfileJsonLd({ webId, name, podUri, issuer }) {
48
54
  'isPrimaryTopicOf': { '@id': 'foaf:isPrimaryTopicOf', '@type': '@id' },
49
55
  'mainEntityOfPage': { '@id': 'schema:mainEntityOfPage', '@type': '@id' },
50
56
  'service': { '@id': 'cid:service', '@container': '@set' },
51
- 'serviceEndpoint': { '@id': 'cid:serviceEndpoint', '@type': '@id' }
57
+ 'serviceEndpoint': { '@id': 'cid:serviceEndpoint', '@type': '@id' },
58
+ // CID v1 terms used by Phase A and prepped for Phase B (the
59
+ // standalone "add my keys" app). Declaring these now means the
60
+ // app can PATCH in verificationMethod entries without having to
61
+ // also rewrite the @context.
62
+ //
63
+ // verificationMethod: NO @type:@id — values are inline verification
64
+ // method *objects* (id/type/controller/publicKey…), not just IRI
65
+ // references. @container:@set so a single entry stays an array.
66
+ // authentication / assertionMethod: @type:@id — values reference a
67
+ // verificationMethod entry by its IRI. @container:@set for arrays.
68
+ // publicKeyJwk: @type:@json so the JWK object round-trips as a
69
+ // literal JSON value (rdf:JSON datatype). Note: JSS's Turtle
70
+ // conneg layer doesn't yet emit @type:@json literals (tracked as
71
+ // a Phase B blocker in the PR description); declaring here is
72
+ // forward-looking and spec-correct.
73
+ 'controller': { '@id': 'cid:controller', '@type': '@id' },
74
+ 'verificationMethod': { '@id': 'cid:verificationMethod', '@container': '@set' },
75
+ 'authentication': { '@id': 'cid:authentication', '@type': '@id', '@container': '@set' },
76
+ 'assertionMethod': { '@id': 'cid:assertionMethod', '@type': '@id', '@container': '@set' },
77
+ 'publicKeyJwk': { '@id': 'cid:publicKeyJwk', '@type': '@json' },
78
+ 'publicKeyMultibase': { '@id': 'cid:publicKeyMultibase' }
52
79
  },
53
80
  '@id': webId,
54
81
  '@type': ['foaf:Person', 'schema:Person'],
55
82
  'foaf:name': name,
56
83
  'isPrimaryTopicOf': '',
57
84
  'mainEntityOfPage': '',
85
+ // CID v1 self-control: the WebID is its own controller. Phase A of
86
+ // #386 ships this triple even with no verificationMethods yet, so a
87
+ // future Phase B "add-keys" app PATCHing in verificationMethod
88
+ // entries doesn't have to also wire up controllership separately.
89
+ 'controller': webId,
58
90
  'inbox': `${pod}inbox/`,
59
91
  'storage': pod,
60
92
  'oidcIssuer': issuer,
@@ -0,0 +1,334 @@
1
+ /**
2
+ * DELETE /idp/account — authenticated owner deletes their own account (#352)
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 path from 'path';
10
+ import { createServer as createNetServer } from 'net';
11
+
12
+ const TEST_HOST = 'localhost';
13
+
14
+ function getAvailablePort() {
15
+ return new Promise((resolve, reject) => {
16
+ const srv = createNetServer();
17
+ srv.on('error', reject);
18
+ srv.listen(0, TEST_HOST, () => {
19
+ const port = srv.address().port;
20
+ srv.close(() => resolve(port));
21
+ });
22
+ });
23
+ }
24
+
25
+ async function createPod(baseUrl, name, email, password) {
26
+ const res = await fetch(`${baseUrl}/.pods`, {
27
+ method: 'POST',
28
+ headers: { 'Content-Type': 'application/json' },
29
+ body: JSON.stringify({ name, email, password }),
30
+ });
31
+ const body = await res.json().catch(() => ({}));
32
+ assert.strictEqual(res.status, 201, `pod create failed: ${JSON.stringify(body)}`);
33
+ return body;
34
+ }
35
+
36
+ async function loginToken(baseUrl, email, password) {
37
+ const res = await fetch(`${baseUrl}/idp/credentials`, {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify({ email, password }),
41
+ });
42
+ const body = await res.json().catch(() => ({}));
43
+ assert.strictEqual(res.status, 200, `login failed: ${JSON.stringify(body)}`);
44
+ return body.access_token;
45
+ }
46
+
47
+ describe('DELETE /idp/account — self-delete', () => {
48
+ let server;
49
+ let baseUrl;
50
+ let originalDataRoot;
51
+ const DATA_DIR = './test-data-delete-account';
52
+
53
+ before(async () => {
54
+ originalDataRoot = process.env.DATA_ROOT;
55
+ await fs.remove(DATA_DIR);
56
+ await fs.ensureDir(DATA_DIR);
57
+ const port = await getAvailablePort();
58
+ baseUrl = `http://${TEST_HOST}:${port}`;
59
+ server = createServer({
60
+ logger: false,
61
+ root: DATA_DIR,
62
+ idp: true,
63
+ idpIssuer: baseUrl,
64
+ forceCloseConnections: true,
65
+ });
66
+ await server.listen({ port, host: TEST_HOST });
67
+ });
68
+
69
+ after(async () => {
70
+ await server.close();
71
+ await fs.remove(DATA_DIR);
72
+ if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
73
+ else process.env.DATA_ROOT = originalDataRoot;
74
+ });
75
+
76
+ it('rejects unauthenticated request with 401', async () => {
77
+ const res = await fetch(`${baseUrl}/idp/account`, {
78
+ method: 'DELETE',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ currentPassword: 'whatever' }),
81
+ });
82
+ assert.strictEqual(res.status, 401);
83
+ });
84
+
85
+ it('rejects missing currentPassword with 400', async () => {
86
+ const id = `alice${Date.now()}`;
87
+ await createPod(baseUrl, id, `${id}@example.com`, 'password123');
88
+ const token = await loginToken(baseUrl, `${id}@example.com`, 'password123');
89
+
90
+ const res = await fetch(`${baseUrl}/idp/account`, {
91
+ method: 'DELETE',
92
+ headers: {
93
+ 'Content-Type': 'application/json',
94
+ 'Authorization': `Bearer ${token}`,
95
+ },
96
+ body: JSON.stringify({}),
97
+ });
98
+ assert.strictEqual(res.status, 400);
99
+ });
100
+
101
+ it('rejects wrong currentPassword with 401, account untouched', async () => {
102
+ const id = `bob${Date.now()}`;
103
+ await createPod(baseUrl, id, `${id}@example.com`, 'password123');
104
+ const token = await loginToken(baseUrl, `${id}@example.com`, 'password123');
105
+
106
+ const res = await fetch(`${baseUrl}/idp/account`, {
107
+ method: 'DELETE',
108
+ headers: {
109
+ 'Content-Type': 'application/json',
110
+ 'Authorization': `Bearer ${token}`,
111
+ },
112
+ body: JSON.stringify({ currentPassword: 'wrongpassword' }),
113
+ });
114
+ assert.strictEqual(res.status, 401);
115
+
116
+ // Account still works
117
+ const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/json' },
120
+ body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }),
121
+ });
122
+ assert.strictEqual(reLogin.status, 200);
123
+ });
124
+
125
+ it('happy path: deletes account; subsequent login fails with 401', async () => {
126
+ const id = `carol${Date.now()}`;
127
+ await createPod(baseUrl, id, `${id}@example.com`, 'password123');
128
+ const token = await loginToken(baseUrl, `${id}@example.com`, 'password123');
129
+
130
+ const res = await fetch(`${baseUrl}/idp/account`, {
131
+ method: 'DELETE',
132
+ headers: {
133
+ 'Content-Type': 'application/json',
134
+ 'Authorization': `Bearer ${token}`,
135
+ },
136
+ body: JSON.stringify({ currentPassword: 'password123' }),
137
+ });
138
+ assert.strictEqual(res.status, 200);
139
+ const body = await res.json();
140
+ assert.strictEqual(body.ok, true);
141
+ assert.ok(body.webid.includes(id), 'response carries webid');
142
+ assert.strictEqual(body.purged, false, 'purgeData defaults to false');
143
+
144
+ // Login as the same user now fails
145
+ const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
146
+ method: 'POST',
147
+ headers: { 'Content-Type': 'application/json' },
148
+ body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }),
149
+ });
150
+ assert.strictEqual(reLogin.status, 401);
151
+ });
152
+
153
+ it('purgeData: true also wipes the pod filesystem tree', async () => {
154
+ const id = `dave${Date.now()}`;
155
+ await createPod(baseUrl, id, `${id}@example.com`, 'password123');
156
+ const token = await loginToken(baseUrl, `${id}@example.com`, 'password123');
157
+
158
+ // Pod tree exists before deletion
159
+ const podPath = path.join(DATA_DIR, id);
160
+ assert.strictEqual(await fs.pathExists(podPath), true,
161
+ 'pod data should exist before deletion');
162
+
163
+ const res = await fetch(`${baseUrl}/idp/account`, {
164
+ method: 'DELETE',
165
+ headers: {
166
+ 'Content-Type': 'application/json',
167
+ 'Authorization': `Bearer ${token}`,
168
+ },
169
+ body: JSON.stringify({ currentPassword: 'password123', purgeData: true }),
170
+ });
171
+ assert.strictEqual(res.status, 200);
172
+ const body = await res.json();
173
+ assert.strictEqual(body.purged, true);
174
+
175
+ // Pod tree gone
176
+ assert.strictEqual(await fs.pathExists(podPath), false,
177
+ 'pod data should be purged');
178
+ });
179
+
180
+ it('purgeData: true removes the on-disk pod dir even when it has uppercase letters', async () => {
181
+ // Regression for the bug where purge derived its path from
182
+ // account.username (which createAccount lowercases) instead of
183
+ // account.podName (which preserves the original case). On
184
+ // case-sensitive filesystems the pod dir at <dataRoot>/Greta…/
185
+ // wouldn't match the derived <dataRoot>/greta…/ path.
186
+ const id = `Greta${Date.now()}`;
187
+ await createPod(baseUrl, id, `${id.toLowerCase()}@example.com`, 'password123');
188
+ const token = await loginToken(baseUrl, `${id.toLowerCase()}@example.com`, 'password123');
189
+
190
+ const podPath = path.join(DATA_DIR, id); // mixed-case as created
191
+ assert.strictEqual(await fs.pathExists(podPath), true,
192
+ 'pod data should exist at the mixed-case path before deletion');
193
+
194
+ const res = await fetch(`${baseUrl}/idp/account`, {
195
+ method: 'DELETE',
196
+ headers: {
197
+ 'Content-Type': 'application/json',
198
+ 'Authorization': `Bearer ${token}`,
199
+ },
200
+ body: JSON.stringify({ currentPassword: 'password123', purgeData: true }),
201
+ });
202
+ assert.strictEqual(res.status, 200);
203
+ const body = await res.json();
204
+ assert.strictEqual(body.purged, true, 'purge should report success');
205
+
206
+ assert.strictEqual(await fs.pathExists(podPath), false,
207
+ 'mixed-case pod dir should be gone (regression: not stranded by username lowercasing)');
208
+ });
209
+
210
+ it('purgeData: false (default) preserves the pod filesystem tree', async () => {
211
+ const id = `frank${Date.now()}`;
212
+ await createPod(baseUrl, id, `${id}@example.com`, 'password123');
213
+ const token = await loginToken(baseUrl, `${id}@example.com`, 'password123');
214
+
215
+ const podPath = path.join(DATA_DIR, id);
216
+ assert.strictEqual(await fs.pathExists(podPath), true);
217
+
218
+ const res = await fetch(`${baseUrl}/idp/account`, {
219
+ method: 'DELETE',
220
+ headers: {
221
+ 'Content-Type': 'application/json',
222
+ 'Authorization': `Bearer ${token}`,
223
+ },
224
+ // Note: no purgeData flag at all
225
+ body: JSON.stringify({ currentPassword: 'password123' }),
226
+ });
227
+ assert.strictEqual(res.status, 200);
228
+
229
+ // Account is gone but pod data preserved
230
+ assert.strictEqual(await fs.pathExists(podPath), true,
231
+ 'pod data should be preserved when purgeData is omitted');
232
+ });
233
+
234
+ it('cross-account: A authenticated, sending B\'s password — fails 401, neither account touched', async () => {
235
+ const aId = `eve${Date.now()}`;
236
+ const bId = `mallory${Date.now() + 1}`;
237
+ await createPod(baseUrl, aId, `${aId}@example.com`, 'apassword123');
238
+ await createPod(baseUrl, bId, `${bId}@example.com`, 'bpassword123');
239
+
240
+ const aToken = await loginToken(baseUrl, `${aId}@example.com`, 'apassword123');
241
+
242
+ // A sends B's password — handler resolves account from A's WebID, so the
243
+ // currentPassword must match A's. With B's password it fails 401 (and
244
+ // crucially doesn't touch either account).
245
+ const res = await fetch(`${baseUrl}/idp/account`, {
246
+ method: 'DELETE',
247
+ headers: {
248
+ 'Content-Type': 'application/json',
249
+ 'Authorization': `Bearer ${aToken}`,
250
+ },
251
+ body: JSON.stringify({ currentPassword: 'bpassword123' }),
252
+ });
253
+ assert.strictEqual(res.status, 401);
254
+
255
+ // Both accounts still functional
256
+ const aLogin = await fetch(`${baseUrl}/idp/credentials`, {
257
+ method: 'POST',
258
+ headers: { 'Content-Type': 'application/json' },
259
+ body: JSON.stringify({ email: `${aId}@example.com`, password: 'apassword123' }),
260
+ });
261
+ assert.strictEqual(aLogin.status, 200);
262
+
263
+ const bLogin = await fetch(`${baseUrl}/idp/credentials`, {
264
+ method: 'POST',
265
+ headers: { 'Content-Type': 'application/json' },
266
+ body: JSON.stringify({ email: `${bId}@example.com`, password: 'bpassword123' }),
267
+ });
268
+ assert.strictEqual(bLogin.status, 200);
269
+ });
270
+ });
271
+
272
+ describe('DELETE /idp/account — single-user mode', () => {
273
+ let server;
274
+ let baseUrl;
275
+ let originalDataRoot;
276
+ let originalPassword;
277
+ const DATA_DIR = './test-data-delete-account-single';
278
+
279
+ before(async () => {
280
+ originalDataRoot = process.env.DATA_ROOT;
281
+ originalPassword = process.env.JSS_SINGLE_USER_PASSWORD;
282
+ process.env.JSS_SINGLE_USER_PASSWORD = 'singletest';
283
+ await fs.remove(DATA_DIR);
284
+ await fs.ensureDir(DATA_DIR);
285
+ const port = await getAvailablePort();
286
+ baseUrl = `http://${TEST_HOST}:${port}`;
287
+ server = createServer({
288
+ logger: false,
289
+ root: DATA_DIR,
290
+ idp: true,
291
+ idpIssuer: baseUrl,
292
+ singleUser: true,
293
+ singleUserName: 'me',
294
+ singleUserPassword: 'singletest',
295
+ forceCloseConnections: true,
296
+ });
297
+ await server.listen({ port, host: TEST_HOST });
298
+ });
299
+
300
+ after(async () => {
301
+ await server.close();
302
+ await fs.remove(DATA_DIR);
303
+ if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
304
+ else process.env.DATA_ROOT = originalDataRoot;
305
+ if (originalPassword === undefined) delete process.env.JSS_SINGLE_USER_PASSWORD;
306
+ else process.env.JSS_SINGLE_USER_PASSWORD = originalPassword;
307
+ });
308
+
309
+ it('returns 403 in single-user mode (deletion would brick the server)', async () => {
310
+ // Even with a valid token, the endpoint refuses in single-user mode.
311
+ // Operator must use the CLI (`jss account delete`) instead.
312
+ const token = await loginToken(baseUrl, 'me', 'singletest');
313
+
314
+ const res = await fetch(`${baseUrl}/idp/account`, {
315
+ method: 'DELETE',
316
+ headers: {
317
+ 'Content-Type': 'application/json',
318
+ 'Authorization': `Bearer ${token}`,
319
+ },
320
+ body: JSON.stringify({ currentPassword: 'singletest' }),
321
+ });
322
+ assert.strictEqual(res.status, 403);
323
+ const body = await res.json();
324
+ assert.match(body.error_description || '', /single-user/i);
325
+
326
+ // Account still functional
327
+ const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
328
+ method: 'POST',
329
+ headers: { 'Content-Type': 'application/json' },
330
+ body: JSON.stringify({ email: 'me', password: 'singletest' }),
331
+ });
332
+ assert.strictEqual(reLogin.status, 200);
333
+ });
334
+ });
@@ -47,6 +47,53 @@ describe('WebID Profile', () => {
47
47
  assert.ok(jsonLd['@id'], 'Should have @id');
48
48
  });
49
49
 
50
+ // LWS-CID document conformance, Phase A of #386. The profile must be
51
+ // structurally a W3C Controlled Identifier document so a future
52
+ // PATCH-in-keys app (or server migration) can drop verificationMethod
53
+ // entries in without further plumbing. CID v1 vocabulary is declared
54
+ // inline rather than via context URL so JSS's conneg layer can
55
+ // expand every term without fetching external contexts — the IRIs
56
+ // are the same either way.
57
+ it('declares all six CID v1 terms in @context (#386 Phase A)', async () => {
58
+ const res = await request(profilePath);
59
+ const jsonLd = await res.json();
60
+ const ctx = jsonLd['@context'];
61
+ assert.ok(ctx, '@context required');
62
+
63
+ // All six CID terms must be declared and expand to the CID v1
64
+ // namespace. Accept either prefixed (cid:term) or full-URI
65
+ // (https://www.w3.org/ns/cid/v1#term) form.
66
+ const cidTerms = ['controller', 'verificationMethod', 'authentication', 'assertionMethod', 'publicKeyJwk', 'publicKeyMultibase'];
67
+ for (const term of cidTerms) {
68
+ const mapping = ctx[term];
69
+ assert.ok(mapping, `@context must define \`${term}\``);
70
+ const id = typeof mapping === 'string' ? mapping : mapping['@id'];
71
+ assert.match(id, new RegExp(`^(cid:${term}|https://www\\.w3\\.org/ns/cid/v1#${term})$`),
72
+ `${term} must map to the CID v1 namespace`);
73
+ }
74
+
75
+ // Container/type flags Phase B relies on:
76
+ // verificationMethod values are inline objects, NOT IRIs — must
77
+ // NOT have @type:@id (would force string-only) and SHOULD have
78
+ // @container:@set so a single entry is still an array.
79
+ assert.notStrictEqual(ctx.verificationMethod['@type'], '@id',
80
+ 'verificationMethod values are objects, not IRIs');
81
+ assert.strictEqual(ctx.verificationMethod['@container'], '@set');
82
+ // authentication / assertionMethod reference verificationMethod
83
+ // entries by IRI, so @type:@id is correct.
84
+ assert.strictEqual(ctx.authentication['@type'], '@id');
85
+ assert.strictEqual(ctx.assertionMethod['@type'], '@id');
86
+ // JWK is a literal JSON value (rdf:JSON datatype) per JSON-LD 1.1.
87
+ assert.strictEqual(ctx.publicKeyJwk['@type'], '@json');
88
+ });
89
+
90
+ it('declares self-control via controller === @id (#386 Phase A)', async () => {
91
+ const res = await request(profilePath);
92
+ const jsonLd = await res.json();
93
+ assert.strictEqual(jsonLd.controller, jsonLd['@id'],
94
+ 'profile must declare itself as its own controller per CID v1');
95
+ });
96
+
50
97
  it('should have correct WebID URI', async () => {
51
98
  const res = await request(profilePath);
52
99
  const jsonLd = await res.json();