ncc-06-js 0.2.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 ADDED
@@ -0,0 +1,110 @@
1
+ # ncc-06-js API Reference
2
+
3
+ `ncc-06-js` exposes a set of helpers organized around NCC-02/NCC-05 publication, NCC-06 resolution, endpoint discovery, transport framing, and runtime utilities. Each section below describes the intention, available arguments, and a usage snippet where appropriate.
4
+
5
+ ## NCC-02 helpers
6
+
7
+ These functions help build, parse, and verify NCC-02 service records (kind `30059`).
8
+
9
+ ### `buildNcc02ServiceRecord(options)`
10
+ - **Purpose**: create a signed service record containing `d`, `u`, `k`, and `exp`.
11
+ - **Example**:
12
+ ```js
13
+ const event = buildNcc02ServiceRecord({
14
+ secretKey,
15
+ serviceId: 'relay',
16
+ endpoint: 'wss://127.0.0.1:7447',
17
+ fingerprint: expectedK,
18
+ expirySeconds: 60 * 60
19
+ });
20
+ ```
21
+
22
+ ### `parseNcc02Tags(event)`
23
+ - Converts the `tags` array to an object map for easy lookups.
24
+
25
+ ### `validateNcc02(event, { expectedAuthor?, expectedD?, now?, allowExpired? })`
26
+ - Validates signature, author, `d`, and expiration windows. Use `allowExpired` for stale fallback flows.
27
+
28
+ ## NCC-05 helpers
29
+
30
+ Utilities for locator payload construction and evaluation.
31
+
32
+ ### `buildLocatorPayload({ endpoints = [], ttl = 3600, updatedAt })`
33
+ - Normalizes each endpoint and generates `{ ttl, updated_at, endpoints }`.
34
+
35
+ ### `parseLocatorPayload(content)`
36
+ - Safely parses JSON from the event content; returns `null` when parsing fails.
37
+
38
+ ### `validateLocatorFreshness(payload, { now?, allowStale? })`
39
+ - Returns `true` when `now <= updated_at + ttl` (unless `allowStale` is true).
40
+
41
+ ### `normalizeLocatorEndpoints(endpoints)`
42
+ - Normalizes each endpoint (protocol, family, priority, fingerprint/k) so `choosePreferredEndpoint` can trust consistent metadata.
43
+
44
+ ## NCC-06 helpers
45
+
46
+ ### `choosePreferredEndpoint(endpoints, { torPreferred?, expectedK? })`
47
+ - Applies NCC-06 policy: prefer `wss://` with matching `k`, favor onion if `torPreferred`, fall back to any `ws://`.
48
+ - Returns `{ endpoint?: NormalizedEndpoint, reason?: string, expected?, actual? }`.
49
+
50
+ ### `resolveServiceEndpoint(options)`
51
+ - Orchestrates resolution by querying bootstrap relays, preferring NCC-05 locators, and falling back to NCC-02 records.
52
+ - **Options** include `bootstrapRelays`, `servicePubkey`, `serviceId`, `locatorId`, `expectedK`, `locatorSecretKey`, `torPreferred`, timeouts, and override hooks.
53
+ - **Returns** `{ endpoint, source, serviceEvent, locatorPayload, selection }`.
54
+
55
+ ## Key and TLS helpers
56
+
57
+ ### `generateExpectedK`, `validateExpectedKFormat`
58
+ - Create or validate placeholder `TESTKEY:` tokens used during development.
59
+
60
+ ### `computeKFromCertPem(pem)`
61
+ - Derives base64url SHA-256 of the certificate’s SPKI for TLS pinning.
62
+
63
+ ### `getExpectedK(cfg, { baseDir? })`
64
+ - Reads a config block (`k.mode`) and returns the expected `k` string for static/generate/tls modes; used by the sidecar.
65
+
66
+ ### Key helpers
67
+ - `generateKeypair()`, `toNpub()`, `fromNsec()` wrap `nostr-tools` key utilities for convenience.
68
+ - `ensureSelfSignedCert(options)` generates a self-signed cert for local `wss://` endpoints.
69
+
70
+ ## Endpoint helpers
71
+
72
+ ### `buildExternalEndpoints(options)`
73
+ - Builds NCC-05 endpoints (onion/IPv6/IPv4) from operator intent, adding `k` on `wss://` entries.
74
+ - **Options**: `ipv4`, `ipv6`, `tor`, `wsPort`, `wssPort`, `ncc02ExpectedKey`, `ensureOnionService`, `publicIpv4Sources`.
75
+
76
+ ### `detectGlobalIPv6()`
77
+ - Returns first global IPv6 (non-link-local/unique-local).
78
+
79
+ ### `getPublicIPv4({ sources? })`
80
+ - Queries HTTP endpoints for the external IPv4 address; used when you don’t hardcode `ipv4.address`.
81
+
82
+ ## Scheduling helpers
83
+
84
+ ### `scheduleWithJitter(baseMs, jitterRatio = 0.15)`
85
+ - Returns a delay between `0` and `baseMs` with ±`jitterRatio` wiggle. Used for sidecar timers to avoid synchronized republishing.
86
+
87
+ ## Light Nostr helpers
88
+
89
+ - `parseNostrMessage(messageString)` / `serializeNostrMessage(messageArray)` guard the transport framing.
90
+ - `createReqMessage(subId, ...filters)` builds a REQ payload for subscriptions.
91
+
92
+ ## Usage snapshot
93
+
94
+ ```js
95
+ import { buildExternalEndpoints, resolveServiceEndpoint, scheduleWithJitter } from 'ncc-06-js';
96
+
97
+ const endpoints = await buildExternalEndpoints({ ipv4: { enabled: true, protocol: 'wss', address: '127.0.0.1', port: 7447 }, ncc02ExpectedKey: 'TESTKEY:relay-local-dev-1' });
98
+ const result = await resolveServiceEndpoint({
99
+ bootstrapRelays: ['ws://127.0.0.1:7000'],
100
+ servicePubkey,
101
+ serviceId: 'relay',
102
+ locatorId: 'relay-locator',
103
+ expectedK: 'TESTKEY:relay-local-dev-1',
104
+ locatorSecretKey
105
+ });
106
+
107
+ const delay = scheduleWithJitter(60000); // use this for republish timers
108
+ ```
109
+
110
+ The helpers are intentionally small, focused on NCC-06 policy, and rely on the calling code for transport/threading so they can be reused inside Node scripts, CLI tools, or downstream SDKs.
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # ncc-06-js
2
+
3
+ Reusable helpers extracted from the NCC-06 example relay, sidecar, and client implementations. This package focuses on the core utilities that compose NCC-02, NCC-05, and NCC-06 resolution and trust semantics without bundling a relay, sidecar, or heavy client.
4
+
5
+ ## Key features
6
+
7
+ - **NCC-02 builders & validators** (`buildNcc02ServiceRecord`, `parseNcc02Tags`, `validateNcc02`) that manage the `d`, `u`, `k`, and `exp` tags a service record must expose.
8
+ - **NCC-05 helpers** (`buildLocatorPayload`, `normalizeLocatorEndpoints`, `validateLocatorFreshness`) that assemble locator payloads, parse stored JSON, and enforce TTL/`updated_at` freshness rules.
9
+ - **Deterministic NCC-06 resolution** via `choosePreferredEndpoint` and `resolveServiceEndpoint`, which query bootstrap relays, prefer fresh NCC-05 locators, verify `k` fingerprints for `wss://`, and fall back to NCC-02 `u` values.
10
+ - **External endpoint helpers** (`buildExternalEndpoints`, `detectGlobalIPv6`, `getPublicIPv4`) so sidecars can declare onion/IPv6/IPv4 reachability in a reproducible order without making the relay probe the network.
11
+ - **Scheduling helpers** (`scheduleWithJitter`) for applying bounded jitter to recurring NCC-02/NCC-05 timers without ever publishing outside the declared window.
12
+ - **TLS/key utilities** (`ensureSelfSignedCert`, `generateKeypair`, `toNpub`, `fromNsec`, `generateExpectedK`, `validateExpectedKFormat`) that mirror the key and fingerprint management used by the example sidecar.
13
+ - **Lightweight protocol helpers** (`parseNostrMessage`, `serializeNostrMessage`, `createReqMessage`) for downstream code that wants to reuse the same framing logic as the example client.
14
+
15
+ ## Usage
16
+
17
+ Install directly from the repository (example workspace):
18
+
19
+ ```bash
20
+ npm install ../ncc-06-js
21
+ ```
22
+
23
+ Then import the helpers you need:
24
+
25
+ ```js
26
+ import {
27
+ resolveServiceEndpoint,
28
+ buildExternalEndpoints,
29
+ generateExpectedK
30
+ } from 'ncc-06-js';
31
+
32
+ const endpoints = await buildExternalEndpoints({
33
+ ipv4: { enabled: true, protocol: 'wss', address: '1.2.3.4', port: 7447 },
34
+ wsPort: 7000,
35
+ wssPort: 7447,
36
+ ncc02ExpectedKey: 'TESTKEY:relay-local-dev-1',
37
+ ensureOnionService
38
+ });
39
+
40
+ const resolution = await resolveServiceEndpoint({
41
+ bootstrapRelays: ['ws://127.0.0.1:7000'],
42
+ servicePubkey: '...',
43
+ serviceId: 'relay',
44
+ locatorId: 'relay-locator',
45
+ expectedK: 'TESTKEY:relay-local-dev-1',
46
+ locatorSecretKey: '...'
47
+ });
48
+
49
+ console.log('Resolved endpoint:', resolution.endpoint);
50
+ ```
51
+
52
+ The package exposes modular helpers so you can keep using your own transport stack while reusing the deterministic NCC-06 behaviour that now powers the `ncc06-client` harness.
53
+
54
+ ## Trust model
55
+
56
+ - `k` is the binding between NCC-02/NCC-05 records and the TLS key that serves `wss://` endpoints. Clients connect with `rejectUnauthorized=false` and enforce trust by comparing the published `k` value to the expected fingerprint before using the endpoint.
57
+ - When migrating to real TLS/SPKI pins, update the sidecar to publish the real fingerprint via `ncc02ExpectedKey` and update the resolver’s `expectedK`. The shared helpers keep the rest of the resolution flow untouched.
58
+
59
+ ## Reference Docs
60
+
61
+ Detailed API documentation lives in `DOCS.md` for quick lookup of every helper described above.
62
+
63
+ ## Testing
64
+
65
+ ```
66
+ npm test
67
+ ```
68
+
69
+ The tests cover the helper modules (NCC-02/NCC-05 builders, selector logic, endpoint builder, resolver) to keep the deterministic behaviour aligned with the example harness.
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "ncc-06-js",
3
+ "version": "0.2.0",
4
+ "description": "Reusable NCC-06 discovery helpers extracted from the example relay, sidecar, and client.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "scripts": {
8
+ "test": "node --test",
9
+ "lint": "eslint . --ext .js"
10
+ },
11
+ "dependencies": {
12
+ "ncc-02-js": "^0.2.2",
13
+ "ncc-05": "^1.1.8",
14
+ "nostr-tools": "^2.19.4",
15
+ "selfsigned": "^5.4.0",
16
+ "ws": "^8.18.3"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^20.7.2",
20
+ "eslint": "^8.54.0"
21
+ }
22
+ }
@@ -0,0 +1,134 @@
1
+ import os from 'os';
2
+
3
+ const IPV4_PRIORITY = 10;
4
+ const IPV6_PRIORITY = 20;
5
+ const ONION_PRIORITY = 30;
6
+
7
+ /**
8
+ * Build a list of external endpoints that the operator wants to publish.
9
+ * The helper never probes reachability; it only reflects config + the optional onion helper.
10
+ */
11
+ export async function buildExternalEndpoints({
12
+ tor,
13
+ ipv4,
14
+ ipv6,
15
+ wsPort = 7000,
16
+ wssPort = 7447,
17
+ ncc02ExpectedKey,
18
+ ensureOnionService,
19
+ publicIpv4Sources = ['https://api.ipify.org?format=json']
20
+ } = {}) {
21
+ const endpoints = [];
22
+ const timestamp = Date.now();
23
+
24
+ const addEndpoint = (entry) =>
25
+ endpoints.push({ ...entry, index: endpoints.length, createdAt: timestamp });
26
+
27
+ if (tor?.enabled && typeof ensureOnionService === 'function') {
28
+ try {
29
+ const onion = await ensureOnionService();
30
+ if (onion) {
31
+ addEndpoint({
32
+ url: `ws://${onion.address}:${onion.servicePort}`,
33
+ priority: ONION_PRIORITY,
34
+ family: 'onion',
35
+ protocol: 'ws'
36
+ });
37
+ }
38
+ } catch (err) {
39
+ console.warn('[NCC06] Onion endpoint could not be created:', err.message);
40
+ }
41
+ }
42
+
43
+ if (ipv6?.enabled) {
44
+ const address = detectGlobalIPv6();
45
+ if (address) {
46
+ const protocol = ipv6.protocol || 'ws';
47
+ const port = ipv6.port || (protocol === 'wss' ? wssPort : wsPort);
48
+ const url = `${protocol}://[${address}]:${port}`;
49
+ addEndpoint({
50
+ url,
51
+ priority: IPV6_PRIORITY,
52
+ family: 'ipv6',
53
+ protocol,
54
+ k: protocol === 'wss' ? ncc02ExpectedKey : undefined
55
+ });
56
+ }
57
+ }
58
+
59
+ if (ipv4?.enabled) {
60
+ const protocol = ipv4.protocol || 'wss';
61
+ const port = ipv4.port || (protocol === 'wss' ? wssPort : wsPort);
62
+ const address = ipv4.address || (await getPublicIPv4({ sources: ipv4.publicSources ?? publicIpv4Sources }));
63
+ if (address) {
64
+ addEndpoint({
65
+ url: `${protocol}://${address}:${port}`,
66
+ priority: IPV4_PRIORITY,
67
+ family: 'ipv4',
68
+ protocol,
69
+ k: protocol === 'wss' ? ncc02ExpectedKey : undefined
70
+ });
71
+ }
72
+ }
73
+
74
+ return endpoints
75
+ .sort((a, b) => {
76
+ if (a.priority !== b.priority) return a.priority - b.priority;
77
+ return a.index - b.index;
78
+ })
79
+ .map(({ index, createdAt, ...endpoint }) => endpoint);
80
+ }
81
+
82
+ /**
83
+ * Look for the first non-internal, global IPv6 address.
84
+ */
85
+ export function detectGlobalIPv6() {
86
+ const interfaces = os.networkInterfaces();
87
+ for (const iface of Object.values(interfaces)) {
88
+ if (!Array.isArray(iface)) continue;
89
+ for (const addr of iface) {
90
+ if (addr.family !== 'IPv6' || addr.internal) continue;
91
+ const value = addr.address.toLowerCase();
92
+ if (value.startsWith('::1')) continue;
93
+ if (value.startsWith('fe80')) continue;
94
+ if (value.startsWith('fc00') || value.startsWith('fd00')) continue;
95
+ if (!value.startsWith('2') && !value.startsWith('3')) continue;
96
+ return value;
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * Query public IPv4 services to fetch the external IPv4 address.
104
+ */
105
+ export async function getPublicIPv4({ sources = ['https://api.ipify.org?format=json'] } = {}) {
106
+ const matcher = /((25[0-5]|2[0-4]\d|[01]?\d?\d)(\.|$)){4}/;
107
+ for (const source of sources) {
108
+ try {
109
+ const res = await fetch(source);
110
+ if (!res.ok) continue;
111
+ const text = (await res.text()).trim();
112
+ if (text.startsWith('{') || text.startsWith('[')) {
113
+ try {
114
+ const parsed = JSON.parse(text);
115
+ if (typeof parsed === 'object' && parsed !== null) {
116
+ const ip = parsed.ip || parsed.address || parsed.result;
117
+ if (typeof ip === 'string' && matcher.test(ip)) {
118
+ return ip;
119
+ }
120
+ }
121
+ } catch {
122
+ // ignore parse errors
123
+ }
124
+ }
125
+ const match = text.match(matcher);
126
+ if (match) {
127
+ return match[0];
128
+ }
129
+ } catch (err) {
130
+ continue;
131
+ }
132
+ }
133
+ return null;
134
+ }
package/src/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export * from './ncc02.js';
2
+ export * from './ncc05.js';
3
+ export * from './selector.js';
4
+ export * from './resolver.js';
5
+ export * from './protocol.js';
6
+ export * from './keys.js';
7
+ export * from './tls.js';
8
+ export * from './k.js';
9
+ export * from './external-endpoints.js';
10
+ export * from './schedule.js';
package/src/k.js ADDED
@@ -0,0 +1,107 @@
1
+ import crypto from 'crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
3
+ import path from 'path';
4
+
5
+ function base64url(buffer) {
6
+ return buffer
7
+ .toString('base64')
8
+ .replace(/\+/g, '-')
9
+ .replace(/\//g, '_')
10
+ .replace(/=+$/, '');
11
+ }
12
+
13
+ function randomSuffix() {
14
+ return Math.random().toString(36).slice(2, 10);
15
+ }
16
+
17
+ function resolveConfigPath(filePath, baseDir) {
18
+ if (!filePath) return null;
19
+ if (path.isAbsolute(filePath)) return filePath;
20
+ return path.resolve(baseDir ?? process.cwd(), filePath);
21
+ }
22
+
23
+ /**
24
+ * Generate an expected `k` token for NCC-02/NCC-05 pinning.
25
+ */
26
+ export function generateExpectedK({ prefix = 'TESTKEY', label = 'ncc06', suffix } = {}) {
27
+ const resolvedSuffix = suffix ?? randomSuffix();
28
+ return `${prefix}:${label}-${resolvedSuffix}`;
29
+ }
30
+
31
+ /**
32
+ * Validate the basic formatting of a `k` token.
33
+ */
34
+ export function validateExpectedKFormat(k) {
35
+ return typeof k === 'string' && /^[A-Z0-9_-]+:[^\s]+$/.test(k);
36
+ }
37
+
38
+ export function computeKFromCertPem(pem) {
39
+ if (typeof pem !== 'string' && !Buffer.isBuffer(pem)) {
40
+ throw new Error('PEM certificate is required to compute `k`');
41
+ }
42
+ const publicKey = crypto.createPublicKey(pem);
43
+ const spki = publicKey.export({ type: 'spki', format: 'der' });
44
+ const hash = crypto.createHash('sha256').update(spki).digest();
45
+ return base64url(hash);
46
+ }
47
+
48
+ function ensureDir(filePath) {
49
+ const dir = path.dirname(filePath);
50
+ if (!existsSync(dir)) {
51
+ mkdirSync(dir, { recursive: true });
52
+ }
53
+ }
54
+
55
+ function hasWssEndpoints(cfg) {
56
+ const endpoints = cfg.externalEndpoints || {};
57
+ const entries = ['ipv4', 'ipv6', 'onion'];
58
+ return entries.some(key => {
59
+ const entry = endpoints[key];
60
+ if (!entry || entry.enabled === false) return false;
61
+ const protocol = (entry.protocol || entry.type || 'ws').toLowerCase();
62
+ return protocol === 'wss';
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Determine the expected `k` value based on the sidecar k configuration.
68
+ */
69
+ export function getExpectedK(cfg = {}, options = {}) {
70
+ const { baseDir } = options;
71
+ const kConfig = cfg.k || {};
72
+ const implicitMode = hasWssEndpoints(cfg) ? 'tls_spki' : 'generate';
73
+ const mode = kConfig.mode || implicitMode;
74
+
75
+ switch (mode) {
76
+ case 'static': {
77
+ if (!kConfig.value) {
78
+ throw new Error('k.value is required when k.mode is "static"');
79
+ }
80
+ return kConfig.value;
81
+ }
82
+ case 'generate': {
83
+ if (!kConfig.persistPath) {
84
+ throw new Error('k.persistPath is required when k.mode is "generate"');
85
+ }
86
+ const persistPath = resolveConfigPath(kConfig.persistPath, baseDir);
87
+ if (existsSync(persistPath)) {
88
+ return readFileSync(persistPath, 'utf-8').trim();
89
+ }
90
+ ensureDir(persistPath);
91
+ const random = crypto.randomBytes(32);
92
+ const token = base64url(random);
93
+ writeFileSync(persistPath, token, { mode: 0o600 });
94
+ return token;
95
+ }
96
+ case 'tls_spki': {
97
+ if (!kConfig.certPath) {
98
+ throw new Error('k.certPath is required when k.mode is "tls_spki"');
99
+ }
100
+ const certPath = resolveConfigPath(kConfig.certPath, baseDir);
101
+ const pem = readFileSync(certPath, 'utf-8');
102
+ return computeKFromCertPem(pem);
103
+ }
104
+ default:
105
+ throw new Error(`Unsupported k.mode "${mode}"`);
106
+ }
107
+ }
package/src/keys.js ADDED
@@ -0,0 +1,52 @@
1
+ import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
2
+ import { nip19 } from 'nostr-tools';
3
+
4
+ /**
5
+ * Generate a deterministic keypair, returning all common formats.
6
+ */
7
+ export function generateKeypair() {
8
+ const secretKey = generateSecretKey();
9
+ const pubkey = getPublicKey(secretKey);
10
+ return {
11
+ secretKey,
12
+ publicKey: pubkey,
13
+ npub: nip19.npubEncode(pubkey),
14
+ nsec: nip19.nsecEncode(secretKey)
15
+ };
16
+ }
17
+
18
+ /**
19
+ * Convert raw pubkey to npub.
20
+ */
21
+ export function toNpub(pubkey) {
22
+ return nip19.npubEncode(pubkey);
23
+ }
24
+
25
+ /**
26
+ * Decode npub back to raw pubkey.
27
+ */
28
+ export function fromNpub(npub) {
29
+ const decoded = nip19.decode(npub);
30
+ if (decoded.type !== 'npub') {
31
+ throw new Error('Invalid npub value');
32
+ }
33
+ return decoded.data;
34
+ }
35
+
36
+ /**
37
+ * Convert raw secret key to nsec.
38
+ */
39
+ export function toNsec(secretKey) {
40
+ return nip19.nsecEncode(secretKey);
41
+ }
42
+
43
+ /**
44
+ * Decode nsec back to secret key.
45
+ */
46
+ export function fromNsec(nsec) {
47
+ const decoded = nip19.decode(nsec);
48
+ if (decoded.type !== 'nsec') {
49
+ throw new Error('Invalid nsec value');
50
+ }
51
+ return decoded.data;
52
+ }
package/src/ncc02.js ADDED
@@ -0,0 +1,73 @@
1
+ import { finalizeEvent, getPublicKey, validateEvent, verifyEvent } from 'nostr-tools/pure';
2
+
3
+ const DEFAULT_KIND = 30059;
4
+
5
+ /**
6
+ * Build an NCC-02 service record event.
7
+ * The event includes `d`, `u`, `k`, and `exp` tags and is signed with the provided secret key.
8
+ * @param {object} options
9
+ */
10
+ export function buildNcc02ServiceRecord({
11
+ secretKey,
12
+ serviceId,
13
+ endpoint,
14
+ fingerprint,
15
+ expirySeconds = 14 * 24 * 60 * 60,
16
+ createdAt,
17
+ kind = DEFAULT_KIND
18
+ }) {
19
+ if (!secretKey) {
20
+ throw new Error('secretKey is required to build NCC-02 records');
21
+ }
22
+ const timestamp = createdAt ?? Math.floor(Date.now() / 1000);
23
+ const expiresAt = timestamp + Number(expirySeconds);
24
+ const tags = [
25
+ ['d', serviceId],
26
+ ['u', endpoint],
27
+ ['k', fingerprint],
28
+ ['exp', expiresAt.toString()]
29
+ ];
30
+ const event = {
31
+ kind,
32
+ pubkey: getPublicKey(secretKey),
33
+ created_at: timestamp,
34
+ tags,
35
+ content: ''
36
+ };
37
+ return finalizeEvent(event, secretKey);
38
+ }
39
+
40
+ /**
41
+ * Extract the NCC-02 relevant tags from an event.
42
+ */
43
+ export function parseNcc02Tags(event) {
44
+ if (!event || !Array.isArray(event.tags)) {
45
+ return {};
46
+ }
47
+ return Object.fromEntries(event.tags);
48
+ }
49
+
50
+ /**
51
+ * Verify an NCC-02 service record, ensuring signature, author, `d` tag, and expiration.
52
+ */
53
+ export function validateNcc02(event, { expectedAuthor, expectedD, now, allowExpired = false } = {}) {
54
+ if (!event || event.kind !== DEFAULT_KIND) {
55
+ return false;
56
+ }
57
+ if (!validateEvent(event) || !verifyEvent(event)) {
58
+ return false;
59
+ }
60
+ const tags = parseNcc02Tags(event);
61
+ if (expectedAuthor && event.pubkey !== expectedAuthor) {
62
+ return false;
63
+ }
64
+ if (expectedD && tags.d !== expectedD) {
65
+ return false;
66
+ }
67
+ const timestamp = now ?? Math.floor(Date.now() / 1000);
68
+ const exp = Number(tags.exp) || 0;
69
+ if (!allowExpired && exp > 0 && timestamp > exp) {
70
+ return false;
71
+ }
72
+ return true;
73
+ }
package/src/ncc05.js ADDED
@@ -0,0 +1,90 @@
1
+ export const DEFAULT_TTL_SECONDS = 3600;
2
+
3
+ /**
4
+ * Build a locator payload that matches NCC-05 expectations.
5
+ */
6
+ export function buildLocatorPayload({ endpoints = [], ttl = DEFAULT_TTL_SECONDS, updatedAt } = {}) {
7
+ const timestamp = updatedAt ?? Math.floor(Date.now() / 1000);
8
+ return {
9
+ ttl,
10
+ updated_at: timestamp,
11
+ endpoints: endpoints.map(normalizeEndpoint)
12
+ };
13
+ }
14
+
15
+ /**
16
+ * Parse string content from a stored locator event.
17
+ */
18
+ export function parseLocatorPayload(content) {
19
+ if (typeof content !== 'string') {
20
+ return null;
21
+ }
22
+ try {
23
+ return JSON.parse(content);
24
+ } catch (err) {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Check TTL/updated_at to determine if locator is fresh.
31
+ */
32
+ export function validateLocatorFreshness(payload, { now, allowStale = false } = {}) {
33
+ if (!payload || typeof payload !== 'object') {
34
+ return false;
35
+ }
36
+ const timestamp = now ?? Math.floor(Date.now() / 1000);
37
+ const ttl = Number(payload.ttl) || 0;
38
+ const updated = Number(payload.updated_at) || 0;
39
+ if (ttl <= 0) {
40
+ return false;
41
+ }
42
+ if (allowStale) {
43
+ return true;
44
+ }
45
+ return timestamp <= updated + ttl;
46
+ }
47
+
48
+ /**
49
+ * Normalize endpoint definitions retrieved from locator payloads.
50
+ */
51
+ export function normalizeLocatorEndpoints(endpoints = []) {
52
+ return endpoints
53
+ .map(normalizeEndpoint)
54
+ .filter(Boolean);
55
+ }
56
+
57
+ function normalizeEndpoint(endpoint = {}) {
58
+ const url = endpoint.url || endpoint.uri || endpoint.value;
59
+ if (!url) {
60
+ return null;
61
+ }
62
+ const protocol = endpoint.protocol || endpoint.type || (url.startsWith('wss://') ? 'wss' : 'ws');
63
+ const family = detectFamily(url, endpoint.family);
64
+ const priority = Number(endpoint.priority ?? endpoint.prio ?? 0);
65
+ const k = endpoint.k || endpoint.fingerprint || null;
66
+ return {
67
+ url,
68
+ protocol,
69
+ family,
70
+ priority,
71
+ k,
72
+ raw: endpoint
73
+ };
74
+ }
75
+
76
+ function detectFamily(url, override) {
77
+ if (override) {
78
+ return override;
79
+ }
80
+ if (!url) {
81
+ return 'unknown';
82
+ }
83
+ if (url.includes('.onion')) {
84
+ return 'onion';
85
+ }
86
+ if (url.includes('[') && url.includes(']')) {
87
+ return 'ipv6';
88
+ }
89
+ return 'ipv4';
90
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Minimal helpers for Nostr protocol framing.
3
+ */
4
+
5
+ /**
6
+ * Parse a Nostr protocol JSON string into an array payload.
7
+ */
8
+ export function parseNostrMessage(messageString) {
9
+ try {
10
+ const payload = JSON.parse(messageString);
11
+ if (!Array.isArray(payload) || payload.length === 0) {
12
+ return null;
13
+ }
14
+ return payload;
15
+ } catch (error) {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Serialize a Nostr protocol message array to JSON.
22
+ */
23
+ export function serializeNostrMessage(messageArray) {
24
+ return JSON.stringify(messageArray);
25
+ }
26
+
27
+ /**
28
+ * Helper to build a REQ message for subscriptions.
29
+ */
30
+ export function createReqMessage(subId, ...filters) {
31
+ return ['REQ', subId, ...filters];
32
+ }
@@ -0,0 +1,238 @@
1
+ import WebSocket from 'ws';
2
+ import { NCC05Resolver } from 'ncc-05';
3
+ import { parseNostrMessage, serializeNostrMessage, createReqMessage } from './protocol.js';
4
+ import { validateNcc02, parseNcc02Tags } from './ncc02.js';
5
+ import { normalizeLocatorEndpoints, validateLocatorFreshness } from './ncc05.js';
6
+ import { choosePreferredEndpoint } from './selector.js';
7
+
8
+ const DEFAULT_RELAY_TIMEOUT_MS = 5000;
9
+
10
+ /**
11
+ * Resolve a concrete service endpoint by applying NCC-06 resolution order.
12
+ */
13
+ export async function resolveServiceEndpoint(options = {}) {
14
+ const {
15
+ bootstrapRelays = [],
16
+ servicePubkey,
17
+ serviceId,
18
+ locatorId,
19
+ expectedK,
20
+ torPreferred = false,
21
+ locatorSecretKey,
22
+ ncc05TimeoutMs = 5000,
23
+ publicationRelayTimeoutMs = DEFAULT_RELAY_TIMEOUT_MS,
24
+ queryRelayEvents,
25
+ resolveLocator,
26
+ now
27
+ } = options;
28
+
29
+ if (!servicePubkey) {
30
+ throw new Error('servicePubkey is required');
31
+ }
32
+ if (!serviceId) {
33
+ throw new Error('serviceId is required');
34
+ }
35
+ if (!locatorId) {
36
+ throw new Error('locatorId is required');
37
+ }
38
+ if (!bootstrapRelays.length) {
39
+ throw new Error('At least one bootstrap relay is required');
40
+ }
41
+
42
+ const timestamp = now ?? Math.floor(Date.now() / 1000);
43
+ const fetchEvents = queryRelayEvents ?? defaultQueryRelayEvents;
44
+ const locatorResolver = resolveLocator ?? defaultResolveLocator;
45
+
46
+ const filter = {
47
+ kinds: [30059],
48
+ authors: [servicePubkey],
49
+ '#d': [serviceId],
50
+ limit: 10
51
+ };
52
+
53
+ const events = await fetchEvents(bootstrapRelays, filter, { timeoutMs: publicationRelayTimeoutMs });
54
+ const serviceEvent = pickBestServiceRecord(events, timestamp, serviceId);
55
+ if (!serviceEvent) {
56
+ throw new Error('No valid NCC-02 service record available');
57
+ }
58
+
59
+ const locatorPayload = await locatorResolver({
60
+ bootstrapRelays,
61
+ servicePubkey,
62
+ locatorId,
63
+ locatorSecretKey,
64
+ timeout: ncc05TimeoutMs
65
+ });
66
+
67
+ const selection = determineEndpoint({
68
+ serviceEvent,
69
+ locatorPayload,
70
+ expectedK,
71
+ torPreferred,
72
+ now: timestamp
73
+ });
74
+
75
+ return {
76
+ endpoint: selection.endpoint,
77
+ source: selection.source,
78
+ locatorPayload,
79
+ serviceEvent,
80
+ selection
81
+ };
82
+ }
83
+
84
+ function determineEndpoint({ serviceEvent, locatorPayload, expectedK, torPreferred, now }) {
85
+ const tags = parseNcc02Tags(serviceEvent);
86
+ const ncc02Url = tags.u;
87
+ const isFreshService = !tags.exp || now <= Number(tags.exp);
88
+ const result = {
89
+ endpoint: null,
90
+ source: null,
91
+ reason: null,
92
+ evidence: null
93
+ };
94
+
95
+ if (locatorPayload && validateLocatorFreshness(locatorPayload, { now })) {
96
+ const normalized = normalizeLocatorEndpoints(locatorPayload.endpoints || []);
97
+ const selection = choosePreferredEndpoint(normalized, {
98
+ torPreferred,
99
+ expectedK
100
+ });
101
+ if (selection.endpoint) {
102
+ return {
103
+ endpoint: selection.endpoint.url,
104
+ source: 'locator',
105
+ reason: 'locator',
106
+ evidence: selection
107
+ };
108
+ }
109
+ result.reason = selection.reason;
110
+ result.evidence = selection;
111
+ }
112
+
113
+ if (ncc02Url && isFreshService) {
114
+ if (ncc02Url.startsWith('wss://') && expectedK && tags.k && tags.k !== expectedK) {
115
+ return {
116
+ endpoint: null,
117
+ source: 'ncc02',
118
+ reason: 'k-mismatch',
119
+ evidence: { expected: expectedK, actual: tags.k }
120
+ };
121
+ }
122
+ return {
123
+ endpoint: ncc02Url,
124
+ source: 'ncc02',
125
+ reason: 'fallback',
126
+ evidence: { tags }
127
+ };
128
+ }
129
+
130
+ result.reason = result.reason || 'no-endpoint';
131
+ return result;
132
+ }
133
+
134
+ function pickBestServiceRecord(events, now, expectedServiceId) {
135
+ const candidates = [];
136
+ for (const event of events) {
137
+ if (!validateNcc02(event, { now, expectedD: expectedServiceId })) {
138
+ continue;
139
+ }
140
+ candidates.push(event);
141
+ }
142
+ return candidates.sort((a, b) => {
143
+ if (b.created_at !== a.created_at) {
144
+ return b.created_at - a.created_at;
145
+ }
146
+ return b.id.localeCompare(a.id);
147
+ })[0] || null;
148
+ }
149
+
150
+ async function defaultQueryRelayEvents(relays, filter, options = {}) {
151
+ const queries = relays.map(relay => queryRelayForEvents(relay, filter, options.timeoutMs));
152
+ const settled = await Promise.allSettled(queries);
153
+ return settled.reduce((acc, item) => {
154
+ if (item.status === 'fulfilled') {
155
+ return acc.concat(item.value);
156
+ }
157
+ return acc;
158
+ }, []);
159
+ }
160
+
161
+ function queryRelayForEvents(relayUrl, filter, timeoutMs = DEFAULT_RELAY_TIMEOUT_MS) {
162
+ return new Promise((resolve, reject) => {
163
+ const ws = new WebSocket(relayUrl);
164
+ const events = [];
165
+ const subId = `ncc06-${Math.random().toString(16).slice(2, 8)}`;
166
+ let settled = false;
167
+ const timer = setTimeout(() => {
168
+ if (!settled) {
169
+ settled = true;
170
+ ws.close();
171
+ resolve(events);
172
+ }
173
+ }, timeoutMs);
174
+
175
+ ws.onopen = () => {
176
+ ws.send(serializeNostrMessage(createReqMessage(subId, filter)));
177
+ };
178
+
179
+ ws.onmessage = raw => {
180
+ const message = parseNostrMessage(raw.data.toString());
181
+ if (!message) {
182
+ return;
183
+ }
184
+ const [type, ...payload] = message;
185
+ if (type === 'EVENT') {
186
+ const [receivedSubId, event] = payload;
187
+ if (receivedSubId === subId) {
188
+ events.push(event);
189
+ }
190
+ } else if (type === 'EOSE') {
191
+ const [receivedSubId] = payload;
192
+ if (receivedSubId === subId && !settled) {
193
+ settled = true;
194
+ clearTimeout(timer);
195
+ ws.close();
196
+ resolve(events);
197
+ }
198
+ }
199
+ };
200
+
201
+ ws.onerror = err => {
202
+ if (!settled) {
203
+ settled = true;
204
+ clearTimeout(timer);
205
+ reject(err);
206
+ }
207
+ };
208
+
209
+ ws.onclose = () => {
210
+ if (!settled) {
211
+ settled = true;
212
+ clearTimeout(timer);
213
+ resolve(events);
214
+ }
215
+ };
216
+ });
217
+ }
218
+
219
+ async function defaultResolveLocator({
220
+ bootstrapRelays,
221
+ servicePubkey,
222
+ locatorId,
223
+ locatorSecretKey,
224
+ timeout
225
+ }) {
226
+ const resolver = new NCC05Resolver({
227
+ bootstrapRelays,
228
+ timeout
229
+ });
230
+ try {
231
+ return await resolver.resolve(servicePubkey, locatorSecretKey, locatorId, {
232
+ strict: false,
233
+ gossip: false
234
+ });
235
+ } finally {
236
+ resolver.close();
237
+ }
238
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Return a next-delay that applies deterministic jitter without exceeding the base interval.
3
+ *
4
+ * @param {number} baseMs - The nominal interval.
5
+ * @param {number} jitterRatio - Fractional jitter (default 15%).
6
+ * @returns {number} Delay in milliseconds (non-negative, never above baseMs).
7
+ */
8
+ export function scheduleWithJitter(baseMs, jitterRatio = 0.15) {
9
+ if (typeof baseMs !== 'number' || Number.isNaN(baseMs) || baseMs < 0) {
10
+ throw new Error('baseMs must be a non-negative number');
11
+ }
12
+ if (typeof jitterRatio !== 'number' || Number.isNaN(jitterRatio) || jitterRatio < 0) {
13
+ throw new Error('jitterRatio must be a non-negative number');
14
+ }
15
+
16
+ const jitterDelta = (Math.random() * 2 - 1) * jitterRatio * baseMs;
17
+ const jittered = baseMs + jitterDelta;
18
+ const clamped = Math.min(baseMs, Math.max(0, jittered));
19
+ return Math.round(clamped);
20
+ }
@@ -0,0 +1,49 @@
1
+ import { normalizeLocatorEndpoints } from './ncc05.js';
2
+
3
+ function pickByPriority(list) {
4
+ if (!list.length) return null;
5
+ return [...list].sort((a, b) => a.priority - b.priority)[0];
6
+ }
7
+
8
+ function findByFamily(list, family) {
9
+ return list.find(ep => ep.family === family);
10
+ }
11
+
12
+ /**
13
+ * Choose which endpoint to connect to based on NCC-06 policy.
14
+ * - prefers onion when torPreferred.
15
+ * - prefers WSS endpoints with matching k.
16
+ */
17
+ export function choosePreferredEndpoint(endpoints = [], options = {}) {
18
+ const { torPreferred = false, expectedK } = options;
19
+ const normalized = endpoints.length ? endpoints : [];
20
+ const wssEndpoints = normalized.filter(ep => ep.protocol === 'wss');
21
+ const wsEndpoints = normalized.filter(ep => ep.protocol === 'ws');
22
+
23
+ let candidate = null;
24
+ if (torPreferred) {
25
+ candidate = findByFamily(wssEndpoints, 'onion') || findByFamily(wsEndpoints, 'onion');
26
+ }
27
+ if (!candidate) {
28
+ candidate = pickByPriority(wssEndpoints) || pickByPriority(wsEndpoints);
29
+ }
30
+ if (!candidate) {
31
+ return { endpoint: null, reason: 'no-endpoint' };
32
+ }
33
+ if (candidate.protocol === 'wss') {
34
+ if (!candidate.k) {
35
+ return { endpoint: null, reason: 'missing-k' };
36
+ }
37
+ if (expectedK && candidate.k !== expectedK) {
38
+ return {
39
+ endpoint: null,
40
+ reason: 'k-mismatch',
41
+ expected: expectedK,
42
+ actual: candidate.k
43
+ };
44
+ }
45
+ }
46
+ return { endpoint: candidate };
47
+ }
48
+
49
+ export { normalizeLocatorEndpoints };
package/src/tls.js ADDED
@@ -0,0 +1,46 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import selfsigned from 'selfsigned';
4
+
5
+ const DEFAULT_KEY_NAME = 'server.key';
6
+ const DEFAULT_CERT_NAME = 'server.crt';
7
+
8
+ /**
9
+ * Ensure a TLS key/cert pair exists (used for local WSS testing).
10
+ */
11
+ export async function ensureSelfSignedCert({
12
+ targetDir = process.cwd(),
13
+ keyFileName = DEFAULT_KEY_NAME,
14
+ certFileName = DEFAULT_CERT_NAME,
15
+ altNames = ['localhost', '127.0.0.1', '::1']
16
+ } = {}) {
17
+ const keyPath = path.resolve(targetDir, keyFileName);
18
+ const certPath = path.resolve(targetDir, certFileName);
19
+
20
+ if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
21
+ return { keyPath, certPath };
22
+ }
23
+
24
+ fs.mkdirSync(path.dirname(keyPath), { recursive: true });
25
+ fs.mkdirSync(path.dirname(certPath), { recursive: true });
26
+
27
+ const attrs = [{ name: 'commonName', value: 'localhost' }];
28
+ const altNameObjects = altNames.map(name => {
29
+ if (name.includes(':')) {
30
+ return { type: 7, ip: name };
31
+ }
32
+ return { type: 2, value: name };
33
+ });
34
+
35
+ const generated = selfsigned.generate(attrs, {
36
+ algorithm: 'rsa',
37
+ keySize: 2048,
38
+ days: 365,
39
+ extensions: [{ name: 'subjectAltName', altNames: altNameObjects }]
40
+ });
41
+
42
+ fs.writeFileSync(keyPath, generated.private, 'utf-8');
43
+ fs.writeFileSync(certPath, generated.cert, 'utf-8');
44
+
45
+ return { keyPath, certPath };
46
+ }
@@ -0,0 +1,59 @@
1
+ import { strict as assert } from 'assert';
2
+ import { test } from 'node:test';
3
+ import {
4
+ buildExternalEndpoints,
5
+ detectGlobalIPv6,
6
+ getPublicIPv4
7
+ } from '../src/external-endpoints.js';
8
+ import os from 'os';
9
+
10
+ test('buildExternalEndpoints orders endpoints and adds k when needed', async () => {
11
+ const onion = { address: 'abc123.onion', servicePort: 80 };
12
+ const endpoints = await buildExternalEndpoints({
13
+ tor: { enabled: true },
14
+ ipv6: { enabled: true, protocol: 'wss', port: 8443 },
15
+ ipv4: { enabled: true, protocol: 'wss', address: '1.2.3.4', port: 7447 },
16
+ wsPort: 7000,
17
+ wssPort: 7447,
18
+ ncc02ExpectedKey: 'TESTKEY',
19
+ ensureOnionService: async () => onion
20
+ });
21
+
22
+ assert.equal(endpoints.length, 3);
23
+ assert.equal(endpoints[0].family, 'ipv4');
24
+ assert.equal(endpoints[1].family, 'ipv6');
25
+ assert.equal(endpoints[2].family, 'onion');
26
+ assert.equal(endpoints[0].k, 'TESTKEY');
27
+ });
28
+
29
+ test('detectGlobalIPv6 filters private addresses', () => {
30
+ const original = os.networkInterfaces;
31
+ os.networkInterfaces = () => ({
32
+ lo: [{ address: '::1', family: 'IPv6', internal: true }],
33
+ eth0: [
34
+ { address: 'fe80::1', family: 'IPv6', internal: false },
35
+ { address: 'fc00::1', family: 'IPv6', internal: false },
36
+ { address: '2001:db8::1', family: 'IPv6', internal: false }
37
+ ]
38
+ });
39
+ try {
40
+ const addr = detectGlobalIPv6();
41
+ assert.equal(addr, '2001:db8::1');
42
+ } finally {
43
+ os.networkInterfaces = original;
44
+ }
45
+ });
46
+
47
+ test('getPublicIPv4 returns from first reachable source', async () => {
48
+ const original = global.fetch;
49
+ global.fetch = async (url) => ({
50
+ ok: true,
51
+ text: async () => '{"ip":"5.6.7.8"}'
52
+ });
53
+ try {
54
+ const ip = await getPublicIPv4({ sources: ['https://example.com'] });
55
+ assert.equal(ip, '5.6.7.8');
56
+ } finally {
57
+ global.fetch = original;
58
+ }
59
+ });
package/test/k.test.js ADDED
@@ -0,0 +1,40 @@
1
+ import { strict as assert } from 'assert';
2
+ import { test } from 'node:test';
3
+ import { computeKFromCertPem, getExpectedK } from '../src/k.js';
4
+ import { readFileSync, existsSync, rmSync, mkdirSync } from 'fs';
5
+ import path from 'path';
6
+ import os from 'os';
7
+
8
+ test('computeKFromCertPem produces stable base64url output', () => {
9
+ const certPath = path.resolve('../ncc-06-example/ncc06-relay/certs/server.crt');
10
+ const pem = readFileSync(certPath, 'utf-8');
11
+ const value = computeKFromCertPem(pem);
12
+ assert.equal(value, 'SOXWTCHPUG9ZLaZ4NgH2kKp5Hmqnaj1tXNI90SY44Mw');
13
+ });
14
+
15
+ test('generate mode persists and reuses k value', () => {
16
+ const tmpDir = path.join(os.tmpdir(), `ncc06-k-${Date.now()}`);
17
+ mkdirSync(tmpDir, { recursive: true });
18
+ const persistPath = path.join(tmpDir, 'k.txt');
19
+ const cfg = { k: { mode: 'generate', persistPath } };
20
+
21
+ const first = getExpectedK(cfg);
22
+ assert.equal(first.length, 43); // base64url of 32 bytes
23
+
24
+ const second = getExpectedK(cfg);
25
+ assert.equal(second, first);
26
+
27
+ rmSync(tmpDir, { recursive: true, force: true });
28
+ });
29
+
30
+ test('missing static value throws error', () => {
31
+ assert.throws(() => getExpectedK({ k: { mode: 'static' } }), /k\.value is required/);
32
+ });
33
+
34
+ test('missing persistPath throws error', () => {
35
+ assert.throws(() => getExpectedK({ k: { mode: 'generate' } }), /k\.persistPath is required/);
36
+ });
37
+
38
+ test('missing certPath throws error', () => {
39
+ assert.throws(() => getExpectedK({ k: { mode: 'tls_spki' } }), /k\.certPath is required/);
40
+ });
@@ -0,0 +1,22 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'assert';
3
+ import { buildNcc02ServiceRecord, parseNcc02Tags, validateNcc02 } from '../src/ncc02.js';
4
+ import { generateKeypair } from '../src/keys.js';
5
+
6
+ test('builds and validates NCC-02 service record', () => {
7
+ const { secretKey, publicKey } = generateKeypair();
8
+ const serviceEvent = buildNcc02ServiceRecord({
9
+ secretKey,
10
+ serviceId: 'relay',
11
+ endpoint: 'wss://127.0.0.1:7447',
12
+ fingerprint: 'TESTKEY:resolver',
13
+ expirySeconds: 60
14
+ });
15
+
16
+ assert.equal(serviceEvent.pubkey, publicKey);
17
+ const tags = parseNcc02Tags(serviceEvent);
18
+ assert.equal(tags.d, 'relay');
19
+ assert.equal(tags.u, 'wss://127.0.0.1:7447');
20
+ assert.equal(tags.k, 'TESTKEY:resolver');
21
+ assert.ok(validateNcc02(serviceEvent, { expectedAuthor: publicKey, expectedD: 'relay' }));
22
+ });
@@ -0,0 +1,23 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'assert';
3
+ import { buildLocatorPayload, normalizeLocatorEndpoints, validateLocatorFreshness } from '../src/ncc05.js';
4
+
5
+ test('locator payload builds and validates freshness', () => {
6
+ const payload = buildLocatorPayload({
7
+ ttl: 60,
8
+ endpoints: [{ url: 'ws://example.com', protocol: 'ws', priority: 1 }]
9
+ });
10
+ assert.equal(payload.ttl, 60);
11
+ assert.ok(Array.isArray(payload.endpoints));
12
+ assert.ok(validateLocatorFreshness(payload, { now: payload.updated_at + 10 }));
13
+ assert.ok(!validateLocatorFreshness(payload, { now: payload.updated_at + 70 }));
14
+ });
15
+
16
+ test('normalizes locator endpoints', () => {
17
+ const items = normalizeLocatorEndpoints([
18
+ { url: 'wss://host', priority: 2, k: 'TESTKEY:1' },
19
+ { uri: 'ws://host2', prio: 1 }
20
+ ]);
21
+ assert.equal(items[0].protocol, 'wss');
22
+ assert.equal(items[1].protocol, 'ws');
23
+ });
@@ -0,0 +1,78 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'assert';
3
+ import { resolveServiceEndpoint } from '../src/resolver.js';
4
+ import { buildNcc02ServiceRecord } from '../src/ncc02.js';
5
+ import { buildLocatorPayload } from '../src/ncc05.js';
6
+ import { generateKeypair } from '../src/keys.js';
7
+
8
+ const SERVICE_ID = 'relay';
9
+ const LOCATOR_ID = 'relay-locator';
10
+
11
+ function buildServiceEvent({ secretKey, serviceId, endpoint, fingerprint }) {
12
+ return buildNcc02ServiceRecord({
13
+ secretKey,
14
+ serviceId,
15
+ endpoint,
16
+ fingerprint,
17
+ expirySeconds: 60
18
+ });
19
+ }
20
+
21
+ test('resolver prefers NCC-05 endpoint when k matches', async () => {
22
+ const { secretKey, publicKey } = generateKeypair();
23
+ const serviceEvent = buildServiceEvent({
24
+ secretKey,
25
+ serviceId: SERVICE_ID,
26
+ endpoint: 'wss://fallback',
27
+ fingerprint: 'TESTKEY:match'
28
+ });
29
+ const locatorPayload = buildLocatorPayload({
30
+ ttl: 60,
31
+ endpoints: [
32
+ { url: 'wss://match', protocol: 'wss', priority: 1, k: 'TESTKEY:match' }
33
+ ]
34
+ });
35
+
36
+ const result = await resolveServiceEndpoint({
37
+ bootstrapRelays: ['wss://example'],
38
+ servicePubkey: publicKey,
39
+ serviceId: SERVICE_ID,
40
+ locatorId: LOCATOR_ID,
41
+ expectedK: 'TESTKEY:match',
42
+ queryRelayEvents: async () => [serviceEvent],
43
+ resolveLocator: async () => locatorPayload
44
+ });
45
+
46
+ assert.equal(result.endpoint, 'wss://match');
47
+ assert.equal(result.source, 'locator');
48
+ });
49
+
50
+ test('resolver falls back to NCC-02 when locator k mismatches', async () => {
51
+ const { secretKey, publicKey } = generateKeypair();
52
+ const serviceEvent = buildServiceEvent({
53
+ secretKey,
54
+ serviceId: SERVICE_ID,
55
+ endpoint: 'wss://fallback',
56
+ fingerprint: 'TESTKEY:match'
57
+ });
58
+ const locatorPayload = buildLocatorPayload({
59
+ ttl: 60,
60
+ endpoints: [
61
+ { url: 'wss://mismatch', protocol: 'wss', priority: 1, k: 'TESTKEY:bad' }
62
+ ]
63
+ });
64
+
65
+ const result = await resolveServiceEndpoint({
66
+ bootstrapRelays: ['wss://example'],
67
+ servicePubkey: publicKey,
68
+ serviceId: SERVICE_ID,
69
+ locatorId: LOCATOR_ID,
70
+ expectedK: 'TESTKEY:match',
71
+ queryRelayEvents: async () => [serviceEvent],
72
+ resolveLocator: async () => locatorPayload
73
+ });
74
+
75
+ assert.equal(result.endpoint, 'wss://fallback');
76
+ assert.equal(result.source, 'ncc02');
77
+ assert.equal(result.selection.reason, 'fallback');
78
+ });
@@ -0,0 +1,19 @@
1
+ import { strict as assert } from 'assert';
2
+ import { test } from 'node:test';
3
+ import { scheduleWithJitter } from '../src/schedule.js';
4
+
5
+ test('scheduleWithJitter bounds delay between 0 and base interval', () => {
6
+ const values = [];
7
+ for (let i = 0; i < 100; i++) {
8
+ const delay = scheduleWithJitter(1000, 0.25);
9
+ assert.ok(delay >= 0, 'delay should not be negative');
10
+ assert.ok(delay <= 1000, 'delay should not exceed base interval');
11
+ values.push(delay);
12
+ }
13
+ assert.ok(values.some(v => v < 1000), 'some jittered delays should be less than base');
14
+ });
15
+
16
+ test('scheduleWithJitter throws for invalid args', () => {
17
+ assert.throws(() => scheduleWithJitter(-1), /non-negative number/);
18
+ assert.throws(() => scheduleWithJitter(1000, -0.1), /non-negative number/);
19
+ });
@@ -0,0 +1,27 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'assert';
3
+ import { choosePreferredEndpoint } from '../src/selector.js';
4
+
5
+ test('chooses WSS endpoint with matching k', () => {
6
+ const { endpoint, reason } = choosePreferredEndpoint([
7
+ { url: 'wss://match', protocol: 'wss', priority: 1, k: 'TESTKEY:match' }
8
+ ], { expectedK: 'TESTKEY:match' });
9
+ assert.ok(endpoint);
10
+ assert.equal(endpoint.url, 'wss://match');
11
+ assert.equal(reason, undefined);
12
+ });
13
+
14
+ test('rejects mismatched k', () => {
15
+ const result = choosePreferredEndpoint([
16
+ { url: 'wss://bad', protocol: 'wss', priority: 1, k: 'TESTKEY:bad' }
17
+ ], { expectedK: 'TESTKEY:expected' });
18
+ assert.equal(result.reason, 'k-mismatch');
19
+ assert.equal(result.endpoint, null);
20
+ });
21
+
22
+ test('returns missing k reason', () => {
23
+ const result = choosePreferredEndpoint([
24
+ { url: 'wss://no-k', protocol: 'wss', priority: 1 }
25
+ ]);
26
+ assert.equal(result.reason, 'missing-k');
27
+ });