ncc-06-js 0.4.2 → 0.6.0

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.
package/DOCS.md CHANGED
@@ -7,10 +7,10 @@
7
7
  These functions help build, parse, and verify NCC-02 service records (kind `30059`).
8
8
 
9
9
  ### `buildNcc02ServiceRecord(options)`
10
- - **Purpose**: create a signed service record containing `d`, `u`, `k`, and `exp`.
10
+ - **Purpose**: asynchronously create a signed service record containing `d`, `u`, `k`, `exp`, and optional privacy metadata.
11
11
  - **Example**:
12
12
  ```js
13
- const event = buildNcc02ServiceRecord({
13
+ const event = await buildNcc02ServiceRecord({
14
14
  secretKey,
15
15
  serviceId: 'relay',
16
16
  endpoint: 'wss://127.0.0.1:7447',
@@ -0,0 +1,31 @@
1
+ import { fileURLToPath, URL } from 'node:url';
2
+ import { FlatCompat } from '@eslint/eslintrc';
3
+ import globals from 'globals';
4
+ import pkg from '@eslint/js';
5
+
6
+ const { configs } = pkg;
7
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
8
+ const compat = new FlatCompat({ baseDirectory: __dirname, recommendedConfig: configs.recommended });
9
+ const envGlobals = {
10
+ ...globals.es2020,
11
+ ...globals.node
12
+ };
13
+
14
+ export default [
15
+ ...compat.extends('eslint:recommended'),
16
+ {
17
+ languageOptions: {
18
+ ecmaVersion: 2020,
19
+ sourceType: 'module',
20
+ globals: envGlobals
21
+ },
22
+ rules: {
23
+ 'no-unused-vars': [
24
+ 'warn',
25
+ {
26
+ argsIgnorePattern: '^_'
27
+ }
28
+ ]
29
+ }
30
+ }
31
+ ];
package/index.d.ts CHANGED
@@ -17,9 +17,11 @@ declare module 'ncc-06-js' {
17
17
  expirySeconds?: number;
18
18
  kind?: number;
19
19
  createdAt?: number;
20
+ isPrivate?: boolean;
21
+ privateRecipients?: string[];
20
22
  }
21
23
 
22
- export function buildNcc02ServiceRecord(options: BuildNcc02Options): NostrEvent;
24
+ export function buildNcc02ServiceRecord(options: BuildNcc02Options): Promise<NostrEvent>;
23
25
  export function parseNcc02Tags(event: NostrEvent): Record<string, string | undefined>;
24
26
  export interface ValidateNcc02Options {
25
27
  expectedAuthor?: string;
@@ -95,6 +97,7 @@ declare module 'ncc-06-js' {
95
97
  locatorId: string;
96
98
  expectedK?: string;
97
99
  torPreferred?: boolean;
100
+ allowedProtocols?: string[];
98
101
  locatorSecretKey?: string;
99
102
  ncc05TimeoutMs?: number;
100
103
  publicationRelayTimeoutMs?: number;
@@ -259,4 +262,4 @@ declare module 'ncc-06-js' {
259
262
  ncc02ExpectedKey?: string;
260
263
  }
261
264
  export function buildClientConfig(options: ClientConfigOptions): ClientConfig;
262
- }
265
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ncc-06-js",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "Reusable NCC-06 discovery helpers for multimodal service identities.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -10,13 +10,15 @@
10
10
  "lint": "eslint . --ext .js"
11
11
  },
12
12
  "dependencies": {
13
- "ncc-02-js": "^0.3.0",
14
- "ncc-05-js": "^1.1.14",
13
+ "ncc-02-js": "^0.5.0",
14
+ "ncc-05-js": "^1.2.0",
15
15
  "nostr-tools": "^2.19.4",
16
16
  "selfsigned": "^5.4.0",
17
17
  "ws": "^8.18.3"
18
18
  },
19
19
  "devDependencies": {
20
+ "@eslint/eslintrc": "^2.0.3",
21
+ "@eslint/js": "^9.39.2",
20
22
  "@types/node": "^25.0.3",
21
23
  "eslint": "^9.39.2",
22
24
  "typescript": "^5.9.3"
@@ -3,6 +3,7 @@ import os from 'os';
3
3
  const IPV4_PRIORITY = 10;
4
4
  const IPV6_PRIORITY = 20;
5
5
  const ONION_PRIORITY = 30;
6
+ const SECURE_PROTOCOLS = new Set(['wss', 'https', 'tls', 'tcps']);
6
7
 
7
8
  /**
8
9
  * Build a list of external endpoints that the operator wants to publish.
@@ -43,22 +44,24 @@ export async function buildExternalEndpoints({
43
44
  if (ipv6?.enabled) {
44
45
  const address = detectGlobalIPv6();
45
46
  if (address) {
46
- const protocol = ipv6.protocol || 'ws';
47
- const port = ipv6.port || (protocol === 'wss' ? wssPort : wsPort);
47
+ const protocol = (ipv6.protocol || 'ws').toLowerCase();
48
+ const secure = isSecureProtocol(protocol);
49
+ const port = ipv6.port || (secure ? wssPort : wsPort);
48
50
  const url = `${protocol}://[${address}]:${port}`;
