From bbc4ed2927c63b4fa362805da8b32a2206a443f1 Mon Sep 17 00:00:00 2001 From: phoenix-server Date: Sat, 18 Apr 2026 13:32:56 -0400 Subject: [PATCH 1/5] chore: remove 5 dependencies by inlining their functionality - bech32: inline encode/decode in transform.ts (~75 lines) - accepts: replace with request.headers.accept?.includes() check - tor-control-ts: inline Tor control protocol via net.Socket (TorClient class) - helmet: inline Content-Security-Policy header construction - dotenv: use Node.js --env-file-if-exists flag in npm scripts Co-Authored-By: Claude Sonnet 4.6 --- package.json | 15 ++-- src/clean-db.ts | 3 - src/factories/web-app-factory.ts | 7 +- .../request-handlers/root-request-handler.ts | 3 +- src/import-events.ts | 4 - src/index.ts | 2 - src/routes/index.ts | 3 +- src/scripts/export-events.ts | 2 - src/tor/client.ts | 76 ++++++++++++++++- src/utils/transform.ts | 82 ++++++++++++++++++- test/unit/tor/onion.spec.ts | 78 ++++++------------ 11 files changed, 186 insertions(+), 89 deletions(-) diff --git a/package.json b/package.json index f9aa8c42..c5cdbf54 100644 --- a/package.json +++ b/package.json @@ -24,12 +24,12 @@ ], "main": "src/index.ts", "scripts": { - "dev": "node -r ts-node/register src/index.ts", - "clean-db": "node -r ts-node/register src/clean-db.ts", + "dev": "node --env-file-if-exists=.env -r ts-node/register src/index.ts", + "clean-db": "node --env-file-if-exists=.env -r ts-node/register src/clean-db.ts", "clean": "rimraf ./{dist,.nyc_output,.test-reports,.coverage}", "build": "tsc --project tsconfig.build.json", "prestart": "npm run build", - "start": "cd dist && node src/index.js", + "start": "cd dist && node --env-file-if-exists=../.env src/index.js", "build:check": "npm run build -- --noEmit", "knip": "knip --config .knip.json --production --include files,dependencies --no-progress --reporter compact", "lint": "biome lint ./src ./test", @@ -38,7 +38,7 @@ "lint:fix": "npm run lint -- --write", "format": "biome format --write ./src ./test", "format:check": "biome format ./src ./test", - "import": "node -r ts-node/register src/import-events.ts", + "import": "node --env-file-if-exists=.env -r ts-node/register src/import-events.ts", "db:migrate": "knex migrate:latest", "db:migrate:rollback": "knex migrate:rollback", "db:seed": "knex seed:run", @@ -50,7 +50,7 @@ "pretest:integration": "mkdir -p .test-reports/integration", "test:integration": "cucumber-js", "cover:integration": "nyc --report-dir .coverage/integration npm run test:integration -- -p cover", - "export": "node -r ts-node/register src/scripts/export-events.ts", + "export": "node --env-file-if-exists=.env -r ts-node/register src/scripts/export-events.ts", "docker:compose:start": "./scripts/start", "docker:compose:stop": "./scripts/stop", "docker:compose:clean": "./scripts/clean", @@ -124,20 +124,15 @@ }, "dependencies": { "@noble/secp256k1": "1.7.1", - "accepts": "^1.3.8", "axios": "^1.15.0", - "bech32": "2.0.0", "debug": "4.3.4", - "dotenv": "16.0.3", "express": "4.22.1", - "helmet": "6.0.1", "js-yaml": "4.1.1", "knex": "2.4.2", "pg": "8.9.0", "pg-query-stream": "4.3.0", "ramda": "0.28.0", "redis": "4.5.1", - "tor-control-ts": "^1.0.0", "ws": "^8.18.0", "zod": "^3.22.4" }, diff --git a/src/clean-db.ts b/src/clean-db.ts index c2b863df..a2c6ba5e 100644 --- a/src/clean-db.ts +++ b/src/clean-db.ts @@ -1,11 +1,8 @@ import { createInterface } from 'readline' -import dotenv from 'dotenv' import { Knex } from 'knex' import { getMasterDbClient } from './database/client' -dotenv.config() - type CleanDbOptions = { all: boolean dryRun: boolean diff --git a/src/factories/web-app-factory.ts b/src/factories/web-app-factory.ts index 4a90fa05..158d53e8 100644 --- a/src/factories/web-app-factory.ts +++ b/src/factories/web-app-factory.ts @@ -1,5 +1,4 @@ import express from 'express' -import helmet from 'helmet' import { randomBytes } from 'crypto' import { createSettings } from './settings-factory' @@ -28,7 +27,11 @@ export const createWebApp = () => { 'font-src': ["'self'", 'https://cdn.jsdelivr.net/npm/'], } - return helmet.contentSecurityPolicy({ directives })(req, res, next) + const csp = Object.entries(directives) + .map(([key, values]) => `${key} ${values.join(' ')}`) + .join('; ') + res.setHeader('Content-Security-Policy', csp) + return next() }) .use('/favicon.ico', express.static('./resources/favicon.ico')) .use('/css', express.static('./resources/css')) diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index a39b943d..b24b257a 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -1,6 +1,5 @@ import { NextFunction, Request, Response } from 'express' import { path, pathEq } from 'ramda' -import accepts from 'accepts' import { createSettings } from '../../factories/settings-factory' import { escapeHtml } from '../../utils/html' import { FeeSchedule } from '../../@types/settings' @@ -11,7 +10,7 @@ import packageJson from '../../../package.json' export const rootRequestHandler = (request: Request, response: Response, next: NextFunction) => { const settings = createSettings() - if (accepts(request).type(['application/nostr+json'])) { + if (request.headers.accept?.includes('application/nostr+json')) { const { info: { name, description, pubkey: rawPubkey, contact, relay_url }, } = settings diff --git a/src/import-events.ts b/src/import-events.ts index e6392a75..9b51ca2d 100644 --- a/src/import-events.ts +++ b/src/import-events.ts @@ -2,10 +2,6 @@ import { extname, resolve } from 'path' import fs from 'fs' -import dotenv from 'dotenv' - -dotenv.config() - import { createEventBatchPersister, EventImportLineError, diff --git a/src/index.ts b/src/index.ts index 7d8ef98b..b7e3a64e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,4 @@ import cluster from 'cluster' -import dotenv from 'dotenv' -dotenv.config() import { appFactory } from './factories/app-factory' import { maintenanceWorkerFactory } from './factories/maintenance-worker-factory' diff --git a/src/routes/index.ts b/src/routes/index.ts index c4c2460b..60a317db 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,4 +1,3 @@ -import accepts from 'accepts' import express from 'express' import { nodeinfo21Handler, nodeinfoHandler } from '../handlers/request-handlers/nodeinfo-handler' @@ -14,7 +13,7 @@ import { rootRequestHandler } from '../handlers/request-handlers/root-request-ha const router = express.Router() router.use((req, res, next) => { - if (req.method === 'GET' && accepts(req).type(['application/nostr+json'])) { + if (req.method === 'GET' && req.headers.accept?.includes('application/nostr+json')) { return rootRequestHandler(req, res, next) } next() diff --git a/src/scripts/export-events.ts b/src/scripts/export-events.ts index 9246473a..62df4362 100644 --- a/src/scripts/export-events.ts +++ b/src/scripts/export-events.ts @@ -1,6 +1,4 @@ import 'pg-query-stream' -import dotenv from 'dotenv' -dotenv.config() import fs from 'fs' import path from 'path' diff --git a/src/tor/client.ts b/src/tor/client.ts index b3fd89e1..4641b80a 100644 --- a/src/tor/client.ts +++ b/src/tor/client.ts @@ -1,7 +1,7 @@ +import net from 'net' import { readFile, writeFile } from 'fs/promises' import { homedir } from 'os' import { join } from 'path' -import { Tor } from 'tor-control-ts' import { createLogger } from '../factories/logger-factory' import { TorConfig } from '../@types/tor' @@ -24,7 +24,75 @@ export const createTorConfig = (): TorConfig => { } } -let client: Tor | undefined +type OnionResult = { ServiceID?: string; PrivateKey?: string } + +export class TorClient { + private socket: net.Socket | undefined + private readonly host: string + private readonly port: number + private readonly password: string + + constructor({ host, port, password }: { host?: string; port?: number; password?: string } = {}) { + this.host = host ?? 'localhost' + this.port = port ?? 9051 + this.password = password ?? '' + } + + connect(): Promise { + return new Promise((resolve, reject) => { + this.socket = net.connect({ host: this.host, port: this.port }) + this.socket.once('error', reject) + this.socket.once('data', (data) => { + if (/^250/.test(data.toString())) { resolve() } + else { reject(new Error(`Tor auth failed: ${data}`)) } + }) + this.socket.write(`AUTHENTICATE "${this.password}"\r\n`) + }) + } + + private sendCommand(command: string): Promise { + return new Promise((resolve, reject) => { + if (!this.socket) { + reject(new Error('Not connected to Tor control port')) + return + } + let buf = '' + const onData = (data: Buffer) => { + buf += data.toString() + if (buf.endsWith('\r\n')) { + this.socket!.off('data', onData) + if (/^250/.test(buf)) { resolve(buf) } + else { reject(new Error(buf.trim())) } + } + } + this.socket.on('data', onData) + this.socket.write(`${command}\r\n`) + }) + } + + async addOnion(port: number, host?: string, privateKey?: string | null): Promise { + const key = privateKey ?? 'NEW:BEST' + const portSpec = host !== undefined ? `${port},${host}:${port}` : `${port}` + const response = await this.sendCommand(`ADD_ONION ${key} Port=${portSpec}`) + + const result: OnionResult = {} + for (const line of response.split('\r\n')) { + const m = line.match(/^250[-\s](\w+)=(.+)$/) + if (m) { (result as Record)[m[1]] = m[2] } + } + if (result.ServiceID) { result.ServiceID += '.onion' } + if (!result.PrivateKey && privateKey) { result.PrivateKey = privateKey } + return result + } + + async quit(): Promise { + await this.sendCommand('QUIT').catch(() => undefined) + this.socket?.destroy() + this.socket = undefined + } +} + +let client: TorClient | undefined export const getTorClient = async () => { if (!client) { @@ -33,7 +101,7 @@ export const getTorClient = async () => { if (config.host !== undefined) { debug('connecting') - client = new Tor(config) + client = new TorClient(config) try{ await client.connect() }catch(_error){ @@ -81,7 +149,7 @@ export const addOnion = async ( debug('saving private key to %s', path) await writeFile(path, hiddenService.PrivateKey, 'utf8') - return hiddenService.ServiceID + return hiddenService.ServiceID! }else{ throw new Error(JSON.stringify(hiddenService)) } diff --git a/src/utils/transform.ts b/src/utils/transform.ts index 9c9d01b2..c2deea33 100644 --- a/src/utils/transform.ts +++ b/src/utils/transform.ts @@ -1,9 +1,83 @@ import { always, applySpec, cond, equals, ifElse, is, isNil, multiply, path, pathSatisfies, pipe, prop, propSatisfies, T } from 'ramda' -import { bech32 } from 'bech32' import { Invoice, InvoiceStatus, InvoiceUnit } from '../@types/invoice' import { User } from '../@types/user' +const BECH32_ALPHABET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' +const BECH32_ALPHABET_MAP: Record = {} +for (let i = 0; i < BECH32_ALPHABET.length; i++) { BECH32_ALPHABET_MAP[BECH32_ALPHABET[i]] = i } + +function bech32PolymodStep(pre: number): number { + const b = pre >> 25 + return (((pre & 0x1ffffff) << 5) ^ + (-((b >> 0) & 1) & 0x3b6a57b2) ^ + (-((b >> 1) & 1) & 0x26508e6d) ^ + (-((b >> 2) & 1) & 0x1ea119fa) ^ + (-((b >> 3) & 1) & 0x3d4233dd) ^ + (-((b >> 4) & 1) & 0x2a1462b3)) +} + +function bech32PrefixChk(prefix: string): number { + let chk = 1 + for (let i = 0; i < prefix.length; ++i) { + const c = prefix.charCodeAt(i) + chk = bech32PolymodStep(chk) ^ (c >> 5) + } + chk = bech32PolymodStep(chk) + for (let i = 0; i < prefix.length; ++i) { + chk = bech32PolymodStep(chk) ^ (prefix.charCodeAt(i) & 0x1f) + } + return chk +} + +function bech32Convert(data: number[], inBits: number, outBits: number, pad: boolean): number[] { + let value = 0, bits = 0 + const maxV = (1 << outBits) - 1 + const result: number[] = [] + for (const byte of data) { + value = (value << inBits) | byte + bits += inBits + while (bits >= outBits) { + bits -= outBits + result.push((value >> bits) & maxV) + } + } + if (pad && bits > 0) { result.push((value << (outBits - bits)) & maxV) } + return result +} + +function bech32Decode(str: string): { prefix: string; words: number[] } { + const lower = str.toLowerCase() + const split = lower.lastIndexOf('1') + if (split < 1 || split + 7 > str.length) { throw new Error(`Invalid bech32: ${str}`) } + const prefix = lower.slice(0, split) + const wordChars = lower.slice(split + 1) + let chk = bech32PrefixChk(prefix) + const words: number[] = [] + for (let i = 0; i < wordChars.length; ++i) { + const v = BECH32_ALPHABET_MAP[wordChars[i]] + if (v === undefined) { throw new Error(`Unknown bech32 character: ${wordChars[i]}`) } + chk = bech32PolymodStep(chk) ^ v + if (i + 6 < wordChars.length) { words.push(v) } + } + if (chk !== 1) { throw new Error('Invalid bech32 checksum') } + return { prefix, words } +} + +function bech32Encode(prefix: string, words: number[]): string { + prefix = prefix.toLowerCase() + let chk = bech32PrefixChk(prefix) + let result = prefix + '1' + for (const w of words) { + chk = bech32PolymodStep(chk) ^ w + result += BECH32_ALPHABET[w] + } + for (let i = 0; i < 6; ++i) { chk = bech32PolymodStep(chk) } + chk ^= 1 + for (let i = 0; i < 6; ++i) { result += BECH32_ALPHABET[(chk >> ((5 - i) * 5)) & 0x1f] } + return result +} + export const toJSON = (input: any) => JSON.stringify(input) export const toBuffer = (input: any) => Buffer.from(input, 'hex') @@ -46,18 +120,18 @@ export const fromDBUser = applySpec({ }) export const fromBech32 = (input: string) => { - const { prefix, words } = bech32.decode(input) + const { prefix, words } = bech32Decode(input) if (!input.startsWith(prefix)) { throw new Error(`Bech32 invalid prefix: ${prefix}`) } return Buffer.from( - bech32.fromWords(words).slice(0, 32) + bech32Convert(words, 5, 8, false).slice(0, 32) ).toString('hex') } export const toBech32 = (prefix: string) => (input: string): string => { - return bech32.encode(prefix, bech32.toWords(Buffer.from(input, 'hex'))) + return bech32Encode(prefix, bech32Convert(Array.from(Buffer.from(input, 'hex')), 8, 5, true)) } export const toDate = (input: string | number) => new Date(input) diff --git a/test/unit/tor/onion.spec.ts b/test/unit/tor/onion.spec.ts index f37310ca..fc008947 100644 --- a/test/unit/tor/onion.spec.ts +++ b/test/unit/tor/onion.spec.ts @@ -1,73 +1,50 @@ -import { addOnion, closeTorClient, createTorConfig, getTorClient } from '../../../src/tor/client' -import { hiddenService, Tor } from 'tor-control-ts' +import { addOnion, closeTorClient, createTorConfig, getTorClient, TorClient } from '../../../src/tor/client' import { expect } from 'chai' import fs from 'fs/promises' import { hostname } from 'os' import Sinon from 'sinon' -export function mockModule - ( - moduleToMock: T, - defaultMockValuesForMock: Partial<{ [K in keyof T]: T[K] }> - ) { - return (sandbox: Sinon.SinonSandbox, returnOverrides?: Partial<{ [K in keyof T]: T[K] }>): void => { - console.log('mockModule func') - const functions = Object.keys(moduleToMock) - const returns = returnOverrides || {} - console.log('mockModule func',functions) - functions.forEach((f) => { - console.log('f: '+f) - sandbox.stub(moduleToMock, f).callsFake(returns[f] || defaultMockValuesForMock[f]) - }) - } -} - describe('onion',()=>{ - Tor.prototype.connect = async function(){ - if(this ===undefined){ - throw new Error() - } - const opts = (this as Tor)['opts'] as { - host:string; - port:number; - password:string; - } - if(opts.host == hostname() && opts.port == 9051 && opts.password === 'nostr_ts_relay'){ + TorClient.prototype.connect = async function(){ + const h = (this as any).host as string + const p = (this as any).port as number + const pw = (this as any).password as string + if(h == hostname() && p == 9051 && pw === 'nostr_ts_relay'){ return }else{ - throw new Error() + throw new Error('Connection refused') } } - Tor.prototype.quit = async function () { + TorClient.prototype.quit = async function () { return } - Tor.prototype.addOnion = async function (port, host, privateKey) { - privateKey + TorClient.prototype.addOnion = async function (port: number, host?: string, privateKey?: string | null) { + void privateKey if(host){ const validHost = /[a-zA-Z]+(:[0-9]+)?/.test(host) if(validHost){ - return {host,port,ServiceID:'pubKey',PrivateKey:'privKey'} as hiddenService + return {ServiceID:'pubKey',PrivateKey:'privKey'} }else{ - return {host,port,ServiceID:undefined,PrivateKey:undefined} as hiddenService + return {ServiceID:undefined,PrivateKey:undefined} } }else{ - return {host,port,ServiceID:'pubKey',PrivateKey:'privKey'} as hiddenService + return {ServiceID:'pubKey',PrivateKey:'privKey'} } } let sandbox: Sinon.SinonSandbox const mock = function(sandbox:Sinon.SinonSandbox,readFail?:boolean,writeFail?:boolean){ sandbox.stub(fs,'readFile').callsFake(async (path,options) => { - path - options + void path + void options if(readFail){ throw new Error() } return 'privKey' }) sandbox.stub(fs,'writeFile').callsFake(async (path,options) =>{ - path - options + void path + void options if(writeFail){ throw new Error() } @@ -95,12 +72,11 @@ describe('onion',()=>{ expect(config).to.include({host: 'localhost', port: 9051,password: 'test' }) }) it('tor connect fail',async ()=>{ - //mockTor(sandbox) process.env.TOR_HOST = 'localhost' process.env.TOR_CONTROL_PORT = '9051' process.env.TOR_PASSWORD = 'nostr_ts_relay' - let client:Tor = undefined + let client:TorClient | undefined = undefined try{ client = await getTorClient() closeTorClient() @@ -109,11 +85,10 @@ describe('onion',()=>{ expect(client).be.undefined }) it('tor connect success',async ()=>{ - //mockTor(sandbox) process.env.TOR_HOST = hostname() process.env.TOR_CONTROL_PORT = '9051' process.env.TOR_PASSWORD = 'nostr_ts_relay' - let client:Tor = undefined + let client:TorClient | undefined = undefined try{ client = await getTorClient() closeTorClient() @@ -122,7 +97,6 @@ describe('onion',()=>{ expect(client).be.not.undefined }) it('add onion connect fail',async ()=>{ - //mockTor(sandbox) mock(sandbox) process.env.TOR_HOST = 'localhost' process.env.TOR_CONTROL_PORT = '9051' @@ -132,14 +106,12 @@ describe('onion',()=>{ try { domain = await addOnion(80) closeTorClient() - //domain = undefined } catch (_error) { - domain + void _error } expect(domain).be.undefined }) it('add onion fail',async ()=>{ - //mockTor(sandbox) mock(sandbox) process.env.TOR_HOST = hostname() process.env.TOR_CONTROL_PORT = '9051' @@ -151,12 +123,11 @@ describe('onion',()=>{ domain = await addOnion(80,'}') closeTorClient() } catch (_error) { - domain + void _error } expect(domain).be.undefined }) it('add onion write fail',async ()=>{ - //mockTor(sandbox) mock(sandbox,false,true) process.env.TOR_HOST = hostname() process.env.TOR_CONTROL_PORT = '9051' @@ -166,9 +137,8 @@ describe('onion',()=>{ try { domain = await addOnion(80) closeTorClient() - //domain = undefined } catch (_error) { - domain + void _error } console.log('domain: '+domain) expect(domain).be.undefined @@ -184,7 +154,7 @@ describe('onion',()=>{ domain = await addOnion(80) closeTorClient() } catch (_error) { - domain + void _error } console.log('domain: '+domain) expect(domain).be.not.undefined @@ -200,7 +170,7 @@ describe('onion',()=>{ domain = await addOnion(80) closeTorClient() } catch (_error) { - domain + void _error } console.log('domain: '+domain) expect(domain).be.not.undefined From cff98da4ab69d68f6ac30dcd1946687fe362d9e0 Mon Sep 17 00:00:00 2001 From: phoenix-server Date: Sat, 18 Apr 2026 13:48:56 -0400 Subject: [PATCH 2/5] revert: restore accepts library, removing inline header check Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 67 ++++++++----------- package.json | 4 +- .../request-handlers/root-request-handler.ts | 3 +- src/routes/index.ts | 3 +- 4 files changed, 36 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 708e41a7..2912cb90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,20 +10,17 @@ "license": "MIT", "dependencies": { "@noble/secp256k1": "1.7.1", + "@types/accepts": "^1.3.7", "accepts": "^1.3.8", "axios": "^1.15.0", - "bech32": "2.0.0", "debug": "4.3.4", - "dotenv": "16.0.3", "express": "4.22.1", - "helmet": "6.0.1", "js-yaml": "4.1.1", "knex": "2.4.2", "pg": "8.9.0", "pg-query-stream": "4.3.0", "ramda": "0.28.0", "redis": "4.5.1", - "tor-control-ts": "^1.0.0", "ws": "^8.18.0", "zod": "^3.22.4" }, @@ -142,6 +139,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1156,6 +1154,7 @@ "integrity": "sha512-Kxap9uP5jD8tHUZVjTWgzxemi/0uOsbGjd4LBOSxcJoOCRbESFwemUzilJuzNTB8pcTQUh8D5oudUyxfkJOKmA==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@cucumber/messages": ">=17.1.1" } @@ -1578,6 +1577,7 @@ "integrity": "sha512-rYKilwgzQ7/imScn3M9/pFfUf4I1AZEH3KhyJmtPdE2zfaXAn2mFfUy4FbKewzc2We5y/LlKLj36fWJLKC2SIQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^3.0.0", "@octokit/graphql": "^5.0.0", @@ -1854,6 +1854,7 @@ "integrity": "sha512-YfcB2QrX+Wx1o6LD1G2Y2fhDhOix/bAY/oAnMpHoNLsKkWIRbt1oKLkIFvxBMzLwAEPqnYWguJrYC+J6i4ywbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "bole": "^5.0.0", "ndjson": "^2.0.0" @@ -2053,6 +2054,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.4.2.tgz", "integrity": "sha512-oUdEjE0I7JS5AyaAjkD3aOXn9NhO7XKyPyXEyrgFDu++VrVBHUPnV6dgEya9TcMuj5nIJRuCzCm8ZP+c9zCHPw==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.1", "generic-pool": "3.9.0", @@ -2840,6 +2842,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -2963,8 +2974,8 @@ "version": "24.12.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", - "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3621,12 +3632,6 @@ "node": ">=6.0.0" } }, - "node_modules/bech32": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", - "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==", - "license": "MIT" - }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", @@ -3781,6 +3786,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4010,6 +4016,7 @@ "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", @@ -4755,6 +4762,7 @@ "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", @@ -5212,15 +5220,6 @@ "node": ">=8" } }, - "node_modules/dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6736,15 +6735,6 @@ "he": "bin/he" } }, - "node_modules/helmet": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-6.0.1.tgz", - "integrity": "sha512-8wo+VdQhTMVBMCITYZaGTbE4lvlthelPYSvoyNvk4RECTmrVjMerp9RfUOQXZWLvCcAn1pKj7ZRxK4lI9Alrcw==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -8411,6 +8401,7 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -11101,6 +11092,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12056,6 +12048,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.9.0.tgz", "integrity": "sha512-ZJM+qkEbtOHRuXjmvBtOgNOXOtLSbxiMiUVMgE4rV6Zwocy03RicCVvDXgx8l4Biwo8/qORUnEqn2fdQzV7KCg==", "license": "MIT", + "peer": true, "dependencies": { "buffer-writer": "2.0.0", "packet-reader": "1.0.0", @@ -13077,8 +13070,7 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "dev": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/regexp-match-indices": { "version": "1.0.2", @@ -13486,6 +13478,7 @@ "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -13591,6 +13584,7 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -15789,6 +15783,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15872,12 +15867,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tor-control-ts": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tor-control-ts/-/tor-control-ts-1.0.0.tgz", - "integrity": "sha512-uV+swAIQuH0QP+SJcQwlj2xrv0XqKa9V1HQOkU+NONR7/8+JM/4uIxzDJDsVtiK6cNq+x5Nt6deh3nx16XK1Yg==", - "license": "MIT" - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -15945,6 +15934,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -16227,6 +16217,7 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16263,7 +16254,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/unescape-js": { @@ -17053,6 +17043,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index d87975ea..5b418f78 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,8 @@ }, "dependencies": { "@noble/secp256k1": "1.7.1", + "@types/accepts": "^1.3.7", + "accepts": "^1.3.8", "axios": "^1.15.0", "debug": "4.3.4", "express": "4.22.1", @@ -145,4 +147,4 @@ "overrides": { "axios@<0.31.0": ">=0.31.0" } -} \ No newline at end of file +} diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index c9e01575..674d588c 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -1,3 +1,4 @@ +import accepts from 'accepts' import { NextFunction, Request, Response } from 'express' import { path, pathEq } from 'ramda' import { createSettings } from '../../factories/settings-factory' @@ -10,7 +11,7 @@ import packageJson from '../../../package.json' export const rootRequestHandler = (request: Request, response: Response, next: NextFunction) => { const settings = createSettings() - if (request.headers.accept?.includes('application/nostr+json')) { + if (accepts(request).type(['application/nostr+json'])) { const { info: { name, description, pubkey: rawPubkey, contact, relay_url }, } = settings diff --git a/src/routes/index.ts b/src/routes/index.ts index 60a317db..c4c2460b 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,3 +1,4 @@ +import accepts from 'accepts' import express from 'express' import { nodeinfo21Handler, nodeinfoHandler } from '../handlers/request-handlers/nodeinfo-handler' @@ -13,7 +14,7 @@ import { rootRequestHandler } from '../handlers/request-handlers/root-request-ha const router = express.Router() router.use((req, res, next) => { - if (req.method === 'GET' && req.headers.accept?.includes('application/nostr+json')) { + if (req.method === 'GET' && accepts(req).type(['application/nostr+json'])) { return rootRequestHandler(req, res, next) } next() From 353cacaffe84dd288b55e5cd4c9e0231d5d20027 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:49:31 +0000 Subject: [PATCH 3/5] chore: merge main into chore/inline-dependencies, resolve conflicts --- package-lock.json | 21 +-- package.json | 2 +- src/tor/client.ts | 20 +-- src/utils/transform.ts | 66 ++++---- test/unit/tor/onion.spec.ts | 318 ++++++++++++++++++------------------ 5 files changed, 196 insertions(+), 231 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2912cb90..1ad055e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -139,7 +139,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1154,7 +1153,6 @@ "integrity": "sha512-Kxap9uP5jD8tHUZVjTWgzxemi/0uOsbGjd4LBOSxcJoOCRbESFwemUzilJuzNTB8pcTQUh8D5oudUyxfkJOKmA==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@cucumber/messages": ">=17.1.1" } @@ -1577,7 +1575,6 @@ "integrity": "sha512-rYKilwgzQ7/imScn3M9/pFfUf4I1AZEH3KhyJmtPdE2zfaXAn2mFfUy4FbKewzc2We5y/LlKLj36fWJLKC2SIQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^3.0.0", "@octokit/graphql": "^5.0.0", @@ -1854,7 +1851,6 @@ "integrity": "sha512-YfcB2QrX+Wx1o6LD1G2Y2fhDhOix/bAY/oAnMpHoNLsKkWIRbt1oKLkIFvxBMzLwAEPqnYWguJrYC+J6i4ywbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "bole": "^5.0.0", "ndjson": "^2.0.0" @@ -2054,7 +2050,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.4.2.tgz", "integrity": "sha512-oUdEjE0I7JS5AyaAjkD3aOXn9NhO7XKyPyXEyrgFDu++VrVBHUPnV6dgEya9TcMuj5nIJRuCzCm8ZP+c9zCHPw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.1", "generic-pool": "3.9.0", @@ -2975,7 +2970,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3786,7 +3780,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4016,7 +4009,6 @@ "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", @@ -4762,7 +4754,6 @@ "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", @@ -8401,7 +8392,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -11092,7 +11082,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12048,7 +12037,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.9.0.tgz", "integrity": "sha512-ZJM+qkEbtOHRuXjmvBtOgNOXOtLSbxiMiUVMgE4rV6Zwocy03RicCVvDXgx8l4Biwo8/qORUnEqn2fdQzV7KCg==", "license": "MIT", - "peer": true, "dependencies": { "buffer-writer": "2.0.0", "packet-reader": "1.0.0", @@ -13070,7 +13058,8 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/regexp-match-indices": { "version": "1.0.2", @@ -13478,7 +13467,6 @@ "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -13584,7 +13572,6 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -15783,7 +15770,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15934,7 +15920,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -16217,7 +16202,6 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17043,7 +17027,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 5b418f78..08c1f199 100644 --- a/package.json +++ b/package.json @@ -147,4 +147,4 @@ "overrides": { "axios@<0.31.0": ">=0.31.0" } -} +} \ No newline at end of file diff --git a/src/tor/client.ts b/src/tor/client.ts index 4641b80a..cad3c7d4 100644 --- a/src/tor/client.ts +++ b/src/tor/client.ts @@ -6,14 +6,10 @@ import { join } from 'path' import { createLogger } from '../factories/logger-factory' import { TorConfig } from '../@types/tor' - const debug = createLogger('tor-client') const getPrivateKeyFile = () => { - return join( - process.env.NOSTR_CONFIG_DIR ?? join(homedir(), '.nostr'), - 'v3_onion_private_key' - ) + return join(process.env.NOSTR_CONFIG_DIR ?? join(homedir(), '.nostr'), 'v3_onion_private_key') } export const createTorConfig = (): TorConfig => { @@ -102,9 +98,9 @@ export const getTorClient = async () => { if (config.host !== undefined) { debug('connecting') client = new TorClient(config) - try{ + try { await client.connect() - }catch(_error){ + } catch (_error) { client = undefined } debug('connected') @@ -115,16 +111,12 @@ export const getTorClient = async () => { } export const closeTorClient = async () => { if (client) { - await client.quit() client = undefined } } -export const addOnion = async ( - port: number, - host?: string -): Promise => { +export const addOnion = async (port: number, host?: string): Promise => { let privateKey = null const path = getPrivateKeyFile() @@ -150,10 +142,10 @@ export const addOnion = async ( await writeFile(path, hiddenService.PrivateKey, 'utf8') return hiddenService.ServiceID! - }else{ + } else { throw new Error(JSON.stringify(hiddenService)) } - }else{ + } else { throw new Error('not connect') } } diff --git a/src/utils/transform.ts b/src/utils/transform.ts index c2deea33..2d3c4eb2 100644 --- a/src/utils/transform.ts +++ b/src/utils/transform.ts @@ -1,4 +1,19 @@ -import { always, applySpec, cond, equals, ifElse, is, isNil, multiply, path, pathSatisfies, pipe, prop, propSatisfies, T } from 'ramda' +import { + always, + applySpec, + cond, + equals, + ifElse, + is, + isNil, + multiply, + path, + pathSatisfies, + pipe, + prop, + propSatisfies, + T, +} from 'ramda' import { Invoice, InvoiceStatus, InvoiceUnit } from '../@types/invoice' import { User } from '../@types/user' @@ -144,21 +159,9 @@ export const fromZebedeeInvoice = applySpec({ description: prop('description'), unit: prop('unit'), status: prop('status'), - expiresAt: ifElse( - propSatisfies(is(String), 'expiresAt'), - pipe(prop('expiresAt'), toDate), - always(null), - ), - confirmedAt: ifElse( - propSatisfies(is(String), 'confirmedAt'), - pipe(prop('confirmedAt'), toDate), - always(null), - ), - createdAt: ifElse( - propSatisfies(is(String), 'createdAt'), - pipe(prop('createdAt'), toDate), - always(null), - ), + expiresAt: ifElse(propSatisfies(is(String), 'expiresAt'), pipe(prop('expiresAt'), toDate), always(null)), + confirmedAt: ifElse(propSatisfies(is(String), 'confirmedAt'), pipe(prop('confirmedAt'), toDate), always(null)), + createdAt: ifElse(propSatisfies(is(String), 'createdAt'), pipe(prop('createdAt'), toDate), always(null)), rawResponse: toJSON, }) @@ -184,21 +187,13 @@ export const fromNodelessInvoice = applySpec({ expiresAt: ifElse( propSatisfies(is(String), 'expiresAt'), pipe(prop('expiresAt'), toDate), - ifElse( - propSatisfies(is(String), 'createdAt'), - pipe(prop('createdAt'), toDate, addTime(15 * 60000)), - always(null), - ), + ifElse(propSatisfies(is(String), 'createdAt'), pipe(prop('createdAt'), toDate, addTime(15 * 60000)), always(null)), ), confirmedAt: cond([ [propSatisfies(is(String), 'paidAt'), pipe(prop('paidAt'), toDate)], [T, always(null)], ]), - createdAt: ifElse( - propSatisfies(is(String), 'createdAt'), - pipe(prop('createdAt'), toDate), - always(null), - ), + createdAt: ifElse(propSatisfies(is(String), 'createdAt'), pipe(prop('createdAt'), toDate), always(null)), // rawResponse: toJSON, }) @@ -208,14 +203,10 @@ export const fromOpenNodeInvoice = applySpec({ bolt11: ifElse( pathSatisfies(is(String), ['lightning_invoice', 'payreq']), path(['lightning_invoice', 'payreq']), - path(['lightning', 'payreq']) + path(['lightning', 'payreq']), ), amountRequested: pipe( - ifElse( - propSatisfies(is(Number), 'amount'), - prop('amount'), - prop('price'), - ) as () => number, + ifElse(propSatisfies(is(Number), 'amount'), prop('amount'), prop('price')) as () => number, toBigInt, ), description: prop('description'), @@ -234,7 +225,10 @@ export const fromOpenNodeInvoice = applySpec({ expiresAt: pipe( cond([ [pathSatisfies(is(String), ['lightning', 'expires_at']), path(['lightning', 'expires_at'])], - [pathSatisfies(is(Number), ['lightning_invoice', 'expires_at']), pipe(path(['lightning_invoice', 'expires_at']), multiply(1000))], + [ + pathSatisfies(is(Number), ['lightning_invoice', 'expires_at']), + pipe(path(['lightning_invoice', 'expires_at']), multiply(1000)), + ], ]), toDate, ), @@ -243,11 +237,7 @@ export const fromOpenNodeInvoice = applySpec({ [T, always(null)], ]), createdAt: pipe( - ifElse( - propSatisfies(is(Number), 'created_at'), - pipe(prop('created_at'), multiply(1000)), - prop('created_at'), - ), + ifElse(propSatisfies(is(Number), 'created_at'), pipe(prop('created_at'), multiply(1000)), prop('created_at')), toDate, ), rawResponse: toJSON, diff --git a/test/unit/tor/onion.spec.ts b/test/unit/tor/onion.spec.ts index fc008947..e3c25234 100644 --- a/test/unit/tor/onion.spec.ts +++ b/test/unit/tor/onion.spec.ts @@ -5,174 +5,174 @@ import fs from 'fs/promises' import { hostname } from 'os' import Sinon from 'sinon' -describe('onion',()=>{ - TorClient.prototype.connect = async function(){ - const h = (this as any).host as string - const p = (this as any).port as number - const pw = (this as any).password as string - if(h == hostname() && p == 9051 && pw === 'nostr_ts_relay'){ - return - }else{ - throw new Error('Connection refused') - } +describe('onion', () => { + TorClient.prototype.connect = async function () { + const h = (this as any).host as string + const p = (this as any).port as number + const pw = (this as any).password as string + if (h == hostname() && p == 9051 && pw === 'nostr_ts_relay') { + return + } else { + throw new Error('Connection refused') } - TorClient.prototype.quit = async function () { - return + } + TorClient.prototype.quit = async function () { + return + } + TorClient.prototype.addOnion = async function (port: number, host?: string, privateKey?: string | null) { + void privateKey + if (host) { + const validHost = /[a-zA-Z]+(:[0-9]+)?/.test(host) + if (validHost) { + return { ServiceID: 'pubKey', PrivateKey: 'privKey' } + } else { + return { ServiceID: undefined, PrivateKey: undefined } + } + } else { + return { ServiceID: 'pubKey', PrivateKey: 'privKey' } } - TorClient.prototype.addOnion = async function (port: number, host?: string, privateKey?: string | null) { - void privateKey - if(host){ - const validHost = /[a-zA-Z]+(:[0-9]+)?/.test(host) - if(validHost){ - return {ServiceID:'pubKey',PrivateKey:'privKey'} - }else{ - return {ServiceID:undefined,PrivateKey:undefined} - } - }else{ - return {ServiceID:'pubKey',PrivateKey:'privKey'} - } - } - let sandbox: Sinon.SinonSandbox - const mock = function(sandbox:Sinon.SinonSandbox,readFail?:boolean,writeFail?:boolean){ - sandbox.stub(fs,'readFile').callsFake(async (path,options) => { - void path - void options - if(readFail){ - throw new Error() - } - return 'privKey' - }) - sandbox.stub(fs,'writeFile').callsFake(async (path,options) =>{ - void path - void options - if(writeFail){ - throw new Error() - } - return - }) - } - - beforeEach(() => { - sandbox = Sinon.createSandbox() + } + let sandbox: Sinon.SinonSandbox + const mock = function (sandbox: Sinon.SinonSandbox, readFail?: boolean, writeFail?: boolean) { + sandbox.stub(fs, 'readFile').callsFake(async (path, options) => { + void path + void options + if (readFail) { + throw new Error() + } + return 'privKey' }) - afterEach(()=>{ - sandbox.restore() + sandbox.stub(fs, 'writeFile').callsFake(async (path, options) => { + void path + void options + if (writeFail) { + throw new Error() + } + return }) + } - it('config empty',()=>{ - const config = createTorConfig() - expect(config).to.include({ port: 9051 }) - }) - it('config set',()=>{ - process.env.TOR_HOST = 'localhost' - process.env.TOR_CONTROL_PORT = '9051' - process.env.TOR_PASSWORD = 'test' - const config = createTorConfig() - // deepcode ignore NoHardcodedPasswords/test: password is part of the test - expect(config).to.include({host: 'localhost', port: 9051,password: 'test' }) - }) - it('tor connect fail',async ()=>{ - process.env.TOR_HOST = 'localhost' - process.env.TOR_CONTROL_PORT = '9051' - process.env.TOR_PASSWORD = 'nostr_ts_relay' + beforeEach(() => { + sandbox = Sinon.createSandbox() + }) + afterEach(() => { + sandbox.restore() + }) - let client:TorClient | undefined = undefined - try{ - client = await getTorClient() - closeTorClient() - }catch(_error){ - } - expect(client).be.undefined - }) - it('tor connect success',async ()=>{ - process.env.TOR_HOST = hostname() - process.env.TOR_CONTROL_PORT = '9051' - process.env.TOR_PASSWORD = 'nostr_ts_relay' - let client:TorClient | undefined = undefined - try{ - client = await getTorClient() - closeTorClient() - }catch(_error){ - } - expect(client).be.not.undefined - }) - it('add onion connect fail',async ()=>{ - mock(sandbox) - process.env.TOR_HOST = 'localhost' - process.env.TOR_CONTROL_PORT = '9051' - process.env.TOR_PASSWORD = 'nostr_ts_relay' + it('config empty', () => { + const config = createTorConfig() + expect(config).to.include({ port: 9051 }) + }) + it('config set', () => { + process.env.TOR_HOST = 'localhost' + process.env.TOR_CONTROL_PORT = '9051' + process.env.TOR_PASSWORD = 'test' + const config = createTorConfig() + // deepcode ignore NoHardcodedPasswords/test: password is part of the test + expect(config).to.include({ host: 'localhost', port: 9051, password: 'test' }) + }) + it('tor connect fail', async () => { + process.env.TOR_HOST = 'localhost' + process.env.TOR_CONTROL_PORT = '9051' + process.env.TOR_PASSWORD = 'nostr_ts_relay' - let domain = undefined - try { - domain = await addOnion(80) - closeTorClient() - } catch (_error) { - void _error - } - expect(domain).be.undefined - }) - it('add onion fail',async ()=>{ - mock(sandbox) - process.env.TOR_HOST = hostname() - process.env.TOR_CONTROL_PORT = '9051' - process.env.TOR_PASSWORD = 'nostr_ts_relay' - process.env.NOSTR_CONFIG_DIR = '/home/node' + let client: TorClient | undefined = undefined + try { + client = await getTorClient() + closeTorClient() + } catch (_error) { + } + expect(client).be.undefined + }) + it('tor connect success', async () => { + process.env.TOR_HOST = hostname() + process.env.TOR_CONTROL_PORT = '9051' + process.env.TOR_PASSWORD = 'nostr_ts_relay' + let client: TorClient | undefined = undefined + try { + client = await getTorClient() + closeTorClient() + } catch (_error) { + } + expect(client).be.not.undefined + }) + it('add onion connect fail', async () => { + mock(sandbox) + process.env.TOR_HOST = 'localhost' + process.env.TOR_CONTROL_PORT = '9051' + process.env.TOR_PASSWORD = 'nostr_ts_relay' - let domain = undefined - try { - domain = await addOnion(80,'}') - closeTorClient() - } catch (_error) { - void _error - } - expect(domain).be.undefined - }) - it('add onion write fail',async ()=>{ - mock(sandbox,false,true) - process.env.TOR_HOST = hostname() - process.env.TOR_CONTROL_PORT = '9051' - process.env.TOR_PASSWORD = 'nostr_ts_relay' + let domain = undefined + try { + domain = await addOnion(80) + closeTorClient() + } catch (_error) { + void _error + } + expect(domain).be.undefined + }) + it('add onion fail', async () => { + mock(sandbox) + process.env.TOR_HOST = hostname() + process.env.TOR_CONTROL_PORT = '9051' + process.env.TOR_PASSWORD = 'nostr_ts_relay' + process.env.NOSTR_CONFIG_DIR = '/home/node' - let domain = undefined - try { - domain = await addOnion(80) - closeTorClient() - } catch (_error) { - void _error - } - console.log('domain: '+domain) - expect(domain).be.undefined - }) - it('add onion success read fail',async ()=>{ - mock(sandbox,true) - process.env.TOR_HOST = hostname() - process.env.TOR_CONTROL_PORT = '9051' - process.env.TOR_PASSWORD = 'nostr_ts_relay' + let domain = undefined + try { + domain = await addOnion(80, '}') + closeTorClient() + } catch (_error) { + void _error + } + expect(domain).be.undefined + }) + it('add onion write fail', async () => { + mock(sandbox, false, true) + process.env.TOR_HOST = hostname() + process.env.TOR_CONTROL_PORT = '9051' + process.env.TOR_PASSWORD = 'nostr_ts_relay' - let domain = undefined - try { - domain = await addOnion(80) - closeTorClient() - } catch (_error) { - void _error - } - console.log('domain: '+domain) - expect(domain).be.not.undefined - }) - it('add onion success',async ()=>{ - mock(sandbox) - process.env.TOR_HOST = hostname() - process.env.TOR_CONTROL_PORT = '9051' - process.env.TOR_PASSWORD = 'nostr_ts_relay' + let domain = undefined + try { + domain = await addOnion(80) + closeTorClient() + } catch (_error) { + void _error + } + console.log('domain: ' + domain) + expect(domain).be.undefined + }) + it('add onion success read fail', async () => { + mock(sandbox, true) + process.env.TOR_HOST = hostname() + process.env.TOR_CONTROL_PORT = '9051' + process.env.TOR_PASSWORD = 'nostr_ts_relay' - let domain = undefined - try { - domain = await addOnion(80) - closeTorClient() - } catch (_error) { - void _error - } - console.log('domain: '+domain) - expect(domain).be.not.undefined - }) + let domain = undefined + try { + domain = await addOnion(80) + closeTorClient() + } catch (_error) { + void _error + } + console.log('domain: ' + domain) + expect(domain).be.not.undefined + }) + it('add onion success', async () => { + mock(sandbox) + process.env.TOR_HOST = hostname() + process.env.TOR_CONTROL_PORT = '9051' + process.env.TOR_PASSWORD = 'nostr_ts_relay' + + let domain = undefined + try { + domain = await addOnion(80) + closeTorClient() + } catch (_error) { + void _error + } + console.log('domain: ' + domain) + expect(domain).be.not.undefined + }) }) From 2ed81f5b3fd9b88be67663c3171a6c0efe240caa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 18:43:46 +0000 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20address=20inline=20review=20comments?= =?UTF-8?q?=20=E2=80=94=20multi-line=20Tor=20parsing,=20bech32=20validatio?= =?UTF-8?q?n,=20type=20deps,=20engines,=20await=20closeTorClient,=20TorCli?= =?UTF-8?q?ent=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/cameri/nostream/sessions/f56791f2-7216-4d11-b88a-63b5d2c432e5 Co-authored-by: cameri <378886+cameri@users.noreply.github.com> --- package-lock.json | 8 +- package.json | 5 +- src/tor/client.ts | 78 ++++++++++++-- src/utils/transform.ts | 21 +++- test/unit/tor/client.spec.ts | 202 +++++++++++++++++++++++++++++++++++ test/unit/tor/onion.spec.ts | 14 +-- 6 files changed, 310 insertions(+), 18 deletions(-) create mode 100644 test/unit/tor/client.spec.ts diff --git a/package-lock.json b/package-lock.json index 1ad055e1..7a740892 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "MIT", "dependencies": { "@noble/secp256k1": "1.7.1", - "@types/accepts": "^1.3.7", "accepts": "^1.3.8", "axios": "^1.15.0", "debug": "4.3.4", @@ -35,6 +34,7 @@ "@semantic-release/github": "8.1.0", "@semantic-release/npm": "13.1.5", "@semantic-release/release-notes-generator": "10.0.3", + "@types/accepts": "^1.3.7", "@types/chai": "^4.3.1", "@types/chai-as-promised": "^7.1.5", "@types/debug": "4.1.7", @@ -67,6 +67,9 @@ "ts-node-dev": "^1.1.8", "typescript": "~5.7.3", "uuid": "^8.3.2" + }, + "engines": { + "node": ">=22.9" } }, "node_modules/@actions/core": { @@ -2841,6 +2844,7 @@ "version": "1.3.7", "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", "integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -2969,6 +2973,7 @@ "version": "24.12.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -16238,6 +16243,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, "license": "MIT" }, "node_modules/unescape-js": { diff --git a/package.json b/package.json index 08c1f199..fb929261 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "@semantic-release/github": "8.1.0", "@semantic-release/npm": "13.1.5", "@semantic-release/release-notes-generator": "10.0.3", + "@types/accepts": "^1.3.7", "@types/chai": "^4.3.1", "@types/chai-as-promised": "^7.1.5", "@types/debug": "4.1.7", @@ -123,9 +124,11 @@ "typescript": "~5.7.3", "uuid": "^8.3.2" }, + "engines": { + "node": ">=22.9" + }, "dependencies": { "@noble/secp256k1": "1.7.1", - "@types/accepts": "^1.3.7", "accepts": "^1.3.8", "axios": "^1.15.0", "debug": "4.3.4", diff --git a/src/tor/client.ts b/src/tor/client.ts index cad3c7d4..e1c10355 100644 --- a/src/tor/client.ts +++ b/src/tor/client.ts @@ -46,23 +46,89 @@ export class TorClient { }) } + private isCompleteTorReply(buffer: string): boolean { + if (!buffer.endsWith('\r\n')) { + return false + } + + const lines = buffer.split('\r\n') + if (lines[lines.length - 1] === '') { + lines.pop() + } + + if (lines.length === 0) { + return false + } + + const firstLine = lines[0].match(/^(\d{3})([\s\-+])/) + if (!firstLine) { + return false + } + + const statusCode = firstLine[1] + let inDataBlock = false + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + if (inDataBlock) { + if (line === '.') { + inDataBlock = false + } + continue + } + + const match = line.match(/^(\d{3})([\s\-+])/) + if (!match || match[1] !== statusCode) { + return false + } + + if (match[2] === ' ') { + return i === lines.length - 1 + } + + if (match[2] === '+') { + inDataBlock = true + } + } + + return false + } + private sendCommand(command: string): Promise { return new Promise((resolve, reject) => { if (!this.socket) { reject(new Error('Not connected to Tor control port')) return } + + const socket = this.socket let buf = '' + + const cleanup = () => { + socket.off('data', onData) + socket.off('error', onError) + } + + const onError = (error: Error) => { + cleanup() + reject(error) + } + const onData = (data: Buffer) => { buf += data.toString() - if (buf.endsWith('\r\n')) { - this.socket!.off('data', onData) - if (/^250/.test(buf)) { resolve(buf) } - else { reject(new Error(buf.trim())) } + if (!this.isCompleteTorReply(buf)) { + return } + + cleanup() + if (/^250/.test(buf)) { resolve(buf) } + else { reject(new Error(buf.trim())) } } - this.socket.on('data', onData) - this.socket.write(`${command}\r\n`) + + socket.on('data', onData) + socket.on('error', onError) + socket.write(`${command}\r\n`) }) } diff --git a/src/utils/transform.ts b/src/utils/transform.ts index 2d3c4eb2..da3cfc5e 100644 --- a/src/utils/transform.ts +++ b/src/utils/transform.ts @@ -48,16 +48,25 @@ function bech32PrefixChk(prefix: string): number { function bech32Convert(data: number[], inBits: number, outBits: number, pad: boolean): number[] { let value = 0, bits = 0 const maxV = (1 << outBits) - 1 + const maxAcc = (1 << (inBits + outBits - 1)) - 1 + const maxInput = (1 << inBits) - 1 const result: number[] = [] for (const byte of data) { - value = (value << inBits) | byte + if (!Number.isInteger(byte) || byte < 0 || byte > maxInput) { + throw new Error(`Invalid value for ${inBits}-bit input: ${byte}`) + } + value = ((value << inBits) | byte) & maxAcc bits += inBits while (bits >= outBits) { bits -= outBits result.push((value >> bits) & maxV) } } - if (pad && bits > 0) { result.push((value << (outBits - bits)) & maxV) } + if (pad) { + if (bits > 0) { result.push((value << (outBits - bits)) & maxV) } + } else if (bits >= inBits || ((value << (outBits - bits)) & maxV) !== 0) { + throw new Error('Invalid bech32 padding') + } return result } @@ -135,8 +144,14 @@ export const fromDBUser = applySpec({ }) export const fromBech32 = (input: string) => { + const normalizedInput = input.toLowerCase() + + if (input !== normalizedInput && input !== input.toUpperCase()) { + throw new Error('Bech32 mixed-case input is invalid') + } + const { prefix, words } = bech32Decode(input) - if (!input.startsWith(prefix)) { + if (!normalizedInput.startsWith(prefix)) { throw new Error(`Bech32 invalid prefix: ${prefix}`) } diff --git a/test/unit/tor/client.spec.ts b/test/unit/tor/client.spec.ts new file mode 100644 index 00000000..e673190d --- /dev/null +++ b/test/unit/tor/client.spec.ts @@ -0,0 +1,202 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import { EventEmitter } from 'events' +import net from 'net' +import Sinon from 'sinon' + +chai.use(chaiAsPromised) + +const { expect } = chai + +import { TorClient } from '../../../src/tor/client' + +// Capture real implementations before onion.spec.ts (loaded later) overrides the prototype +const realConnect = TorClient.prototype.connect + +type MockSocket = EventEmitter & { write: Sinon.SinonStub; destroy: Sinon.SinonStub } + +function createMockSocket(): MockSocket { + return Object.assign(new EventEmitter(), { + write: Sinon.stub(), + destroy: Sinon.stub(), + }) +} + +describe('TorClient', () => { + let sandbox: Sinon.SinonSandbox + + beforeEach(() => { + sandbox = Sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('isCompleteTorReply', () => { + let client: TorClient + let isComplete: (buf: string) => boolean + + beforeEach(() => { + client = new TorClient() + isComplete = (buf: string) => (client as any).isCompleteTorReply(buf) + }) + + it('returns false for empty string', () => { + expect(isComplete('')).to.be.false + }) + + it('returns false for partial reply without trailing CRLF', () => { + expect(isComplete('250 OK')).to.be.false + }) + + it('returns true for a complete single-line 250 OK reply', () => { + expect(isComplete('250 OK\r\n')).to.be.true + }) + + it('returns true for a complete single-line error reply', () => { + expect(isComplete('551 Error\r\n')).to.be.true + }) + + it('returns true for a complete multi-line reply ending with 250 OK', () => { + expect(isComplete('250-ServiceID=abc\r\n250 OK\r\n')).to.be.true + }) + + it('returns true for a multi-line ADD_ONION reply with PrivateKey', () => { + const response = '250-ServiceID=abcdefghij\r\n250-PrivateKey=RSA:xxx\r\n250 OK\r\n' + expect(isComplete(response)).to.be.true + }) + + it('returns false for incomplete multi-line reply (continuation only)', () => { + expect(isComplete('250-ServiceID=abc\r\n')).to.be.false + }) + + it('returns false for buffer ending mid-line', () => { + expect(isComplete('250-ServiceID=abc\r\n250 OK')).to.be.false + }) + + it('handles data block (250+) terminated by . then final 250 OK', () => { + const response = '250+data=\r\nsome content\r\n.\r\n250 OK\r\n' + expect(isComplete(response)).to.be.true + }) + + it('returns false for data block without terminating 250 OK', () => { + expect(isComplete('250+data=\r\nsome content\r\n.\r\n')).to.be.false + }) + }) + + describe('sendCommand', () => { + let client: TorClient + let mockSocket: MockSocket + + beforeEach(() => { + client = new TorClient() + mockSocket = createMockSocket() + ;(client as any).socket = mockSocket + }) + + it('resolves with full response on successful single-line 250 reply', async () => { + const p = (client as any).sendCommand('GETINFO version') + mockSocket.emit('data', Buffer.from('250 version=0.4\r\n')) + const result = await p + expect(result).to.equal('250 version=0.4\r\n') + }) + + it('rejects when socket is not connected', async () => { + ;(client as any).socket = undefined + await expect((client as any).sendCommand('GETINFO version')).to.be.rejectedWith('Not connected') + }) + + it('rejects on non-250 reply', async () => { + const p = (client as any).sendCommand('GETINFO unknown') + mockSocket.emit('data', Buffer.from('552 Unrecognized option\r\n')) + await expect(p).to.be.rejectedWith('552') + }) + + it('rejects on socket error during command', async () => { + const p = (client as any).sendCommand('GETINFO version') + mockSocket.emit('error', new Error('Connection reset')) + await expect(p).to.be.rejectedWith('Connection reset') + }) + + it('buffers fragmented TCP chunks until multi-line reply is complete', async () => { + const p = (client as any).sendCommand('ADD_ONION NEW:BEST Port=80') + mockSocket.emit('data', Buffer.from('250-ServiceID=abc\r\n')) + // not yet complete — continuation line only + mockSocket.emit('data', Buffer.from('250 PrivateKey=RSA:xxx\r\n')) + const result = await p + expect(result).to.include('ServiceID=abc') + expect(result).to.include('PrivateKey=RSA:xxx') + }) + + it('writes the command with CRLF to the socket', async () => { + const p = (client as any).sendCommand('QUIT') + mockSocket.emit('data', Buffer.from('250 OK\r\n')) + await p + expect(mockSocket.write.calledOnceWith('QUIT\r\n')).to.be.true + }) + + it('removes data and error listeners after resolution', async () => { + const p = (client as any).sendCommand('GETINFO version') + mockSocket.emit('data', Buffer.from('250 version=0.4\r\n')) + await p + expect(mockSocket.listenerCount('data')).to.equal(0) + expect(mockSocket.listenerCount('error')).to.equal(0) + }) + + it('removes data and error listeners after rejection', async () => { + const p = (client as any).sendCommand('GETINFO unknown') + mockSocket.emit('data', Buffer.from('552 Unrecognized option\r\n')) + await p.catch(() => undefined) + expect(mockSocket.listenerCount('data')).to.equal(0) + expect(mockSocket.listenerCount('error')).to.equal(0) + }) + }) + + describe('connect', () => { + let mockSocket: MockSocket + let savedConnect: typeof TorClient.prototype.connect + + beforeEach(() => { + // Temporarily restore the real connect for these tests (onion.spec.ts overrides the prototype) + savedConnect = TorClient.prototype.connect + TorClient.prototype.connect = realConnect + + mockSocket = createMockSocket() + sandbox.stub(net, 'connect').returns(mockSocket as any) + }) + + afterEach(() => { + TorClient.prototype.connect = savedConnect + }) + + it('resolves when control port responds with 250', async () => { + const client = new TorClient({ host: 'localhost', port: 9051, password: 'test' }) + const p = client.connect() + mockSocket.emit('data', Buffer.from('250 OK\r\n')) + await expect(p).to.be.fulfilled + }) + + it('rejects when control port responds with non-250', async () => { + const client = new TorClient({ host: 'localhost', port: 9051, password: 'wrong' }) + const p = client.connect() + mockSocket.emit('data', Buffer.from('515 Authentication failed\r\n')) + await expect(p).to.be.rejectedWith('Tor auth failed') + }) + + it('rejects on socket error during connect', async () => { + const client = new TorClient({ host: 'localhost', port: 9051, password: 'test' }) + const p = client.connect() + mockSocket.emit('error', new Error('ECONNREFUSED')) + await expect(p).to.be.rejectedWith('ECONNREFUSED') + }) + + it('sends AUTHENTICATE command with the configured password', async () => { + const client = new TorClient({ host: 'localhost', port: 9051, password: 'secret' }) + const p = client.connect() + mockSocket.emit('data', Buffer.from('250 OK\r\n')) + await p + expect(mockSocket.write.calledOnceWith('AUTHENTICATE "secret"\r\n')).to.be.true + }) + }) +}) diff --git a/test/unit/tor/onion.spec.ts b/test/unit/tor/onion.spec.ts index e3c25234..c114f684 100644 --- a/test/unit/tor/onion.spec.ts +++ b/test/unit/tor/onion.spec.ts @@ -79,7 +79,7 @@ describe('onion', () => { let client: TorClient | undefined = undefined try { client = await getTorClient() - closeTorClient() + await closeTorClient() } catch (_error) { } expect(client).be.undefined @@ -91,7 +91,7 @@ describe('onion', () => { let client: TorClient | undefined = undefined try { client = await getTorClient() - closeTorClient() + await closeTorClient() } catch (_error) { } expect(client).be.not.undefined @@ -105,7 +105,7 @@ describe('onion', () => { let domain = undefined try { domain = await addOnion(80) - closeTorClient() + await closeTorClient() } catch (_error) { void _error } @@ -121,7 +121,7 @@ describe('onion', () => { let domain = undefined try { domain = await addOnion(80, '}') - closeTorClient() + await closeTorClient() } catch (_error) { void _error } @@ -136,7 +136,7 @@ describe('onion', () => { let domain = undefined try { domain = await addOnion(80) - closeTorClient() + await closeTorClient() } catch (_error) { void _error } @@ -152,7 +152,7 @@ describe('onion', () => { let domain = undefined try { domain = await addOnion(80) - closeTorClient() + await closeTorClient() } catch (_error) { void _error } @@ -168,7 +168,7 @@ describe('onion', () => { let domain = undefined try { domain = await addOnion(80) - closeTorClient() + await closeTorClient() } catch (_error) { void _error } From 79fe36e56efec9678fbd30b68c21fb8de0644c3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 18:49:21 +0000 Subject: [PATCH 5/5] chore: use node version from .nvmrc in engines field Agent-Logs-Url: https://github.com/cameri/nostream/sessions/40ebf88c-b9ee-415d-85b0-248f84afbb78 Co-authored-by: cameri <378886+cameri@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fb929261..e524b014 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "uuid": "^8.3.2" }, "engines": { - "node": ">=22.9" + "node": ">=24.14.1" }, "dependencies": { "@noble/secp256k1": "1.7.1",