javascript-solid-server 0.0.178 → 0.0.180

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.
@@ -0,0 +1,631 @@
1
+ /**
2
+ * Integration tests for the well-known did:nostr HTTP-resolution
3
+ * endpoint (#407): JSS publishes DID docs at
4
+ * `/.well-known/did/nostr/<pubkey>.json` for any local account whose
5
+ * profile carries that pubkey as a CID verificationMethod, so JSS's
6
+ * own resolver (and external clients like nostr.social, nostr.rocks)
7
+ * can resolve same-pod identities without a third-party round-trip.
8
+ */
9
+
10
+ import { describe, it, before, after, beforeEach } from 'node:test';
11
+ import assert from 'node:assert';
12
+ import path from 'path';
13
+ import fs from 'fs-extra';
14
+ import { createServer as createNetServer } from 'net';
15
+ import { generateSecretKey, getPublicKey } from '../src/nostr/event.js';
16
+ import { createServer } from '../src/server.js';
17
+ import { _resetIndexForTests, profilePathFromWebId } from '../src/idp/well-known-did-nostr.js';
18
+ import { extractNostrPubkeysFromProfile } from '../src/auth/nostr.js';
19
+
20
+ const TEST_HOST = '127.0.0.1';
21
+ // Dedicated per-suite directory so we don't clobber a developer's
22
+ // local `./data` (which is also JSS's default data root) and don't
23
+ // race with other suites that use `./data` via the shared helper.
24
+ const TEST_DATA_DIR = './test-data-well-known-did-nostr';
25
+
26
+ /** Pick an OS-assigned port up front so idpIssuer can include it. */
27
+ async function getAvailablePort() {
28
+ return new Promise((resolve, reject) => {
29
+ const srv = createNetServer();
30
+ srv.on('error', reject);
31
+ srv.listen(0, TEST_HOST, () => {
32
+ const port = srv.address().port;
33
+ srv.close(() => resolve(port));
34
+ });
35
+ });
36
+ }
37
+
38
+ function fformMultikey(xOnlyHex, parity = '02') {
39
+ return 'f' + 'e701' + parity + xOnlyHex.toLowerCase();
40
+ }
41
+
42
+ async function patchProfileWithMultikey(podName, pubkey) {
43
+ const profilePath = path.join(TEST_DATA_DIR, podName, 'profile', 'card.jsonld');
44
+ const profile = await fs.readJson(profilePath);
45
+ const VM_ID = `${profile['@id'].replace('#me', '')}#nostr-key-1`;
46
+ profile.verificationMethod = [{
47
+ id: VM_ID,
48
+ type: 'Multikey',
49
+ controller: profile['@id'],
50
+ publicKeyMultibase: fformMultikey(pubkey),
51
+ }];
52
+ profile.authentication = [VM_ID];
53
+ await fs.writeJson(profilePath, profile, { spaces: 2 });
54
+ }
55
+
56
+ describe('GET /.well-known/did/nostr/:pubkey (#407)', () => {
57
+ let server;
58
+ let baseUrl;
59
+ let alicePk;
60
+ // Capture the original DATA_ROOT before the suite mutates it, so
61
+ // the after() hook can restore it. Other tests in the repo follow
62
+ // this save/restore pattern (e.g. idp-change-password.test.js) to
63
+ // avoid cross-test environment leakage.
64
+ const originalDataRoot = process.env.DATA_ROOT;
65
+
66
+ before(async () => {
67
+ // IdP must be enabled — pod creation only writes an account
68
+ // record (the index this endpoint reads from) when the IdP is
69
+ // running. Pods without IdP are out of scope for this MVP.
70
+ //
71
+ // Match the pattern in test/idp.test.js: pick an available port
72
+ // BEFORE listen so we can pass the real baseUrl as idpIssuer.
73
+ // (oidc-provider behavior depends on the issuer being accurate;
74
+ // a static `http://127.0.0.1` with no port would mismatch.)
75
+ await fs.remove(TEST_DATA_DIR);
76
+ await fs.ensureDir(TEST_DATA_DIR);
77
+ const port = await getAvailablePort();
78
+ baseUrl = `http://${TEST_HOST}:${port}`;
79
+ server = createServer({
80
+ logger: false,
81
+ root: TEST_DATA_DIR,
82
+ idp: true,
83
+ idpIssuer: baseUrl,
84
+ forceCloseConnections: true,
85
+ });
86
+ await server.listen({ port, host: TEST_HOST });
87
+ process.env.DATA_ROOT = path.resolve(TEST_DATA_DIR);
88
+ // IdP-enabled pod creation requires email + password (so the
89
+ // account record is written to _webid_index.json).
90
+ const r = await fetch(`${baseUrl}/.pods`, {
91
+ method: 'POST',
92
+ headers: { 'Content-Type': 'application/json' },
93
+ body: JSON.stringify({
94
+ name: 'alice',
95
+ email: 'alice@example.com',
96
+ password: 'wellknown-test-password',
97
+ }),
98
+ });
99
+ if (!r.ok) throw new Error(`pod create failed: ${r.status} ${await r.text()}`);
100
+ const sk = generateSecretKey();
101
+ alicePk = getPublicKey(sk);
102
+ await patchProfileWithMultikey('alice', alicePk);
103
+ });
104
+
105
+ after(async () => {
106
+ await server.close();
107
+ await fs.remove(TEST_DATA_DIR);
108
+ if (originalDataRoot === undefined) {
109
+ delete process.env.DATA_ROOT;
110
+ } else {
111
+ process.env.DATA_ROOT = originalDataRoot;
112
+ }
113
+ });
114
+
115
+ beforeEach(() => {
116
+ _resetIndexForTests();
117
+ });
118
+
119
+ it('returns a CID-shaped DID doc for a local account with the matching VM', async () => {
120
+ const r = await fetch(`${baseUrl}/.well-known/did/nostr/${alicePk}.json`);
121
+ assert.strictEqual(r.status, 200);
122
+ assert.match(r.headers.get('content-type') || '', /did\+json/);
123
+ assert.ok(r.headers.get('cache-control'));
124
+ assert.ok(r.headers.get('nostr-timestamp'));
125
+ assert.ok(r.headers.get('last-modified'));
126
+
127
+ const doc = await r.json();
128
+ assert.deepStrictEqual(doc['@context'], ['https://w3id.org/did', 'https://w3id.org/nostr/context']);
129
+ assert.strictEqual(doc.id, `did:nostr:${alicePk}`);
130
+ assert.strictEqual(doc.type, 'DIDNostr');
131
+ assert.ok(Array.isArray(doc.alsoKnownAs));
132
+ assert.match(doc.alsoKnownAs[0], /\/alice\/profile\/card\.jsonld#me$/);
133
+ assert.strictEqual(doc.verificationMethod[0].type, 'Multikey');
134
+ assert.strictEqual(doc.verificationMethod[0].publicKeyMultibase, fformMultikey(alicePk));
135
+ assert.strictEqual(doc.authentication[0], `did:nostr:${alicePk}#key1`);
136
+ });
137
+
138
+ it('accepts the .jsonld suffix (alias)', async () => {
139
+ const r = await fetch(`${baseUrl}/.well-known/did/nostr/${alicePk}.jsonld`);
140
+ assert.strictEqual(r.status, 200);
141
+ assert.match(r.headers.get('content-type') || '', /did\+ld\+json/);
142
+ const doc = await r.json();
143
+ assert.strictEqual(doc.id, `did:nostr:${alicePk}`);
144
+ });
145
+
146
+ it('accepts the bare pubkey (no extension)', async () => {
147
+ const r = await fetch(`${baseUrl}/.well-known/did/nostr/${alicePk}`);
148
+ assert.strictEqual(r.status, 200);
149
+ const doc = await r.json();
150
+ assert.strictEqual(doc.id, `did:nostr:${alicePk}`);
151
+ });
152
+
153
+ it('returns 404 for a pubkey no local account claims', async () => {
154
+ const otherPk = getPublicKey(generateSecretKey());
155
+ const r = await fetch(`${baseUrl}/.well-known/did/nostr/${otherPk}.json`);
156
+ assert.strictEqual(r.status, 404);
157
+ // Per-status header policy: 404 still sets Nostr-Timestamp (so
158
+ // clients can correlate the resolver clock with the negative
159
+ // answer) and a short cache so newly added keys surface fast.
160
+ assert.ok(r.headers.get('nostr-timestamp'));
161
+ assert.match(r.headers.get('cache-control') || '', /max-age=60/);
162
+ });
163
+
164
+ it('returns 400 for a non-hex pubkey', async () => {
165
+ const r = await fetch(`${baseUrl}/.well-known/did/nostr/not-a-real-pubkey.json`);
166
+ assert.strictEqual(r.status, 400);
167
+ // 400 sets Nostr-Timestamp but never caches (request was malformed).
168
+ assert.ok(r.headers.get('nostr-timestamp'));
169
+ assert.match(r.headers.get('cache-control') || '', /no-store/);
170
+ });
171
+
172
+ it('returns 400 for a wrong-length hex pubkey', async () => {
173
+ const r = await fetch(`${baseUrl}/.well-known/did/nostr/abcdef.json`);
174
+ assert.strictEqual(r.status, 400);
175
+ });
176
+
177
+ it('responds to HEAD with the same headers as GET (no body)', async () => {
178
+ const r = await fetch(`${baseUrl}/.well-known/did/nostr/${alicePk}.json`, { method: 'HEAD' });
179
+ assert.strictEqual(r.status, 200);
180
+ assert.match(r.headers.get('content-type') || '', /did\+json/);
181
+ assert.ok(r.headers.get('cache-control'));
182
+ assert.ok(r.headers.get('last-modified'));
183
+ // HEAD bodies must be empty.
184
+ const text = await r.text();
185
+ assert.strictEqual(text, '');
186
+ });
187
+
188
+ it('rejects writes (PUT/POST/PATCH/DELETE) with 405 Method Not Allowed', async () => {
189
+ // Without these explicit handlers, the wildcard write routes
190
+ // would accept unauthenticated writes under /.well-known/* (the
191
+ // namespace bypasses the WAC preHandler).
192
+ for (const method of ['PUT', 'POST', 'PATCH', 'DELETE']) {
193
+ const r = await fetch(`${baseUrl}/.well-known/did/nostr/${alicePk}.json`, {
194
+ method,
195
+ headers: { 'Content-Type': 'application/json' },
196
+ body: method === 'DELETE' ? undefined : '{}',
197
+ });
198
+ assert.strictEqual(r.status, 405, `${method} should be 405`);
199
+ assert.match(r.headers.get('allow') || '', /GET/);
200
+ }
201
+ });
202
+
203
+ it('OPTIONS advertises only safe methods (Allow consistent with 405) AND sets CORS headers', async () => {
204
+ // The wildcard `OPTIONS /*` advertises GET/HEAD/PUT/DELETE/PATCH/POST,
205
+ // which is wrong for the read-only well-known namespace and
206
+ // confusing to CORS preflights. Explicit OPTIONS handlers must
207
+ // return the same Allow set as the 405 responses AND the full
208
+ // CORS header set so browser preflights work cross-origin.
209
+ for (const subpath of ['', '/', '/x', '/a/b']) {
210
+ const r = await fetch(`${baseUrl}/.well-known/did/nostr${subpath}`, {
211
+ method: 'OPTIONS',
212
+ headers: { Origin: 'https://other.example' },
213
+ });
214
+ assert.strictEqual(r.status, 204, `OPTIONS ${subpath} should be 204`);
215
+ const allow = (r.headers.get('allow') || '').toUpperCase();
216
+ assert.match(allow, /GET/, `Allow should include GET (got "${allow}")`);
217
+ assert.match(allow, /HEAD/);
218
+ assert.doesNotMatch(allow, /\bPUT\b/, `Allow should not advertise PUT (got "${allow}")`);
219
+ assert.doesNotMatch(allow, /\bPOST\b/);
220
+ assert.doesNotMatch(allow, /\bDELETE\b/);
221
+ assert.doesNotMatch(allow, /\bPATCH\b/);
222
+ // CORS preflights need these. Without them, browsers refuse
223
+ // to follow up with the actual request.
224
+ const acAllowMethods = (r.headers.get('access-control-allow-methods') || '').toUpperCase();
225
+ assert.match(acAllowMethods, /GET/, `ACAM missing GET (got "${acAllowMethods}")`);
226
+ assert.match(acAllowMethods, /HEAD/);
227
+ assert.match(acAllowMethods, /OPTIONS/);
228
+ assert.doesNotMatch(acAllowMethods, /\bPUT\b/, `ACAM should not advertise PUT`);
229
+ assert.ok(r.headers.get('access-control-allow-origin'), 'ACAO must be set');
230
+ assert.ok(r.headers.get('access-control-allow-headers'), 'ACAH must be set');
231
+ }
232
+ });
233
+
234
+ it('blocks writes to multi-segment paths under the namespace', async () => {
235
+ // The single-segment `:pubkeyAndExt` route only matches one
236
+ // path component — `PUT /.well-known/did/nostr/a/b` would
237
+ // otherwise fall through to the wildcard `PUT /*` and accept
238
+ // an unauthenticated write since `/.well-known/*` bypasses
239
+ // WAC. The wildcard 405 handler closes that.
240
+ for (const subpath of ['', '/', '/a/b', '/foo/bar/baz.json']) {
241
+ const url = `${baseUrl}/.well-known/did/nostr${subpath}`;
242
+ for (const method of ['PUT', 'POST', 'PATCH', 'DELETE']) {
243
+ const r = await fetch(url, {
244
+ method,
245
+ headers: { 'Content-Type': 'application/json' },
246
+ body: method === 'DELETE' ? undefined : '{}',
247
+ });
248
+ assert.strictEqual(r.status, 405,
249
+ `${method} ${url} should be 405 (got ${r.status})`);
250
+ }
251
+ }
252
+ });
253
+
254
+ it('indexes root-level pods (profile at /profile/card.jsonld, no podName prefix)', async () => {
255
+ // Single-user / root-pod layout: the profile lives directly at
256
+ // <DATA_ROOT>/profile/card.jsonld with no podName subdirectory,
257
+ // even though the seeded IDP account record can have
258
+ // `podName: 'me'`. The indexer must derive the on-disk path
259
+ // from the WebID's pathname, NOT from podName, or this whole
260
+ // class of pods is invisible to local DID resolution.
261
+ const sk = generateSecretKey();
262
+ const rootPk = getPublicKey(sk);
263
+ const rootWebId = `${baseUrl}/profile/card.jsonld#me`;
264
+ const rootProfilePath = path.join(TEST_DATA_DIR, 'profile', 'card.jsonld');
265
+ const VM_ID = `${baseUrl}/profile/card.jsonld#nostr-root`;
266
+ await fs.ensureDir(path.dirname(rootProfilePath));
267
+ await fs.writeJson(rootProfilePath, {
268
+ '@context': 'https://www.w3.org/ns/solid/v1',
269
+ '@id': rootWebId,
270
+ verificationMethod: [{
271
+ id: VM_ID,
272
+ type: 'Multikey',
273
+ controller: rootWebId,
274
+ publicKeyMultibase: fformMultikey(rootPk),
275
+ }],
276
+ authentication: [VM_ID],
277
+ }, { spaces: 2 });
278
+
279
+ // Synthesize a matching account record + index entry. We bypass
280
+ // the IdP /pods POST flow because that creates a named pod with
281
+ // its own subdirectory; we want the root-pod shape specifically.
282
+ const accountsDir = path.join(TEST_DATA_DIR, '.idp', 'accounts');
283
+ const indexPath = path.join(accountsDir, '_webid_index.json');
284
+ const idx = await fs.readJson(indexPath);
285
+ const accountId = 'root-pod-test-account';
286
+ idx[rootWebId] = accountId;
287
+ await fs.writeJson(indexPath, idx, { spaces: 2 });
288
+ await fs.writeJson(path.join(accountsDir, `${accountId}.json`), {
289
+ id: accountId,
290
+ podName: 'me', // intentionally != on-disk layout
291
+ webId: rootWebId,
292
+ email: 'root@example.com',
293
+ // Other fields the account loader expects can be undefined for
294
+ // the lookup we're doing — findById just returns the JSON.
295
+ }, { spaces: 2 });
296
+
297
+ const r = await fetch(`${baseUrl}/.well-known/did/nostr/${rootPk}.json`);
298
+ assert.strictEqual(r.status, 200);
299
+ const doc = await r.json();
300
+ assert.strictEqual(doc.id, `did:nostr:${rootPk}`);
301
+ assert.strictEqual(doc.alsoKnownAs[0], rootWebId);
302
+ });
303
+
304
+ // No `it()` here — path containment is now exercised directly
305
+ // by unit tests on `profilePathFromWebId` below. The previous
306
+ // integration-style test couldn't actually trigger the
307
+ // containment branch because WHATWG URL parsing strips `..`
308
+ // segments before path-resolution sees them, so the test
309
+ // returned 404 for the wrong reason (URL normalization, not
310
+ // containment).
311
+
312
+
313
+ it('handles profiles whose authentication entries are relative fragments', async () => {
314
+ // Profiles in the wild often use relative `#me`-style fragments
315
+ // for the subject. The indexer must absolutize `authentication`
316
+ // entries against the validated absolute subject, not re-derive
317
+ // the base from `profile['@id']` (which would itself be relative
318
+ // and produce unusable IDs).
319
+ //
320
+ // We simulate this by writing the profile with the `@id` set to
321
+ // a relative fragment and the authentication entry as a relative
322
+ // fragment too. If the absolute base is honored, `#nostr-rel`
323
+ // resolves to the same VM ID as the absolutized version inside
324
+ // `verificationMethod`, the auth-membership check passes, and
325
+ // the DID doc is published.
326
+ const sk = generateSecretKey();
327
+ const pk = getPublicKey(sk);
328
+ const profilePath = path.join(TEST_DATA_DIR, 'alice', 'profile', 'card.jsonld');
329
+ const profile = await fs.readJson(profilePath);
330
+ const absSubject = profile['@id']; // e.g. http://.../alice/profile/card.jsonld#me
331
+ const absSubjectNoHash = absSubject.replace('#me', '');
332
+ profile['@id'] = '#me'; // relative subject
333
+ profile.verificationMethod = [{
334
+ id: `${absSubjectNoHash}#nostr-rel`, // VM stays absolute
335
+ type: 'Multikey',
336
+ controller: absSubject,
337
+ publicKeyMultibase: fformMultikey(pk),
338
+ }];
339
+ profile.authentication = ['#nostr-rel']; // relative auth ref
340
+ await fs.writeJson(profilePath, profile, { spaces: 2 });
341
+
342
+ const r = await fetch(`${baseUrl}/.well-known/did/nostr/${pk}.json`);
343
+ assert.strictEqual(r.status, 200);
344
+ const doc = await r.json();
345
+ assert.strictEqual(doc.id, `did:nostr:${pk}`);
346
+
347
+ // Restore the profile so the rest of the suite (and any later
348
+ // re-runs without isolation) sees a well-formed absolute subject.
349
+ profile['@id'] = absSubject;
350
+ profile.verificationMethod = [{
351
+ id: `${absSubjectNoHash}#nostr-key-1`,
352
+ type: 'Multikey',
353
+ controller: absSubject,
354
+ publicKeyMultibase: fformMultikey(alicePk),
355
+ }];
356
+ profile.authentication = [`${absSubjectNoHash}#nostr-key-1`];
357
+ await fs.writeJson(profilePath, profile, { spaces: 2 });
358
+ });
359
+
360
+ it('logs a diagnostic when an account profile is unreadable (not silent)', async () => {
361
+ // Operators need to be able to debug "why isn't my pubkey
362
+ // publishing?" without grepping silence. Pre-fix, the
363
+ // rebuildPubkeyIndex catch was `catch { continue; }` and a
364
+ // broken profile produced a 404 with zero log output.
365
+ const sk = generateSecretKey();
366
+ const orphanPk = getPublicKey(sk);
367
+ const accountsDir = path.join(TEST_DATA_DIR, '.idp', 'accounts');
368
+ const indexPath = path.join(accountsDir, '_webid_index.json');
369
+ const idx = await fs.readJson(indexPath);
370
+ const orphanId = 'orphan-broken-profile';
371
+ const orphanWebId = `${baseUrl}/orphan/profile/card.jsonld#me`;
372
+ idx[orphanWebId] = orphanId;
373
+ await fs.writeJson(indexPath, idx, { spaces: 2 });
374
+ await fs.writeJson(path.join(accountsDir, `${orphanId}.json`), {
375
+ id: orphanId,
376
+ podName: 'orphan',
377
+ webId: orphanWebId,
378
+ }, { spaces: 2 });
379
+ // Write a malformed profile so JSON.parse will throw.
380
+ const profilePath = path.join(TEST_DATA_DIR, 'orphan', 'profile', 'card.jsonld');
381
+ await fs.ensureDir(path.dirname(profilePath));
382
+ await fs.writeFile(profilePath, '{ this is not valid json', 'utf8');
383
+
384
+ // Capture console.error.
385
+ const errors = [];
386
+ const origError = console.error;
387
+ console.error = (...args) => errors.push(args.map(String).join(' '));
388
+ try {
389
+ _resetIndexForTests();
390
+ const r = await fetch(`${baseUrl}/.well-known/did/nostr/${orphanPk}.json`);
391
+ assert.strictEqual(r.status, 404);
392
+ } finally {
393
+ console.error = origError;
394
+ }
395
+ const matched = errors.find((m) => m.includes(orphanId) && m.includes('orphan/profile/card.jsonld'));
396
+ assert.ok(matched, `expected a log entry mentioning ${orphanId} and the profile path; got: ${errors.join('\n')}`);
397
+
398
+ // Cleanup.
399
+ delete idx[orphanWebId];
400
+ await fs.writeJson(indexPath, idx, { spaces: 2 });
401
+ await fs.remove(path.join(accountsDir, `${orphanId}.json`));
402
+ await fs.remove(path.dirname(path.dirname(profilePath)));
403
+ });
404
+
405
+ it('does NOT publish a VM that is in verificationMethod but not in authentication', async () => {
406
+ // Add a key to the profile under verificationMethod but explicitly
407
+ // omit it from `authentication` — the user has decided this key
408
+ // is NOT for auth (revocation pending, assertion-only, etc.).
409
+ // Index must respect that intent.
410
+ const otherSk = generateSecretKey();
411
+ const otherPk = getPublicKey(otherSk);
412
+ const profilePath = path.join(TEST_DATA_DIR, 'alice', 'profile', 'card.jsonld');
413
+ const profile = await fs.readJson(profilePath);
414
+ const REVOKED_VM_ID = `${profile['@id'].replace('#me', '')}#nostr-revoked`;
415
+ profile.verificationMethod.push({
416
+ id: REVOKED_VM_ID,
417
+ type: 'Multikey',
418
+ controller: profile['@id'],
419
+ publicKeyMultibase: fformMultikey(otherPk),
420
+ });
421
+ // NOTE: NOT added to profile.authentication
422
+ await fs.writeJson(profilePath, profile, { spaces: 2 });
423
+
424
+ const r = await fetch(`${baseUrl}/.well-known/did/nostr/${otherPk}.json`);
425
+ assert.strictEqual(r.status, 404);
426
+ });
427
+ });
428
+
429
+ describe('Non-IdP /.well-known/did/nostr write blocking', () => {
430
+ // Regression test for the case Copilot caught: even with IdP
431
+ // disabled, writes under /.well-known/did/nostr/* must be 405.
432
+ // /.well-known/* bypasses the WAC preHandler unconditionally,
433
+ // so without dedicated 405 handlers the wildcard write routes
434
+ // would accept unauthenticated PUT/POST and create files on
435
+ // disk under this namespace.
436
+ let server;
437
+ let baseUrl;
438
+ // createServer mutates process.env.DATA_ROOT — capture and
439
+ // restore so we don't leak the test value into anything that
440
+ // runs after this describe (mirrors the pattern in the first
441
+ // describe block).
442
+ const originalDataRoot = process.env.DATA_ROOT;
443
+
444
+ before(async () => {
445
+ const port = await getAvailablePort();
446
+ baseUrl = `http://${TEST_HOST}:${port}`;
447
+ server = createServer({
448
+ logger: false,
449
+ root: TEST_DATA_DIR + '-noidp',
450
+ idp: false, // <-- the point of the test
451
+ forceCloseConnections: true,
452
+ });
453
+ await server.listen({ port, host: TEST_HOST });
454
+ });
455
+
456
+ after(async () => {
457
+ await server.close();
458
+ await fs.remove(TEST_DATA_DIR + '-noidp');
459
+ if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
460
+ else process.env.DATA_ROOT = originalDataRoot;
461
+ });
462
+
463
+ it('returns 405 for PUT/POST/PATCH/DELETE under the namespace', async () => {
464
+ for (const subpath of ['', '/x', '/a/b']) {
465
+ for (const method of ['PUT', 'POST', 'PATCH', 'DELETE']) {
466
+ const r = await fetch(`${baseUrl}/.well-known/did/nostr${subpath}`, {
467
+ method,
468
+ headers: { 'Content-Type': 'application/json' },
469
+ body: method === 'DELETE' ? undefined : '{}',
470
+ });
471
+ assert.strictEqual(r.status, 405,
472
+ `${method} /.well-known/did/nostr${subpath} should be 405 in non-IdP mode (got ${r.status})`);
473
+ }
474
+ }
475
+ });
476
+ });
477
+
478
+ describe('extractNostrPubkeysFromProfile', () => {
479
+ it('finds f-form Multikey entries', () => {
480
+ const sk = generateSecretKey();
481
+ const pk = getPublicKey(sk);
482
+ const profile = {
483
+ verificationMethod: [{
484
+ id: '#k1',
485
+ type: 'Multikey',
486
+ publicKeyMultibase: fformMultikey(pk),
487
+ }],
488
+ };
489
+ const found = extractNostrPubkeysFromProfile(profile);
490
+ assert.strictEqual(found.length, 1);
491
+ assert.strictEqual(found[0].pubkey, pk);
492
+ });
493
+
494
+ it('finds JsonWebKey entries when y matches the BIP-340 canonical point', async () => {
495
+ const { secp256k1 } = await import('@noble/curves/secp256k1');
496
+ const sk = generateSecretKey();
497
+ const pk = getPublicKey(sk);
498
+ // x-coord is the hex pubkey base64url-encoded.
499
+ const b64u = (hex) => Buffer.from(hex, 'hex').toString('base64')
500
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
501
+ const x = b64u(pk);
502
+ // Compute the canonical (even-y) y for this x — same logic as
503
+ // the verifier in src/auth/nostr.js.
504
+ const point = secp256k1.ProjectivePoint.fromHex('02' + pk);
505
+ const yHex = point.toAffine().y.toString(16).padStart(64, '0');
506
+ const y = b64u(yHex);
507
+ const profile = {
508
+ verificationMethod: [{
509
+ id: '#k1',
510
+ type: 'JsonWebKey',
511
+ publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x, y },
512
+ }],
513
+ };
514
+ const found = extractNostrPubkeysFromProfile(profile);
515
+ assert.strictEqual(found.length, 1);
516
+ assert.strictEqual(found[0].pubkey, pk);
517
+ });
518
+
519
+ it('rejects JsonWebKey entries with mismatched y (not the BIP-340 canonical point)', () => {
520
+ const sk = generateSecretKey();
521
+ const pk = getPublicKey(sk);
522
+ const b64u = (hex) => Buffer.from(hex, 'hex').toString('base64')
523
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
524
+ const profile = {
525
+ verificationMethod: [{
526
+ id: '#k1',
527
+ type: 'JsonWebKey',
528
+ // Right x, but a y that's clearly not on-curve. Indexer must
529
+ // refuse, otherwise it could publish a key the verifier will
530
+ // reject (401 on advertised pubkey).
531
+ publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: b64u(pk), y: b64u('00'.repeat(32)) },
532
+ }],
533
+ };
534
+ assert.deepStrictEqual(extractNostrPubkeysFromProfile(profile), []);
535
+ });
536
+
537
+ it('rejects JsonWebKey entries missing y', () => {
538
+ const sk = generateSecretKey();
539
+ const pk = getPublicKey(sk);
540
+ const x = Buffer.from(pk, 'hex').toString('base64')
541
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
542
+ const profile = {
543
+ verificationMethod: [{ id: '#k1', type: 'JsonWebKey',
544
+ publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x } }],
545
+ };
546
+ assert.deepStrictEqual(extractNostrPubkeysFromProfile(profile), []);
547
+ });
548
+
549
+ it('returns empty for profiles without Nostr-shaped VMs', () => {
550
+ assert.deepStrictEqual(extractNostrPubkeysFromProfile({}), []);
551
+ assert.deepStrictEqual(extractNostrPubkeysFromProfile({ verificationMethod: [] }), []);
552
+ assert.deepStrictEqual(extractNostrPubkeysFromProfile({
553
+ verificationMethod: [{ type: 'Ed25519VerificationKey2020' }],
554
+ }), []);
555
+ });
556
+
557
+ it('returns empty for malformed input', () => {
558
+ assert.deepStrictEqual(extractNostrPubkeysFromProfile(null), []);
559
+ assert.deepStrictEqual(extractNostrPubkeysFromProfile('not an object'), []);
560
+ });
561
+ });
562
+
563
+ describe('profilePathFromWebId — DATA_ROOT containment', () => {
564
+ // Pure unit tests; no server. Exercises the containment branch
565
+ // directly with raw inputs that bypass URL parsing's `..`
566
+ // normalization, since that's the layer that would matter if a
567
+ // future caller ever bypassed `new URL()`.
568
+ const DATA_ROOT = '/srv/jss/data';
569
+
570
+ it('resolves a normal pathname under dataRoot', () => {
571
+ const p = profilePathFromWebId(DATA_ROOT, 'http://example/alice/profile/card.jsonld#me');
572
+ assert.strictEqual(p, '/srv/jss/data/alice/profile/card.jsonld');
573
+ });
574
+
575
+ it('resolves a root-pod pathname under dataRoot', () => {
576
+ const p = profilePathFromWebId(DATA_ROOT, 'http://example/profile/card.jsonld#me');
577
+ assert.strictEqual(p, '/srv/jss/data/profile/card.jsonld');
578
+ });
579
+
580
+ it('rejects unparseable webIds', () => {
581
+ assert.strictEqual(profilePathFromWebId(DATA_ROOT, 'not a url'), null);
582
+ assert.strictEqual(profilePathFromWebId(DATA_ROOT, null), null);
583
+ assert.strictEqual(profilePathFromWebId(DATA_ROOT, 42), null);
584
+ });
585
+
586
+ it('does NOT escape dataRoot for `..` traversal in the URL pathname', () => {
587
+ // WHATWG URL parsing already strips this — confirm the result
588
+ // stays inside dataRoot regardless.
589
+ const p = profilePathFromWebId(DATA_ROOT, 'http://example/../../../etc/passwd');
590
+ assert.ok(p === null || p.startsWith('/srv/jss/data'),
591
+ `expected containment, got ${p}`);
592
+ });
593
+
594
+ it('keeps a relative dataRoot + plausible webId inside the absolute dataRoot', () => {
595
+ // Sanity test for the relative-dataRoot case. With dataRoot
596
+ // `./inner` and a normal-looking webId pathname, the resolved
597
+ // path lives at `<cwd>/inner/some/profile/card.jsonld` —
598
+ // INSIDE the resolved-absolute innerRoot. (URL normalization
599
+ // already strips `..` segments before path-resolution sees
600
+ // them, so a "real" outside-dataRoot result isn't reachable
601
+ // through URL-parsed webIds in practice. The containment
602
+ // check stays as defense-in-depth for any future caller that
603
+ // bypasses URL parsing.)
604
+ const innerRoot = './nonexistent-inner-root';
605
+ const p = profilePathFromWebId(innerRoot, 'http://example/some/profile/card.jsonld');
606
+ assert.ok(p && p.startsWith(path.resolve(innerRoot)),
607
+ `expected ${p} to be under ${path.resolve(innerRoot)}`);
608
+ });
609
+
610
+ it('every URL-parseable webId with `..` segments still resolves inside dataRoot', () => {
611
+ // The production path is unreachable via URL-parsed input —
612
+ // WHATWG URL parsing strips `..` before our path-resolution
613
+ // sees it. This test asserts the resulting INVARIANT (every
614
+ // URL-parseable webId stays inside dataRoot) across a few
615
+ // traversal-shaped inputs, so any future regression where
616
+ // someone bypasses URL parsing or breaks the leading-slash
617
+ // strip would surface here.
618
+ for (const evil of [
619
+ 'http://h/../../../etc/passwd',
620
+ 'http://h//../etc/passwd',
621
+ 'http://h/.%2e/etc/passwd',
622
+ 'http://h/foo/../../../etc/passwd',
623
+ ]) {
624
+ const p = profilePathFromWebId(DATA_ROOT, evil);
625
+ assert.ok(
626
+ p === null || p.startsWith(DATA_ROOT + path.sep) || p === DATA_ROOT,
627
+ `${evil} → ${p} escaped DATA_ROOT`,
628
+ );
629
+ }
630
+ });
631
+ });