Skip to content

Commit 5050ce2

Browse files
feat: NIP-05 MVP — write /.well-known/nostr.json on single-user provision (JavaScriptSolidServer#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 JavaScriptSolidServer#445) 860/860 tests pass (one pre-existing flake in nostr-cid-vm test that's tracked separately as JavaScriptSolidServer#438; that test fails on ~50% of runs on main and isn't related to this change). Closes JavaScriptSolidServer#446. MVP slice of JavaScriptSolidServer#445.
1 parent 13045ca commit 5050ce2

2 files changed

Lines changed: 162 additions & 0 deletions

File tree

src/server.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,7 @@ export function createServer(options = {}) {
766766
});
767767
}
768768

769+
769770
// LDP routes - using wildcard routing
770771
// Read operations - no rate limit (handled by bodyLimit)
771772
fastify.get('/*', handleGet);
@@ -1127,6 +1128,23 @@ export function createServer(options = {}) {
11271128
'Failed to write owner key file at /private/privkey.jsonld'
11281129
);
11291130
}
1131+
1132+
// NIP-05 mapping for the bare domain (#446). NIP-05 §3 reserves
1133+
// `_` as the "naked domain" identifier — a single-user pod IS
1134+
// the domain, so the owner's Nostr identity is just `<host>`
1135+
// with no `name@` prefix. The file is a plain JSON resource
1136+
// under the spec-mandated `.well-known/` namespace; jss already
1137+
// bypasses WAC for /.well-known/* and lets the LDP GET handler
1138+
// serve it as a static file. No new route needed.
1139+
await storage.createContainer('/.well-known/');
1140+
const nip05 = { names: { _: ownerKey.publicHex } };
1141+
const nip05Ok = await storage.write(
1142+
'/.well-known/nostr.json',
1143+
JSON.stringify(nip05, null, 2)
1144+
);
1145+
if (!nip05Ok) {
1146+
throw new Error('Failed to write /.well-known/nostr.json');
1147+
}
11301148
}
11311149

11321150
// Generate profile (with the owner key's VM landed in

