From 5050ce26cb60964130fc6cb6ae54bd583f496ee1 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 14 May 2026 14:23:49 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20NIP-05=20MVP=20=E2=80=94=20write=20?= =?UTF-8?q?/.well-known/nostr.json=20on=20single-user=20provision=20(#446)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-05 is just a static JSON file at a well-known path. JSS already exposes /.well-known/* as a public namespace (WAC bypass + dotfile allow-list); the LDP GET handler serves it like any other resource. So this is purely a file write — no new route, no new module. Single-user pods with --provision-keys now write DATA_ROOT/.well-known/nostr.json containing: { "names": { "_": "" } } NIP-05 §3 reserves `_` as the "naked domain" identifier — a single-user pod IS the domain, so the owner's Nostr identity is just `` with no `name@` prefix. Other Nostr clients can verify the identity via NIP-05 the moment they discover it. Implementation lives in createRootPodStructure alongside the privkey + profile writes. Same ordering rationale as the other provisioning files: ACLs → privkey → NIP-05 → profile (last for orphan-VM safety). Tests: - File written with correct shape on --single-user --provision-keys - Served over HTTP without auth (NIP-05 verifiers are unauth) - Cross-checks the NIP-05 pubkey against the profile VM's publicKeyMultibase — they must describe the same key - Without --provision-keys, no file written - Multi-user mode: no file written (aggregation is the next slice of #445) 860/860 tests pass (one pre-existing flake in nostr-cid-vm test that's tracked separately as #438; that test fails on ~50% of runs on main and isn't related to this change). Closes #446. MVP slice of #445. --- src/server.js | 18 ++++ test/well-known-nostr-json.test.js | 144 +++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 test/well-known-nostr-json.test.js diff --git a/src/server.js b/src/server.js index 32f3cf9..33783cf 100644 --- a/src/server.js +++ b/src/server.js @@ -766,6 +766,7 @@ export function createServer(options = {}) { }); } + // LDP routes - using wildcard routing // Read operations - no rate limit (handled by bodyLimit) fastify.get('/*', handleGet); @@ -1127,6 +1128,23 @@ export function createServer(options = {}) { 'Failed to write owner key file at /private/privkey.jsonld' ); } + + // NIP-05 mapping for the bare domain (#446). NIP-05 §3 reserves + // `_` as the "naked domain" identifier — a single-user pod IS + // the domain, so the owner's Nostr identity is just `` + // with no `name@` prefix. The file is a plain JSON resource + // under the spec-mandated `.well-known/` namespace; jss already + // bypasses WAC for /.well-known/* and lets the LDP GET handler + // serve it as a static file. No new route needed. + await storage.createContainer('/.well-known/'); + const nip05 = { names: { _: ownerKey.publicHex } }; + const nip05Ok = await storage.write( + '/.well-known/nostr.json', + JSON.stringify(nip05, null, 2) + ); + if (!nip05Ok) { + throw new Error('Failed to write /.well-known/nostr.json'); + } } // Generate profile (with the owner key's VM landed in diff --git a/test/well-known-nostr-json.test.js b/test/well-known-nostr-json.test.js new file mode 100644 index 0000000..aac9f78 --- /dev/null +++ b/test/well-known-nostr-json.test.js @@ -0,0 +1,144 @@ +/** + * NIP-05 MVP — /.well-known/nostr.json on single-user pods (#446). + * + * Implementation is a static file written during pod creation, not + * a server-side handler. JSS already exposes /.well-known/* as a + * public namespace (WAC bypass + dotfile allow-list); the LDP GET + * handler serves the file like any other resource. + * + * Acceptance: + * - single-user + --provision-keys → 200 { names: { _: } } + * served at /.well-known/nostr.json, publicly readable, with + * CORS open for browser-based NIP-05 verifiers. + * - single-user without --provision-keys → no file written. + * - multi-user → no file written (next slice of #445 aggregates). + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert'; +import fs from 'fs-extra'; +import { createServer } from '../src/server.js'; + +const DATA_DIR = './test-data-nip05'; + +async function startServer(options = {}) { + await fs.remove(DATA_DIR); + await fs.ensureDir(DATA_DIR); + const server = createServer({ + logger: false, + forceCloseConnections: true, + root: DATA_DIR, + ...options + }); + await server.listen({ port: 0, host: '127.0.0.1' }); + const baseUrl = `http://127.0.0.1:${server.server.address().port}`; + return { server, baseUrl }; +} + +async function stopServer(server) { + await server.close(); + await fs.remove(DATA_DIR); +} + +describe('NIP-05 MVP — single-user with provisioned key', () => { + let server, baseUrl; + let savedDataRoot; + + before(async () => { + savedDataRoot = process.env.DATA_ROOT; + ({ server, baseUrl } = await startServer({ + singleUser: true, + provisionKeys: true + })); + }); + + after(async () => { + await stopServer(server); + if (savedDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = savedDataRoot; + }); + + it('writes the NIP-05 mapping with the reserved `_` name', async () => { + const onDisk = JSON.parse( + await fs.readFile(`${DATA_DIR}/.well-known/nostr.json`, 'utf8') + ); + assert.deepStrictEqual(Object.keys(onDisk.names), ['_'], + 'MVP emits exactly one mapping (the `_` reserved name)'); + assert.match(onDisk.names._, /^[0-9a-f]{64}$/, + 'the `_` mapping must be a 32-byte hex pubkey'); + }); + + it('serves the mapping over HTTP without auth (NIP-05 verifiers are unauth)', async () => { + const res = await fetch(`${baseUrl}/.well-known/nostr.json`); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.match(body.names._, /^[0-9a-f]{64}$/); + }); + + it('matches the same pubkey landed in the WebID profile VM', async () => { + // Cross-check: the NIP-05 pubkey and the profile's + // verificationMethod publicKeyMultibase should describe the + // same key. If they ever diverge, an LWS-CID verifier would + // accept the profile's VM but a NIP-05 verifier would attest + // to a different identity for the same domain. + const onDisk = JSON.parse( + await fs.readFile(`${DATA_DIR}/.well-known/nostr.json`, 'utf8') + ); + const profile = JSON.parse( + await fs.readFile(`${DATA_DIR}/profile/card.jsonld`, 'utf8') + ); + const vm = profile.verificationMethod[0]; + assert.ok(vm.publicKeyMultibase.includes(onDisk.names._), + 'NIP-05 pubkey hex must appear in the profile VM publicKeyMultibase'); + }); +}); + +describe('NIP-05 MVP — single-user without a provisioned key', () => { + let server; + let savedDataRoot; + + before(async () => { + savedDataRoot = process.env.DATA_ROOT; + ({ server } = await startServer({ singleUser: true })); + }); + + after(async () => { + await stopServer(server); + if (savedDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = savedDataRoot; + }); + + it('does not create the file (nothing to publish)', async () => { + assert.strictEqual( + await fs.pathExists(`${DATA_DIR}/.well-known/nostr.json`), + false, + 'no key → no NIP-05 mapping → no file' + ); + }); +}); + +describe('NIP-05 MVP — multi-user mode', () => { + let server; + let savedDataRoot; + + before(async () => { + savedDataRoot = process.env.DATA_ROOT; + // No singleUser → multi-user. createRootPodStructure isn't called; + // each pod's createPodStructure is, but the MVP only writes the + // NIP-05 file in single-user mode. + ({ server } = await startServer({})); + }); + + after(async () => { + await stopServer(server); + if (savedDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = savedDataRoot; + }); + + it('does not write a server-level NIP-05 file (aggregation is the next slice)', async () => { + assert.strictEqual( + await fs.pathExists(`${DATA_DIR}/.well-known/nostr.json`), + false + ); + }); +}); From 2cfc8c1d07b081af92d4ccdf79bcda6f69183187 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 14 May 2026 14:34:04 +0200 Subject: [PATCH 2/3] review: 405 guards + named-pod NIP-05 + harden multi-user test (#447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four batched pickups from Copilot. 1. CRITICAL — /.well-known/nostr.json was writable by anyone. The global preHandler bypasses WAC for /.well-known/* (the spec- mandated public namespace), so without method guards the wildcard PUT/POST/PATCH/DELETE handlers would let an unauthenticated client overwrite the NIP-05 mapping and hijack the pod's identity. Added 405 method-not-allowed registrations mirroring the /.well-known/did/nostr/* pattern so only GET/HEAD/OPTIONS reach the LDP layer. 2. The NIP-05 file was only being written from createRootPodStructure (root-pod single-user). Named single-user pods take the createPodStructure branch and got no /.well-known/nostr.json — the PR description claimed "for jss start --single-user --provision-keys" but didn't deliver on --single-user-name=foo. Moved the write up to the single-user onReady block, after both pod-shape branches converge. The file lives at the server root regardless of whether the pod is at / or //, because .well-known/ is per-origin not per-pod. 3. Stray double blank line between the .well-known route block and the LDP wildcard route registrations. Removed. 4. The multi-user test was hollow — it created no pods, so no NIP-05 code path was ever exercised. The "no file" assertion proved nothing about the MVP's multi-user-skip claim. Now provisions an actual multi-user pod via POST /.pods with provisionKeys: true, asserts the pod-internal /alice/private/privkey.jsonld DOES land (provisioning ran), and the server-level /.well-known/nostr.json still doesn't. 860/860 tests pass. --- src/server.js | 53 ++++++++++++++++++++---------- test/well-known-nostr-json.test.js | 38 +++++++++++++++++---- 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/src/server.js b/src/server.js index 33783cf..c5d338f 100644 --- a/src/server.js +++ b/src/server.js @@ -743,6 +743,14 @@ export function createServer(options = {}) { '/.well-known/did/nostr/', '/.well-known/did/nostr/:pubkeyAndExt', '/.well-known/did/nostr/*', + // /.well-known/nostr.json — NIP-05 mapping (#446). Same WAC-bypass + // concern as /.well-known/did/nostr/*: the global preHandler skips + // auth for /.well-known/* (the spec-mandated public namespace), + // so without 405 blocks the wildcard write handlers would let + // anyone PUT/DELETE/PATCH this file and hijack the pod's NIP-05 + // identity. GET/HEAD reach the LDP layer normally and serve the + // file written by --provision-keys. + '/.well-known/nostr.json', ]) { fastify.put(pat, methodNotAllowed); fastify.post(pat, methodNotAllowed); @@ -766,7 +774,6 @@ export function createServer(options = {}) { }); } - // LDP routes - using wildcard routing // Read operations - no rate limit (handled by bodyLimit) fastify.get('/*', handleGet); @@ -892,6 +899,30 @@ export function createServer(options = {}) { 'Filesystem reads bypass WAC; use FDE / OS keyring / restrictive umask ' + 'for any pod that matters. See docs/provision-keys.md.' ); + + // NIP-05 mapping for the bare domain (#446). Lives at the + // server root regardless of whether the pod is at / or + // // — `.well-known/` is per-origin, not per-pod. + // This branch covers BOTH single-user shapes (root pod via + // createRootPodStructure, named pod via createPodStructure) + // because the file is logically server-level identity, not + // pod-internal data. Multi-user aggregation is the next + // slice of #445. WAC bypass on /.well-known/* is balanced + // by the 405 method-not-allowed guards registered earlier + // so an attacker can't PUT-overwrite this mapping. + await storage.createContainer('/.well-known/'); + const nip05Ok = await storage.write( + '/.well-known/nostr.json', + JSON.stringify({ names: { _: creation.ownerKey.publicHex } }, null, 2) + ); + if (!nip05Ok) { + fastify.log.warn( + 'Failed to write /.well-known/nostr.json — pod is provisioned ' + + 'but NIP-05 verification will not resolve to this server.' + ); + } else { + fastify.log.info(`NIP-05 mapping at ${baseUrl}/.well-known/nostr.json`); + } } } @@ -1129,23 +1160,11 @@ export function createServer(options = {}) { ); } - // NIP-05 mapping for the bare domain (#446). NIP-05 §3 reserves - // `_` as the "naked domain" identifier — a single-user pod IS - // the domain, so the owner's Nostr identity is just `` - // with no `name@` prefix. The file is a plain JSON resource - // under the spec-mandated `.well-known/` namespace; jss already - // bypasses WAC for /.well-known/* and lets the LDP GET handler - // serve it as a static file. No new route needed. - await storage.createContainer('/.well-known/'); - const nip05 = { names: { _: ownerKey.publicHex } }; - const nip05Ok = await storage.write( - '/.well-known/nostr.json', - JSON.stringify(nip05, null, 2) - ); - if (!nip05Ok) { - throw new Error('Failed to write /.well-known/nostr.json'); - } } + // NIP-05 mapping is written outside this function (in the + // single-user onReady block) so it covers both root pods and + // named single-user pods (which take the createPodStructure + // path), not just the root case. See #447 review. // Generate profile (with the owner key's VM landed in // verificationMethod when --provision-keys is on). Written last — diff --git a/test/well-known-nostr-json.test.js b/test/well-known-nostr-json.test.js index aac9f78..edff143 100644 --- a/test/well-known-nostr-json.test.js +++ b/test/well-known-nostr-json.test.js @@ -118,15 +118,15 @@ describe('NIP-05 MVP — single-user without a provisioned key', () => { }); describe('NIP-05 MVP — multi-user mode', () => { - let server; + let server, baseUrl; let savedDataRoot; before(async () => { savedDataRoot = process.env.DATA_ROOT; - // No singleUser → multi-user. createRootPodStructure isn't called; - // each pod's createPodStructure is, but the MVP only writes the - // NIP-05 file in single-user mode. - ({ server } = await startServer({})); + // No singleUser → multi-user. The MVP only writes the NIP-05 + // file in single-user mode; aggregation across multi-user pods + // is the next slice of #445. + ({ server, baseUrl } = await startServer({})); }); after(async () => { @@ -135,10 +135,34 @@ describe('NIP-05 MVP — multi-user mode', () => { else process.env.DATA_ROOT = savedDataRoot; }); - it('does not write a server-level NIP-05 file (aggregation is the next slice)', async () => { + it('still does not write a server-level NIP-05 file when a pod is provisioned with keys', async () => { + // Actually exercise the code path that *could* have written the + // file — provision a multi-user pod with provisionKeys: true via + // POST /.pods. The pod's own //private/privkey.jsonld + // lands as expected, but the server-level /.well-known/nostr.json + // must remain absent because this is multi-user mode. + const res = await fetch(`${baseUrl}/.pods`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'alice', provisionKeys: true }) + }); + assert.strictEqual(res.status, 201, 'pod creation must succeed'); + const body = await res.json(); + assert.ok(body.ownerKey, 'pod-level owner key must still be provisioned'); + + // The pod's own privkey IS on disk — sanity check the multi-user + // provisioning path still ran. + assert.strictEqual( + await fs.pathExists(`${DATA_DIR}/alice/private/privkey.jsonld`), + true, + 'multi-user pod provisioning still writes the pod-internal privkey' + ); + + // …but no server-level NIP-05 file. assert.strictEqual( await fs.pathExists(`${DATA_DIR}/.well-known/nostr.json`), - false + false, + 'multi-user mode must not write a server-level NIP-05 mapping' ); }); }); From b000fa57dddbb08403fae980b28351d95bc04df1 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 14 May 2026 14:54:19 +0200 Subject: [PATCH 3/3] review: cleanup nits + honest about WAC bypass on /.well-known/* (#447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four batched pickups from Copilot. 1. Stray blank line inside the if (ownerKey) block in createRootPodStructure. Removed. 2. Code comment said 'See #447 review' — but #447 IS this PR. Changed to '#446' (the issue this PR closes), which is the actually-stable reference. 3. Per-describe DATA_DIR suffixes in test/well-known-nostr-json.test.js. The three suites previously shared './test-data-nip05'; latent collision risk if --test-concurrency is ever raised above 1 (currently 1 per the npm script). Each describe now uses its own dir. 4. PR description honesty fix is in the PR body (separate from this commit): the file is intentionally public per NIP-05 spec and /.well-known/* is WAC-bypassed at the request layer, so the earlier 'operator can lock it down with a .acl' claim was misleading. Updated the description to say the file is intentionally public. 860/860 tests pass. --- src/server.js | 3 +-- test/well-known-nostr-json.test.js | 33 +++++++++++++++++------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/server.js b/src/server.js index c5d338f..ae11b6f 100644 --- a/src/server.js +++ b/src/server.js @@ -1159,12 +1159,11 @@ export function createServer(options = {}) { 'Failed to write owner key file at /private/privkey.jsonld' ); } - } // NIP-05 mapping is written outside this function (in the // single-user onReady block) so it covers both root pods and // named single-user pods (which take the createPodStructure - // path), not just the root case. See #447 review. + // path), not just the root case. See #446. // Generate profile (with the owner key's VM landed in // verificationMethod when --provision-keys is on). Written last — diff --git a/test/well-known-nostr-json.test.js b/test/well-known-nostr-json.test.js index edff143..ee160dd 100644 --- a/test/well-known-nostr-json.test.js +++ b/test/well-known-nostr-json.test.js @@ -19,15 +19,17 @@ import assert from 'node:assert'; import fs from 'fs-extra'; import { createServer } from '../src/server.js'; -const DATA_DIR = './test-data-nip05'; - -async function startServer(options = {}) { - await fs.remove(DATA_DIR); - await fs.ensureDir(DATA_DIR); +// Per-describe DATA_DIR suffixes so the three suites in this file +// don't collide if --test-concurrency is ever raised above 1. +// Currently 1 per the npm test script, but the latent risk was +// flagged in the #447 review. +async function startServer(dataDir, options = {}) { + await fs.remove(dataDir); + await fs.ensureDir(dataDir); const server = createServer({ logger: false, forceCloseConnections: true, - root: DATA_DIR, + root: dataDir, ...options }); await server.listen({ port: 0, host: '127.0.0.1' }); @@ -35,25 +37,26 @@ async function startServer(options = {}) { return { server, baseUrl }; } -async function stopServer(server) { +async function stopServer(server, dataDir) { await server.close(); - await fs.remove(DATA_DIR); + await fs.remove(dataDir); } describe('NIP-05 MVP — single-user with provisioned key', () => { + const DATA_DIR = './test-data-nip05-with-key'; let server, baseUrl; let savedDataRoot; before(async () => { savedDataRoot = process.env.DATA_ROOT; - ({ server, baseUrl } = await startServer({ + ({ server, baseUrl } = await startServer(DATA_DIR, { singleUser: true, provisionKeys: true })); }); after(async () => { - await stopServer(server); + await stopServer(server, DATA_DIR); if (savedDataRoot === undefined) delete process.env.DATA_ROOT; else process.env.DATA_ROOT = savedDataRoot; }); @@ -94,16 +97,17 @@ describe('NIP-05 MVP — single-user with provisioned key', () => { }); describe('NIP-05 MVP — single-user without a provisioned key', () => { + const DATA_DIR = './test-data-nip05-no-key'; let server; let savedDataRoot; before(async () => { savedDataRoot = process.env.DATA_ROOT; - ({ server } = await startServer({ singleUser: true })); + ({ server } = await startServer(DATA_DIR, { singleUser: true })); }); after(async () => { - await stopServer(server); + await stopServer(server, DATA_DIR); if (savedDataRoot === undefined) delete process.env.DATA_ROOT; else process.env.DATA_ROOT = savedDataRoot; }); @@ -118,6 +122,7 @@ describe('NIP-05 MVP — single-user without a provisioned key', () => { }); describe('NIP-05 MVP — multi-user mode', () => { + const DATA_DIR = './test-data-nip05-multiuser'; let server, baseUrl; let savedDataRoot; @@ -126,11 +131,11 @@ describe('NIP-05 MVP — multi-user mode', () => { // No singleUser → multi-user. The MVP only writes the NIP-05 // file in single-user mode; aggregation across multi-user pods // is the next slice of #445. - ({ server, baseUrl } = await startServer({})); + ({ server, baseUrl } = await startServer(DATA_DIR, {})); }); after(async () => { - await stopServer(server); + await stopServer(server, DATA_DIR); if (savedDataRoot === undefined) delete process.env.DATA_ROOT; else process.env.DATA_ROOT = savedDataRoot; });