49
51
  addEndpoint({
50
52
  url,
51
53
  priority: IPV6_PRIORITY,
52
54
  family: 'ipv6',
53
55
  protocol,
54
- k: protocol === 'wss' ? ncc02ExpectedKey : undefined
56
+ k: secure ? ncc02ExpectedKey : undefined
55
57
  });
56
58
  }
57
59
  }
58
60
 
59
61
  if (ipv4?.enabled) {
60
- const protocol = ipv4.protocol || 'wss';
61
- const port = ipv4.port || (protocol === 'wss' ? wssPort : wsPort);
62
+ const protocol = (ipv4.protocol || 'wss').toLowerCase();
63
+ const secure = isSecureProtocol(protocol);
64
+ const port = ipv4.port || (secure ? wssPort : wsPort);
62
65
  const address = ipv4.address || (await getPublicIPv4({ sources: ipv4.publicSources ?? publicIpv4Sources }));
63
66
  if (address) {
64
67
  addEndpoint({
@@ -66,7 +69,7 @@ export async function buildExternalEndpoints({
66
69
  priority: IPV4_PRIORITY,
67
70
  family: 'ipv4',
68
71
  protocol,
69
- k: protocol === 'wss' ? ncc02ExpectedKey : undefined
72
+ k: secure ? ncc02ExpectedKey : undefined
70
73
  });
71
74
  }
72
75
  }
@@ -126,13 +129,19 @@ export async function getPublicIPv4({ sources = ['https://api.ipify.org?format=j
126
129
  if (match) {
127
130
  return match[0];
128
131
  }
129
- } catch (err) {
132
+ } catch {
130
133
  continue;
131
134
  }
132
135
  }
133
136
  return null;
134
137
  }
135
138
 
139
+ function isSecureProtocol(protocol) {
140
+ if (!protocol) return false;
141
+ const normalized = protocol.toLowerCase();
142
+ return SECURE_PROTOCOLS.has(normalized) || (normalized.endsWith('s') && normalized !== 'ws');
143
+ }
144
+
136
145
  export function normalizeRelayUrl(url) {
137
146
  if (!url) return '';
138
147
  let normalized = url.trim();
@@ -152,5 +161,3 @@ export function normalizeRelays(relays) {
152
161
  .map(normalizeRelayUrl);
153
162
  return [...new Set(normalized)];
154
163
  }
155
-
156
-
package/src/ncc02.js CHANGED
@@ -1,39 +1,37 @@
1
- import { finalizeEvent, getPublicKey, validateEvent, verifyEvent } from 'nostr-tools/pure';
1
+ import { NCC02Builder, verifyNCC02Event } from 'ncc-02-js';
2
2
 
3
- const DEFAULT_KIND = 30059;
3
+ const DEFAULT_EXPIRY_SECONDS = 14 * 24 * 60 * 60;
4
4
 
5
- /**
6
- * Build an NCC-02 service record event.
7
- */
8
- export function buildNcc02ServiceRecord({
5
+ function ensureSecretKey(key) {
6
+ if (!key) {
7
+ throw new Error('secretKey is required');
8
+ }
9
+ return key;
10
+ }
11
+
12
+ export async function buildNcc02ServiceRecord({
9
13
  secretKey,
10
14
  serviceId,
11
15
  endpoint,
12
16
  fingerprint,
13
- expirySeconds = 14 * 24 * 60 * 60,
14
- createdAt,
15
- kind = DEFAULT_KIND
17
+ expirySeconds = DEFAULT_EXPIRY_SECONDS,
18
+ isPrivate = false,
19
+ privateRecipients
16
20
  }) {
17
- if (!secretKey) {
18
- throw new Error('secretKey is required');
21
+ ensureSecretKey(secretKey);
22
+ if (!serviceId) {
23
+ throw new Error('serviceId is required');
19
24
  }
20
- const timestamp = createdAt ?? Math.floor(Date.now() / 1000);
21
- const expiresAt = timestamp + Number(expirySeconds);
22
- const tags = [
23
- ['d', serviceId],
24
- ['exp', expiresAt.toString()]
25
- ];
26
- if (endpoint) tags.push(['u', endpoint]);
27
- if (fingerprint) tags.push(['k', fingerprint]);
28
-
29
- const event = {
30
- kind,
31
- pubkey: getPublicKey(secretKey),
32
- created_at: timestamp,
33
- tags,
34
- content: ''
35
- };
36
- return finalizeEvent(event, secretKey);
25
+ const builder = new NCC02Builder(secretKey);
26
+ const expiryDays = Number(expirySeconds) / (24 * 60 * 60);
27
+ return builder.createServiceRecord({
28
+ serviceId,
29
+ endpoint,
30
+ fingerprint,
31
+ expiryDays,
32
+ isPrivate,
33
+ privateRecipients
34
+ });
37
35
  }
38
36
 
39
37
  export function parseNcc02Tags(event) {
@@ -44,10 +42,10 @@ export function parseNcc02Tags(event) {
44
42
  }
45
43
 
46
44
  export function validateNcc02(event, { expectedAuthor, expectedD, now, allowExpired = false } = {}) {
47
- if (!event || event.kind !== DEFAULT_KIND) {
45
+ if (!event || event.kind !== 30059) {
48
46
  return false;
49
47
  }
50
- if (!validateEvent(event) || !verifyEvent(event)) {
48
+ if (!verifyNCC02Event(event)) {
51
49
  return false;
52
50
  }
53
51
  const tags = parseNcc02Tags(event);
package/src/ncc05.js CHANGED
@@ -21,7 +21,7 @@ export function parseLocatorPayload(content) {
21
21
  }
22
22
  try {
23
23
  return JSON.parse(content);
24
- } catch (err) {
24
+ } catch {
25
25
  return null;
26
26
  }
27
27
  }
package/src/protocol.js CHANGED
@@ -12,7 +12,7 @@ export function parseNostrMessage(messageString) {
12
12
  return null;
13
13
  }
14
14
  return payload;
15
- } catch (error) {
15
+ } catch {
16
16
  return null;
17
17
  }
18
18
  }
package/src/resolver.js CHANGED
@@ -1,6 +1,6 @@
1
- import WebSocket from 'ws';
2
1
  import { SimplePool } from 'nostr-tools';
3
2
  import { NCC05Resolver } from 'ncc-05-js';
3
+ import { parsePrivateFlag } from 'ncc-02-js';
4
4
  import { validateNcc02, parseNcc02Tags } from './ncc02.js';
5
5
  import { validateLocatorFreshness, normalizeLocatorEndpoints } from './ncc05.js';
6
6
  import { choosePreferredEndpoint } from './selector.js';
@@ -18,6 +18,7 @@ export async function resolveServiceEndpoint(options = {}) {
18
18
  locatorId,
19
19
  expectedK,
20
20
  torPreferred = false,
21
+ allowedProtocols,
21
22
  locatorSecretKey,
22
23
  ncc05TimeoutMs = 5000,
23
24
  publicationRelayTimeoutMs = DEFAULT_RELAY_TIMEOUT_MS,
@@ -46,37 +47,49 @@ export async function resolveServiceEndpoint(options = {}) {
46
47
 
47
48
  let serviceRecord;
48
49
  if (ncc02Resolver) {
49
- serviceRecord = await ncc02Resolver.resolve(servicePubkey, serviceId, {});
50
+ try {
51
+ serviceRecord = await ncc02Resolver.resolve(servicePubkey, serviceId, {});
52
+ } catch (e) {
53
+ serviceRecord = await ncc02Resolver.resolveLatest(servicePubkey, {});
54
+ }
50
55
  } else {
51
56
  const poolToUse = pool || new SimplePool();
52
57
  try {
53
- const filters = [{
58
+ const filter = {
54
59
  kinds: [30059],
55
- authors: [servicePubkey],
56
- '#d': [serviceId]
57
- }];
58
- const events = await new Promise((resolve) => {
59
- const results = [];
60
- const sub = poolToUse.subscribeMany(bootstrapRelays, filters, {
61
- onevent(e) { results.push(e); },
62
- oneose() { sub.close(); resolve(results); }
63
- });
64
- setTimeout(() => { sub.close(); resolve(results); }, publicationRelayTimeoutMs);
65
- });
66
-
67
- // Sort and validate
68
- const validEvents = events
69
- .filter(e => validateNcc02(e, { expectedAuthor: servicePubkey, expectedD: serviceId, now: timestamp }))
60
+ authors: [servicePubkey]
61
+ };
62
+
63
+ console.log(`[NCC-Resolver] Querying relays for author ${servicePubkey}...`);
64
+ const events = await poolToUse.querySync(bootstrapRelays, filter);
65
+
66
+ let validEvents = (events || [])
67
+ .filter(e => {
68
+ const tags = parseNcc02Tags(e);
69
+ return tags.d === serviceId && validateNcc02(e, { expectedAuthor: servicePubkey, expectedD: serviceId, now: timestamp });
70
+ })
70
71
  .sort((a, b) => b.created_at - a.created_at);
71
72
 
73
+ if (validEvents.length === 0) {
74
+ console.log(`[NCC-Resolver] No specific record for "${serviceId}", falling back to latest...`);
75
+ validEvents = (events || [])
76
+ .filter(e => validateNcc02(e, { expectedAuthor: servicePubkey, now: timestamp }))
77
+ .sort((a, b) => b.created_at - a.created_at);
78
+ }
79
+
80
+ console.log(`[NCC-Resolver] Found ${validEvents.length} candidate events.`);
81
+
72
82
  if (validEvents[0]) {
73
83
  const tags = parseNcc02Tags(validEvents[0]);
84
+ console.log(`[NCC-Resolver] Selected NCC-02 record: ${validEvents[0].id} (d=${tags.d})`);
85
+ console.log(`[NCC-Resolver] Raw tags:`, JSON.stringify(validEvents[0].tags));
74
86
  serviceRecord = {
75
87
  endpoint: tags.u,
76
88
  fingerprint: tags.k,
77
89
  expiry: Number(tags.exp),
78
90
  eventId: validEvents[0].id,
79
- pubkey: validEvents[0].pubkey
91
+ pubkey: validEvents[0].pubkey,
92
+ isPrivate: parsePrivateFlag(validEvents[0].tags) === true
80
93
  };
81
94
  }
82
95
  } finally {
@@ -85,7 +98,7 @@ export async function resolveServiceEndpoint(options = {}) {
85
98
  }
86
99
 
87
100
  if (!serviceRecord) {
88
- throw new Error(`No valid NCC-02 record found for ${serviceId}`);
101
+ throw new Error(`No valid NCC-02 record found for ${servicePubkey}`);
89
102
  }
90
103
 
91
104
  // 2. Resolve NCC-05 Locator
@@ -103,6 +116,7 @@ export async function resolveServiceEndpoint(options = {}) {
103
116
  locatorPayload,
104
117
  expectedK,
105
118
  torPreferred,
119
+ allowedProtocols,
106
120
  now: timestamp
107
121
  });
108
122
 
@@ -115,14 +129,9 @@ export async function resolveServiceEndpoint(options = {}) {
115
129
  };
116
130
  }
117
131
 
118
- function determineEndpoint({ serviceRecord, locatorPayload, expectedK, torPreferred, now }) {
119
- // serviceRecord is { endpoint, fingerprint, expiry, attestations, ... }
120
- // It is already validated (signature, expiry).
121
-
132
+ function determineEndpoint({ serviceRecord, locatorPayload, expectedK, torPreferred, allowedProtocols, now }) {
122
133
  const ncc02Url = serviceRecord.endpoint;
123
134
  const k = serviceRecord.fingerprint;
124
- // expiry check was done by resolver, but we check if we need to?
125
- // NCC02Resolver throws if expired. So we can assume it's fresh.
126
135
 
127
136
  const result = {
128
137
  endpoint: null,
@@ -135,7 +144,8 @@ function determineEndpoint({ serviceRecord, locatorPayload, expectedK, torPrefer
135
144
  const normalized = normalizeLocatorEndpoints(locatorPayload.endpoints || []);
136
145
  const selection = choosePreferredEndpoint(normalized, {
137
146
  torPreferred,
138
- expectedK
147
+ expectedK,
148
+ allowedProtocols
139
149
  });
140
150
  if (selection.endpoint) {
141
151
  return {
@@ -169,6 +179,10 @@ function determineEndpoint({ serviceRecord, locatorPayload, expectedK, torPrefer
169
179
  }
170
180
 
171
181
  result.reason = result.reason || 'no-endpoint';
182
+ if (serviceRecord.isPrivate && !locatorPayload) {
183
+ result.reason = 'private-no-decryption';
184
+ }
185
+
172
186
  return result;
173
187
  }
174
188
 
@@ -184,7 +198,7 @@ async function defaultResolveLocator({
184
198
  timeout
185
199
  });
186
200
  try {
187
- return await resolver.resolve(servicePubkey, locatorSecretKey, locatorId, {
201
+ return await resolver.resolveLatest(servicePubkey, locatorSecretKey, {
188
202
  strict: false,
189
203
  gossip: false
190
204
  });
@@ -193,5 +207,4 @@ async function defaultResolveLocator({
193
207
  resolver.close();
194
208
  }
195
209
  }
196
- }
197
-
210
+ }
@@ -1,17 +1,4 @@
1
- import { DEFAULT_TTL_SECONDS } from './ncc05.js';
2
- import { getExpectedK } from './k.js';
3
-
4
- const DEFAULT_TOR_CONTROL = {
5
- enabled: false,
6
- host: '127.0.0.1',
7
- port: 9051,
8
- password: '',
9
- servicePort: 80,
10
- serviceFile: './onion-service.json',
11
- timeout: 5000
12
- };
13
-
14
- import { normalizeRelayUrl, normalizeRelays } from './external-endpoints.js';
1
+ import { normalizeRelayUrl } from './external-endpoints.js';
15
2
 
16
3
  const RELAY_MODE_PUBLIC = 'public';
17
4
  const RELAY_MODE_PRIVATE = 'private';
@@ -3,14 +3,17 @@ import { strict as assert } from 'assert';
3
3
  import { buildNcc02ServiceRecord, parseNcc02Tags, validateNcc02 } from '../src/ncc02.js';
4
4
  import { generateKeypair } from '../src/keys.js';
5
5
 
6
- test('builds and validates NCC-02 service record', () => {
6
+ test('builds and validates NCC-02 service record', async () => {
7
7
  const { secretKey, publicKey } = generateKeypair();
8
- const serviceEvent = buildNcc02ServiceRecord({
8
+ const recipients = ['cipher-text-1', 'cipher-text-2'];
9
+ const serviceEvent = await buildNcc02ServiceRecord({
9
10
  secretKey,
10
11
  serviceId: 'relay',
11
12
  endpoint: 'wss://127.0.0.1:7447',
12
13
  fingerprint: 'TESTKEY:resolver',
13
- expirySeconds: 60
14
+ expirySeconds: 60,
15
+ isPrivate: true,
16
+ privateRecipients: recipients
14
17
  });
15
18
 
16
19
  assert.equal(serviceEvent.pubkey, publicKey);
@@ -18,5 +21,8 @@ test('builds and validates NCC-02 service record', () => {
18
21
  assert.equal(tags.d, 'relay');
19
22
  assert.equal(tags.u, 'wss://127.0.0.1:7447');
20
23
  assert.equal(tags.k, 'TESTKEY:resolver');
24
+ assert.equal(tags.private, 'true');
25
+ const privateTagCount = serviceEvent.tags.filter(t => t[0] === 'privateRecipients').length;
26
+ assert.strictEqual(privateTagCount, recipients.length);
21
27
  assert.ok(validateNcc02(serviceEvent, { expectedAuthor: publicKey, expectedD: 'relay' }));
22
28
  });