From e24914e76a34ae803da40fa6493f5df9f64adb90 Mon Sep 17 00:00:00 2001 From: sergeych Date: Thu, 30 Jul 2026 22:52:14 +0400 Subject: [PATCH] Simplify sample with universa-core2 API --- README.md | 13 ++- package-lock.json | 10 -- package.json | 6 +- scripts/test-network.js | 194 --------------------------------- scripts/test-network.sh | 2 +- src/index.js | 236 ++++++++++++---------------------------- 6 files changed, 82 insertions(+), 379 deletions(-) delete mode 100644 scripts/test-network.js diff --git a/README.md b/README.md index 861add1..202da6c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # u-js-sample -A small Node.js console script project. +A small Node.js command-line example using `universa-core2@2.0.0-alpha.1`. ## Initialize and test @@ -9,11 +9,14 @@ npm ci scripts/test-network.sh ``` -The script uses the topology in `src/universa.json` to verify that the Universa -network is reachable and functioning correctly. +The test loads `src/universa.json` through `Topology`, connects with `Network`, +authenticates to every available node, and verifies `sping=spong`. The alpha +package intentionally has no bundled default topology, so applications must +provide one explicitly. -## Check +## Examples ```sh -npm run check +npm start -- --generate-key +npm start -- --test-network ``` diff --git a/package-lock.json b/package-lock.json index cb852e2..14f8a5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "u-js-sample", "version": "0.1.0", "dependencies": { - "commander": "^14.0.2", "universa-core2": "2.0.0-alpha.1" }, "bin": { @@ -80,15 +79,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "node_modules/diffie-hellman": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", diff --git a/package.json b/package.json index 5fb7d57..6fb04f3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "u-js-sample", "version": "0.1.0", - "description": "A Node.js console script sample.", + "description": "A small universa-core2 Node.js sample.", "type": "module", "private": true, "bin": { @@ -10,13 +10,13 @@ "scripts": { "start": "node src/index.js", "test:network": "bash scripts/test-network.sh", - "check": "node --check src/index.js" + "check": "node --check src/index.js", + "test": "npm run check && npm run test:network" }, "engines": { "node": ">=20" }, "dependencies": { - "commander": "^14.0.2", "universa-core2": "2.0.0-alpha.1" } } diff --git a/scripts/test-network.js b/scripts/test-network.js deleted file mode 100644 index b1a1b0a..0000000 --- a/scripts/test-network.js +++ /dev/null @@ -1,194 +0,0 @@ -#!/usr/bin/env node - -import { readFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const require = createRequire(import.meta.url); -const { - Boss, - PrivateKey, - PublicKey, - SymmetricKey, - decode64, - encode64, - randomBytes, - unicryptoReady -} = require('universa-core2'); - -const projectDirectory = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const topologyFile = resolve(projectDirectory, 'src/universa.json'); -const requestTimeoutMs = 5000; -const clientVersion = 3; - -function describeError(error) { - if (error instanceof Error) return error.message; - try { - return JSON.stringify(error); - } catch { - return String(error); - } -} - -function loadTopology() { - const topology = JSON.parse(readFileSync(topologyFile, 'utf8')); - const nodes = Array.isArray(topology) ? topology : topology.list; - - if (!Array.isArray(nodes) || nodes.length === 0) { - throw new Error(`${topologyFile} must contain a non-empty node list`); - } - - for (const node of nodes) { - if (!node.number || !node.key || !node.direct_urls?.length) { - throw new Error(`Invalid node entry in ${topologyFile}`); - } - } - - return nodes; -} - -async function request(fetchImpl, method, url, params) { - const options = { - method, - signal: AbortSignal.timeout(requestTimeoutMs) - }; - - if (method === 'POST') { - options.headers = { 'Content-Type': 'application/json' }; - options.body = JSON.stringify({ requestData64: encode64(Boss.dump(params)) }); - } - - const response = await fetchImpl(url, options); - if (!response.ok) throw new Error(`${method} ${url} returned HTTP ${response.status}`); - - const answer = Boss.load(new Uint8Array(await response.arrayBuffer())); - if (answer?.result !== 'ok') throw answer || new Error(`Invalid response from ${url}`); - return answer.response; -} - -function packedNodeId(node) { - const key = typeof node.key === 'string' ? node.key : encode64(node.key); - return `${node.number}:${key}`; -} - -async function verifySignedTopology(fetchImpl, node, publicKey, expectedNodeIds) { - const response = await request(fetchImpl, 'GET', `${node.direct_urls[0]}/topology`); - const packed = response?.packed_data; - const signature = response?.signature; - - if (!packed || !signature || !await publicKey.verifyExtended(signature, packed)) { - throw new Error('invalid topology signature'); - } - - const advertised = Boss.load(packed)?.nodes; - if (!Array.isArray(advertised)) throw new Error('signed topology has no node list'); - - const advertisedNodeIds = new Set(advertised.map(packedNodeId)); - for (const expectedNodeId of expectedNodeIds) { - if (!advertisedNodeIds.has(expectedNodeId)) { - throw new Error('signed topology does not contain every configured node'); - } - } -} - -async function openSession(fetchImpl, node, publicKey, privateKey) { - const baseUrl = node.direct_urls[0]; - const signatureOptions = { pssHash: 'sha512' }; - const clientNonce = randomBytes(47); - const clientKey = await privateKey.publicKey.pack(); - const connection = await request(fetchImpl, 'POST', `${baseUrl}/connect`, { - client_key: clientKey, - client_version: clientVersion - }); - const serverVersion = connection.server_version || 1; - const version = Math.min(serverVersion, clientVersion); - const sessionId = connection.session_id; - const authenticationData = Boss.dump({ - client_nonce: clientNonce, - server_nonce: connection.server_nonce, - client_version: clientVersion, - server_version: serverVersion - }); - const token = await request(fetchImpl, 'POST', `${baseUrl}/get_token`, { - data: authenticationData, - signature: await privateKey.sign(authenticationData, signatureOptions), - session_id: sessionId - }); - - if (!await publicKey.verify(token.data, token.signature, signatureOptions)) { - throw new Error('invalid authentication signature'); - } - - const tokenData = Boss.load(token.data); - if (encode64(tokenData.client_nonce) !== encode64(clientNonce)) { - throw new Error('authentication nonce mismatch'); - } - - const decryptedToken = Boss.load(await privateKey.decrypt(tokenData.encrypted_token)); - const sessionKey = new SymmetricKey({ keyBytes: decryptedToken.sk }); - - return async (name, params = {}) => { - const data = Boss.dump({ command: name, params }); - const encrypted = version >= 2 - ? await sessionKey.etaEncrypt(data) - : await sessionKey.encrypt(data); - const response = await request(fetchImpl, 'POST', `${baseUrl}/command`, { - command: 'command', - params: encrypted, - session_id: sessionId - }); - const decrypted = version >= 2 - ? await sessionKey.etaDecrypt(response.result) - : await sessionKey.decrypt(response.result); - const result = Boss.load(decrypted); - if (result.error) throw result.error; - return result.result; - }; -} - -async function testNode(fetchImpl, node, privateKey, expectedNodeIds) { - const keyBytes = typeof node.key === 'string' ? decode64(node.key) : node.key; - const publicKey = await PublicKey.unpack(keyBytes); - await verifySignedTopology(fetchImpl, node, publicKey, expectedNodeIds); - const command = await openSession(fetchImpl, node, publicKey, privateKey); - const hello = await command('hello'); - if (hello?.status !== 'OK') throw new Error(`hello returned ${hello?.status || 'no status'}`); - const ping = await command('sping'); - if (ping?.sping !== 'spong') throw new Error('sping did not return spong'); -} - -async function main(fetchImpl) { - const nodes = loadTopology(); - const expectedNodeIds = new Set(nodes.map(packedNodeId)); - const quorum = Math.max(1, Math.ceil(nodes.length * 0.4)); - const privateKey = await PrivateKey.generate({ strength: 2048 }); - - console.log(`Testing ${nodes.length} Universa nodes from ${topologyFile}`); - const results = await Promise.all(nodes.map(async (node) => { - try { - await testNode(fetchImpl, node, privateKey, expectedNodeIds); - console.log(`PASS ${node.name}: signed topology, authenticated hello, sping=spong`); - return true; - } catch (error) { - console.error(`FAIL ${node.name}: ${describeError(error)}`); - return false; - } - })); - - const passed = results.filter(Boolean).length; - if (passed < quorum) { - throw new Error(`Universa network quorum failed: ${passed}/${nodes.length} nodes passed; ${quorum} required`); - } - console.log(`Universa network is functioning: ${passed}/${nodes.length} nodes passed (${quorum} required)`); -} - -try { - await unicryptoReady; - const fetchImpl = globalThis.fetch; - if (!fetchImpl) throw new Error('This test requires the Node.js fetch API'); - await main(fetchImpl); -} catch (error) { - console.error(describeError(error)); - process.exitCode = 1; -} diff --git a/scripts/test-network.sh b/scripts/test-network.sh index 071ccd5..924ae08 100755 --- a/scripts/test-network.sh +++ b/scripts/test-network.sh @@ -2,4 +2,4 @@ set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." -exec node scripts/test-network.js +exec node src/index.js --test-network diff --git a/src/index.js b/src/index.js index 805435d..fae08ee 100644 --- a/src/index.js +++ b/src/index.js @@ -1,199 +1,103 @@ #!/usr/bin/env node -import { readFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { createConnection } from 'node:net'; -import { basename, dirname, resolve } from 'node:path'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { Command } from 'commander'; +import { + Network, + PrivateKey, + Topology, + unicryptoReady +} from 'universa-core2'; -const require = createRequire(import.meta.url); -const scriptDirectory = dirname(fileURLToPath(import.meta.url)); -const topologyFile = resolve(scriptDirectory, 'universa.json'); -const universaCoreDist = resolve(scriptDirectory, '../node_modules/universa-core/dist'); -const networkTimeoutMs = 20000; -const endpointProbeTimeoutMs = 3000; +const sourceDirectory = dirname(fileURLToPath(import.meta.url)); +const topologyFile = resolve(sourceDirectory, 'universa.json'); -function withTimeout(promise, timeoutMs, message) { +async function withTimeout(promise, milliseconds, message) { let timeoutId; const timeout = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); - }); - - return Promise.race([promise, timeout]).finally(() => clearTimeout(timeoutId)); -} - -async function withUniversaWasmLoader(callback) { - const fetch = globalThis.fetch; - const read = globalThis.read; - const readBinary = globalThis.readBinary; - - globalThis.fetch = undefined; - globalThis.read = (fileName, binary) => { - const wasmPath = resolve(universaCoreDist, basename(fileName)); - const data = readFileSync(wasmPath); - return binary ? data : data.toString('binary'); - }; - globalThis.readBinary = (fileName) => { - const wasmPath = resolve(universaCoreDist, basename(fileName)); - return readFileSync(wasmPath); - }; - - try { - return await callback(); - } finally { - globalThis.fetch = fetch; - - if (read === undefined) delete globalThis.read; - else globalThis.read = read; - - if (readBinary === undefined) delete globalThis.readBinary; - else globalThis.readBinary = readBinary; - } -} - -function loadUniversaCore() { - const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window'); - const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); - const localStorageDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); - - Object.defineProperty(globalThis, 'window', { - configurable: true, - value: globalThis - }); - Object.defineProperty(globalThis, 'navigator', { - configurable: true, - value: { - hardwareConcurrency: 1, - languages: ['en-US'] - } - }); - Object.defineProperty(globalThis, 'localStorage', { - configurable: true, - value: { - getItem: () => null, - setItem: () => {}, - removeItem: () => {}, - clear: () => {} - } + timeoutId = setTimeout(() => reject(new Error(message)), milliseconds); }); try { - return require('universa-core'); + return await Promise.race([promise, timeout]); } finally { - if (windowDescriptor) Object.defineProperty(globalThis, 'window', windowDescriptor); - else delete globalThis.window; - - if (navigatorDescriptor) Object.defineProperty(globalThis, 'navigator', navigatorDescriptor); - else delete globalThis.navigator; - - if (localStorageDescriptor) Object.defineProperty(globalThis, 'localStorage', localStorageDescriptor); - else delete globalThis.localStorage; + clearTimeout(timeoutId); } } -function generatePrivateKey() { - return withUniversaWasmLoader(async () => { - const { PrivateKey } = require('unicrypto'); - return PrivateKey.generate({ strength: 2048 }); +async function loadTopology() { + const packed = JSON.parse(await readFile(topologyFile, 'utf8')); + return Topology.load(packed); +} + +async function testNetwork(privateKey) { + const topology = await loadTopology(); + const network = new Network(privateKey, { + topology, + directConnection: true }); -} -async function loadTopology(Topology) { - return withUniversaWasmLoader(async () => { - const packedTopology = readTopology(); - return Topology.load(packedTopology); - }); -} + console.log(`Testing ${topology.size()} Universa nodes from ${topologyFile}`); + await withTimeout(network.connect(), 15_000, 'Network connection timed out'); -function readTopology() { - const parsedTopology = JSON.parse(readFileSync(topologyFile, 'utf8')); - const packedTopology = Array.isArray(parsedTopology) - ? { list: parsedTopology, updated: Math.floor(Date.now() / 1000) } - : parsedTopology; - - if (!Array.isArray(packedTopology.list)) { - throw new Error(`Topology file must contain a node array or an object with a list array: ${topologyFile}`); - } - - return packedTopology; -} - -async function probeEndpoint(url) { - return new Promise((resolveProbe) => { - const endpoint = new URL(url); - const port = endpoint.port || (endpoint.protocol === 'https:' ? 443 : 80); - const socket = createConnection({ - host: endpoint.hostname, - port: Number(port), - timeout: endpointProbeTimeoutMs - }); - - function finish(ok, error = '') { - socket.destroy(); - resolveProbe({ url, ok, error }); + const nodes = Object.entries(network.topology.nodes); + const results = await Promise.all(nodes.map(async ([id, node]) => { + try { + const connection = await withTimeout( + network.nodeConnection(id), + 10_000, + `${node.name} connection timed out` + ); + const response = await connection.command('sping', {}, { timeout: 5_000 }); + if (response?.sping !== 'spong') throw new Error('unexpected sping response'); + console.log(`PASS ${node.name}: authenticated, sping=spong`); + return true; + } catch (error) { + console.error(`FAIL ${node.name}: ${error instanceof Error ? error.message : error}`); + return false; } + })); - socket.once('connect', () => finish(true)); - socket.once('timeout', () => finish(false, 'TCP connection timed out')); - socket.once('error', (error) => finish(false, error.message)); - }); -} - -async function assertReachableTopology(directConnection) { - const topology = readTopology(); - const urls = topology.list.flatMap((node) => ( - directConnection ? node.direct_urls : node.domain_urls - ) || []); - - if (urls.length === 0) { - throw new Error(`Topology file does not contain ${directConnection ? 'direct_urls' : 'domain_urls'} entries: ${topologyFile}`); + const passed = results.filter(Boolean).length; + const quorum = Math.max(1, Math.ceil(nodes.length * 0.4)); + if (passed < quorum) { + throw new Error(`Network quorum failed: ${passed}/${nodes.length} nodes passed; ${quorum} required`); } - const results = await Promise.all(urls.map(probeEndpoint)); - if (results.some((result) => result.ok)) return; - - const details = results - .map(({ url, error }) => ` ${url}/topology: ${error}`) - .join('\n'); - - throw new Error(`No reachable topology endpoints in ${topologyFile}:\n${details}`); + console.log(`Universa network is functioning: ${passed}/${nodes.length} nodes passed`); } -const program = new Command(); +function printHelp() { + console.log(`Usage: u-js-sample [options] -program - .name('u-js-sample') - .description('Example Node.js console script with universa-core functions.') - .option('--generate-key', 'generate a Universa private key and print its short address') - .option('--test-network', `generate a key and connect to the Universa network from ${basename(topologyFile)}`) - .action(async (options) => { - if (options.generateKey || options.testNetwork) { - const privateKey = await generatePrivateKey(); - console.log(`Universa short address: ${privateKey.publicKey.shortAddress58}`); +Options: + --generate-key Generate a private key and print its short address + --test-network Test every node in src/universa.json + --help Show this help`); +} - if (options.testNetwork) { - const { Network, Topology } = loadUniversaCore(); - const topology = await loadTopology(Topology); - const directConnection = true; - const network = new Network(privateKey, { topology, directConnection }); +async function main() { + const options = new Set(process.argv.slice(2)); + if (options.size === 0 || options.has('--help')) return printHelp(); - console.log(`Connecting with topology: ${topologyFile}`); - await assertReachableTopology(directConnection); - await withTimeout(network.connect(), networkTimeoutMs, 'Network connection timed out'); - console.log(`Connected to ${network.size()} nodes`); + const unknown = [...options].find((option) => ( + option !== '--generate-key' && option !== '--test-network' + )); + if (unknown) throw new Error(`Unknown option: ${unknown}`); - const response = await withTimeout(network.command('sping'), 10000, 'Network sping command timed out'); - console.log(`sping response: ${JSON.stringify(response)}`); - } - } - }); + if (options.has('--generate-key') || options.has('--test-network')) { + await unicryptoReady; + const privateKey = await PrivateKey.generate({ strength: 2048 }); + console.log(`Universa short address: ${privateKey.publicKey.shortAddress58}`); + + if (options.has('--test-network')) await testNetwork(privateKey); + } +} try { - await program.parseAsync(); + await main(); } catch (error) { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; -} finally { - process.exit(); }