Skip to content

feat: NIP-05 MVP — /.well-known/nostr.json on single-user provision (#446)#447

Merged
melvincarvalho merged 3 commits into
gh-pagesfrom
issue-446-nip05-mvp
May 14, 2026
Merged

feat: NIP-05 MVP — /.well-known/nostr.json on single-user provision (#446)#447
melvincarvalho merged 3 commits into
gh-pagesfrom
issue-446-nip05-mvp

Conversation

@melvincarvalho
Copy link
Copy Markdown
Contributor

@melvincarvalho melvincarvalho commented May 14, 2026

Summary

NIP-05 MVP for single-user pods. Auto-publish /.well-known/nostr.json mapping 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:

  • allows .well-known through the dotfile filter
  • bypasses WAC for /.well-known/* (it's the spec-mandated public namespace)
  • serves files under the data dir via the LDP GET /* handler

So 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:

GET /.well-known/nostr.json

{
  "names": {
    "_": "<provisioned pubkey hex>"
  }
}

NIP-05 §3 reserves _ as the "naked domain" identifier — the user IS the domain, no name@ 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-configuration and .well-known/did/nostr/* be reachable without auth). Operator-written .acl files 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)

  • Multi-user aggregation (path mode → one shared file; subdomain mode → per-subdomain).
  • ?name= query filter (NIP-05 §3).
  • relays field auto-populated from --nostr.
  • nip46 remote signer.
  • Per-pod opt-out / custom names.

Tests

npm test — 860/860 passing locally (one pre-existing flake in nostr-cid-vm.test.js tracked as #438, unrelated to this change).

  • File written with the correct shape on --single-user --provision-keys
  • Served over HTTP without auth (NIP-05 verifiers are unauth)
  • Cross-check: the NIP-05 pubkey matches the profile VM's publicKeyMultibase — same key in both places, no chance of identity drift
  • Without --provision-keys: no file written
  • Multi-user mode: no file written (aggregation is the next slice)

Test plan

Closes #446. MVP of #445.

…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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 write nostr.json with { 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.

Comment thread src/server.js Outdated
Comment on lines +1132 to +1147
// 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');
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/server.js Outdated
Comment on lines 1131 to 1148

// 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');
}
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/server.js Outdated
Comment on lines +768 to +769


Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed: removed the stray double blank line.

Comment on lines +119 to +144

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
);
});
});
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-user onReady block (so it covers the named single-user branch via createPodStructure too). The description should be updated to match the new placement so future readers/auditors aren't sent looking for the write inside createRootPodStructure.

          // 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 createRootPodStructure between 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).

    }

Comment thread src/server.js
Comment on lines 869 to 927
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`);
}
}
}
Copy link
Copy Markdown
Contributor Author

@melvincarvalho melvincarvalho May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@melvincarvalho
Copy link
Copy Markdown
Contributor Author

Closing without merge after #444 review discussion. The code itself is small (~30 lines + 405 guards + tests) but auto-publishing NIP-05 on every --provision-keys bundles a public-discovery decision into the key-minting flag — not every operator wants their domain advertised to the Nostr graph just because they minted a key for in-pod signing / did:nostr.

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.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

Comment thread src/server.js Outdated
Comment on lines 1162 to 1163

}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed: removed the stray blank line.

Comment thread src/server.js Outdated
// 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.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed: changed the in-code reference from '#447 review' to '#446' (the issue this PR closes), which is the actually-stable identifier.

Comment thread src/server.js
Comment on lines +913 to +917
await storage.createContainer('/.well-known/');
const nip05Ok = await storage.write(
'/.well-known/nostr.json',
JSON.stringify({ names: { _: creation.ownerKey.publicHex } }, null, 2)
);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Comment thread test/well-known-nostr-json.test.js Outdated
import fs from 'fs-extra';
import { createServer } from '../src/server.js';

const DATA_DIR = './test-data-nip05';
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@melvincarvalho melvincarvalho requested a review from Copilot May 14, 2026 12:55
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@melvincarvalho melvincarvalho merged commit fc93241 into gh-pages May 14, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: NIP-05 MVP — /.well-known/nostr.json for single-user pods (#445)

2 participants