feat: NIP-05 MVP — /.well-known/nostr.json on single-user provision (#446)#447
Conversation
…sion (#446) 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": { "_": "<hex pubkey>" } } NIP-05 §3 reserves `_` as the "naked domain" identifier — a single-user pod IS the domain, so the owner's Nostr identity is just `<host>` 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.
There was a problem hiding this comment.
Pull request overview
Adds a NIP-05 MVP for single-user pods: when createRootPodStructure provisions an owner key, it now also writes /.well-known/nostr.json mapping the reserved _ (naked-domain) name to the owner's hex pubkey. There is no new route — the file is served by the existing LDP GET /* handler under the WAC-bypassed /.well-known/* namespace.
Changes:
- In
createRootPodStructure, after writing/private/privkey.jsonld, ensure/.well-known/exists and writenostr.jsonwith{ names: { _: ownerKey.publicHex } }. - New test file covering: file shape on disk + HTTP fetch, cross-check that the hex matches the profile VM's
publicKeyMultibase, no-key case, and multi-user case. - Stray extra blank line near the LDP wildcard route registration.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| src/server.js | Adds NIP-05 file write to root pod provisioning. |
| test/well-known-nostr-json.test.js | New test suite covering provisioned, no-key, and multi-user cases. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 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 `<host>` | ||
| // 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'); | ||
| } |
There was a problem hiding this comment.
Addressed: added 405 method-not-allowed for non-GET/HEAD/OPTIONS on /.well-known/nostr.json, mirroring the /.well-known/did/nostr/* pattern. Without this the WAC bypass for /.well-known/* would have let an unauthenticated PUT overwrite the NIP-05 mapping — identity hijack, exactly as you flagged. Critical catch.
|
|
||
| // 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 `<host>` | ||
| // 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'); | ||
| } | ||
| } |
There was a problem hiding this comment.
Addressed: moved the NIP-05 write out of createRootPodStructure and into the single-user onReady block, after both pod-shape branches converge. Now jss start --single-user --provision-keys produces /.well-known/nostr.json regardless of --single-user-name. PR description was indeed misleading on the named-pod case; the new placement matches what it claims.
|
|
||
|
|
There was a problem hiding this comment.
Addressed: removed the stray double blank line.
|
|
||
| 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 | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Addressed: the multi-user test now actually creates a pod via POST /.pods with provisionKeys: true, asserts the pod-internal privkey DOES land (provisioning ran), and the server-level /.well-known/nostr.json still doesn't. Real multi-user-skip behaviour verified.
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 /<name>/, 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.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
src/server.js:925
- The PR description states the NIP-05 write happens "in
createRootPodStructure", but per the latest revision the write was moved out of that function and into the single-useronReadyblock (so it covers the named single-user branch viacreatePodStructuretoo). The description should be updated to match the new placement so future readers/auditors aren't sent looking for the write insidecreateRootPodStructure.
// NIP-05 mapping for the bare domain (#446). Lives at the
// server root regardless of whether the pod is at / or
// /<name>/ — `.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`);
}
src/server.js:1163
- Stray blank line introduced inside
createRootPodStructurebetween the privkey write block and the closing brace. The previous review thread already flagged a similar stray-blank-line nit; this one was added by the same edit (the// NIP-05 mapping is written outside this function…comment was inserted just below).
}
| if (!profileExists) { | ||
| fastify.log.info(`Creating single-user pod at ${podUri}...`); | ||
|
|
||
| let creation; | ||
| if (isRootPod) { | ||
| // Root-level pod - create structure directly at / | ||
| creation = await createRootPodStructure(webId, podUri, issuer, displayName); | ||
| } else { | ||
| // Named pod at /{name}/ | ||
| creation = await createPodStructure( | ||
| singleUserName, webId, podUri, issuer, defaultQuota, | ||
| { provisionKeys: provisionKeysEnabled } | ||
| ); | ||
| } | ||
| fastify.log.info(`Single-user pod created at ${podUri}`); | ||
|
|
||
| // Surface the public side of any provisioned owner key, plus | ||
| // a prominent backup reminder. The secret is NOT logged — it | ||
| // lives on disk only, under /private/privkey.jsonld with | ||
| // owner-only WAC and file mode 0o600. | ||
| if (creation?.ownerKey) { | ||
| // `podUri` already includes the trailing slash + any pod | ||
| // name segment, so the same expression covers root and | ||
| // named single-user pods. | ||
| const keyPath = `${podUri}private/privkey.jsonld`; | ||
| fastify.log.info(`Provisioned Schnorr secp256k1 owner key`); | ||
| fastify.log.info(` Public key file: ${keyPath}`); | ||
| fastify.log.info(` publicKeyMultibase: ${creation.ownerKey.publicMultibase}`); | ||
| fastify.log.warn( | ||
| `BACK UP ${keyPath} — losing this file means losing this identity. ` + | ||
| '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 | ||
| // /<name>/ — `.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`); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Acknowledging this as a deferred edge case rather than implementing. The MVP scope ('first start only, fresh pod') is the right line — adding a self-healing idempotent block to handle upgrades / accidental deletes is the kind of NIP-05 'extra' that bloats the server beyond the value it adds. Operators upgrading or restoring can write the file with a one-liner, and that's the honest answer for an MVP. Will note the limitation in the PR description rather than expanding scope.
|
Closing without merge after #444 review discussion. The code itself is small (~30 lines + 405 guards + tests) but auto-publishing NIP-05 on every Phase 2 (#444) already lands the Nostr-shaped key in the WebID profile's verificationMethod, which is enough for Nostr-aware clients that have the WebID. NIP-05 is convenience sugar on top — there are other paths (operator writes the file as a one-liner, a separate plugin, a Solid app that publishes the mapping, an external static site) that don't couple discovery to key provisioning. Deferring until someone actually asks for it. |
|
|
||
| } |
There was a problem hiding this comment.
Addressed: removed the stray blank line.
| // 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. |
| await storage.createContainer('/.well-known/'); | ||
| const nip05Ok = await storage.write( | ||
| '/.well-known/nostr.json', | ||
| JSON.stringify({ names: { _: creation.ownerKey.publicHex } }, null, 2) | ||
| ); |
There was a problem hiding this comment.
Addressed: PR description updated to remove the misleading 'lock it down with .acl' claim. The file is intentionally public per NIP-05 spec and /.well-known/* is WAC-bypassed at the request-handler layer — same bypass that already exempts /.well-known/openid-configuration and /.well-known/did/nostr/*. The new description spells out that operator-written .acls don't apply here and recommends overwriting the file content if a different mapping is wanted. (No code change for this one — the bypass is correct, only the docs were wrong.)
| import fs from 'fs-extra'; | ||
| import { createServer } from '../src/server.js'; | ||
|
|
||
| const DATA_DIR = './test-data-nip05'; |
There was a problem hiding this comment.
Addressed: each describe now uses its own DATA_DIR suffix (./test-data-nip05-with-key, ./test-data-nip05-no-key, ./test-data-nip05-multiuser). Latent only at current --test-concurrency=1 but the suite stays robust if that's ever raised.
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.
Summary
NIP-05 MVP for single-user pods. Auto-publish
/.well-known/nostr.jsonmapping the bare domain to the provisioned owner pubkey, so other Nostr clients can verify the pod's owner identity.No new route, no new module. NIP-05 is a static JSON file at a well-known path. JSS already:
.well-knownthrough the dotfile filter/.well-known/*(it's the spec-mandated public namespace)GET /*handlerSo this is purely a file write in
createRootPodStructure(single-user only for the MVP) alongside the privkey + profile writes. Same ordering as the other provisioning files: ACLs → privkey → NIP-05 → profile.What lands
For
jss start --single-user --provision-keys:NIP-05 §3 reserves
_as the "naked domain" identifier — the user IS the domain, noname@prefix. For a single-user pod where the pod IS the server, that's the natural identifier. The pod owner's Nostr ID becomes<host>, verifiable in any NIP-05-aware client.The file is publicly readable since
.well-known/is the spec-mandated public namespace — JSS already short-circuits WAC for/.well-known/*at the request-handler layer (the same bypass that lets.well-known/openid-configurationand.well-known/did/nostr/*be reachable without auth). Operator-written.aclfiles don't apply here because the bypass runs before WAC enforcement; this is intentional (NIP-05 verifiers are unauthenticated by spec). Operators who want a different mapping should overwrite the file content; lockdown via WAC isn't meaningful for this resource.Out of scope (next slices of #445)
?name=query filter (NIP-05 §3).relaysfield auto-populated from--nostr.nip46remote signer.Tests
npm test— 860/860 passing locally (one pre-existing flake innostr-cid-vm.test.jstracked as #438, unrelated to this change).--single-user --provision-keyspublicKeyMultibase— same key in both places, no chance of identity drift--provision-keys: no file writtenTest plan
npm test— 860/860 + the known Flaky test: nostr-cid-vm.test.js JWK-y corruption can yield a valid curve point #438 flakeCloses #446. MVP of #445.