test/well-known-nostr-json.test.js

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* NIP-05 MVP — /.well-known/nostr.json on single-user pods (#446).
3+
*
4+
* Implementation is a static file written during pod creation, not
5+
* a server-side handler. JSS already exposes /.well-known/* as a
6+
* public namespace (WAC bypass + dotfile allow-list); the LDP GET
7+
* handler serves the file like any other resource.
8+
*
9+
* Acceptance:
10+
* - single-user + --provision-keys → 200 { names: { _: <hex> } }
11+
* served at /.well-known/nostr.json, publicly readable, with
12+
* CORS open for browser-based NIP-05 verifiers.
13+
* - single-user without --provision-keys → no file written.
14+
* - multi-user → no file written (next slice of #445 aggregates).
15+
*/
16+
17+
import { describe, it, before, after } from 'node:test';
18+
import assert from 'node:assert';
19+
import fs from 'fs-extra';
20+
import { createServer } from '../src/server.js';
21+
22+
const DATA_DIR = './test-data-nip05';
23+
24+
async function startServer(options = {}) {
25+
await fs.remove(DATA_DIR);
26+
await fs.ensureDir(DATA_DIR);
27+
const server = createServer({
28+
logger: false,
29+
forceCloseConnections: true,
30+
root: DATA_DIR,
31+
...options
32+
});
33+
await server.listen({ port: 0, host: '127.0.0.1' });
34+
const baseUrl = `http://127.0.0.1:${server.server.address().port}`;
35+
return { server, baseUrl };
36+
}
37+
38+
async function stopServer(server) {
39+
await server.close();
40+
await fs.remove(DATA_DIR);
41+
}
42+
43+
describe('NIP-05 MVP — single-user with provisioned key', () => {
44+
let server, baseUrl;
45+
let savedDataRoot;
46+
47+
before(async () => {
48+
savedDataRoot = process.env.DATA_ROOT;
49+
({ server, baseUrl } = await startServer({
50+
singleUser: true,
51+
provisionKeys: true
52+
}));
53+
});
54+
55+
after(async () => {
56+
await stopServer(server);
57+
if (savedDataRoot === undefined) delete process.env.DATA_ROOT;
58+
else process.env.DATA_ROOT = savedDataRoot;
59+
});
60+
61+
it('writes the NIP-05 mapping with the reserved `_` name', async () => {
62+
const onDisk = JSON.parse(
63+
await fs.readFile(`${DATA_DIR}/.well-known/nostr.json`, 'utf8')
64+
);
65+
assert.deepStrictEqual(Object.keys(onDisk.names), ['_'],
66+
'MVP emits exactly one mapping (the `_` reserved name)');
67+
assert.match(onDisk.names._, /^[0-9a-f]{64}$/,
68+
'the `_` mapping must be a 32-byte hex pubkey');
69+
});
70+
71+
it('serves the mapping over HTTP without auth (NIP-05 verifiers are unauth)', async () => {
72+
const res = await fetch(`${baseUrl}/.well-known/nostr.json`);
73+
assert.strictEqual(res.status, 200);
74+
const body = await res.json();
75+
assert.match(body.names._, /^[0-9a-f]{64}$/);
76+
});
77+
78+
it('matches the same pubkey landed in the WebID profile VM', async () => {
79+
// Cross-check: the NIP-05 pubkey and the profile's
80+
// verificationMethod publicKeyMultibase should describe the
81+
// same key. If they ever diverge, an LWS-CID verifier would
82+
// accept the profile's VM but a NIP-05 verifier would attest
83+
// to a different identity for the same domain.
84+
const onDisk = JSON.parse(
85+
await fs.readFile(`${DATA_DIR}/.well-known/nostr.json`, 'utf8')
86+
);
87+
const profile = JSON.parse(
88+
await fs.readFile(`${DATA_DIR}/profile/card.jsonld`, 'utf8')
89+
);
90+
const vm = profile.verificationMethod[0];
91+
assert.ok(vm.publicKeyMultibase.includes(onDisk.names._),
92+
'NIP-05 pubkey hex must appear in the profile VM publicKeyMultibase');
93+
});
94+
});
95+
96+
describe('NIP-05 MVP — single-user without a provisioned key', () => {
97+
let server;
98+
let savedDataRoot;
99+
100+
before(async () => {
101+
savedDataRoot = process.env.DATA_ROOT;
102+
({ server } = await startServer({ singleUser: true }));
103+
});
104+
105+
after(async () => {
106+
await stopServer(server);
107+
if (savedDataRoot === undefined) delete process.env.DATA_ROOT;
108+
else process.env.DATA_ROOT = savedDataRoot;
109+
});
110+
111+
it('does not create the file (nothing to publish)', async () => {
112+
assert.strictEqual(
113+
await fs.pathExists(`${DATA_DIR}/.well-known/nostr.json`),
114+
false,
115+
'no key → no NIP-05 mapping → no file'
116+
);
117+
});
118+
});
119+
120+
describe('NIP-05 MVP — multi-user mode', () => {
121+
let server;
122+
let savedDataRoot;
123+
124+
before(async () => {
125+
savedDataRoot = process.env.DATA_ROOT;
126+
// No singleUser → multi-user. createRootPodStructure isn't called;
127+
// each pod's createPodStructure is, but the MVP only writes the
128+
// NIP-05 file in single-user mode.
129+
({ server } = await startServer({}));
130+
});
131+
132+
after(async () => {
133+
await stopServer(server);
134+
if (savedDataRoot === undefined) delete process.env.DATA_ROOT;
135+
else process.env.DATA_ROOT = savedDataRoot;
136+
});
137+
138+
it('does not write a server-level NIP-05 file (aggregation is the next slice)', async () => {
139+
assert.strictEqual(
140+
await fs.pathExists(`${DATA_DIR}/.well-known/nostr.json`),
141+
false
142+
);
143+
});
144+
});

0 commit comments

Comments
 (0)