diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index e9afa141..7c5d7390 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -91,19 +91,28 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: 'npm' + + - name: Use canonical npm version + run: corepack prepare npm@9.8.1 --activate - name: Install dependencies run: npm ci + + - name: Verify package-manager policy + run: npm run package-manager:verify - name: Lint run: npm run lint - - name: Run tests + - name: Run routine tests run: npm test + + - name: Run slow security and performance tests + run: npm run test:slow - - name: Run test coverage + - name: Run complete test coverage if: matrix.node-version == '20.x' - run: npm run test:coverage + run: npm run test:coverage:all - name: Build library run: npm run build @@ -141,12 +150,18 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Verify package-manager policy + run: bun run package-manager:verify + - name: Lint run: bun run lint - - name: Run Bun tests + - name: Run Bun routine tests run: bun run test:bun + - name: Run Bun slow security and performance tests + run: bun run test:bun:slow + - name: Build library run: bun run build diff --git a/AGENTS.md b/AGENTS.md index ade17f69..e98e9c90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # Repository Guidelines ## Project Structure & Module Organization + - `src/` — library code by NIP in `src/nipXX/` (e.g., `src/nip46/`). Shared types in `src/types/`, utilities in `src/utils/`. Export surfaces in `src/index.ts` (update when adding modules). Follow `src/NIP_STANDARDIZATION.md` for NIP changes. - `tests/` — Jest tests mirror `src/` by NIP (`tests/nipXX/`) plus focused tests like `tests/utils/*.test.ts`. - `examples/` — runnable TypeScript examples by feature/NIP. @@ -8,35 +9,43 @@ - `scripts/` — helper scripts (e.g., `scripts/promote-to-main.sh`). ## Build, Test, and Development Commands -- `npm install` — install dependencies. + +- `corepack prepare npm@9.8.1 --activate` — activate the canonical npm toolchain. +- `npm ci` — install the canonical dependency graph from `package-lock.json`. +- `npm run package-manager:verify` — verify npm/Bun metadata, lockfiles, and CI policy. - `npm run build` — clean and compile TypeScript to `dist/`. -- `npm test` | `npm run test:watch` | `npm run test:coverage` — run Jest, watch mode, or coverage (reports in `coverage/`). +- `npm test` | `npm run test:watch` | `npm run test:coverage` — run the routine Jest lane, routine watch mode, or routine coverage. +- `npm run test:slow` | `npm run test:all` | `npm run test:coverage:all` — run the named security/performance lane or the complete test/coverage inventory. - `npm run lint` — ESLint (`@typescript-eslint`) over `.ts` sources. - `npm run format` — Prettier 3 for `src/`, `tests/`, `examples/`. - `npm run example` (or `example:*`) | `npm start` — run examples; default is the NIP‑07 example. - `npm run promote` — maintainers: promote `staging` → `main`. ## Coding Style & Naming Conventions + - Language: TypeScript (strict). Prefer explicit types; avoid `any`. - Formatting: Prettier, 2‑space indent; run `npm run format` before pushing. - Linting: `@typescript-eslint`; intentionally unused vars prefixed with `_`. - Naming: `camelCase` for variables/functions; `PascalCase` for types/classes. New NIP code goes under `src/nipXX/` and is exported via `src/index.ts`. ## Testing Guidelines + - Framework: Jest with `ts-jest`, Node environment. - Naming: `*.test.ts` or `*.spec.ts`; mirror the `src/` layout. -- Coverage: keep or improve; use `npm run test:coverage`. +- Coverage: keep or improve; use `npm run test:coverage:all` before review so slow-lane files remain covered. - Use test vectors and the ephemeral relay; never include real credentials. ## Commit & Pull Request Guidelines + - Commits: Conventional Commits (`feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`), imperative and concise. - Branches: create from `staging` using `feature/` or `fix/`; PRs target `staging`. -- PR checklist: clear what/why, linked issues (e.g., `#123`), tests and examples updated if behavior changes. Run `npm run lint && npm test && npm run build` before opening. +- PR checklist: clear what/why, linked issues (e.g., `#123`), tests and examples updated if behavior changes. Run `npm run lint && npm run test:all && npm run build` before opening. ## Security & Configuration Tips + - Do not commit private keys, secrets, or real credentials. - Prefer `.env` files ignored by Git; document required vars in examples or readme. ## Agent‑Specific Notes -- This AGENTS.md applies repo‑wide. A deeper `AGENTS.md` overrides within its folder subtree. Follow the structure and style above when adding files and exports. +- This AGENTS.md applies repo‑wide. A deeper `AGENTS.md` overrides within its folder subtree. Follow the structure and style above when adding files and exports. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e852fe4..f1e1b0e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- A supported `snstr/testing` subpath now owns the Node-only `NostrRelay` test Relay and framework-neutral Relay test-double types without leaking Jest into published application declarations. +- Canonical NIP-01 client and Relay wire-message tuple types now provide one authoritative protocol definition. +- Repository tooling now enforces npm 9.8.1 as the release package manager, Bun as a pinned compatibility runner, and explicit routine, slow, and complete test lanes. + +### Changed +- Relay event storage, Nostr Relay registry management, NIP-47 protocol codecs and dispatch, NIP-46 request machinery, NIP-57 client behavior, and ephemeral Relay transport/session/filter responsibilities now live behind smaller internal modules while preserving their public 0.x facades. +- Production diagnostics now use one compatible logger policy across Nostr, Relay, RelayPool, NIP-46, NIP-47, NIP-57, and stateless protocol helpers. +- Structural regression tests now prefer public behavior and owned testing seams over private implementation shapes. + +### Fixed +- NIP-44 decryption now rejects unsupported legacy payload versions and malformed v2 nonce sizes through the public decrypt path. +- NIP-47 service initialization is idempotent, concurrent-safe, and restartable after disconnect. +- NIP-57 client flows share cache, filter, invoice, and statistics behavior while preserving explicit limit values, including zero. +- Ephemeral Relay shutdown now retains runtime error handling, releases transports when session cleanup rejects, and stops session polling without stale routes. + +### Security +- NIP-46 connection secrets, private keys, decrypted payloads, and untrusted error text are excluded from diagnostic output. +- Generic key, wire-format, and resource-limit validation now has canonical ownership so NIP-specific policies cannot silently drift. + ## [0.5.0] - 2026-07-18 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index f084d23a..ef5dfabb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,9 +32,12 @@ npm run format # Format code with Prettier ### Testing ```bash -npm test # Run all tests -npm run test:watch # Run tests in watch mode -npm run test:coverage # Generate coverage report +npm test # Run the routine feedback lane +npm run test:slow # Run security/performance load tests +npm run test:all # Run the complete routine + slow inventory +npm run test:watch # Run routine tests in watch mode +npm run test:coverage # Generate routine coverage +npm run test:coverage:all # Generate complete coverage for review/CI npm run test:nip01 # Test core NIP-01 functionality npm run test:nip04 # Test specific NIP (replace 04 with any NIP number) npm run test:crypto # Test all crypto functionality diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d8c56485..bc170088 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,8 +16,9 @@ Thank you for your interest in contributing to SNSTR! This document provides gui 1. Fork the repository 2. Clone your fork: `git clone https://github.com/your-username/snstr.git` -3. Install dependencies: `npm install` -4. Build the project: `npm run build` +3. Activate the canonical npm version: `corepack prepare npm@9.8.1 --activate` +4. Install dependencies: `npm ci` +5. Build the project: `npm run build` ## Development Workflow @@ -91,6 +92,21 @@ The CI pipeline runs tests for both `main` and `staging` branches, but releases ## Release Process +### Package-manager policy + +npm 9.8.1 is the canonical package manager for dependency changes, clean +installs, scripts, and releases. Use `npm install` when intentionally changing +dependencies, commit the resulting `package-lock.json`, and use `npm ci` for a +reproducible clean install. Do not add pnpm, Yarn, or shrinkwrap lockfiles. +Activate it with `corepack prepare npm@9.8.1 --activate`, then confirm +`npm --version` prints `9.8.1` before running canonical workflows. + +Bun 1.3.9 is a supported compatibility runner, pinned in `.bun-version`. +When dependencies change, refresh and commit `bun.lock`, then confirm +`bun install --frozen-lockfile` succeeds. Bun does not own releases or the +canonical dependency graph. Run `npm run package-manager:verify` before +submitting dependency or CI changes. + SNSTR releases are currently performed manually from `main` after verified changes are promoted from `staging`. diff --git a/README.md b/README.md index cf6a4a9e..ad1a1084 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ SNSTR is a secure, lightweight TypeScript library for interacting with the Nostr protocol. It provides a simple, easy-to-use API with minimal dependencies. -*SNSTR is fierce. Fierce in its speed, in its flexibility, and most of all its security.* +_SNSTR is fierce. Fierce in its speed, in its flexibility, and most of all its security._ -*SNSTR is steadfast, ever persistent, watching, waiting.* +_SNSTR is steadfast, ever persistent, watching, waiting._ -*SNSTR has vengeance on its mind.* +_SNSTR has vengeance on its mind._ -*SNSTR is a Nostr Development Kit for people that go swimming in jeans* +_SNSTR is a Nostr Development Kit for people that go swimming in jeans_ **⚠️ Important**: This library is in beta testing. While mostly stable, some features may still undergo changes. We encourage users to test thoroughly and report any issues or unexpected behavior. @@ -108,12 +108,12 @@ npm run build - Works out of the box — no Node polyfills are required. - Add secure RNG once at app startup (required by various features): ```ts - import 'react-native-get-random-values'; + import "react-native-get-random-values"; ``` - NIP-04 now works on Web and React Native with the exact same API as Node: ```ts - import { encryptNIP04, decryptNIP04 } from 'snstr'; - const c = encryptNIP04(alicePriv, bobPub, 'hello'); + import { encryptNIP04, decryptNIP04 } from "snstr"; + const c = encryptNIP04(alicePriv, bobPub, "hello"); const p = decryptNIP04(bobPriv, alicePub, c); ``` - Prefer NIP-44 for new apps; keep NIP-04 for legacy compatibility. @@ -134,7 +134,66 @@ Resolution summary: This dual build removes getter-based CJS re-exports from browser bundles and avoids interop issues observed with Turbopack. -## Basic Usage +## Testing utilities + +`NostrRelay` is a Node-only, in-memory relay for integration tests and runnable +examples. Import it from the supported testing subpath: + +```typescript +import { NostrRelay } from "snstr/testing"; + +const relay = new NostrRelay(0); +await relay.start(); +try { + // Exercise a client against relay.url. +} finally { + await relay.close(); +} +``` + +Framework-neutral relay test-double types live at the same boundary, so using +them does not add Jest (or another test runner) to application declarations: + +```typescript +import type { RelayTestContext, RelayTestMock } from "snstr/testing"; +``` + +The testing subpath is supported during the 0.x release line, but it is not a +production relay server and may evolve between minor 0.x releases. The legacy +`snstr/utils/ephemeral-relay` subpath remains available for 0.x compatibility. +`close()` resolves only after owned transports and observable client disconnect +coordination complete; callers should not add fixed teardown delays. + +## Diagnostics + +Production diagnostics use the shared `DiagnosticLogger` contract. Warnings and +errors remain console-visible by default; inject one logger to capture, filter, +or silence diagnostics from a `Nostr`, `Relay`, or `RelayPool` instance and its +child relays: + +```typescript +import { Nostr, RelayPool } from "snstr"; +import type { DiagnosticLogger } from "snstr"; + +const logger: DiagnosticLogger = { + error: (message, ...context) => appLogger.error(message, ...context), + warn: (message, ...context) => appLogger.warn(message, ...context), + info: (message, ...context) => appLogger.info(message, ...context), + debug: (message, ...context) => appLogger.debug(message, ...context), + trace: (message, ...context) => appLogger.trace(message, ...context), +}; + +const client = new Nostr(["wss://relay.example"], { logger }); +const pool = new RelayPool(["wss://relay.example"], { logger }); +``` + +Stateless helpers that can report recoverable input or network failures accept +the same logger as an optional final argument or in their existing options bag. +Diagnostic sinks are observational: an exception thrown by a custom logger does +not replace the helper or client result. The deprecated warn-only NIP-02 logger +contract remains compatible throughout the 0.x line. + +## Basic client usage ```typescript import { Nostr, RelayEvent } from "snstr"; @@ -171,14 +230,14 @@ async function main() { // Query events from all relays const manyEvents = await client.fetchMany( [{ kinds: [1], authors: ["pubkey"], limit: 10 }], - { maxWait: 5000 } + { maxWait: 5000 }, ); console.log(`Found ${manyEvents.length} events`); // Get the most recent event from all relays const latestEvent = await client.fetchOne( [{ kinds: [1], authors: ["pubkey"] }], - { maxWait: 3000 } + { maxWait: 3000 }, ); if (latestEvent) { console.log("Latest event:", latestEvent.content); @@ -204,9 +263,9 @@ import { Nostr } from "snstr"; const client = new Nostr(["wss://relay.nostr.band"], { rateLimits: { subscribe: { limit: 100, windowMs: 60000 }, // 100 per minute (default: 50) - publish: { limit: 200, windowMs: 60000 }, // 200 per minute (default: 100) - fetch: { limit: 500, windowMs: 60000 } // 500 per minute (default: 200) - } + publish: { limit: 200, windowMs: 60000 }, // 200 per minute (default: 100) + fetch: { limit: 500, windowMs: 60000 }, // 500 per minute (default: 200) + }, }); // Update limits dynamically @@ -224,8 +283,8 @@ async function relayPoolExample() { // Initialize RelayPool with multiple relays const pool = new RelayPool([ "wss://relay.nostr.band", - "wss://nos.lol", - "wss://relay.damus.io" + "wss://nos.lol", + "wss://relay.damus.io", ]); // Generate keypair @@ -236,12 +295,12 @@ async function relayPoolExample() { kind: 1, content: "Hello from RelayPool!", tags: [], - privateKey: keys.privateKey + privateKey: keys.privateKey, }); const publishPromises = pool.publish( - ["wss://relay.nostr.band", "wss://nos.lol"], - event + ["wss://relay.nostr.band", "wss://nos.lol"], + event, ); const results = await Promise.all(publishPromises); @@ -254,14 +313,14 @@ async function relayPoolExample() { }, () => { console.log("All relays finished sending stored events"); - } + }, ); // Query events synchronously from multiple relays const events = await pool.querySync( ["wss://relay.nostr.band", "wss://nos.lol"], { kinds: [1], limit: 5 }, - { timeout: 10000 } + { timeout: 10000 }, ); console.log(`Retrieved ${events.length} events`); @@ -286,19 +345,19 @@ async function queryExample() { const events = await client.fetchMany( [ { kinds: [1], authors: ["pubkey1", "pubkey2"], limit: 20 }, - { kinds: [0], authors: ["pubkey1"] } // Profile metadata + { kinds: [0], authors: ["pubkey1"] }, // Profile metadata ], - { maxWait: 5000 } // Wait up to 5 seconds + { maxWait: 5000 }, // Wait up to 5 seconds ); - + console.log(`Retrieved ${events.length} events from all relays`); - + // Fetch the most recent single event const latestNote = await client.fetchOne( [{ kinds: [1], authors: ["pubkey1"] }], - { maxWait: 3000 } + { maxWait: 3000 }, ); - + if (latestNote) { console.log("Latest note:", latestNote.content); } @@ -383,7 +442,7 @@ Runnable examples cover core usage, NIP-specific flows, and curated groups. See ## Testing -The Jest suite uses an ephemeral relay where possible so normal test runs avoid external services. See the [testing guide](./tests/README.md) for organization and methodology, and use the [Command Reference](#command-reference) for every supported test command. +The Jest suite uses an ephemeral relay where possible so normal test runs avoid external services. `npm test` is the fast routine lane; `npm run test:slow` contains the explicitly named security/performance load suites, and `npm run test:all` runs the complete assurance set. CI always runs both lanes for Node and Bun. See the [testing guide](./tests/README.md) for organization and methodology, and use the [Command Reference](#command-reference) for every supported test command. ## Command Reference @@ -391,62 +450,67 @@ The `scripts` object in [package.json](./package.json) is the executable source ### Build -| Command | Definition | -| --- | --- | -| `npm run build` | `npx rimraf dist && npm run build:cjs && npm run build:esm` | -| `npm run build:cjs` | `tsc -p tsconfig.build.json` | -| `npm run build:esm` | `tsc -p tsconfig.esm.json && node scripts/postbuild-esm.js` | -| `npm run pack:verify` | `node scripts/verify-pack.js` | -| `npm run commands:verify` | `node scripts/verify-commands.js` | -| `npm run prepack` | `npm run build && npm run pack:verify` | -| `npm run build:examples` | `tsc -p examples/tsconfig.json` | +| Command | Definition | +| -------------------------------- | ----------------------------------------------------------- | +| `npm run build` | `npx rimraf dist && npm run build:cjs && npm run build:esm` | +| `npm run build:cjs` | `tsc -p tsconfig.build.json` | +| `npm run build:esm` | `tsc -p tsconfig.esm.json && node scripts/postbuild-esm.js` | +| `npm run pack:verify` | `node scripts/verify-pack.js` | +| `npm run package-manager:verify` | `node scripts/verify-package-manager.js` | +| `npm run commands:verify` | `node scripts/verify-commands.js` | +| `npm run prepack` | `npm run build && npm run pack:verify` | +| `npm run build:examples` | `tsc -p examples/tsconfig.json` | ### Code Quality -| Command | Definition | -| --- | --- | -| `npm run lint` | `eslint . --ext .ts` | +| Command | Definition | +| ---------------- | ------------------------------------------------------------------- | +| `npm run lint` | `eslint . --ext .ts` | | `npm run format` | `prettier --write "src/**/*.ts" "tests/**/*.ts" "examples/**/*.ts"` | ### Primary Tests -| Command | Definition | -| --- | --- | -| `npm run test` | `jest` | -| `npm run test:watch` | `jest --watch` | -| `npm run test:coverage` | `jest --coverage` | -| `npm run test:integration` | `jest tests/integration.test.ts` | +| Command | Definition | +| ------------------------------ | -------------------------------------------------------------- | +| `npm run test` | `node scripts/run-test-lane.js jest routine` | +| `npm run test:watch` | `node scripts/run-test-lane.js jest routine --watch` | +| `npm run test:coverage` | `node scripts/run-test-lane.js jest routine --coverage` | +| `npm run test:coverage:all` | `node scripts/run-test-lane.js jest all --coverage` | +| `npm run test:slow` | `node scripts/run-test-lane.js jest slow` | +| `npm run test:integration` | `jest tests/integration.test.ts` | ### Bun Tests -| Command | Definition | -| --- | --- | -| `npm run test:bun` | `bun test ./tests --max-concurrency 1 --timeout 30000` | -| `npm run test:bun:watch` | `bun test ./tests --watch --max-concurrency 1 --timeout 30000` | +| Command | Definition | +| ------------------------ | ------------------------------------------------------ | +| `npm run test:bun` | `node scripts/run-test-lane.js bun routine` | +| `npm run test:bun:watch` | `node scripts/run-test-lane.js bun routine --watch` | +| `npm run test:bun:slow` | `node scripts/run-test-lane.js bun slow` | +| `npm run test:bun:all` | `bun run test:bun && bun run test:bun:slow` | ### NIP-01 and Core Tests -| Command | Definition | -| --- | --- | -| `npm run test:nip01` | `jest tests/nip01` | -| `npm run test:nip01:event` | `jest tests/nip01/event` | -| `npm run test:nip01:relay` | `jest tests/nip01/relay` | -| `npm run test:event` | `jest tests/nip01/event/event.test.ts` | -| `npm run test:event:ordering` | `jest tests/nip01/event/event-ordering.test.ts` | -| `npm run test:event:addressable` | `jest tests/nip01/event/addressable-events.test.ts` | -| `npm run test:nostr` | `jest tests/nip01/nostr.test.ts` | -| `npm run test:nip01:relay:connection` | `jest tests/nip01/relay/relay.test.ts` | -| `npm run test:nip01:relay:filter` | `jest tests/nip01/relay/filters.test.ts` | -| `npm run test:nip01:relay:reconnect` | `jest tests/nip01/relay/relay-reconnect.test.ts` | -| `npm run test:nip01:relay:pool` | `jest tests/nip01/relay/relayPool.test.ts` | -| `npm run test:nip01:relay:websocket` | `jest tests/nip01/relay/websocket-implementation.test.ts` | -| `npm run test:crypto:core` | `jest tests/utils/crypto.test.ts` | -| `npm run test:utils:relayUrl` | `jest tests/utils/relayUrl.test.ts` | +| Command | Definition | +| ------------------------------------- | --------------------------------------------------------- | +| `npm run test:nip01` | `jest tests/nip01` | +| `npm run test:nip01:event` | `jest tests/nip01/event` | +| `npm run test:nip01:relay` | `jest tests/nip01/relay` | +| `npm run test:event` | `jest tests/nip01/event/event.test.ts` | +| `npm run test:event:ordering` | `jest tests/nip01/event/event-ordering.test.ts` | +| `npm run test:event:addressable` | `jest tests/nip01/event/addressable-events.test.ts` | +| `npm run test:nostr` | `jest tests/nip01/nostr.test.ts` | +| `npm run test:nip01:relay:connection` | `jest tests/nip01/relay/relay.test.ts` | +| `npm run test:nip01:relay:filter` | `jest tests/nip01/relay/filters.test.ts` | +| `npm run test:nip01:relay:reconnect` | `jest tests/nip01/relay/relay-reconnect.test.ts` | +| `npm run test:nip01:relay:pool` | `jest tests/nip01/relay/relayPool.test.ts` | +| `npm run test:nip01:relay:websocket` | `jest tests/nip01/relay/websocket-implementation.test.ts` | +| `npm run test:crypto:core` | `jest tests/utils/crypto.test.ts` | +| `npm run test:utils:relayUrl` | `jest tests/utils/relayUrl.test.ts` | ### NIP-Specific Tests -| Command | Definition | -| --- | --- | +| Command | Definition | +| -------------------- | ------------------ | | `npm run test:nip02` | `jest tests/nip02` | | `npm run test:nip04` | `jest tests/nip04` | | `npm run test:nip05` | `jest tests/nip05` | @@ -472,130 +536,130 @@ The `scripts` object in [package.json](./package.json) is the executable source ### Test Groups -| Command | Definition | -| --- | --- | -| `npm run test:all` | `npm test` | -| `npm run test:crypto` | `jest tests/utils/crypto.test.ts tests/nip04 tests/nip44` | -| `npm run test:identity` | `jest tests/nip05 tests/nip07 tests/nip19` | -| `npm run test:protocols` | `jest tests/nip46 tests/nip47 tests/nip57` | +| Command | Definition | +| ------------------------ | --------------------------------------------------------- | +| `npm run test:all` | `npm test && npm run test:slow` | +| `npm run test:crypto` | `jest tests/utils/crypto.test.ts tests/nip04 tests/nip44` | +| `npm run test:identity` | `jest tests/nip05 tests/nip07 tests/nip19` | +| `npm run test:protocols` | `jest tests/nip46 tests/nip47 tests/nip57` | ### Core Examples -| Command | Definition | -| --- | --- | -| `npm run example` | `ts-node examples/basic-example.ts` | -| `npm run example:verbose` | `VERBOSE=true ts-node examples/basic-example.ts` | -| `npm run example:debug` | `DEBUG=true ts-node examples/basic-example.ts` | -| `npm run example:custom-websocket` | `ts-node examples/custom-websocket-example.ts` | -| `npm run example:crypto` | `ts-node examples/crypto-demo.ts` | -| `npm run example:rate-limits` | `ts-node examples/rate-limit-configuration-example.ts` | +| Command | Definition | +| ---------------------------------- | ------------------------------------------------------ | +| `npm run example` | `ts-node examples/basic-example.ts` | +| `npm run example:verbose` | `VERBOSE=true ts-node examples/basic-example.ts` | +| `npm run example:debug` | `DEBUG=true ts-node examples/basic-example.ts` | +| `npm run example:custom-websocket` | `ts-node examples/custom-websocket-example.ts` | +| `npm run example:crypto` | `ts-node examples/crypto-demo.ts` | +| `npm run example:rate-limits` | `ts-node examples/rate-limit-configuration-example.ts` | ### NIP-01 Examples -| Command | Definition | -| --- | --- | -| `npm run example:nip01:event:ordering` | `ts-node examples/nip01/event/event-ordering-demo.ts` | -| `npm run example:nip01:event:addressable` | `ts-node examples/nip01/event/addressable-events.ts` | -| `npm run example:nip01:event:replaceable` | `ts-node examples/nip01/event/replaceable-events.ts` | -| `npm run example:nip01:relay:connection` | `ts-node examples/nip01/relay/relay-connection-example.ts` | -| `npm run example:nip01:relay:filters` | `ts-node examples/nip01/relay/filter-types-example.ts` | -| `npm run example:nip01:relay:auto-close` | `ts-node examples/nip01/relay/auto-unsubscribe-example.ts` | -| `npm run example:nip01:relay:query` | `ts-node examples/nip01/relay/relay-query-example.ts` | -| `npm run example:nip01:relay:reconnect` | `ts-node examples/nip01/relay/relay-reconnect-example.ts` | -| `npm run example:nip01:relay:pool` | `ts-node examples/nip01/relay/relay-pool-example.ts` | -| `npm run example:nip01:url-preprocessing` | `ts-node examples/nip01/url-preprocessing-example.ts` | +| Command | Definition | +| ---------------------------------------------------- | ---------------------------------------------------------------- | +| `npm run example:nip01:event:ordering` | `ts-node examples/nip01/event/event-ordering-demo.ts` | +| `npm run example:nip01:event:addressable` | `ts-node examples/nip01/event/addressable-events.ts` | +| `npm run example:nip01:event:replaceable` | `ts-node examples/nip01/event/replaceable-events.ts` | +| `npm run example:nip01:relay:connection` | `ts-node examples/nip01/relay/relay-connection-example.ts` | +| `npm run example:nip01:relay:filters` | `ts-node examples/nip01/relay/filter-types-example.ts` | +| `npm run example:nip01:relay:auto-close` | `ts-node examples/nip01/relay/auto-unsubscribe-example.ts` | +| `npm run example:nip01:relay:query` | `ts-node examples/nip01/relay/relay-query-example.ts` | +| `npm run example:nip01:relay:reconnect` | `ts-node examples/nip01/relay/relay-reconnect-example.ts` | +| `npm run example:nip01:relay:pool` | `ts-node examples/nip01/relay/relay-pool-example.ts` | +| `npm run example:nip01:url-preprocessing` | `ts-node examples/nip01/url-preprocessing-example.ts` | | `npm run example:nip01:relay:pool-url-normalization` | `ts-node examples/nip01/relay-pool-url-normalization-example.ts` | -| `npm run example:nip01:validation` | `ts-node examples/client/validation-flow.ts` | +| `npm run example:nip01:validation` | `ts-node examples/client/validation-flow.ts` | ### NIP-Specific Examples -| Command | Definition | -| --- | --- | -| `npm run example:nip02` | `ts-node examples/nip02/nip02-demo.ts` | -| `npm run example:nip02:pubkey-normalization` | `ts-node examples/nip02/pubkey-normalization-example.ts` | -| `npm run example:nip04` | `ts-node examples/nip04/direct-message.ts` | -| `npm run example:nip05` | `ts-node examples/nip05/nip05-demo.ts` | -| `npm run example:nip09` | `ts-node examples/nip09/deletion-request.ts` | -| `npm run example:nip10` | `ts-node examples/nip10/nip10-demo.ts` | -| `npm run example:nip07` | `cd examples/nip07 && npm install && npm run build && npm start` | -| `npm run example:nip07:build` | `cd examples/nip07 && npm install && npm run build` | -| `npm run example:nip07:dm` | `ts-node examples/nip07/direct-message.ts` | -| `npm run example:nip11` | `ts-node examples/nip11/relay-info-example.ts` | -| `npm run example:nip19` | `ts-node examples/nip19/nip19-demo.ts` | -| `npm run example:nip19:bech32` | `ts-node examples/nip19/bech32-example.ts` | -| `npm run example:nip19:tlv` | `ts-node examples/nip19/tlv-example.ts` | -| `npm run example:nip19:validation` | `ts-node examples/nip19/validation-example.ts` | -| `npm run example:nip19:security` | `ts-node examples/nip19/nip19-security.ts` | -| `npm run example:nip19:security-example` | `ts-node examples/nip19/security-example.ts` | -| `npm run example:nip21` | `ts-node examples/nip21/nip21-demo.ts` | -| `npm run example:nip44` | `ts-node examples/nip44/nip44-demo.ts` | -| `npm run example:nip44:version-compat` | `ts-node examples/nip44/nip44-version-compatibility.ts` | -| `npm run example:nip44:test-vector` | `ts-node examples/nip44/nip44-test-vector.ts` | -| `npm run example:nip44:compliance` | `ts-node examples/nip44/nip44-compliance-demo.ts` | -| `npm run example:nip17` | `ts-node examples/nip17/nip17-demo.ts` | -| `npm run example:nip46` | `ts-node examples/nip46/unified-example.ts` | -| `npm run example:nip46:minimal` | `ts-node examples/nip46/minimal.ts` | -| `npm run example:nip46:basic` | `ts-node examples/nip46/basic-example.ts` | -| `npm run example:nip46:advanced` | `ts-node examples/nip46/advanced/remote-signing-demo.ts` | -| `npm run example:nip46:from-scratch` | `ts-node examples/nip46/from-scratch/implementation-from-scratch.ts` | -| `npm run example:nip46:simple` | `ts-node examples/nip46/simple/simple-example.ts` | -| `npm run example:nip46:simple-client` | `ts-node examples/nip46/simple/simple-client-test.ts` | -| `npm run example:nip46:test-all` | `ts-node examples/nip46/test-all-examples.ts` | -| `npm run example:nip46:connection-string-validation` | `ts-node examples/nip46/connection-string-validation-example.ts` | -| `npm run example:nip47` | `ts-node examples/nip47/basic-example.ts` | -| `npm run example:nip47:verbose` | `VERBOSE=true ts-node examples/nip47/basic-example.ts` | -| `npm run example:nip47:client-service` | `ts-node examples/nip47/basic-client-service.ts` | -| `npm run example:nip47:error-handling` | `ts-node examples/nip47/error-handling-example.ts` | -| `npm run example:nip47:expiration` | `ts-node examples/nip47/request-expiration-example.ts` | -| `npm run example:nip47:nip44` | `ts-node examples/nip47/nip44-encryption.ts` | -| `npm run example:nip47:encryption-negotiation` | `ts-node examples/nip47/encryption-negotiation.ts` | -| `npm run example:nip50` | `ts-node examples/nip50/search-demo.ts` | -| `npm run example:nip57` | `ts-node examples/nip57/basic-example.ts` | -| `npm run example:nip57:client` | `ts-node examples/nip57/zap-client-example.ts` | -| `npm run example:nip57:lnurl` | `ts-node examples/nip57/lnurl-server-simulation.ts` | -| `npm run example:nip57:validation` | `ts-node examples/nip57/invoice-validation-example.ts` | -| `npm run example:nip65` | `ts-node examples/nip65/nip65-demo.ts` | -| `npm run example:nip66` | `ts-node examples/nip66/nip66-demo.ts` | +| Command | Definition | +| ---------------------------------------------------- | -------------------------------------------------------------------- | +| `npm run example:nip02` | `ts-node examples/nip02/nip02-demo.ts` | +| `npm run example:nip02:pubkey-normalization` | `ts-node examples/nip02/pubkey-normalization-example.ts` | +| `npm run example:nip04` | `ts-node examples/nip04/direct-message.ts` | +| `npm run example:nip05` | `ts-node examples/nip05/nip05-demo.ts` | +| `npm run example:nip09` | `ts-node examples/nip09/deletion-request.ts` | +| `npm run example:nip10` | `ts-node examples/nip10/nip10-demo.ts` | +| `npm run example:nip07` | `cd examples/nip07 && npm install && npm run build && npm start` | +| `npm run example:nip07:build` | `cd examples/nip07 && npm install && npm run build` | +| `npm run example:nip07:dm` | `ts-node examples/nip07/direct-message.ts` | +| `npm run example:nip11` | `ts-node examples/nip11/relay-info-example.ts` | +| `npm run example:nip19` | `ts-node examples/nip19/nip19-demo.ts` | +| `npm run example:nip19:bech32` | `ts-node examples/nip19/bech32-example.ts` | +| `npm run example:nip19:tlv` | `ts-node examples/nip19/tlv-example.ts` | +| `npm run example:nip19:validation` | `ts-node examples/nip19/validation-example.ts` | +| `npm run example:nip19:security` | `ts-node examples/nip19/nip19-security.ts` | +| `npm run example:nip19:security-example` | `ts-node examples/nip19/security-example.ts` | +| `npm run example:nip21` | `ts-node examples/nip21/nip21-demo.ts` | +| `npm run example:nip44` | `ts-node examples/nip44/nip44-demo.ts` | +| `npm run example:nip44:version-compat` | `ts-node examples/nip44/nip44-version-compatibility.ts` | +| `npm run example:nip44:test-vector` | `ts-node examples/nip44/nip44-test-vector.ts` | +| `npm run example:nip44:compliance` | `ts-node examples/nip44/nip44-compliance-demo.ts` | +| `npm run example:nip17` | `ts-node examples/nip17/nip17-demo.ts` | +| `npm run example:nip46` | `ts-node examples/nip46/unified-example.ts` | +| `npm run example:nip46:minimal` | `ts-node examples/nip46/minimal.ts` | +| `npm run example:nip46:basic` | `ts-node examples/nip46/basic-example.ts` | +| `npm run example:nip46:advanced` | `ts-node examples/nip46/advanced/remote-signing-demo.ts` | +| `npm run example:nip46:from-scratch` | `ts-node examples/nip46/from-scratch/implementation-from-scratch.ts` | +| `npm run example:nip46:simple` | `ts-node examples/nip46/simple/simple-example.ts` | +| `npm run example:nip46:simple-client` | `ts-node examples/nip46/simple/simple-client-test.ts` | +| `npm run example:nip46:test-all` | `ts-node examples/nip46/test-all-examples.ts` | +| `npm run example:nip46:connection-string-validation` | `ts-node examples/nip46/connection-string-validation-example.ts` | +| `npm run example:nip47` | `ts-node examples/nip47/basic-example.ts` | +| `npm run example:nip47:verbose` | `VERBOSE=true ts-node examples/nip47/basic-example.ts` | +| `npm run example:nip47:client-service` | `ts-node examples/nip47/basic-client-service.ts` | +| `npm run example:nip47:error-handling` | `ts-node examples/nip47/error-handling-example.ts` | +| `npm run example:nip47:expiration` | `ts-node examples/nip47/request-expiration-example.ts` | +| `npm run example:nip47:nip44` | `ts-node examples/nip47/nip44-encryption.ts` | +| `npm run example:nip47:encryption-negotiation` | `ts-node examples/nip47/encryption-negotiation.ts` | +| `npm run example:nip50` | `ts-node examples/nip50/search-demo.ts` | +| `npm run example:nip57` | `ts-node examples/nip57/basic-example.ts` | +| `npm run example:nip57:client` | `ts-node examples/nip57/zap-client-example.ts` | +| `npm run example:nip57:lnurl` | `ts-node examples/nip57/lnurl-server-simulation.ts` | +| `npm run example:nip57:validation` | `ts-node examples/nip57/invoice-validation-example.ts` | +| `npm run example:nip65` | `ts-node examples/nip65/nip65-demo.ts` | +| `npm run example:nip66` | `ts-node examples/nip66/nip66-demo.ts` | ### Example Groups -| Command | Definition | -| --- | --- | -| `npm run example:all` | `npm run example` | -| `npm run example:basic` | `npm run example && npm run example:crypto && npm run example:nip04` | -| `npm run example:nip01` | `npm run example:nip01:event:ordering && npm run example:nip01:relay:connection && npm run example:nip01:relay:query && npm run example:nip01:validation` | -| `npm run example:messaging` | `npm run example:nip04 && npm run example:nip44 && npm run example:nip17` | -| `npm run example:identity` | `npm run example:nip05 && npm run example:nip07 && npm run example:nip19` | -| `npm run example:payments` | `npm run example:nip47 && npm run example:nip57` | -| `npm run example:advanced` | `npm run example:nip46 && npm run example:nip47:error-handling` | -| `npm run example:validation` | `npm run example:nip01:validation` | +| Command | Definition | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `npm run example:all` | `npm run example` | +| `npm run example:basic` | `npm run example && npm run example:crypto && npm run example:nip04` | +| `npm run example:nip01` | `npm run example:nip01:event:ordering && npm run example:nip01:relay:connection && npm run example:nip01:relay:query && npm run example:nip01:validation` | +| `npm run example:messaging` | `npm run example:nip04 && npm run example:nip44 && npm run example:nip17` | +| `npm run example:identity` | `npm run example:nip05 && npm run example:nip07 && npm run example:nip19` | +| `npm run example:payments` | `npm run example:nip47 && npm run example:nip57` | +| `npm run example:advanced` | `npm run example:nip46 && npm run example:nip47:error-handling` | +| `npm run example:validation` | `npm run example:nip01:validation` | ### Release -| Command | Definition | -| --- | --- | +| Command | Definition | +| ------------------------- | ------------------------------------------------------------------ | | `npm run release:prepare` | `npm run lint && npm test && npm run build && npm run pack:verify` | -| `npm run release:patch` | `npm run release:prepare && npm version patch` | -| `npm run release:minor` | `npm run release:prepare && npm version minor` | -| `npm run release:major` | `npm run release:prepare && npm version major` | -| `npm run release:push` | `git push && git push --tags` | -| `npm run release` | `npm run release:patch && npm run release:push` | +| `npm run release:patch` | `npm run release:prepare && npm version patch` | +| `npm run release:minor` | `npm run release:prepare && npm version minor` | +| `npm run release:major` | `npm run release:prepare && npm version major` | +| `npm run release:push` | `git push && git push --tags` | +| `npm run release` | `npm run release:patch && npm run release:push` | ### Branch Management -| Command | Definition | -| --- | --- | +| Command | Definition | +| ----------------- | ---------------------------- | | `npm run promote` | `scripts/promote-to-main.sh` | ### Application Shortcuts -| Command | Definition | -| --- | --- | +| Command | Definition | +| --------------- | ----------------------- | | `npm run start` | `npm run example:nip07` | ## Development -Install dependencies with `npm install`, then use the build, test, quality, and verification workflows in the [Command Reference](#command-reference). Keep source, tests, and examples aligned when changing a NIP. +Activate the canonical toolchain with `corepack prepare npm@9.8.1 --activate` and install dependencies reproducibly with `npm ci`, then use the build, test, quality, and verification workflows in the [Command Reference](#command-reference). npm 9.8.1 is the canonical package manager; Bun 1.3.9 is a frozen-lockfile compatibility runner. See [CONTRIBUTING.md](CONTRIBUTING.md#package-manager-policy) for the lockfile policy. Keep source, tests, and examples aligned when changing a NIP. ### Directory Structure Notes diff --git a/RELEASE.md b/RELEASE.md index d51a95c5..18af9e40 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -8,10 +8,15 @@ Releases are currently performed manually from `main`; the repository does not have an npm publishing workflow. Promote the verified `staging` branch first, then run the release from a clean, up-to-date `main` checkout. +npm 9.8.1 is the canonical release package manager. Bun is tested as a +compatibility runner, but it is not used to version or publish releases. + ```bash git checkout main git pull --ff-only origin main +corepack prepare npm@9.8.1 --activate npm ci +npm run package-manager:verify npm run release:prepare npm publish --dry-run npm version minor # or patch/major @@ -20,8 +25,12 @@ npm publish --access public gh release create "v$(node -p 'require("./package.json").version')" --generate-notes ``` -The cleanup release is backward-compatible and adds browser/React Native -exports, so its expected version bump is **minor** (`0.3.4` → `0.4.0`). +The current cleanup candidate is backward-compatible and adds supported public +testing and protocol surfaces, so its expected version bump is **minor** +(`0.5.0` → `0.6.0`). Keep the package at the currently published version while +the candidate remains on `staging`. After promotion, create the version commit +from a clean `main` checkout and move the populated `Unreleased` changelog +entries under the dated `0.6.0` heading before publishing. ## Pre-release Checklist diff --git a/docs/agents/runs/cleanup-1-9-ledger.md b/docs/agents/runs/cleanup-1-9-ledger.md new file mode 100644 index 00000000..c3daffb6 --- /dev/null +++ b/docs/agents/runs/cleanup-1-9-ledger.md @@ -0,0 +1,91 @@ +# Feature Dev Run Ledger: High-impact Cleanup 1–9 + +## Run + +- Run ID: `snstr-cleanup-1-9-20260718` +- Loop: nine sequential `feature-dev` runs +- Target repo: `snstr` +- Base branch: `staging` +- Feature branches: one branch per approved ticket, created from the latest integrated `staging` +- Human owner: plebdev +- Started: 2026-07-18 +- Current status: complete; items 1–9 / issues #131–#139 merged into `staging` through PRs #140–#148; parent spec #130 closed +- Skill setup status: present and verified (`AGENTS.md`, GitHub issue tracker, triage labels, domain docs, ADRs, CI, CodeRabbit) + +## Goal + +Complete cleanup items 1–9 from the staging audit end to end, branch by branch, with every healthy slice reviewed, verified, and merged into `staging`: redact NIP-46 diagnostics, remove Jest from published declarations, complete the shared diagnostic seam, make NIP-47 lifecycle restart-safe, consolidate NIP-57 behavior, unify the NIP-46 protocol core, shorten the default test loop, move tests off private shapes, and split ephemeral Relay internals. + +## Durable Artifacts + +- CONTEXT updates: none currently required; existing Nostr Event, Relay, Subscription Filter, and NIP terms cover the work +- ADRs: ADR 0002 governs compatible diagnostic consolidation; no new hard-to-reverse decision identified yet +- Prototype source branch, if any: none +- Spec issue: #130 — Complete the high-impact cleanup chain +- Tickets: #131–#139 +- Ticket sessions: created as each ticket starts +- Agent briefs: Grok 4.5 is the exclusive delegated sidecar; Cursor exposes the highest available tier as `cursor-grok-4.5-high`, which is used for all standards/spec passes +- Review packets: `issue-131-review-packet.md` through `issue-137-review-packet.md`, plus `issue-139-review-packet.md`; issue #138 keeps its review evidence in `issue-138-session.md` +- Local CodeRabbit reports: `issue-131-coderabbit-local.md` through `issue-136-coderabbit-local.md`, plus `issue-139-coderabbit-local.md`; issues #137 and #138 keep their CodeRabbit evidence in their review packet and session record respectively +- PR URL: #140 merged for issue #131; #141 merged for issue #132; #142 merged for issue #133; #143 merged for issue #134; #144 merged for issue #135; #145 merged for issue #136; #146 merged for issue #137; #147 merged for issue #138; #148 merged for issue #139; all were non-draft and targeted `staging` + +## Commands + +- Install: `npm ci`; compatibility lane `bun install --frozen-lockfile` +- Typecheck: `npx tsc --noEmit -p tsconfig.json` and packed-consumer checks where relevant +- Test: focused Jest/Bun suites during TDD; full Jest and Bun suites once per issue +- Build: `npm run commands:verify && npm run package-manager:verify && npm run lint && npm run build && npm run build:examples && npm run pack:verify` +- Visual verification: not applicable + +## Ticket Ledger + +| Issue | Type | Status | Branch | Review | Verified | +| --------------------------------- | ---- | ----------- | --------------------------------------- | -------------------------------------------------------- | ------------------------------------ | +| #131 NIP-46 diagnostic redaction | AFK | merged | `feature/nip46-diagnostic-redaction` | Grok approved; CodeRabbit local/hosted clean after fixes | Jest/Bun 1054/1054; hosted CI green | +| #132 published declaration purity | AFK | merged | `feature/public-type-test-purity` | Grok pass; CodeRabbit local/hosted clean | Jest/Bun 1055/1055; hosted CI green | +| #133 shared diagnostic seam | AFK | merged | `feature/shared-diagnostics-completion` | Grok standards/spec pass; local and hosted clean | Jest/Bun 1067/1067; hosted CI green | +| #134 NIP-47 service lifecycle | AFK | merged | `feature/nip47-service-lifecycle` | Grok pass; CodeRabbit local/hosted clean | Jest/Bun 1073/1073; hosted CI green | +| #135 NIP-57 consolidation | AFK | merged | `feature/nip57-client-consolidation` | Grok pass; CodeRabbit local/hosted clean after fixes | Jest/Bun 1082/1082; hosted CI green | +| #136 NIP-46 protocol core | AFK | merged | `feature/nip46-protocol-core` | Grok and CodeRabbit local/hosted clean after fixes | Jest/Bun 1096/1096; hosted CI green | +| #137 default test feedback loop | AFK | merged | `feature/fast-default-test-loop` | Grok pass; CodeRabbit hosted finding fixed and confirmed | routine 1063; slow 40; coverage 1103 | +| #138 public behavior test seams | AFK | merged | `feature/public-behavior-test-seams` | Grok approved; CodeRabbit local/hosted findings resolved | Jest/Bun 1061; hosted CI green | +| #139 ephemeral Relay internals | AFK | merged | `feature/ephemeral-relay-internals` | Grok approved; local CodeRabbit findings resolved; hosted timeout fallback documented | routine 1074; slow 35; coverage 1109; hosted CI green | + +## Parked HITL Slices + +| Issue | Why parked | Blocks | Required human action | Final PR decision | +| ----- | ---------- | ------ | --------------------- | ----------------- | +| None | — | — | — | — | + +## Issue Session Ledger + +| Issue | Fixed point | Implementation owner | Commit | Review result | Checks | +| ----- | ----------- | --------------------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| #131 | `f4bda34` | current Codex orchestrator; Grok 4.5 High reviewers | `7ed8433`, `00104cc`, `21b4ad9`, `426c17b` | Grok approved after hosted fixes; CodeRabbit local code review clean | focused Jest/Bun 7/7; final Jest/Bun 1054/1054; policies, lint, types, builds, examples, pack | +| #132 | `cf705f0` | current Codex orchestrator; Grok 4.5 High reviewers | `0c111d7`, `20565a0`, `abfc836`, `dea7aa0` | Grok passed; CodeRabbit local/hosted clean after four local fixes | focused 8/8; Jest/Bun 1055/1055; all local gates and four hosted lanes green | +| #133 | `46d7289` | current Codex orchestrator; Grok 4.5 High reviewers | `b238461`, `6dd75c3`, `ae15ace` | Grok standards/spec passed; CodeRabbit local/hosted clean | focused 204/204; Jest/Bun 1067/1067; all local gates and four hosted lanes green | +| #134 | `2a3556d` | current Codex orchestrator; Grok 4.5 High reviewers | `569b266` plus review artifacts | Grok standards/spec passed; CodeRabbit local/hosted clean | focused 6/6; Jest/Bun 1073/1073; all local gates and four hosted lanes green | +| #135 | `25e055d` | current Codex orchestrator; Grok 4.5 High reviewers | `0909227`, `1b12872`, `b66d483` | Grok passes; CodeRabbit local/hosted clean after fixes | focused 23/23; NIP-57 41/41; Jest/Bun 1082/1082; all local gates and four hosted lanes green | +| #136 | `ed9fa4a` | current Codex orchestrator; Grok 4.5 High reviewers | PR #145 through merge `8b970e4` | Grok and CodeRabbit local/hosted clean after fixes | NIP-46 185/185; Jest/Bun 1096/1096; all local and hosted gates green | +| #137 | `8b970e4` | current Codex orchestrator; Grok 4.5 High reviewers | PR #146 through merge `b33f31f` | Grok standards/spec passed after Bun 1.3.9 fix; CodeRabbit hosted finding fixed and explicitly confirmed | baseline 85/1096/58.793s; routine 84/1063/32.356s; slow 2/40/43.154s; coverage 86/1103; hosted CI green | +| #138 | `b33f31f` | current Codex orchestrator; Grok 4.5 High reviewers | PR #147 through merge `3c1e905` | Grok approved; CodeRabbit local clean; two hosted nits fixed and false positive withdrawn | routine Jest/Bun 1061; slow Jest/Bun 35; coverage 1096; all local gates and four hosted lanes green | +| #139 | `3c1e905` | current Codex orchestrator; Grok 4.5 High reviewers | PR #148 through merge `9c9e14c` | Grok standards/spec approved with 0 findings; local CodeRabbit actionable findings fixed; hosted review timed out after 20 minutes without findings | focused 50/50; routine Jest/Bun 1074; slow Jest/Bun 35; coverage 1109; all local gates and four hosted lanes green | + +## Alignment Decisions + +- The ranked staging audit is the authoritative scope for cleanup items 1–9. +- The owner's instruction grants approval for the public testing seams, nine-ticket granularity, linear dependency graph, and agent-performable AFK classification. +- Each ticket uses a dedicated branch and PR, then integrates into `staging` before dependent work begins; this intentionally runs the Feature Dev loop back to back. +- Existing 0.x public interfaces remain compatible, except for removal of accidental Jest ownership from production declarations in issue #132. +- NIP-46 diagnostic redaction lands before the broader NIP-46 consolidation so the canonical engine inherits the safe policy. +- The shared core logger remains canonical and NIP-specific compatibility aliases protected by ADR 0002 remain through 0.x. +- Grok is the exclusive delegated system. CodeRabbit remains a required review gate and is not used as an implementation subagent. +- Production deployment, release, and promotion to `main` are out of scope. + +## Open Questions + +- None. + +## Escalations + +- Resolved: Cursor CLI authentication completed. The skill's `grok-4.5-xhigh` alias is not present in the installed catalog; the highest available Grok 4.5 tier, `cursor-grok-4.5-high`, is used and verified. diff --git a/docs/agents/runs/deep-cleanup-1-8-ledger.md b/docs/agents/runs/deep-cleanup-1-8-ledger.md new file mode 100644 index 00000000..9f06ee5a --- /dev/null +++ b/docs/agents/runs/deep-cleanup-1-8-ledger.md @@ -0,0 +1,87 @@ +# Feature Dev Run Ledger: Deep Cleanup 1–8 + +## Run + +- Run ID: `snstr-deep-cleanup-1-8-20260718` +- Loop: eight sequential `feature-dev` runs +- Target repo: `snstr` +- Base branch: `staging` +- Feature branches: one branch per approved ticket, rebased from the latest integrated `staging` +- Human owner: plebdev +- Started: 2026-07-18 +- Current status: issues #112–#119 completed; delivery PRs #120–#127 merged into `staging`; final integration audit green at `8a04c9a` +- Skill setup status: present and verified (`AGENTS.md`, GitHub issue tracker, triage labels, single-context domain docs) + +## Goal + +Complete cleanup items 1–8 from the post-v0.5.0 repository audit end to end, branch by branch, with each healthy slice integrated into `staging`: make NIP-44 legacy behavior explicit, deepen Relay, extract NIP-47 protocol machinery, deepen the Nostr facade, clarify ephemeral Relay ownership, consolidate security validation, centralize NIP-01 message types, and make package-manager policy canonical. + +## Durable Artifacts + +- CONTEXT updates: none currently required; existing Nostr Event, Relay, Subscription Filter, and NIP terms cover the work +- ADRs: none currently required; public compatibility is preserved and each internal extraction is independently reversible +- Prototype source branch, if any: none +- Spec issue: #111 — Deepen core protocol modules and remove remaining maintenance drift +- Tickets: #112–#119 +- Ticket sessions: created as each ticket starts +- Agent briefs: Grok 4.5 is the only owner-approved delegated sidecar; `agent` authentication was unavailable at preflight, so no substitute subagent is used and Codex owns local execution/review +- Review packets: created per ticket +- Local CodeRabbit report: issue #112 round completed with five worthy fixes in `issue-112-coderabbit-local.md` +- Delivery PRs: #120 for issue #112, #121 for issue #118, #122 for issue #113, #123 for issue #114, #124 for issue #115, #125 for issue #116, #126 for issue #117, and #127 for issue #119; all merged into `staging` + +## Commands + +- Install: `npm ci`; compatibility lane `bun install --frozen-lockfile` +- Typecheck: `npx tsc --noEmit -p tsconfig.json` and `npx tsc --noEmit -p examples/tsconfig.json` +- Test: targeted Jest/Bun suites per ticket; `npm test -- --runInBand --detectOpenHandles`; `npm run test:bun` +- Build: `npm run commands:verify && npm run lint && npm run build && npm run build:examples && npm run pack:verify` +- Visual verification: not applicable + +## Ticket Ledger + +| Issue | Type | Status | Branch | Review | Verified | +| ------------------------------------ | ---- | --------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| #112 NIP-44 legacy behavior | AFK | merged into `staging` | `feature/nip44-legacy-compat` | standards/spec pass; local CodeRabbit 5 fixed; hosted CodeRabbit 3 fixed | yes | +| #118 authoritative protocol messages | AFK | merged into `staging` | `feature/protocol-message-types` | standards/spec pass; local CodeRabbit 4 fixed, 1 evidence-based skip; hosted 2 fixed | Jest 994/994; Bun full; hosted Node 16/18/20 + Bun; commands, lint, types, builds, pack | +| #113 Relay event-store seam | AFK | merged into `staging` | `feature/relay-event-store` | standards/spec pass; local CodeRabbit 4 fixed; hosted clean | focused Jest/Bun 80/80; full Jest/Bun 1013/1013; hosted Node 16/18/20 + Bun; commands, lint, types, builds, pack | +| #114 NIP-47 protocol machinery | AFK | merged into `staging` | `feature/nip47-protocol-codecs` | standards/spec pass; local CodeRabbit 2 fixed, 7 compatibility skips; hosted 3 fixed; final hosted clean | focused 71/71; full Jest/Bun 1021/1021; hosted Node 16/18/20 + Bun; commands, lint, types, builds, pack | +| #115 Nostr relay registry | AFK | merged into `staging` | `feature/nostr-relay-registry` | standards/spec pass; local CodeRabbit 1 fixed; hosted clean | focused 61/61; full Jest/Bun 1026/1026; hosted Node 16/18/20 + Bun; commands, lint, types, builds, pack | +| #116 ephemeral Relay ownership | AFK | merged into `staging` | `feature/ephemeral-relay-ownership` | standards/spec pass; local CodeRabbit clean; hosted 2 fixed, 2 unrelated skips; final hosted clean | focused 25/25; full Jest/Bun 1026/1026; hosted Node 16/18/20 + Bun; commands, lint, types, builds, examples, pack | +| #117 security validation ownership | AFK | merged into `staging` | `feature/security-validation-ownership` | standards/spec pass; local CodeRabbit 1 fixed; hosted 2 fixed | focused 273/273; coverage 231/231 and 81.19% branches; full Jest/Bun 1030/1030; commands, lint, types, builds, pack | +| #119 package-manager policy | AFK | merged into `staging` | `feature/package-manager-policy` | standards/spec pass; local 5 fixed/2 skipped; hosted 4 fixed; final hosted clean | clean npm/Bun installs; focused 28/28; full Jest/Bun 1033/1033; hosted Node 16/18/20 + Bun; all gates | + +## Parked HITL Slices + +| Issue | Why parked | Blocks | Required human action | Final PR decision | +| ----- | ---------- | ------ | --------------------- | ----------------- | +| None | — | — | — | — | + +## Issue Session Ledger + +| Issue | Fixed point | Implementation owner | Commit | Review result | Checks | +| ----- | ----------- | -------------------------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| #112 | `df13432` | current Codex orchestrator; Grok unavailable at auth preflight | PR #120, merge `c7cb99f` | standards/spec pass after one fix; local CodeRabbit 5/5 and hosted CodeRabbit 3/3 fixed | NIP-44 107/107; Jest 991/991; Bun 991/991; hosted Node 16/18/20 + Bun; lint, types, builds, commands, pack | +| #118 | `c7cb99f` | current Codex orchestrator; Grok unavailable at auth preflight | PR #121, merge `8c36233` | standards/spec pass; local CodeRabbit 4 fixed, 1 skipped with type evidence; hosted 2/2 fixed | Jest 994/994; Bun full; hosted Node 16/18/20 + Bun; commands, lint, types, builds, pack | +| #113 | `8c36233` | current Codex orchestrator; Grok unavailable at auth preflight | PR #122, merge `da7361e` | standards/spec pass; local CodeRabbit 4/4 fixed; hosted clean | focused Relay/store Jest and Bun 80/80; full Jest/Bun 1013/1013; hosted Node 16/18/20 + Bun; commands, lint, types, builds, pack | +| #114 | `da7361e` | current Codex orchestrator; Grok unavailable at auth preflight | PR #123, merge `4e40b63` | standards/spec pass; local CodeRabbit 2 fixed, 7 skipped to preserve public compatibility; hosted 3 fixed; final hosted clean | focused NIP-47 71/71; full Jest/Bun 1021/1021; hosted Node 16/18/20 + Bun; commands, lint, types, builds, pack | +| #115 | `4e40b63` | current Codex orchestrator; Grok unavailable at auth preflight | PR #124, merge `9e3aca8` | standards/spec pass; local CodeRabbit 1/1 fixed; hosted clean | focused Nostr/registry/integration 61/61; full Jest/Bun 1026/1026; hosted Node 16/18/20 + Bun; commands, lint, types, builds, pack | +| #116 | `9e3aca8` | current Codex orchestrator; Grok unavailable at auth preflight | PR #125, merge `94eed4a` | standards/spec pass; local CodeRabbit clean; hosted 2/2 package findings fixed, 2 unrelated NIP-02 findings skipped; final clean | focused 25/25; full Jest/Bun 1026/1026; hosted Node 16/18/20 + Bun; commands, lint, types, builds, examples, pack | +| #117 | `94eed4a` | current Codex orchestrator; Grok unavailable at auth preflight | PR #126, merge `1838789` | standards/spec pass; local CodeRabbit 1/1 fixed; hosted 2/2 fixed; final hosted clean | focused 273/273; coverage 231/231 with 81.19% aggregate branches; full Jest/Bun 1030/1030; hosted Node 16/18/20 + Bun; all gates | +| #119 | `1838789` | current Codex orchestrator; Grok unavailable at auth preflight | PR #127, merge `8a04c9a` | standards/spec pass; local CodeRabbit 5 fixed/2 skipped with evidence; hosted 4/4 fixed; final hosted clean | clean installs; verifier 28/28; Jest/coverage/Bun 1033/1033; coverage 77.38% statements/64.43% branches; hosted all lanes | + +## Alignment Decisions + +- The ranked audit list is the authoritative scope for cleanup items 1–8. +- The owner's instruction grants approval for the public testing seams, eight-ticket granularity, dependency graph, and agent-performable AFK classification. +- Each ticket uses a dedicated branch and PR, then integrates into `staging` before dependent work begins; this intentionally runs the Feature Dev loop back to back rather than putting all tickets on one feature branch. +- Existing public interfaces are preserved unless authoritative NIP-44 evidence proves current legacy behavior incorrect or unsupported. +- Grok is the exclusive delegated system. If unavailable, the Grok skill requires recording the limitation and local completion; no alternate subagent is substituted. +- Production deployment and release promotion remain out of scope. + +## Open Questions + +- None. + +## Escalations + +- Grok preflight: the `agent` and Node CLIs exist, but `agent models` requires authentication. This does not block local work under the Grok skill fallback. diff --git a/docs/agents/runs/deep-cleanup-final-audit.md b/docs/agents/runs/deep-cleanup-final-audit.md new file mode 100644 index 00000000..6f0f1ef9 --- /dev/null +++ b/docs/agents/runs/deep-cleanup-final-audit.md @@ -0,0 +1,24 @@ +# Deep Cleanup 1–8: Final Staging Audit + +## Integrated State + +- Audited staging commit: `8a04c9a` (PR #127 merge) +- Umbrella issue: #111 +- Delivery PRs: #120–#127, all merged into `staging` +- Slice issues: #112, #118, #113, #114, #115, #116, #117, and #119, all closed +- Parked or human-only slices: none + +## Integrated Verification + +- The final PR head passed hosted Node 16, Node 18, Node 20 with coverage, and Bun. +- The merge commit triggered the same four-lane staging workflow as an independent integration check. +- Local final audit passed package-manager and command policy, lint, CJS/ESM builds, example typecheck/build, and packed-tarball verification. +- The final package-policy verifier passed 28/28 focused tests; the integrated full suite passed Jest, coverage, and Bun at 1033/1033. + +## Outcome + +All eight ranked cleanup slices are independently reviewed, merged in dependency order, and represented in the staging integration state. Public compatibility seams, package exports, protocol ownership, security ownership, test-support ownership, and package-manager policy are verified. No release promotion or production deployment was performed. + +## Tooling Limitation + +Grok delegation remained unavailable because the required `agent` CLI was unauthenticated. In accordance with the Grok skill fallback, work was completed locally and no substitute subagent system was used. diff --git a/docs/agents/runs/deep-cleanup-final-coderabbit-local.md b/docs/agents/runs/deep-cleanup-final-coderabbit-local.md new file mode 100644 index 00000000..cf9973ea --- /dev/null +++ b/docs/agents/runs/deep-cleanup-final-coderabbit-local.md @@ -0,0 +1,20 @@ +# CodeRabbit Round: Deep Cleanup Final Audit + +## Round + +- Scope: final integration ledger and audit +- Command: `coderabbit review --agent --type all --base staging` +- Completed: 2026-07-18 +- Findings: 2 + +## Decisions + +| Finding | Decision | Notes | +| ------------------------------------ | -------- | ------------------------------------------------------------------------------- | +| Top-level run summary was stale | fixed | It now records all eight issues and PRs #120–#127 as complete in staging. | +| #119 coverage evidence was ambiguous | fixed | The ledger now records the collected 77.38% statement and 64.43% branch result. | + +## Result + +- Worthy findings fixed: 2 +- Findings skipped: 0 diff --git a/docs/agents/runs/feature-cleanup-1-3-ledger.md b/docs/agents/runs/feature-cleanup-1-3-ledger.md index 9449dfa4..c47a5051 100644 --- a/docs/agents/runs/feature-cleanup-1-3-ledger.md +++ b/docs/agents/runs/feature-cleanup-1-3-ledger.md @@ -9,7 +9,7 @@ - Feature branch: `feature/cleanup-logging-web-build` - Human owner: plebdev - Started: 2026-07-11 -- Current status: PR open; checks and review complete +- Current status: checks and review complete; hosted gates passed; PR #92 merged into `staging` - Skill setup status: present and verified ## Goal @@ -26,7 +26,7 @@ Implement cleanup items 1–3 from the repository scan end to end: - ADRs: none; choices are reversible and follow existing logger/build conventions - Prototype source branch, if any: none - Spec issue: #88 — https://github.com/AustinKelsay/snstr/issues/88 -- Tickets: #89, #90, #91 — implemented; ready-for-review +- Tickets: #89, #90, #91 — implemented and merged through PR #92 - Ticket sessions: `issue-89-session.md`, `issue-90-session.md`, `issue-91-session.md` - Agent briefs: not applicable; current orchestrator implemented the slices - Review packets: `issue-89-review-packet.md`, `issue-90-review-packet.md`, `issue-91-review-packet.md` diff --git a/docs/agents/runs/feature-cleanup-1-8-ledger.md b/docs/agents/runs/feature-cleanup-1-8-ledger.md index 3271cc31..845d429a 100644 --- a/docs/agents/runs/feature-cleanup-1-8-ledger.md +++ b/docs/agents/runs/feature-cleanup-1-8-ledger.md @@ -9,7 +9,7 @@ - Feature branch: `feature/cleanup-logging-web-build` - Human owner: plebdev - Started: 2026-07-13 -- Current status: items 1–8 complete locally; final review fixes verified and ready for PR CI refresh +- Current status: items 1–8 complete; final review fixes and hosted gates passed; PR #92 merged into `staging` - Skill setup status: present and verified ## Goal @@ -63,7 +63,7 @@ Complete all eight cleanup items from the repository scan end to end, optimizing | Issue | Fixed point | Worker session | Commit | Review result | Checks | | --- | --- | --- | --- | --- | --- | -| Items 1–3 (#89–#91) | `staging` | completed prior sessions + final review fixes | `934a2d5`, `1543c6a`, `62ba743`, `d4fb538`, `2f3126e`, `932a787` | pass/pass | lint, typecheck, 879 tests, builds, pack, CI refresh pending | +| Items 1–3 (#89–#91) | `staging` | completed prior sessions + final review fixes | `934a2d5`, `1543c6a`, `62ba743`, `d4fb538`, `2f3126e`, `932a787` | pass/pass | lint, typecheck, 879 tests, builds, pack; hosted gates passed | | Item 4 (#94) | `1334370` | Luna-high worker + orchestrator | `e77c49c` | pass/pass | coverage 66/879; 23 nested indexes measured | | Item 5 (#95) | `2f6a6f4` | orchestrator | `3daf6ad` | pass/pass after wording fix | five targeted scripts; integrated 66/879 | | Item 6 (#96) | `e77c49c` | orchestrator | `40f01c9` | pass/pass | lockfiles, 56 focused tests, builds, pack | diff --git a/docs/agents/runs/feature-cleanup-runtime-debt-ledger.md b/docs/agents/runs/feature-cleanup-runtime-debt-ledger.md index 72023282..c29fc846 100644 --- a/docs/agents/runs/feature-cleanup-runtime-debt-ledger.md +++ b/docs/agents/runs/feature-cleanup-runtime-debt-ledger.md @@ -9,7 +9,7 @@ - Feature branch: `feature/cleanup-runtime-debt` - Human owner: plebdev - Started: 2026-07-14 -- Current status: implementation, integrated verification, and local final review complete; non-draft staging PR #108 open for hosted review +- Current status: implementation and integrated verification complete; hosted gates passed; PR #108 merged into `staging` - Skill setup status: present and verified (`AGENTS.md`, GitHub issue tracker, triage labels, single-context domain docs) ## Goal diff --git a/docs/agents/runs/feature-collapse-event-validation-ledger.md b/docs/agents/runs/feature-collapse-event-validation-ledger.md index 48a4d77f..6f66524a 100644 --- a/docs/agents/runs/feature-collapse-event-validation-ledger.md +++ b/docs/agents/runs/feature-collapse-event-validation-ledger.md @@ -9,7 +9,7 @@ - Feature branch: `feature/collapse-event-validation` - Human owner: plebdev - Started: 2026-07-06T13:30:37Z -- Current status: PR opened +- Current status: hosted gates passed; PR #86 merged into `staging` - Skill setup status: created `docs/agents/*`; GitHub triage labels confirmed/created ## Goal diff --git a/docs/agents/runs/issue-112-coderabbit-local.md b/docs/agents/runs/issue-112-coderabbit-local.md new file mode 100644 index 00000000..f19c0d5c --- /dev/null +++ b/docs/agents/runs/issue-112-coderabbit-local.md @@ -0,0 +1,46 @@ +# CodeRabbit Round: #112 Local Branch + +## Round + +- Scope: local +- Round number: 1 +- Command or trigger: `coderabbit review --agent --type all --base staging` +- Started: 2026-07-18 +- Completed: 2026-07-18 +- Availability: completed +- Fallback review thread: none + +## Findings To Address + +| Finding | Severity | Decision | Notes | +| --- | --- | --- | --- | +| v2 nonce/AAD helpers accepted values longer than 32 bytes | minor | fixed | Both helpers now require exact 32-byte values; 31/33-byte regression tests added. | +| Example README claimed rejection behavior the demo did not execute | minor | fixed | Demo now mutates a valid payload to versions 0, 1, and 3 and calls public decryption. | +| Compatibility demo did not exercise non-v2 decryption | minor | fixed | Public `decryptNIP44` rejection is demonstrated for reserved, undefined, and unknown versions. | +| Compliance output omitted unknown versions | minor | fixed | Summary now includes unknown versions. | +| Basic demo wording omitted unknown versions | major | fixed as wording issue | Existing text already rejected v0/v1; wording now explicitly includes unknown versions. | + +## Findings Not Addressed + +| Finding | Reason | +| --- | --- | +| None | — | + +## Result + +- Continue: yes +- Escalate: no +- Notes: Focused NIP-44 tests 25/25, lint, root typecheck, examples build, and the real version-compatibility example passed after fixes. The pre-review integrated matrix was Jest 991/991 and Bun 991/991. + +## Hosted PR Round + +- Scope: PR #120 +- Trigger: `@coderabbitai review` +- Completed: 2026-07-18 +- Findings: 3 + +| Finding | Severity | Decision | Notes | +| --- | --- | --- | --- | +| Main demo retained a contradictory v0/v1/v2 decryption claim | minor | fixed | The versioning summary now states the v2-only acceptance contract. | +| Compatibility demo accepted any thrown error and did not fail on unexpected success | minor | fixed | The runnable example now requires the exact stable rejection message and throws on unrelated behavior. | +| Example overview omitted unknown versions | minor | fixed | The overview now matches the reserved, undefined, and unknown rejection contract. | diff --git a/docs/agents/runs/issue-112-review-packet.md b/docs/agents/runs/issue-112-review-packet.md new file mode 100644 index 00000000..8eb09693 --- /dev/null +++ b/docs/agents/runs/issue-112-review-packet.md @@ -0,0 +1,46 @@ +# Review Packet: #112 NIP-44 Legacy-Version Behavior + +## Issue + +- Issue: #112 +- Slice type: correctness/security compatibility cleanup +- Acceptance criteria: authoritative version registry; public version tests; no placeholder compatibility; stable unsupported-version errors; full verification +- Baseline: `df13432` +- Current diff: `git diff df13432...HEAD` + +## Implementation Summary + +SNSTR now accepts the only defined NIP-44 algorithm version, v2, and reports reserved v0, undefined v1, future version bytes, and future non-base64 encodings as unsupported. Official v2 vector decryption can no longer be swallowed by a warning-only test path. Source and examples no longer claim nonexistent v0/v1 compatibility. + +## Implementation Evidence + +- `implement` session: `docs/agents/runs/issue-112-session.md` +- `tdd` used: yes +- Red test: reserved v0 tampering remained accepted at `decodePayload` +- Green implementation: `decodePayload` requires v2 before extracting cryptographic fields; legacy passthrough decryptors and constants removed +- Refactor: version dispatch and contradictory placeholder branches removed +- Commands run: 107 focused NIP-44 tests; 991 Jest tests; 991 Bun tests; lint; command verification; root/examples typecheck; CJS/ESM build; examples build; package verification + +## Review Instructions + +Review only this issue's slice unless you find a severe cross-slice regression. Keep standards and spec findings separate. + +Check: + +- Acceptance criteria are met. +- Tests verify behavior through public interfaces. +- No implementation-only tests are masquerading as behavior tests. +- No obvious incomplete work, TODO placeholders, or unrelated changes. +- Relevant test, typecheck, build, or visual verification commands pass. + +## Reviewer Output + +```text +STANDARDS_STATUS: pass +STANDARDS_FINDINGS: +- None. The change removes speculative legacy branches and contradictory comments, keeps the public package-root NIP-44 interface stable, and follows repository TypeScript/test conventions. + +SPEC_STATUS: pass +SPEC_FINDINGS: +- One partial gap was found and fixed: reserved/undefined version rejection is now asserted through public `decrypt` as well as `decodePayload`. +``` diff --git a/docs/agents/runs/issue-112-session.md b/docs/agents/runs/issue-112-session.md new file mode 100644 index 00000000..ef2b4632 --- /dev/null +++ b/docs/agents/runs/issue-112-session.md @@ -0,0 +1,37 @@ +# Issue Session: #112 NIP-44 Legacy-Version Behavior + +## Issue + +- Issue: #112 — Make NIP-44 legacy-version behavior explicit and vector-tested +- Fixed point before session: `df13432` +- Implementation owner: current Codex orchestrator; Grok unavailable at authentication preflight +- Commits: `ab22d13`, `fa85df2`, `03e4eb5` +- Status: implementation, integrated verification, and local review complete + +## Inputs + +- Spec issue: #111 +- Ticket: #112 +- Relevant glossary terms: Nostr Event, NIP +- Relevant ADRs: none; ADR 0001 does not own NIP-44 payload decoding +- Prototype answer and source branch, if any: none + +## Implementation + +- Public interface used: NIP-44 `encrypt`, `decodePayload`, and `decrypt` +- Behaviors covered: v2 official vectors decrypt; reserved v0, undefined v1, future versions, and non-base64 future encodings report unsupported versions; v0/v1 encryption remains prohibited with accurate reasons +- `tdd` used: yes; changed public decode expectations first and observed v0 decoding remain green before implementation +- Commands run during implementation: focused official-vector and format-validation tests; all NIP-44 tests; root and example typechecks; example build +- Full suite command: `npm test -- --runInBand --detectOpenHandles` and `npm run test:bun` + +## Review + +- Review fixed point: `df13432` +- Standards findings: pass; no documented-standard violations or unresolved Fowler smells +- Spec findings: one partial acceptance-criterion gap: reserved/undefined versions were asserted through `decodePayload`, but not through public `decrypt` +- Worthy fixes applied: added public `decrypt` rejection assertions for tampered v0/v1 payloads; exact 32-byte helper nonce enforcement; runnable public rejection demo and aligned documentation +- Findings ignored with reasons: none + +## Risks + +- This intentionally stops accepting payloads whose version byte was changed to reserved/undefined values while otherwise using the v2 layout. The authoritative NIP defines no v0/v1 decryption algorithm and requires unknown versions to be reported as unsupported. diff --git a/docs/agents/runs/issue-113-coderabbit-local.md b/docs/agents/runs/issue-113-coderabbit-local.md new file mode 100644 index 00000000..4297e6a6 --- /dev/null +++ b/docs/agents/runs/issue-113-coderabbit-local.md @@ -0,0 +1,24 @@ +# CodeRabbit Round: #113 Local Branch + +## Round + +- Scope: local +- Round number: 1 +- Command: `coderabbit review --agent --type all --base staging` +- Completed: 2026-07-18 +- Findings: 4 + +## Decisions + +| Finding | Decision | Notes | +| --- | --- | --- | +| Disconnect cleanup occurred after the no-socket early return, and late validation could repopulate retained state | fixed | Common teardown now precedes the early return; validation results are accepted only for the same still-active subscription. | +| Bulk addressable reads did not refresh matching entries' LRU timestamps | fixed | Both bulk queries use one collection path that refreshes only matching addresses. | +| Capacity options accepted zero, non-finite, fractional, negative, and unsafe values | fixed | All five capacities require positive safe integers and retain defaults when omitted. | +| Deterministic coverage omitted invalid capacities, per-pubkey kind eviction, and bulk-read LRU refresh | fixed | Focused store tests now cover all three policy paths. | + +## Result + +- Worthy findings fixed: 4 +- Findings skipped: 0 +- Post-fix verification: lint and strict typecheck pass; focused Relay/store/order/addressability suites pass 80/80 in both Jest and Bun diff --git a/docs/agents/runs/issue-113-review-packet.md b/docs/agents/runs/issue-113-review-packet.md new file mode 100644 index 00000000..7ed04630 --- /dev/null +++ b/docs/agents/runs/issue-113-review-packet.md @@ -0,0 +1,50 @@ +# Review Packet: #113 Relay Event Store + +## Fixed Point + +- Base: `staging` at `8c36233` +- Branch: `feature/relay-event-store` +- Issue: #113 + +## Change Story + +- A focused internal `RelayEventStore` owns event buffers, sorting, capacity, LRU eviction, and replaceable/addressable retention. +- Relay retains network lifecycle, validation, subscription state, EOSE coordination, timers, and callback delivery. +- Existing private storage maps and eviction algorithms are removed from Relay. +- Ordering tests call the authoritative policy rather than duplicating its comparator. +- Deterministic unit tests cover buffer ordering/capacity/LRU, replaceable tie-breaking, address keys/capacity, misses, and clearing. + +## Compatibility + +- Public Relay methods and constructor options are unchanged. +- Valid event delivery and EOSE ordering remain covered through Relay integration tests. +- Storage lookup methods retain their public signatures. +- The internal store is not part of the package export surface. + +## Review Axes + +### Standards + +- The extracted module owns a cohesive policy rather than merely moving helper functions. +- Relay depends on a small retention API and no longer owns maps, capacity counters, or eviction loops. +- Clock and eviction diagnostics are injected at the deterministic boundary. + +### Specification + +- Buffered delivery is newest-first, then lowest event ID. +- Replaceable and addressable timestamp ties retain the lowest event ID. +- Addressable identity is kind, pubkey, and `d` tag. +- Public delivery, EOSE, and storage tests remain green. + +## Verification + +- Focused Jest/Bun after local review: 80/80 each +- Lint and strict typecheck: pass +- Jest: 74 suites, 1013/1013 +- Bun: 1013/1013 +- Commands, lint, root/examples typechecks, CJS/ESM build, examples build, and pack verification: pass +- Local CodeRabbit: 4/4 findings fixed; hosted review passed before PR #122 merged into `staging` + +## Known Tooling Limitation + +- Grok could not be delegated because the required `agent` CLI is unauthenticated. Per the Grok skill, no substitute subagent was used. diff --git a/docs/agents/runs/issue-113-session.md b/docs/agents/runs/issue-113-session.md new file mode 100644 index 00000000..14a5a865 --- /dev/null +++ b/docs/agents/runs/issue-113-session.md @@ -0,0 +1,34 @@ +# Issue Session: #113 Relay Event Store + +## Scope + +- Fixed point: `8c36233` (`staging` after PR #121) +- Branch: `feature/relay-event-store` +- Issue: #113 +- Owner: current Codex orchestrator; Grok unavailable because `agent` authentication is required + +## Acceptance Map + +| Acceptance criterion | Implementation / verification seam | +| --- | --- | +| Preserve public Relay behavior and EOSE/order/storage semantics | existing Relay integration suites plus focused store tests | +| One owner for limits, eviction, replacement, addressability, and ordering | `src/nip01/relayEventStore.ts` | +| Remove storage implementation detail from Relay | Relay holds one store and delegates retention decisions | +| Exercise public behavior and deterministic module behavior | existing public Relay tests and `relayEventStore.test.ts` | +| Full verification matrix | completed; see verification results below | + +## Decisions + +- Keep connection lifecycle, validation coordination, EOSE state, timers, subscriptions, and callback delivery in `Relay`. +- Move only deterministic buffering and retained-event policy into the internal store. +- Inject the clock and eviction observer so capacity behavior is deterministic and directly testable. +- Apply NIP-01's lower-ID tie-break to replaceable events as well as addressable events. +- Do not export the internal store from the package entrypoints. + +## Verification + +- Strict typecheck and lint: pass +- Focused Relay, ordering, addressability, and store suites after review: Jest 80/80 and Bun 80/80 +- Full Jest: 74 suites, 1013/1013 pass +- Full Bun: 1013/1013 pass +- Commands, lint, root/examples typecheck, CJS/ESM build, examples build, and pack verification: pass diff --git a/docs/agents/runs/issue-114-coderabbit-hosted.md b/docs/agents/runs/issue-114-coderabbit-hosted.md new file mode 100644 index 00000000..8684615c --- /dev/null +++ b/docs/agents/runs/issue-114-coderabbit-hosted.md @@ -0,0 +1,26 @@ +# CodeRabbit Round: #114 Hosted PR + +## Round + +- Scope: PR #123 +- Command: `@coderabbit full review` +- Completed: 2026-07-18 +- Inline findings: 7 + +## Decisions + +| Finding | Decision | Notes | +| --- | --- | --- | +| Parser accepted relay-less NWC URLs | fixed | Parse and generate now share the required-relay invariant. | +| Malformed request envelopes became INTERNAL_ERROR | fixed | Typed parse errors now map to INVALID_REQUEST with UNKNOWN correlation. | +| Invalid secondary lookup identifier reached the wallet | fixed | Both optional identifiers are validated when present. | +| Normalize all unknown methods to UNKNOWN | skipped | Existing public behavior correlates unsupported-method errors to the supplied method. | +| Require make-invoice description | skipped | Existing runtime intentionally accepts omission despite the stricter TypeScript wallet interface. | +| Add broad sanitization and redact wallet exceptions | deferred | Material behavior/security policy change belongs to #117. | +| Exhaustively cover every branch | partially addressed | Added regression tests for every hosted fix; existing public suite covers all methods and encryption/lifecycle behavior. | + +## Result + +- Hosted fixes: 3 +- Evidence-based skips/deferments: 4 +- Post-fix focused verification: lint, strict typecheck, and NIP-47 71/71 pass diff --git a/docs/agents/runs/issue-114-coderabbit-local.md b/docs/agents/runs/issue-114-coderabbit-local.md new file mode 100644 index 00000000..2789d034 --- /dev/null +++ b/docs/agents/runs/issue-114-coderabbit-local.md @@ -0,0 +1,26 @@ +# CodeRabbit Round: #114 Local Branch + +## Round + +- Scope: local +- Command: `coderabbit review --agent --type all --base staging` +- Completed: 2026-07-18 +- Findings: 9 + +## Decisions + +| Finding group | Decision | Notes | +| --- | --- | --- | +| Malformed response JSON bypassed the supplied client error factory | fixed | Syntax errors now become the caller's protocol error type. | +| Request params accepted arrays | fixed | The canonical request parser now requires a non-array object. | +| Require relays and canonical 32-byte URL keys | skipped | Changes existing `parseNWCURL` acceptance and public behavior; belongs in #117 if approved. | +| Sanitize arbitrary wallet errors | skipped | Existing service intentionally preserves wallet error codes, messages, and data. | +| Override wallet-reported GET_INFO methods | skipped | Existing service preserves wallet info and adds only encryption capabilities. | +| Tighten amount, description, and all parameter bounds | skipped | Changes established accepted inputs; security policy is tracked by #117. | +| Map unknown wire methods to `UNKNOWN` | skipped | Existing behavior returns INVALID_REQUEST correlated to the supplied method. | + +## Result + +- Worthy findings fixed: 2 +- Evidence-based compatibility/scope skips: 7 +- Post-review verification: focused 70/70; full Jest/Bun 1021/1021; all types, builds, commands, and pack checks pass diff --git a/docs/agents/runs/issue-114-review-packet.md b/docs/agents/runs/issue-114-review-packet.md new file mode 100644 index 00000000..d4bfafa0 --- /dev/null +++ b/docs/agents/runs/issue-114-review-packet.md @@ -0,0 +1,27 @@ +# Review Packet: #114 NIP-47 Protocol Machinery + +## Fixed Point + +- Base: `staging` at `da7361e` +- Branch: `feature/nip47-protocol-codecs` +- Issue: #114 + +## Change Story + +- One internal codec owns NWC URLs, request JSON parsing, response JSON parsing, and response envelope validation. +- One internal dispatcher owns supported-method gating, method parameter guards, wallet invocation, response envelopes, and compatible error mapping. +- Stateful client/service classes retain encryption negotiation, event handling, request correlation, timeouts, relays, and notifications. +- Direct codec/dispatcher tests supplement the unchanged public integration suite. + +## Verification + +- Focused NIP-47 after hosted review: 8 suites, 71/71 +- Full Jest: 76 suites, 1021/1021 +- Full Bun: 1021/1021 +- Commands, lint, root/examples typechecks, CJS/ESM build, examples build, and pack verification: pass +- Local CodeRabbit: 2 findings fixed; 7 compatibility/security-scope suggestions skipped with evidence +- Hosted CodeRabbit: relay-less URLs, malformed-envelope error mapping, and invalid secondary lookup identifiers fixed; broader security/behavior changes deferred to #117 or skipped for compatibility + +## Known Tooling Limitation + +- Grok could not be delegated because the required `agent` CLI is unauthenticated. Per the Grok skill, no substitute subagent was used. diff --git a/docs/agents/runs/issue-114-session.md b/docs/agents/runs/issue-114-session.md new file mode 100644 index 00000000..3fc38538 --- /dev/null +++ b/docs/agents/runs/issue-114-session.md @@ -0,0 +1,24 @@ +# Issue Session: #114 NIP-47 Protocol Machinery + +## Scope + +- Fixed point: `da7361e` (`staging` after PR #122) +- Branch: `feature/nip47-protocol-codecs` +- Issue: #114 +- Owner: current Codex orchestrator; Grok unavailable because `agent` authentication is required + +## Acceptance Map + +| Acceptance criterion | Implementation / verification seam | +| --- | --- | +| Canonical URL and wire validation owners | `src/nip47/protocol.ts` | +| Client and service delegate pure protocol work | client delegates URL/response codecs; service delegates request parsing and dispatch | +| Exhaustive compatible dispatch | `src/nip47/requestDispatcher.ts` plus direct tests | +| Preserve lifecycle and encryption negotiation | existing public NIP-47 and NIP-44 integration suites | +| Full verification | post-hosted-review focused 71/71; full Jest/Bun 1021/1021; commands, types, builds, pack; hosted CodeRabbit and Bun/Node 16/18/20 checks green | + +## Decisions + +- Keep relays, encryption, correlation, timeouts, TTL state, and publishing in the client/service. +- Preserve historical loose public parameter acceptance and wallet error propagation; stricter security policy belongs to #117. +- Keep the codec and dispatcher internal rather than expanding the package export surface. diff --git a/docs/agents/runs/issue-115-coderabbit-local.md b/docs/agents/runs/issue-115-coderabbit-local.md new file mode 100644 index 00000000..d2f0117a --- /dev/null +++ b/docs/agents/runs/issue-115-coderabbit-local.md @@ -0,0 +1,20 @@ +# CodeRabbit Round: #115 Local Branch + +## Round + +- Scope: local +- Command: `coderabbit review --agent --type all --base staging` +- Completed: 2026-07-18 +- Findings: 1 + +## Decisions + +| Finding | Decision | Notes | +| --- | --- | --- | +| Map-compatible accessors bypassed canonical identity and disconnect ownership | fixed | Get/has canonicalize gracefully; set canonicalizes and disconnects a displaced Relay; delete canonicalizes and disconnects the removed Relay. | + +## Result + +- Worthy findings fixed: 1 +- Findings skipped: 0 +- Post-fix focused verification: 61/61; full Jest/Bun 1026/1026; all types, builds, commands, and pack checks pass diff --git a/docs/agents/runs/issue-115-review-packet.md b/docs/agents/runs/issue-115-review-packet.md new file mode 100644 index 00000000..9fb64f74 --- /dev/null +++ b/docs/agents/runs/issue-115-review-packet.md @@ -0,0 +1,26 @@ +# Review Packet: #115 Nostr Relay Registry + +## Fixed Point + +- Base: `staging` at `4e40b63` +- Branch: `feature/nostr-relay-registry` +- Issue: #115 + +## Change Story + +- `RelayRegistry` owns canonical URLs, instance registration, lookup, required lookup, replacement, removal, and disconnect-on-removal. +- `Nostr` remains the public facade and delegates only relay identity/ownership decisions. +- Public tests prove normalized operations share one Relay and invalid lookup/removal remain graceful. +- Direct registry tests cover replacement and deletion lifecycle policy. + +## Verification + +- Focused Nostr/registry/integration: 3 suites, 61/61 +- Full Jest: 77 suites, 1026/1026 +- Full Bun: 1026/1026 +- Commands, lint, root/examples typechecks, CJS/ESM build, examples build, and pack verification: pass +- Local CodeRabbit: 1/1 finding fixed + +## Known Tooling Limitation + +- Grok could not be delegated because the required `agent` CLI is unauthenticated. Per the Grok skill, no substitute subagent was used. diff --git a/docs/agents/runs/issue-115-session.md b/docs/agents/runs/issue-115-session.md new file mode 100644 index 00000000..5fdcbdb3 --- /dev/null +++ b/docs/agents/runs/issue-115-session.md @@ -0,0 +1,24 @@ +# Issue Session: #115 Nostr Relay Registry + +## Scope + +- Fixed point: `4e40b63` (`staging` after PR #123) +- Branch: `feature/nostr-relay-registry` +- Issue: #115 +- Owner: current Codex orchestrator; Grok unavailable because `agent` authentication is required + +## Acceptance Map + +| Acceptance criterion | Implementation / verification seam | +| --- | --- | +| Stable public Nostr facade | unchanged constructor and relay-management signatures | +| One owner for normalization, identity, lookup, removal, and lifecycle | `src/nip01/relayRegistry.ts` | +| Same observable Relay set | Nostr publishing, subscriptions, auth, fetch, and rate limits iterate the registry | +| Public behavior coverage | normalized add/get/remove and graceful invalid operations in `nostr.test.ts` | +| Full verification | focused 61/61; full Jest/Bun 1026/1026; commands, types, builds, pack | + +## Decisions + +- Keep callback attachment and all protocol operations in the Nostr facade. +- Keep the registry internal and Map-compatible only for existing test seams. +- Make every compatibility accessor uphold canonical identity and disconnect ownership. diff --git a/docs/agents/runs/issue-116-coderabbit-local.md b/docs/agents/runs/issue-116-coderabbit-local.md new file mode 100644 index 00000000..388bbca8 --- /dev/null +++ b/docs/agents/runs/issue-116-coderabbit-local.md @@ -0,0 +1,20 @@ +# CodeRabbit Round: #116 Local Branch + +## Round + +- Scope: local +- Command: `coderabbit review --agent --type all --base staging` +- Completed: 2026-07-18 +- Findings: 0 + +## Result + +- Worthy findings fixed: 0 +- Findings skipped: 0 +- Verification: focused lifecycle/integration Bun 25/25; full Jest/Bun 1026/1026; all types, builds, commands, examples, and pack checks pass + +## Hosted Follow-up + +- Added the missing ESM probe for the compatible legacy subpath. +- Changed package resolution proof to exercise an unpacked npm tarball as well as the checkout. +- Kept two pre-existing NIP-02 demo observations out of this ownership-only branch because its sole change there is the testing import boundary. diff --git a/docs/agents/runs/issue-116-review-packet.md b/docs/agents/runs/issue-116-review-packet.md new file mode 100644 index 00000000..3317540a --- /dev/null +++ b/docs/agents/runs/issue-116-review-packet.md @@ -0,0 +1,28 @@ +# Review Packet: #116 Ephemeral Relay Ownership + +## Fixed Point + +- Base: `staging` at `9e3aca8` +- Branch: `feature/ephemeral-relay-ownership` +- Issue: #116 + +## Change Story + +- `snstr/testing` is now the canonical Node-only package boundary for the supported in-memory `NostrRelay` integration utility. +- The legacy ephemeral-relay subpath remains compatible, while source tests, examples, and consumer documentation use the canonical export. +- Test setup moved out of production source and now observes teardown by awaiting `close()` instead of sleeping for a fixed delay. +- Unused ambient test globals were removed, and package verification rejects both former private support surfaces. +- Package verification executes CJS and ESM self-reference checks for the canonical subpath and a CJS compatibility check for the legacy alias. + +## Verification + +- Focused ephemeral Relay lifecycle/integration Bun: 3 files, 25/25 +- Full Jest: 77 suites, 1026/1026 +- Full Bun: 1026/1026 +- Commands, lint, root/examples typechecks, CJS/ESM build, examples build, and pack verification: pass +- Local CodeRabbit: zero findings +- Hosted CodeRabbit: 2 package-verification findings fixed; 2 unrelated pre-existing NIP-02 example findings skipped as outside this branch's behavior + +## Known Tooling Limitation + +- Grok could not be delegated because the required `agent` CLI is unauthenticated. Per the Grok skill, no substitute subagent was used. diff --git a/docs/agents/runs/issue-116-session.md b/docs/agents/runs/issue-116-session.md new file mode 100644 index 00000000..1e8a4a14 --- /dev/null +++ b/docs/agents/runs/issue-116-session.md @@ -0,0 +1,24 @@ +# Issue Session: #116 Ephemeral Relay Ownership + +## Scope + +- Fixed point: `9e3aca8` (`staging` after PR #124) +- Branch: `feature/ephemeral-relay-ownership` +- Issue: #116 +- Owner: current Codex orchestrator; Grok unavailable because `agent` authentication is required + +## Acceptance Map + +| Acceptance criterion | Implementation / verification seam | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Stability and intended consumers documented | README defines `NostrRelay` as a supported Node-only testing utility, not production relay infrastructure | +| Test-only globals and helpers do not ship | private source helpers removed; tarball verifier rejects their former CJS/ESM paths | +| Retained public testing surface works in CJS and ESM | canonical `snstr/testing` export plus compatible legacy `snstr/utils/ephemeral-relay` alias; automated package self-reference checks | +| Cleanup is lifecycle-observable | test setup awaits `NostrRelay.close()` without a fixed delay or swallowed teardown failure | +| Full verification | focused lifecycle Bun 25/25; full Jest/Bun 1026/1026; commands, types, builds, examples, and pack pass | + +## Decisions + +- Keep `NostrRelay` supported for Node integration tests through the canonical `snstr/testing` subpath. +- Retain the legacy subpath for 0.x compatibility while teaching all repository consumers the canonical boundary. +- Keep integration-only setup in `tests/`; production builds and tarballs must not contain private test helpers or ambient test globals. diff --git a/docs/agents/runs/issue-117-coderabbit-local.md b/docs/agents/runs/issue-117-coderabbit-local.md new file mode 100644 index 00000000..aa91e692 --- /dev/null +++ b/docs/agents/runs/issue-117-coderabbit-local.md @@ -0,0 +1,25 @@ +# CodeRabbit Round: #117 Local Branch + +## Round + +- Scope: local +- Command: `coderabbit review --agent --type all --base staging` +- Completed: 2026-07-18 +- Findings: 1 + +## Decisions + +| Finding | Decision | Notes | +| ----------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------- | +| Canonical key tests did not reach the curve parser or scalar/field boundaries | fixed | Added field-prime, in-range off-curve x, curve-order, malformed width, and malformed character cases. | + +## Result + +- Worthy findings fixed: 1 +- Findings skipped: 0 +- Post-fix focused coverage: 231/231; 81.19% aggregate branches; key validation 100% branches; wire/limits 100% + +## Hosted Follow-up + +- Clarified that the issue-session commit cell represents an open but fully verified branch, not incomplete verification. +- Added explicit uppercase public-key format compatibility coverage. diff --git a/docs/agents/runs/issue-117-review-packet.md b/docs/agents/runs/issue-117-review-packet.md new file mode 100644 index 00000000..61016b0d --- /dev/null +++ b/docs/agents/runs/issue-117-review-packet.md @@ -0,0 +1,29 @@ +# Review Packet: #117 Shared Security Validation Ownership + +## Fixed Point + +- Base: `staging` at `94eed4a` +- Branch: `feature/security-validation-ownership` +- Issue: #117 + +## Change Story + +- `wire-validation` owns exact-width hexadecimal forms and UTF-8 byte measurement. +- `key-validation` owns secp256k1 public-key format, curve-point, and private-scalar validity. +- `security-limits` owns shared resource ceilings; the former validator export remains compatible. +- NIP-01, NIP-02, NIP-29, NIP-42, NIP-44, NIP-46, NIP-56, Relay, signer/security paths, and the testing Relay delegate generic facts to those owners. +- NIP-specific policy and public error contracts are unchanged. + +## Verification + +- Focused public/security behavior: 9 suites, 273/273 +- Focused coverage after review fix: 7 suites, 231/231; 81.19% aggregate branches; key validation 100% branches; wire/limits 100% +- Local CodeRabbit: 1/1 finding fixed +- Hosted CodeRabbit: 2/2 findings fixed +- Full Jest: 78 suites, 1030/1030 +- Full Bun: 1030/1030 +- Commands, lint, root/examples typechecks, CJS/ESM build, examples build, and pack verification: pass + +## Known Tooling Limitation + +- Grok could not be delegated because the required `agent` CLI is unauthenticated. Per the Grok skill, no substitute subagent was used. diff --git a/docs/agents/runs/issue-117-session.md b/docs/agents/runs/issue-117-session.md new file mode 100644 index 00000000..b63e92af --- /dev/null +++ b/docs/agents/runs/issue-117-session.md @@ -0,0 +1,24 @@ +# Issue Session: #117 Shared Security Validation Ownership + +## Scope + +- Fixed point: `94eed4a` (`staging` after PR #125) +- Branch: `feature/security-validation-ownership` +- Issue: #117 +- Owner: current Codex orchestrator; Grok unavailable because `agent` authentication is required + +## Acceptance Map + +| Acceptance criterion | Implementation / verification seam | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Generic validation rules have canonical owners | `wire-validation.ts`, `key-validation.ts`, and `security-limits.ts` | +| NIP-specific policy remains local | NIP-46 request limits, permissions, timestamp windows, production relay policy, and error sanitization remain in NIP-46 | +| Public compatibility remains stable | NIP-44 re-exports its existing key validators; `security-validator` re-exports `SECURITY_LIMITS`; public error paths remain unchanged | +| Public behavior plus pure seams are tested | Nostr Event, Relay, NIP-46, security-limit, and focused canonical-validator suites | +| Security coverage and full verification pass | focused coverage and full Jest/Bun release matrix | + +## Decisions + +- Separate wire representation, cryptographic key validity, and shared resource ceilings rather than moving all policy into one catch-all validator. +- Delegate generic facts from NIP modules while preserving their existing error text and compatibility behavior. +- Keep NIP-46's deliberately different content/tag limits and permission rules local because they are protocol policy, not generic facts. diff --git a/docs/agents/runs/issue-118-coderabbit-local.md b/docs/agents/runs/issue-118-coderabbit-local.md new file mode 100644 index 00000000..f1b7cbf1 --- /dev/null +++ b/docs/agents/runs/issue-118-coderabbit-local.md @@ -0,0 +1,40 @@ +# CodeRabbit Round: #118 Local Branch + +## Round + +- Scope: local +- Round number: 1 +- Command: `coderabbit review --agent --type all --base staging` +- Completed: 2026-07-18 +- Findings: 5 + +## Decisions + +| Finding | Decision | Notes | +| --- | --- | --- | +| Session acceptance map still said full verification was pending | fixed | Status now matches the completed matrix. | +| Review packet and other run artifacts had inconsistent verification status | fixed | Session and ledger now report the same completed results. | +| Ledger still described #118 as pending / targeted-only | fixed | Both #118 ledger rows now contain the full verified matrix. | +| Main Relay uses `Filter[]` while the wire tuple uses `NostrFilter[]` | skipped | `Filter` is intentionally the public extensible subtype of `NostrFilter`; strict TypeScript proves it is assignable, while changing subscription storage would remove custom-filter support. | +| Ephemeral Relay asserted attacker-controlled REQ filters as trusted | fixed | REQ subscription ids are narrowed and filters now pass through the existing security validator before `_onreq`. | + +## Result + +- Worthy findings fixed: 4 +- Findings skipped with evidence: 1 +- Post-fix verification: lint and strict typecheck pass; focused protocol, Relay, ephemeral Relay, filter, and export-policy suites pass 85/85 + +## Hosted PR Round + +- Scope: PR #121 +- Trigger: `@coderabbit full review` +- Completed: 2026-07-18 +- Findings: 2 + +| Finding | Decision | Notes | +| --- | --- | --- | +| Invalid REQ filters were reported as generic parse failures | fixed | `SecurityValidationError` now maps to the stable `invalid: REQ filters` NOTICE. | +| Public direction-specific aliases were missing from the type inventory | fixed | All four EVENT/AUTH direction aliases are documented. | + +- Post-hosted-review verification: lint and strict typecheck pass; filter, protocol type, and export-policy suites pass 20/20, including a raw-WebSocket malformed REQ regression test. +- CI portability follow-up: the first raw socket test bypassed the repository's in-memory transport under Bun. It now sends through the connected Relay test seam; the filter suite passes 13/13 in both Jest and Bun. diff --git a/docs/agents/runs/issue-118-review-packet.md b/docs/agents/runs/issue-118-review-packet.md new file mode 100644 index 00000000..c00507b5 --- /dev/null +++ b/docs/agents/runs/issue-118-review-packet.md @@ -0,0 +1,49 @@ +# Review Packet: #118 Authoritative Protocol Messages + +## Fixed Point + +- Base: `staging` at `c7cb99f` +- Branch: `feature/protocol-message-types` +- Issue: #118 + +## Change Story + +- `src/types/protocol.ts` is the single owner of supported client/Relay wire tuples. +- Direction-specific unions prevent client and Relay producers from emitting opposite-direction shapes. +- Main Relay subscription, unsubscription, publication, and authentication messages consume canonical client types. +- Ephemeral Relay output consumes the canonical Relay union; its duplicate EVENT alias is removed. +- Protocol types are exposed from the package root as type-only exports, preserving Node/web runtime parity. +- Current NIP-01 `CLOSED` is included because runtime support already existed even though the ticket list omitted it. + +## Compatibility + +- Existing individual tuple type names remain available. +- JSON tuple serialization is unchanged and explicitly tested. +- No runtime export was added. +- Valid wire parsing and serialization are unchanged; malformed ephemeral Relay REQ filters now use the existing validation boundary instead of an unchecked assertion. + +## Review Axes + +### Standards + +- Canonical type ownership is deep enough to remove producer-side tuple duplication. +- Direction-specific names make ownership and valid use clear. +- Type-only root exports avoid accidental runtime API expansion. + +### Specification + +- Current NIP-01 client tuples: EVENT, REQ, CLOSE. +- Current NIP-01 Relay tuples: EVENT, OK, EOSE, CLOSED, NOTICE. +- Current NIP-42 AUTH is covered in both directions. +- All ticket acceptance criteria have an implementation and verification seam. + +## Verification + +- Targeted protocol/Relay: 60/60 +- Jest: 73 suites, 994/994 +- Bun: pass +- Commands, lint, root/examples typechecks, CJS/ESM build, examples build, pack verification: pass + +## Known Tooling Limitation + +- Grok could not be delegated because the required `agent` CLI is unauthenticated. Per the Grok skill, no substitute subagent was used. diff --git a/docs/agents/runs/issue-118-session.md b/docs/agents/runs/issue-118-session.md new file mode 100644 index 00000000..2673cf59 --- /dev/null +++ b/docs/agents/runs/issue-118-session.md @@ -0,0 +1,34 @@ +# Issue Session: #118 Authoritative Protocol Messages + +## Scope + +- Fixed point: `c7cb99f` (`staging` after PR #120) +- Branch: `feature/protocol-message-types` +- Issue: #118 +- Owner: current Codex orchestrator; Grok unavailable because `agent` authentication is required + +## Acceptance Map + +| Acceptance criterion | Implementation / verification seam | +| --- | --- | +| One canonical owner for EVENT, REQ, CLOSE, OK, EOSE, NOTICE, and AUTH tuples | `src/types/protocol.ts`; current NIP-01 `CLOSED` support is included as well | +| Main and ephemeral Relay consume canonical definitions | `src/nip01/relay.ts`, `src/utils/ephemeral-relay.ts` | +| Remove stale local aliases and comments | `ClientMessage` and `NostrRelayEventMessage` removed | +| Preserve runtime behavior and compile-time coverage | direction-specific tuple assertions and serialization tests | +| Full verification matrix | completed; see verification results below | + +## Decisions + +- Keep the existing individual public tuple names for compatibility. +- Add direction-specific `NostrClientMessage` and `NostrRelayMessage` unions so producers cannot accidentally emit a tuple belonging to the opposite side. +- Re-export the authoritative protocol module from the package root. +- Preserve JSON parsing and serialization behavior; this ticket changes type ownership, not wire semantics. +- Include the existing runtime-supported `CLOSED` tuple, which current NIP-01 defines but the ticket list omitted. + +## Verification + +- `npx tsc --noEmit`: pass +- Targeted protocol and Relay suites: 60/60 pass +- Full Jest: 73 suites, 994/994 pass +- Full Bun: pass +- Commands, lint, root/examples typecheck, CJS/ESM build, examples build, and pack verification: pass diff --git a/docs/agents/runs/issue-119-coderabbit-hosted.md b/docs/agents/runs/issue-119-coderabbit-hosted.md new file mode 100644 index 00000000..3841a2c1 --- /dev/null +++ b/docs/agents/runs/issue-119-coderabbit-hosted.md @@ -0,0 +1,23 @@ +# CodeRabbit Round: #119 Hosted PR + +## Round + +- Scope: PR #127 +- Trigger: exact `@coderabbit full review` +- Completed: 2026-07-18 +- Findings: 4 + +## Decisions + +| Finding | Decision | Notes | +| ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------- | +| #117 ledger state was stale | fixed | Session ledger now records PR #126's merge and final hosted matrix. | +| Canonical docs did not activate the pinned npm | fixed | Contributor, agent, README, and release flows now activate npm 9.8.1 through Corepack. | +| Workflow verification lost job ownership | fixed | Required commands are checked inside their intended Node and Bun jobs, with a swapped-job regression test. | +| Newly enforced policy branches lacked coverage | fixed | Added focused cases for Bun inputs, lock shape, forbidden locks, and missing workflow. | + +## Result + +- Worthy findings fixed: 4 +- Findings skipped: 0 +- Post-fix verifier tests: 28/28 diff --git a/docs/agents/runs/issue-119-coderabbit-local.md b/docs/agents/runs/issue-119-coderabbit-local.md new file mode 100644 index 00000000..2e3abb61 --- /dev/null +++ b/docs/agents/runs/issue-119-coderabbit-local.md @@ -0,0 +1,26 @@ +# CodeRabbit Round: #119 Local Branch + +## Round + +- Scope: local +- Command: `coderabbit review --agent --type all --base staging` +- Completed: 2026-07-18 +- Findings: 7 across two rounds + +## Decisions + +| Finding | Decision | Notes | +| ----------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------ | +| Global npm install was an ad hoc toolchain fetch | fixed | CI now activates the pinned npm through Corepack. | +| Missing/malformed files could throw | fixed | Reads and JSON parsing now return normal verifier diagnostics. | +| Workflow substring checks accepted comments or echoes | fixed | The verifier inspects active inline and block `run` commands. | +| Nonstandard package-manager metadata in package-lock | fixed | Removed; `package.json` remains the canonical metadata owner. | +| Test requested the removed lockfile metadata | skipped | Contradicted the valid finding immediately above it. | +| Repository assertion allegedly crossed an unspecified layer | skipped | This repository has no layered change contract; the workflow is part of issue #119's scope and branch. | +| Non-object JSON roots and direct Bun-pin reads | fixed | Object shape is enforced and `.bun-version` uses the diagnostic read seam. | + +## Result + +- Worthy findings fixed: 5 +- Findings skipped with evidence: 2 +- Post-fix verifier tests: 19/19 diff --git a/docs/agents/runs/issue-119-review-packet.md b/docs/agents/runs/issue-119-review-packet.md new file mode 100644 index 00000000..948a5c07 --- /dev/null +++ b/docs/agents/runs/issue-119-review-packet.md @@ -0,0 +1,29 @@ +# Review Packet: #119 Canonical Package-manager Policy + +## Fixed Point + +- Base: `staging` at `1838789` +- Branch: `feature/package-manager-policy` +- Issue: #119 + +## Change Story + +- npm 9.8.1 is the canonical dependency and release manager across metadata, CI, and contributor documentation. +- Bun 1.3.9 remains a pinned, frozen-lockfile compatibility lane. +- The obsolete root `pnpm-lock.yaml` is removed. +- A tested repository verifier rejects manager, lockfile, Bun-pin, and active CI-command drift and reports malformed inputs without stack traces. + +## Verification + +- Clean installs: npm 9.8.1 `ci`; Bun frozen lockfile +- Focused verifier tests after hosted review: 2 suites, 28/28 +- Local CodeRabbit: 7 findings; 5 fixed, 2 evidence-based skips +- Hosted CodeRabbit: 4/4 findings fixed +- Full Jest: 79 suites, 1033/1033 +- Coverage: 79 suites, 1033/1033; 77.38% statements / 64.43% branches +- Full Bun: 1033/1033 +- Commands, lint, CJS/ESM build, examples build, and pack verification: pass + +## Known Tooling Limitation + +- Grok could not be delegated because the required `agent` CLI is unauthenticated. Per the Grok skill, no substitute subagent was used. diff --git a/docs/agents/runs/issue-119-session.md b/docs/agents/runs/issue-119-session.md new file mode 100644 index 00000000..16da446e --- /dev/null +++ b/docs/agents/runs/issue-119-session.md @@ -0,0 +1,24 @@ +# Issue Session: #119 Canonical Package-manager Policy + +## Scope + +- Fixed point: `1838789` (`staging` after PR #126) +- Branch: `feature/package-manager-policy` +- Issue: #119 +- Owner: current Codex orchestrator; Grok unavailable because `agent` authentication is required + +## Acceptance Map + +| Acceptance criterion | Implementation / verification seam | +| ------------------------------------ | --------------------------------------------------------------------------------------------------- | +| npm owns installs and releases | `packageManager`, contributor/release docs, pinned Corepack activation, and `npm ci` | +| Bun remains a compatibility runner | `.bun-version`, `bun.lock`, frozen install, and Bun CI lane | +| Unsupported managers cannot drift in | root pnpm lock removed; verifier rejects pnpm, Yarn, and shrinkwrap locks | +| Metadata and CI remain consistent | `package-manager:verify` validates manifests, locks, active workflow commands, and malformed inputs | +| Both clean-install paths work | npm 9.8.1 clean install and frozen Bun install completed without lockfile drift | + +## Decisions + +- Pin npm 9.8.1 because it supports the Node 16/18/20 matrix and lockfile v3. +- Activate npm through Corepack in CI so the declared version is integrity-controlled instead of installed ad hoc. +- Keep the Bun pin at the repository's existing 1.3.9 policy; the local 1.3.11 runner also proved the frozen lock remains compatible. diff --git a/docs/agents/runs/issue-131-coderabbit-local.md b/docs/agents/runs/issue-131-coderabbit-local.md new file mode 100644 index 00000000..46155c91 --- /dev/null +++ b/docs/agents/runs/issue-131-coderabbit-local.md @@ -0,0 +1,32 @@ +# CodeRabbit Round: #131 Local Branch + +## Round + +- Scope: local committed branch against `staging` +- Round number: 1–4 +- Command or trigger: `coderabbit review --agent --type committed --base staging -c AGENTS.md` +- Started: 2026-07-18 +- Completed: 2026-07-18 +- Availability: completed +- Fallback review thread: not needed + +## Findings To Address + +| Finding | Severity | Decision | Notes | +| ------------------------------------------------------------------ | -------- | -------- | --------------------------------------------------------------------------------------------- | +| Normalized `privateKey` was absent from the sensitive-field policy | major | fixed | Added `privatekey` and actual generated private keys to public leak sentinels. | +| Tests did not include generated user and signer private keys | minor | fixed | Every relevant assertion now treats both private keys as forbidden output. | +| Throwing-logger test lacked a successful operation | minor | fixed | A real simple client/bunker connect and ping completes with every diagnostic method throwing. | +| Session artifact had contradictory hosted-gate status | minor | fixed | Clarified that local review is complete while hosted rerun and CI still gate merge. | + +## Findings Not Addressed + +| Finding | Reason | +| ------- | ------ | +| None | — | + +## Result + +- Continue: yes; round 2 reviewed the pre-hosted-fix committed diff with zero issues; round 3 found no code defects and one fixed documentation inconsistency; round 4 reviewed the complete branch with zero findings +- Escalate: no +- Notes: focused Jest and Bun redaction suites are green after all fixes; final full Jest and Bun suites are also green at 1054/1054 tests. diff --git a/docs/agents/runs/issue-131-review-packet.md b/docs/agents/runs/issue-131-review-packet.md new file mode 100644 index 00000000..056580b7 --- /dev/null +++ b/docs/agents/runs/issue-131-review-packet.md @@ -0,0 +1,43 @@ +# Review Packet: #131 NIP-46 Diagnostic Redaction + +## Issue + +- Issue: #131 +- Slice type: AFK security and diagnostic compatibility cleanup +- Acceptance criteria: no sensitive NIP-46 material at info/debug/trace; useful safe metadata retained; public behavior and logger compatibility preserved; all four facades covered through public seams +- Baseline: `f4bda34` +- Current diff: `git diff staging...HEAD` + +## Implementation Summary + +All four NIP-46 client and bunker facades now route diagnostics through one internal redacting, non-throwing adapter. Consumers may inject the existing structural `DiagnosticLogger`; defaults and 0.x logger aliases remain compatible. Connection strings, secrets, private keys, request parameters, plaintext, decrypted payloads, response results, auth URLs, and legacy interpolated envelopes are removed before delegation while operation, method, request ID, event ID, public-key, and safe failure context remains. + +## Implementation Evidence + +- `implement` session: `issue-131-session.md` +- `tdd` used: yes +- Red test, if applicable: public constructor options rejected logger injection before implementation; subsequent red cycles caught `connectResult` and the simple bunker's response envelope +- Green implementation, if applicable: seven public integration tests cover advanced/simple cross-pairs, legacy simple/simple, invalid-secret metadata, encryption plaintext, private-key sentinels, all diagnostic levels, throwing loggers, multiline payloads, reflected `Error` messages, and malformed legacy permissions +- Refactor, if applicable: one internal adapter owns recursive field policy, legacy message handling, non-throwing delegation, and optional level forwarding +- Commands run: focused Jest/Bun; NIP-46 Jest; full Jest/Bun; policy verifiers; lint; TypeScript; CJS/ESM builds; examples; pack verifier + +## Review Instructions + +Review only issue #131 unless a severe cross-slice regression appears. Keep standards and spec axes separate. Verify sensitive data cannot cross the configured diagnostic seam, diagnostics cannot alter public control flow, safe metadata remains useful, and the new logger option is additive and platform-safe. + +## Reviewer Output + +```text +STANDARDS_STATUS: pass +STANDARDS_FINDINGS: +- Two hard findings and several judgment-call gaps were fixed; Grok follow-up found no remaining actionable defect. + +SPEC_STATUS: pass +SPEC_FINDINGS: +- Initial coverage gaps and one free-form secret interpolation were fixed; all four acceptance criteria pass on follow-up. + +HOSTED_REVIEW_STATUS: pass after fixes +HOSTED_REVIEW_FINDINGS: +- CodeRabbit found multiline legacy payload leakage and reflected untrusted error messages; both were reproduced through public seams and fixed. +- A follow-up Grok pass found one additional legacy permission interpolation path; a public red test reproduced it, the log now exposes only permission count, and Grok approved the final patch. +``` diff --git a/docs/agents/runs/issue-131-session.md b/docs/agents/runs/issue-131-session.md new file mode 100644 index 00000000..27803e17 --- /dev/null +++ b/docs/agents/runs/issue-131-session.md @@ -0,0 +1,49 @@ +# Issue Session: #131 NIP-46 Diagnostic Redaction + +## Issue + +- Issue: #131 +- Fixed point before session: `f4bda34` +- Worker session: current Codex orchestrator; Grok 4.5 High standards/spec reviewers +- Commit: `7ed8433`, `00104cc`, `21b4ad9`, `426c17b` +- Status: local and hosted review complete; PR #140 merged into `staging` as `cf705f0`; all hosted gates passed + +## Inputs + +- Spec issue: #130 +- Ticket: #131 +- Relevant glossary terms: Nostr Event, Relay, NIP +- Relevant ADRs: ADR 0002 — unify diagnostics compatibly +- Prototype answer and source branch, if any: none + +## Implementation + +- Public interface used: diagnostic logger options on all four NIP-46 client and bunker constructors +- Behaviors covered: connection secrets, private keys, request parameters, decrypted protocol payloads, event and encryption plaintext, and full response envelopes never reach injected diagnostics; safe method, correlation, and failure metadata remains +- `tdd` used: yes; cross-facade public integration tests written before the logger adapter +- Commands run during implementation: focused Jest and Bun redaction suites; full NIP-46 Jest suite; targeted ESLint; TypeScript; Prettier; repository policy, lint, build, examples, and pack gates +- Full suite command: `npm test -- --runInBand --detectOpenHandles` + +## Review + +- Review fixed point: `f4bda34` +- Standards findings: Grok found two hard issues (throwing diagnostics could alter behavior; one interpolated connect secret) and judgment-call gaps around repeated object graphs, structural `setLevel`, and test coverage +- Spec findings: Grok found partial failure/secret-class/level coverage and the same free-form interpolation gap +- Worthy fixes applied: made diagnostics non-throwing; removed secret interpolation; fixed repeated-reference handling; forwarded optional structural `setLevel`; added encryption, invalid-secret, legacy simple/simple, per-level, private-key, and throwing-logger operation coverage; Grok follow-up reported no remaining defects +- Hosted review fixes: made legacy payload matching cross line boundaries; replaced raw `Error.message` with safe type/code metadata; redacted `error`, `message`, and `details` fields plus positional error/warn strings; removed two interpolated error messages; replaced legacy permission interpolation with count-only metadata +- Findings ignored with reasons: workflow ledgers are required Feature Dev artifacts; four facade wiring sites remain intentionally until issue #136 unifies the NIP-46 core; broad `data`/`result` redaction is deliberate security policy + +## Verification + +- Focused redaction: Jest 7/7; Bun 7/7 +- NIP-46 regression: Jest 166/166 before review fixes +- Final Jest after hosted fixes: 80/80 suites, 1054/1054 tests, no open handles, 298.293 seconds +- Final Bun after hosted fixes: 1054/1054 tests, 8173 assertions, 269.65 seconds +- Build/package: commands and package-manager policy, lint, TypeScript, CJS/ESM builds, examples, and pack verification green +- Local CodeRabbit: round 1 raised three valid code/test issues; all fixed; round 2 raised zero issues; round 3 raised one documentation-status inconsistency, fixed; round 4 raised zero issues +- Hosted CodeRabbit: raised two valid major findings; both fixed with public regressions; final hosted rerun passed +- Grok hosted-fix review: initial pass found one additional permission interpolation; fixed after a public red test; follow-up verdict `APPROVE` + +## Risks + +- No code or hosted-gate risks remained when PR #140 merged into `staging`. diff --git a/docs/agents/runs/issue-132-coderabbit-local.md b/docs/agents/runs/issue-132-coderabbit-local.md new file mode 100644 index 00000000..1494d364 --- /dev/null +++ b/docs/agents/runs/issue-132-coderabbit-local.md @@ -0,0 +1,35 @@ +# Local CodeRabbit Review: #132 Published Declaration Purity + +## Review Scope + +- Base: `staging` +- Branch: `feature/public-type-test-purity` +- Review mode: committed changes +- Initial implementation commit: `0c111d7` + +## Initial Findings + +CodeRabbit reported three minor findings, all accepted: + +1. Restrict `capturedCallbacks` to `RelayEvent` keys while preserving each event's callback signature. +2. Diagnose a missing consumer type version before forming an invalid `@types/*@undefined` install spec. +3. Detect dynamic TypeScript imports such as `import("jest").Mock` in packed declarations. + +A later pass reported one additional minor finding: preserve npm stderr when the isolated packed-consumer installation fails. This was also accepted. + +## Fixes and Verification + +- Replaced the arbitrary callback string index with an event-specific mapped type and added a negative type assertion for foreign keys. +- Validated every required consumer type against `devDependencies` before installation. +- Extended the declaration scan to cover dynamic imports from `jest` and `@jest/globals`. +- Wrapped the packed-consumer install so npm stderr survives in the final verification diagnostic. +- Focused Jest: 3/3 suites and 8/8 tests. +- Lint, strict TypeScript, CJS/ESM build, and packed-consumer verification: green. + +## Follow-up + +- The second committed review found no code issues. Its only finding was that this section still described the clean rerun as pending. +- A subsequent pass found the missing npm stderr diagnostic described above; the pack gate remained green after the fix. +- The post-fix green checks were the focused Jest, lint, strict TypeScript, CJS/ESM build, and packed-consumer run recorded above. +- Final committed review: zero findings across all eight changed files +- Status: clean diff --git a/docs/agents/runs/issue-132-review-packet.md b/docs/agents/runs/issue-132-review-packet.md new file mode 100644 index 00000000..a97c7c34 --- /dev/null +++ b/docs/agents/runs/issue-132-review-packet.md @@ -0,0 +1,42 @@ +# Review Packet: #132 Published Declaration Purity + +## Issue + +- Issue: #132 +- Slice type: AFK package declaration cleanup +- Acceptance criteria: root declarations contain no Jest ownership; extracted consumer typechecks without Jest; equivalent relay test context remains at `snstr/testing`; Node/web declaration parity remains green +- Baseline: `cf705f0` +- Current diff: `git diff staging...HEAD` + +## Implementation Summary + +The accidental `RelayTestContext` export has moved from the shared root/web type barrel to the existing Node-only `snstr/testing` boundary. Its mock slots now accept framework-neutral callables, including Jest mocks, without naming Jest. Pack verification scans all published declarations for Jest ownership and compiles an extracted consumer with library checking enabled and automatic ambient types disabled. + +## Implementation Evidence + +- `implement` session: `issue-132-session.md` +- `tdd` used: yes +- Red test: missing testing-entrypoint types and CJS/ESM declarations containing `jest.Mock` +- Green implementation: focused public type/entry suites, both builds, declaration scan, and extracted packed consumer +- Refactor: one test-only package boundary owns test context; production barrels own no test framework +- Commands run: focused Jest; CJS/ESM build; pack verification + +## Review Instructions + +Review only issue #132 unless a severe cross-slice regression appears. Keep standards and spec axes separate. Verify no published declaration names Jest, the extracted consumer cannot receive Jest ambient types, existing Jest mocks remain assignable to the framework-neutral type, and `snstr/testing` stays Node-only. + +## Reviewer Output + +```text +STANDARDS_STATUS: pass +STANDARDS_FINDINGS: +- Initial: `unknown[]` rejected specifically typed test doubles; public purity assertions and packed slot assignment were incomplete. +- Resolved: use a documented permissive callable, assert root/web absence, and exercise plain, typed, and Jest mocks at the testing boundary and in the packed consumer. +- Follow-up: no remaining findings. + +SPEC_STATUS: pass +SPEC_FINDINGS: +- Initial: a temp consumer below the checkout could resolve workspace Jest types; declaration patterns and installed-artifact coverage were incomplete. +- Resolved: install the tarball in an OS temp directory, assert Jest packages are absent, scan all installed declarations for Jest ownership, and compile with `types: []` and `skipLibCheck: false`. +- Follow-up: no remaining findings. +``` diff --git a/docs/agents/runs/issue-132-session.md b/docs/agents/runs/issue-132-session.md new file mode 100644 index 00000000..535fb8ed --- /dev/null +++ b/docs/agents/runs/issue-132-session.md @@ -0,0 +1,56 @@ +# Issue Session: #132 Published Declaration Purity + +## Issue + +- Issue: #132 +- Fixed point before session: `cf705f0` +- Worker session: current Codex orchestrator; Grok 4.5 High reviewers +- Commits: `0c111d7`, `20565a0`, `abfc836`, `dea7aa0`, `466d1d5`, `0d0ef41` +- PR: #141 +- Status: merged into `staging` as `46d7289`; issue closed + +## Inputs + +- Spec issue: #130 +- Ticket: #132 +- Relevant glossary terms: Relay +- Relevant ADRs: none +- Prototype answer and source branch, if any: none + +## Implementation + +- Public interface used: `snstr/testing` owns `RelayTestContext` and framework-neutral `RelayTestMock`; neither root nor web declarations expose test-runner types +- Behaviors covered: every packed declaration rejects Jest ownership; an extracted package consumer imports root and testing types with ambient type packages disabled; Node, browser, and React Native type resolution retain their existing targets +- `tdd` used: yes; the testing-entrypoint type test failed on missing exports and pack verification failed on both CJS and ESM `jest.Mock` declarations before implementation +- Commands run during implementation: focused Jest type/entry suites; CJS/ESM build; packed-consumer verification +- Full suite commands: `npm test -- --runInBand`; `npm run test:bun`; repository policy, lint, type, build, example, and pack gates + +## Review + +- Review fixed point: `cf705f0` +- Design findings: Grok recommended moving the context to the existing Node-only testing boundary, using framework-neutral callables, scanning every packed declaration, and compiling an extracted no-Jest consumer +- Standards findings: the first pass found that `unknown[]` made specifically typed doubles unassignable under strict function variance; it also requested root/web negative assertions and assignment of the exported mock type into packed-consumer context slots +- Spec findings: the first pass found that a consumer beneath the repository could still resolve workspace Jest types, the declaration scan needed namespace/import coverage, and scanning build output rather than the installed tarball left a packaging gap +- Worthy fixes applied: the mock callable uses a documented permissive parameter list; type tests lock root/web absence and typed/Jest mock compatibility; pack verification installs the tarball outside the repository, asserts Jest packages are absent, scans every installed declaration, and compiles a strict consumer that assigns the mock into context slots +- Findings ignored with reasons: hosted CodeRabbit's out-of-scope warning applies to the required feature-loop run artifacts, and its generic docstring-coverage warning conflicts with the existing style for small internal verifier helpers; neither produced an actionable review comment +- Follow-up result: Grok standards and spec reviews both passed with no remaining findings +- Local CodeRabbit findings: four minor findings accepted—event-specific callback keys, explicit missing consumer dependency diagnostics, dynamic Jest type-import detection, and preserved npm stderr on consumer install failure +- Local CodeRabbit fixes: mapped callback captures, dependency version guards, dynamic-import scan coverage, a negative callback-key type assertion, and surfaced npm install diagnostics; focused and package gates remain green +- Local CodeRabbit follow-up: no code findings; one stale pending-status note in the review record was corrected +- Local CodeRabbit final result: zero findings +- Hosted CodeRabbit result: full review completed with no actionable comments + +## Verification + +- Focused Jest: 3/3 suites, 8/8 tests +- CJS/ESM declarations: green +- Packed no-Jest consumer: green with `skipLibCheck: false` and automatic ambient types disabled +- Repository gates: commands/package-manager policy, lint, TypeScript, CJS/ESM builds, examples, and pack verification all green +- Full Jest: 81/81 suites and 1055/1055 tests in 296.062 seconds +- Full Bun: 1055/1055 tests and 8172 assertions across 81 files in 263.38 seconds +- Post-CodeRabbit focused verification: 3/3 suites and 8/8 tests; lint, strict TypeScript, CJS/ESM build, and pack verification green +- Hosted CI: Node 16, Node 18, Node 20, and Bun lanes green on PR #141 + +## Risks + +- `RelayTestContext` intentionally leaves the accidental root/web export and remains available from the documented `snstr/testing` subpath. diff --git a/docs/agents/runs/issue-133-coderabbit-local.md b/docs/agents/runs/issue-133-coderabbit-local.md new file mode 100644 index 00000000..b8f7ccfe --- /dev/null +++ b/docs/agents/runs/issue-133-coderabbit-local.md @@ -0,0 +1,31 @@ +# Local CodeRabbit Review: #133 Shared Diagnostic Seam + +## Review + +- Command: `coderabbit review --agent --type committed --base staging -c AGENTS.md` +- Reviewed commit: `b238461` +- Initial result: four major findings +- Re-review: complete; hosted follow-up findings were fixed and the final Grok standards/spec pass found no remaining issues + +## Findings and Resolutions + +1. `diagnosticFailureType` forwarded mutable `Error.name` values without a safe shape or length bound. Fixed with a bounded error identifier and an `Error` fallback; red-to-green coverage includes unsafe, overlong, and throwing getter names. +2. `RelayPool.addRelay` ignored `logger` when updating an existing Relay. Fixed with the additive non-throwing `Relay.setLogger` seam and public pool reconfiguration coverage. +3. Nostr did not propagate its effective default logger policy to child Relays. Fixed by always preserving relay options while assigning the effective logger; coverage proves the test-silent policy reaches children. +4. RelayPool did not propagate its effective default logger policy to child Relays. Fixed by always assigning the canonical pool logger unless an explicit child logger wins; coverage proves child output uses the pool policy. + +## Additional Standards Fix + +The post-CodeRabbit Grok standards pass found that an unknown relay wire type was copied verbatim into default-visible WARN context. It now emits the stable label `unknown`; a controlled public WebSocket regression proves the untrusted value is absent. + +## Hosted Review Follow-up + +The first hosted full review found two additional items. The cleanup ledger now records the issue #133 local report and PR #142, and `diagnosticFailureType` now catches a throwing custom `Error.name` getter before falling back to `Error`. Both were fixed with focused verification, followed by the full local and Grok gates. + +## Verification + +- Focused shared seam: 11/11 +- Jest: 82/82 suites, 1067/1067 tests +- Bun: 82 files, 1067/1067 tests +- Commands/package-manager policy, ESLint, strict TypeScript, CJS/ESM builds, examples, and pack verification: green +- Grok spec and final standards re-review: pass with no remaining hard findings diff --git a/docs/agents/runs/issue-133-review-packet.md b/docs/agents/runs/issue-133-review-packet.md new file mode 100644 index 00000000..7094337e --- /dev/null +++ b/docs/agents/runs/issue-133-review-packet.md @@ -0,0 +1,40 @@ +# Review Packet: #133 Shared Diagnostic Seam + +## Issue + +- Issue: #133 +- Slice type: AFK diagnostic consolidation +- Acceptance criteria: only the canonical logger writes to console; Relay, RelayPool, and stateless warning/error behavior remains compatible; levels and safe context follow ADR 0002; public seams cover configuration, silence, and compatibility +- Baseline: `46d7289` +- Current diff: `git diff staging...HEAD` + +## Implementation Summary + +Every production TypeScript module now delegates diagnostics to the canonical logger boundary. Stateful clients accept and propagate one additive `DiagnosticLogger`; stateless helpers accept the same contract through trailing parameters or existing options bags. Defaults retain WARN/ERROR console visibility, injected sinks are non-throwing, and unsafe raw relay values, protocol payloads, events, and error messages are replaced by stable structured metadata. No mutable package-global configuration or new logger contract was introduced. + +## Implementation Evidence + +- `implement` session: `issue-133-session.md` +- `tdd` used: yes +- Red test: logger properties/arguments were rejected by Relay, RelayPool, Nostr, NIP-65, and NIP-11; the structural gate listed direct production console owners +- Green implementation: focused affected suites 204/204, final shared-seam regression 11/11, strict TypeScript and lint green; secret-prefix, error-name, parent-policy, existing-child replacement, and unknown-wire regressions failed before their fixes and passed afterward; final Jest and Bun runs each passed 1067/1067 tests +- Refactor: one internal diagnostics module owns default construction, sink protection, failure typing, and relay-identifier redaction; the canonical logger remains the only console writer +- Commands run: focused and full Jest, full Bun, source console scan, command/package-manager policy verification, ESLint, strict TypeScript, CJS/ESM builds, example build, and pack verification + +## Review Instructions + +Review only issue #133 unless a severe cross-slice regression appears. Keep standards and spec axes separate. Verify there is no production console ownership outside the canonical logger, defaults retain visible warnings/errors, injected loggers can silence and capture output without changing control flow, structured context is safe, and ADR 0002 compatibility aliases remain unchanged. + +## Reviewer Output + +```text +STANDARDS_STATUS: pass +STANDARDS_FINDINGS: +- no hard findings after fixing the raw Relay URL prefix and unknown relay wire label leaks +- optional follow-ups only: test-only Nostr silence and DEBUG metadata shape + +SPEC_STATUS: pass +SPEC_FINDINGS: +- no missing, partial, over-scoped, or incorrectly implemented criteria +- raw Relay prefix, bounded failure type, parent policy, runtime replacement, unknown-wire redaction, and shared default-factory fixes verified +``` diff --git a/docs/agents/runs/issue-133-session.md b/docs/agents/runs/issue-133-session.md new file mode 100644 index 00000000..0f65872d --- /dev/null +++ b/docs/agents/runs/issue-133-session.md @@ -0,0 +1,45 @@ +# Issue Session: #133 Shared Diagnostic Seam + +## Issue + +- Issue: #133 +- Fixed point before session: `46d7289` +- Worker session: current Codex orchestrator; Grok 4.5 High reviewers +- Commit: `b238461`, `6dd75c3`, `ae15ace`; merged into `staging` at `2a3556d` +- Status: merged; issue closed + +## Inputs + +- Spec issue: #130 +- Ticket: #133 +- Relevant glossary terms: Relay +- Relevant ADRs: ADR 0002 — unify diagnostics compatibly +- Prototype answer and source branch, if any: none + +## Implementation + +- Public interface used: canonical `DiagnosticLogger`; additive logger injection on stateful options and stateless helpers +- Behaviors covered: exclusive console ownership, injected configuration and silence, WARN/ERROR-visible defaults, non-throwing sinks, policy propagation from Nostr/RelayPool to child Relays, safe stateless context, and root/web logger type compatibility +- `tdd` used: yes; the public seam test first failed because Relay, RelayPool, Nostr, NIP-65, and NIP-11 did not accept the canonical logger, and its structural scan found every remaining production console owner +- Commands run during implementation: focused Jest across 14 affected suites; strict TypeScript; ESLint; structural production console scan +- Full suite command: `npm test -- --runInBand`; `npm run test:bun`; repository policy, lint, type, build, example, and pack gates + +## Review + +- Review fixed point: `46d7289` +- Design findings: Grok inventoried every production `console.*` call and recommended additive instance/call injection over mutable global configuration, WARN/ERROR-visible defaults, a non-throwing dispatcher, safe structured context, and unchanged NIP-02 compatibility aliases +- Standards findings: initial review failed because the default Relay logger prefix retained the raw relay URL; a later pass caught an unbounded unknown relay wire type; both secret-exposure paths were fixed with red-to-green tests, and the final standards re-review passed with no hard findings +- Spec findings: passed with no missing, partial, over-scoped, or incorrectly implemented acceptance criteria; final hosted incremental review was clean +- Worthy fixes applied: a shared non-throwing dispatcher; additive stateful and stateless logger seams; canonical policy propagation to default and explicitly configured child Relays; replacement logging for existing pooled Relays; bounded failure types; redacted relay identifiers and prefixes; stable unknown-wire metadata; and migration of console-spy tests to injected public seams +- Findings ignored with reasons: test-only Nostr silence and DEBUG eviction metadata shape remain optional because they are not default-visible production WARN/ERROR behavior; all CodeRabbit findings were accepted and fixed + +## Verification + +- Focused: 14/14 suites and 204/204 tests; final seam test 11/11 after review-driven red-to-green cycles +- Full Jest: 82/82 suites and 1067/1067 tests, rerun after hosted fixes +- Full Bun: 82 files and 1067/1067 tests, rerun after hosted fixes +- Repository gates: commands and package-manager policy, ESLint, strict TypeScript, CJS/ESM builds, examples, pack verification, and all four hosted CI lanes green + +## Risks + +- Default warnings and errors must remain observable while injected diagnostics cannot change control flow or expose raw protocol payloads. diff --git a/docs/agents/runs/issue-134-coderabbit-local.md b/docs/agents/runs/issue-134-coderabbit-local.md new file mode 100644 index 00000000..bedb9aff --- /dev/null +++ b/docs/agents/runs/issue-134-coderabbit-local.md @@ -0,0 +1,22 @@ +# Local CodeRabbit Review: #134 NIP-47 Service Lifecycle + +## Review + +- Command: `coderabbit review --agent --type committed --base staging -c AGENTS.md` +- Reviewed commits: `569b266`, `644b52f` +- Initial result: one minor documentation finding after the included-review cooldown +- Re-review: clean with 0 findings after commit `ec8a754` + +## Findings and Resolutions + +1. The issue session still recorded its implementation commit as pending. Fixed by recording implementation commit `569b266` and verification-artifact commit `644b52f`. + +## Verification Before Retry + +- Focused lifecycle Jest/Bun: 6/6 +- NIP-47 Jest: 9/9 suites, 76/76 tests before the final review-driven test addition +- Full Jest: 83/83 suites, 1073/1073 tests +- Full Bun: 83 files, 1073/1073 tests +- Commands/package-manager policy, ESLint, strict TypeScript, CJS/ESM builds, examples, and pack verification: green +- Grok spec and final standards re-review: pass with no remaining hard findings +- Final CodeRabbit committed review: 0 findings across all seven changed files diff --git a/docs/agents/runs/issue-134-review-packet.md b/docs/agents/runs/issue-134-review-packet.md new file mode 100644 index 00000000..485f760d --- /dev/null +++ b/docs/agents/runs/issue-134-review-packet.md @@ -0,0 +1,46 @@ +# Review Packet: #134 NIP-47 Service Lifecycle + +## Issue + +- Issue: #134 +- Slice type: AFK lifecycle cleanup +- Acceptance criteria: sequential initialization keeps at most one subscription; concurrent initialization is single-flight; disconnect is idempotent and releases lifecycle resources; restart restores one working expiration-aware subscription; tests use the public service API +- Baseline: `2a3556d` +- Current diff: `git diff staging...HEAD` + +## Implementation Summary + +`NostrWalletService` now owns an explicit single-flight lifecycle. Initialization coalesces concurrent callers, no-ops once ready, waits for active teardown, scopes subscriptions to one lifecycle generation, and cleans failed attempts. Disconnect invalidates stale initialization, coalesces concurrent callers, releases subscriptions, relay connections, and expiration tracking, and synchronously observes expected cancellation rejections without hiding them from callers that await the original initialization promise. Reinitialization restarts expiration cleanup and creates one fresh request subscription. + +## Implementation Evidence + +- `implement` session: `issue-134-session.md` +- `tdd` used: yes +- Red tests: sequential initialization left two active Relay subscriptions; concurrent initialization returned different promises and duplicated work; a queued restart originally survived a later disconnect +- Green implementation: public lifecycle suite covers sequential and concurrent initialization, repeated disconnect, queued and in-flight cancellation, restart, one active subscription, and an encrypted expired request after restart +- Refactor: lifecycle state and resource ownership remain private to `NostrWalletService`; no production test hook or public API addition was introduced +- Commands run: focused Jest/Bun lifecycle suites, NIP-47 Jest regression suite, full Jest/Bun, command and package-manager policy checks, strict TypeScript, ESLint, CJS/ESM builds, examples, pack verification, and diff checks + +## Review Instructions + +Review only issue #134 unless a severe cross-slice regression appears. Keep standards and spec axes separate. Verify promise identity and failure consistency, all init/disconnect interleavings, synchronous observation of expected cancellation, attempt-scoped subscription cleanup, stale callback suppression, TTL cleanup restart, idempotent resource release, unchanged public signatures, and public-behavior test coverage. + +## Reviewer Output + +```text +STANDARDS_STATUS: pass +STANDARDS_FINDINGS: +- initial queued-cancellation rejection observer fixed +- primary cancellation observer timing fixed in follow-up +- final targeted re-review found no remaining correctness or standards blockers + +SPEC_STATUS: pass +SPEC_FINDINGS: +- all five acceptance criteria met +- no missing or incorrectly implemented criteria + +CODERABBIT_STATUS: pass +CODERABBIT_FINDINGS: +- one minor stale session commit record fixed +- final committed re-review returned 0 findings +``` diff --git a/docs/agents/runs/issue-134-session.md b/docs/agents/runs/issue-134-session.md new file mode 100644 index 00000000..cf40b867 --- /dev/null +++ b/docs/agents/runs/issue-134-session.md @@ -0,0 +1,46 @@ +# Issue Session: #134 NIP-47 Service Lifecycle + +## Issue + +- Issue: #134 +- Fixed point before session: `2a3556d` +- Worker session: current Codex orchestrator; Grok 4.5 High reviewers +- Commit: `569b266`; verification artifacts: `644b52f` +- Status: merged through PR #143 into `staging` at `25e055d`; issue closed + +## Inputs + +- Spec issue: #130 +- Ticket: #134 +- Relevant glossary terms: Relay, Subscription Filter, NIP-47 +- Relevant ADRs: none +- Prototype answer and source branch, if any: none + +## Implementation + +- Public interface used: existing `NostrWalletService.init()` and `disconnect()` methods +- Behaviors covered: sequential and concurrent initialization, idempotent disconnect, restart with one fresh request subscription, and expiration-aware request handling after restart +- `tdd` used: yes; the focused suite observes service behavior through the public service API and the exported ephemeral Relay test surface +- Commands run during implementation: focused Jest/Bun lifecycle suites, NIP-47 Jest regression suite, strict TypeScript, ESLint, and diff checks +- Full suite command: `npm test -- --runInBand`; `npm run test:bun`; repository policy, lint, type, build, example, and pack gates + +## Review + +- Review fixed point: `2a3556d` +- Design findings: Grok confirmed duplicate sequential/concurrent subscriptions, a permanently stopped TTL cleanup interval after disconnect, and stale initialization races; it recommended client-parity single-flight and generation state while preserving the public API +- Standards findings: initial review found that a second disconnect cancelled a queued initialization without attaching a rejection observer for fire-and-forget callers; re-review found the same observer was attached too late on the primary disconnect path; both paths now synchronously absorb expected cancellation while preserving rejection for callers that await the original promise +- Spec findings: passed all five acceptance criteria; noted only partial public observability of the TTL cleanup interval and session-document drift +- Worthy fixes applied: client-parity single-flight initialization, generation cancellation, teardown serialization, attempt-scoped subscription cleanup, restartable TTL cleanup, stale callback generation guard, and cancellation rejection absorption +- Findings ignored with reasons: direct TTL interval assertions require private-shape access and conflict with the public-API test criterion; restart behavior is instead proved by an encrypted expired request after reconnect; hosted CodeRabbit completed with no findings + +## Verification + +- Focused: Jest/Bun lifecycle suite 6/6 after review-driven additions; NIP-47 Jest regression 9/9 suites and 76/76 tests +- Full Jest: 83/83 suites and 1073/1073 tests +- Full Bun: 83 files and 1073/1073 tests +- Repository gates: commands and package-manager policy, ESLint, strict TypeScript, CJS/ESM builds, examples, pack verification, and all four hosted CI lanes green + +## Risks + +- Disconnect must invalidate stale initialization without allowing it to publish or retain a zombie subscription. +- Reinitialization must restart or recreate lifecycle-owned expiration tracking rather than leave a destroyed TTL map inactive. diff --git a/docs/agents/runs/issue-135-coderabbit-local.md b/docs/agents/runs/issue-135-coderabbit-local.md new file mode 100644 index 00000000..820692b5 --- /dev/null +++ b/docs/agents/runs/issue-135-coderabbit-local.md @@ -0,0 +1,25 @@ +# Local CodeRabbit Review: #135 NIP-57 Client Consolidation + +## Review + +- Command: `coderabbit review --agent --type committed --base staging -c AGENTS.md` +- Reviewed commit: `0909227` +- Initial result: four findings (one critical, two major, one minor) +- Re-review: clean with 0 findings after review-fix commit `1b12872` + +## Findings and Resolutions + +1. Anonymous zap requests used an all-zero event pubkey that could not match the signing private key. Fixed by deriving the request pubkey from the supplied ephemeral private key and verifying the resulting signature through the public client result. +2. LNURL capability cache entries were reused after a profile LNURL changed and grew without a bound. Fixed with URL-aware hits and instance-local least-recently-used eviction above 256 entries. +3. The LNURL invoice callback had no request bound and accepted successful payloads without a usable invoice. Fixed with abort-backed 10-second timeout cleanup and non-empty invoice validation. +4. The review packet still described full verification as pending. Fixed to match the completed session record. + +## Verification Before Retry + +- Focused public-client Jest/Bun: 22/22 +- NIP-57 Jest: 4/4 suites, 40/40 tests +- Full Jest: 83/83 suites, 1081/1081 tests +- Full Bun: 83 files, 1081/1081 tests +- Commands/package-manager policy, ESLint, strict TypeScript, CJS/ESM builds, examples, and pack verification: green +- Grok standards/spec and targeted review-fix passes: clean with no remaining findings +- Final CodeRabbit committed review: 0 findings across all seven changed files diff --git a/docs/agents/runs/issue-135-review-packet.md b/docs/agents/runs/issue-135-review-packet.md new file mode 100644 index 00000000..4e153149 --- /dev/null +++ b/docs/agents/runs/issue-135-review-packet.md @@ -0,0 +1,49 @@ +# Review Packet: #135 NIP-57 Client Consolidation + +## Issue + +- Issue: #135 +- Slice type: AFK structural cleanup +- Acceptance criteria: repeated operations reuse persistent LNURL cache and collaborators; both public facades produce equivalent receipt filters and statistics for equivalent inputs; explicit `limit: 0` is preserved; existing public exports and 0.x behavior remain compatible; tests exercise both public facades without private helpers +- Baseline: `25e055d` +- Current diff: working tree against `25e055d` + +## Implementation Summary + +`NostrZapClient` and `ZapClient` now delegate to one instance-owned, non-exported `ZapClientCore`. The core owns the Nostr collaborator, relays, logger, LNURL cache, invoice generation, subscription collection, receipt-filter construction, validation, statistics, and split helpers. Both public constructor shapes and existing method contracts remain intact; `ZapClient` gains additive receipt-query and statistics methods so the two public facades can expose the same behavior without private test seams. Receipt filters now share one builder and preserve explicit zero limits. + +## Implementation Evidence + +- `implement` session: `issue-135-session.md` +- `tdd` used: yes +- Red tests: `ZapClient` lacked the five receipt-query/statistics methods required to compare both public facades; focused compilation failed on those missing public methods +- Green implementation: both facades reuse per-instance LNURL state, emit equivalent user/event/general receipt filters, preserve `limit: 0`, and calculate equivalent user/event statistics +- Refactor: all behavior ownership remains private; no private shape or production test hook is used +- Commands run: focused Jest/Bun public-client suites, NIP-57 Jest regression suite, full Jest/Bun suites, strict TypeScript, ESLint, CJS/ESM builds, examples, package verification, and diff checks; all completed successfully before committed-diff review + +## Review Instructions + +Review only issue #135 unless a severe cross-slice regression appears. Keep standards and spec axes separate. Verify instance-local cache ownership, absence of short-lived internal client allocations, constructor and method compatibility, subscription/EOSE/timeout behavior, filter parity including zero-valued options, statistics parity, additive API safety, export compatibility, logger propagation, and tests through public facades only. + +## Reviewer Output + +```text +STANDARDS_STATUS: pass +STANDARDS_FINDINGS: +- initial exported-JSDoc and targeted-filter compatibility findings fixed +- golden filter shapes added; final targeted re-review found no remaining findings + +SPEC_STATUS: pass +SPEC_FINDINGS: +- all acceptance criteria met; zero findings + +CODERABBIT_STATUS: pass +CODERABBIT_FINDINGS: +- stale verification packet corrected +- anonymous signer pubkey now derives from the supplied ephemeral private key and verifies cryptographically +- LNURL cache now respects URL changes and evicts least-recently-used entries above 256 +- invoice callback is bounded to 10 seconds and successful responses require a non-empty invoice +- all four findings fixed, independently re-reviewed by Grok, fully verified, and accepted by the final committed-diff CodeRabbit pass with zero findings +- hosted full review found inconsistent seeded fields for an all-invalid receipt set; fixed to return canonical empty statistics through both public facades, passed by Grok, and fully re-verified +- final hosted rerun completed successfully; the remaining mandatory-error-result cast note was a low-value nitpick intentionally retained for 0.x contract compatibility +``` diff --git a/docs/agents/runs/issue-135-session.md b/docs/agents/runs/issue-135-session.md new file mode 100644 index 00000000..249fa840 --- /dev/null +++ b/docs/agents/runs/issue-135-session.md @@ -0,0 +1,47 @@ +# Issue Session: #135 NIP-57 Client Consolidation + +## Issue + +- Issue: #135 +- Fixed point before session: `25e055d` +- Worker session: current Codex orchestrator; Grok 4.5 High reviewers +- Commit: `0909227`; review fixes: `1b12872` +- Status: merged through PR #144 into `staging` at `ed9fa4a`; issue closed + +## Inputs + +- Spec issue: #130 +- Ticket: #135 +- Relevant glossary terms: Nostr Event, Relay, Subscription Filter, NIP-57 +- Relevant ADRs: none +- Prototype answer and source branch, if any: none + +## Implementation + +- Public interface used: existing `NostrZapClient` and `ZapClient` facades +- Behaviors covered: persistent LNURL cache and collaborators, equivalent filters and statistics, explicit zero limits, and 0.x-compatible exports and methods +- `tdd` used: yes; public-facade tests cover persistent, isolated, URL-aware, and bounded cache state; equivalent receipt filters and statistics; explicit zero limits; valid anonymous signing; invoice response validation; and callback timeout cleanup +- Commands run during implementation: focused Jest and Bun public-facade suites, NIP-57 Jest regression suite, strict TypeScript, ESLint, and diff checks +- Full suite command: `npm test -- --runInBand --coverage=false`; `bun test ./tests --max-concurrency 1 --timeout 30000`; repository policy, lint, type, build, example, and pack gates + +## Review + +- Review fixed point: `25e055d` +- Design findings: Grok mapped the short-lived `ZapClient` allocations, duplicated filter and statistics logic, cache ownership, subscription semantics, and export asymmetry; a targeted follow-up confirmed that five additive `ZapClient` receipt/statistics delegates are required to satisfy the literal public-facade equivalence criterion +- Standards findings: initial pass found exported adapter JSDoc drift, facade-only filter comparisons without golden shapes, and a compatibility drift where targeted user queries inherited the general `events` filter; all were fixed and the targeted re-review passed with zero findings; Grok also passed both subsequent CodeRabbit-driven deltas with zero findings +- Spec findings: passed every acceptance criterion with zero findings +- Worthy fixes applied: one instance-owned private core, persistent per-facade LNURL state, shared receipt filter and statistics implementations, explicit zero-limit preservation, public `ZapClient` receipt/statistics delegates, exported adapter JSDoc, golden filter assertions that protect targeted-query compatibility, valid ephemeral-key anonymous signatures, URL-aware bounded LNURL caching, a bounded invoice callback, successful-response invoice validation, and canonical empty statistics when all receipts are invalid +- Findings ignored with reasons: additional duplicate subscription-lifecycle tests through `ZapClient` were optional because both adapters delegate to the same private collector and the parity tests exercise the new facade routes; the private core remains in the existing client module to avoid a circular or overly fragmented NIP-57 implementation; the hosted generic docstring-percentage warning conflicts with the actual exported JSDoc and produced no missing-docstring code finding; the remaining hosted unsafe-cast note was explicitly a trivial nitpick and changing the mandatory `zapRequest` error contract would create 0.x type churn; final local and hosted CodeRabbit reviews completed successfully + +## Verification + +- Focused: Jest/Bun public-client suite 23/23; NIP-57 regression 4/4 suites and 41/41 tests +- Full Jest: 83/83 suites and 1082/1082 tests +- Full Bun: 83 files and 1082/1082 tests +- Repository gates: commands and package-manager policy, ESLint, strict TypeScript, CJS/ESM builds, examples, pack verification, and diff checks all green +- Hosted verification: all four CI lanes green; final full CodeRabbit rerun completed successfully after the all-invalid statistics fix + +## Risks + +- Consolidation must preserve both public constructor shapes and return values while removing duplicated behavior ownership. +- A shared persistent implementation must not share cache state between distinct public client instances. diff --git a/docs/agents/runs/issue-136-coderabbit-local.md b/docs/agents/runs/issue-136-coderabbit-local.md new file mode 100644 index 00000000..6c7a9a78 --- /dev/null +++ b/docs/agents/runs/issue-136-coderabbit-local.md @@ -0,0 +1,37 @@ +# Local CodeRabbit Review: #136 NIP-46 Protocol Core + +## Review + +- Commands: committed review from `ed9fa4a`; follow-up uncommitted reviews after hosted fixes +- Reviewed commits: `cf3819b` through `1c38f2c` +- Initial result: six issues (five major, one minor) +- Follow-up result: two test issues +- Final result: 0 issues across all 9 follow-up files after two iterative passes +- Hosted result: the initial two findings and the later lifecycle, rate-limiter, typed-error, tracking, and correlator-coverage findings are fixed; the raw-log claim was rejected because the canonical diagnostic boundary redacts those fields + +## Findings and Resolutions + +1. Decrypted wire payloads relied on unchecked casts. Added distinct request and response shape validation while preserving well-shaped extension methods and supported large NIP-44/event parameters. +2. Duplicate request IDs could overwrite an existing pending entry. The correlator now rejects duplicate registration without replacing the original owner. +3. Advanced bunker envelope validation followed the rate-limit hook and was optional for simple bunkers. Validation is mandatory and runs before every profile hook, decrypt, or dispatch. +4. Concurrent bunker start/stop calls could race. A shared transition queue and explicit running state now coalesce starts and make stops idempotent. +5. Client publishes did not carry the request deadline. Client publishes use the facade timeout, and bunker response/metadata publishes explicitly retain the relay's 10-second bound. +6. Simple facade logger initialization assumed Node's global `process`. All touched facade initializers now guard browser access. +7. Focused timeout and `cancelAll` cleanup coverage was missing. Portable Jest/Bun tests assert settlement errors and empty pending state. +8. Wire tests used random keypairs. Fixed synthetic test-only private keys now make the round-trip setup reproducible without real credentials. +9. PR tracking still said pending and omitted two commits. The ledger and session now record PR #145, its hosted state, and the complete implementation commit set. +10. A connect error returned by the bunker was raised after `engine.connect` completed, so the advanced facade did not invoke engine cleanup. The facade now awaits `engine.disconnect` in every catch path, with a public rejected-connect then successful-retry regression test. +11. Advanced bunker stop/start destroyed the rate-limiter cleanup interval permanently. The limiter now has an idempotent start operation invoked by bunker start, with restart coverage. +12. Client connect/disconnect transitions could interleave. The client engine now serializes session transitions, including success and failed-connect cleanup paths, while ordinary requests remain concurrent and disconnect cancels pending correlation. +13. Simple-client engine-level rejection made typed signing/encryption/decryption branches unreachable. Error envelopes now reach facade inspection, including explicit public-key and relay error handling. +14. Normal correlator settlement lacked direct coverage. The focused core suite now proves successful resolution and pending-entry removal. +15. A local suggestion to serialize all client requests was rejected after Grok review because it would deadlock nested CONNECT/DISCONNECT operations, collapse concurrent RPC throughput, and replace prompt disconnect cancellation with timeout waits. + +## Verification Before Final Review + +- NIP-46 Jest: 10/10 suites, 185/185 tests +- Full Jest: 85/85 suites, 1096/1096 tests +- Full Bun: 85 files, 1096/1096 tests +- Commands/package-manager policy, ESLint, strict TypeScript, CJS/ESM builds, examples, and pack verification: green +- Grok design, standards/spec, finding-fix, and publish-bound follow-ups: pass +- Final CodeRabbit follow-up review: 0 issues across all 9 changed files diff --git a/docs/agents/runs/issue-136-review-packet.md b/docs/agents/runs/issue-136-review-packet.md new file mode 100644 index 00000000..e9f8aa33 --- /dev/null +++ b/docs/agents/runs/issue-136-review-packet.md @@ -0,0 +1,48 @@ +# Review Packet: #136 NIP-46 Protocol Core + +## Issue + +- Issue: #136 +- Slice type: AFK structural cleanup +- Acceptance criteria: canonical request correlation, encryption/decryption, timeout, dispatch, and lifecycle ownership; four compatible public facades; redacted diagnostics; cross-facade success/failure/timeout/reconnect/shutdown tests; duplicate machinery removed +- Baseline: `ed9fa4a` +- Current diff: committed branch against `ed9fa4a` + +## Implementation Summary + +The four public NIP-46 facades now delegate transport behavior to four internal owners: `NIP46Wire`, `NIP46RequestCorrelator`, `NIP46ClientEngine`, and `NIP46BunkerEngine`. The engines retain facade-specific policies for connect parameters and return values, request error handling, relay composition, delays, response filtering, publish result handling, and disconnect errors. Advanced bunker security, rate limiting, replay defense, auth challenges, and permission handlers remain adapter-owned hooks; top-level method dispatch and response transport are canonical. The diff removes 1,757 lines while adding 1,489 lines including 330 lines of public seam and focused protocol-core coverage. + +## Implementation Evidence + +- `implement` session: `issue-136-session.md` +- `tdd` used: characterization-first for behavior-preserving refactoring +- Baseline characterization: the public seam suite passed before consolidation, fixing expected observable outcomes +- Green implementation: 10 NIP-46 suites / 185 tests, including both facade pairings, typed protocol failures, both timeout contracts, simple and advanced reconnect, client shutdown, bunker shutdown, concurrent bunker lifecycle, serialized client success/failure transitions, restart-safe rate limiting, correlation settle/timeout/cancel cleanup, malformed envelopes, and extension methods +- Commands run: focused Jest/Bun, full NIP-46, full Jest/Bun, command/package-manager policy, strict TypeScript, ESLint, CJS/ESM builds, examples, pack verification, and diff checks + +## Review Instructions + +Review only issue #136 unless a severe cross-slice regression appears. Keep standards and spec axes separate. Verify that the four public constructors and method contracts are unchanged; simple and advanced connect/ping/error/delay differences are explicit; NIP-44 wire event creation and parsing exist only in `NIP46Wire`; request registration, timeout, settlement, rejection, and cancellation exist only in `NIP46RequestCorrelator`; relay subscription and client/bunker lifecycle exist only in the engines; top-level bunker dispatch has one handler map; advanced security hooks retain ordering and redaction; no fallback protocol implementation remains in a facade. + +## Reviewer Output + +```text +STANDARDS_STATUS: pass +STANDARDS_FINDINGS: +- initial correlator bypass, dead auth path, disconnect-error collapse, and seam-coverage gaps fixed +- explicit client and bunker publish bounds added +- final targeted Grok follow-ups passed with no remaining P0-P2 findings + +SPEC_STATUS: pass +SPEC_FINDINGS: +- all acceptance criteria met; zero remaining findings + +CODERABBIT_STATUS: local and hosted pass; PR #145 merged into `staging` +CODERABBIT_FINDINGS: +- initial six issues fixed: envelope schemas, duplicate IDs, validation order, lifecycle serialization, publish deadline, browser-safe process access +- two follow-up test issues addressed with timeout/cancel coverage and deterministic synthetic keypairs +- final committed-diff review raised 0 issues across all 10 changed files +- hosted full review found stale PR tracking and a leaked advanced-client transport after a rejected connect response; tracking is synchronized and the catch path now awaits canonical engine cleanup before preserving the public error +- follow-up hosted review found client lifecycle, bunker rate-limiter restart, simple typed-error, tracking, and correlator-coverage gaps; all were fixed and local CodeRabbit returned 0 findings +- the raw engine-log finding was rejected because the canonical diagnostic logger redacts `params` and `result`, with dedicated secret-leak regression coverage +``` diff --git a/docs/agents/runs/issue-136-session.md b/docs/agents/runs/issue-136-session.md new file mode 100644 index 00000000..ad567092 --- /dev/null +++ b/docs/agents/runs/issue-136-session.md @@ -0,0 +1,46 @@ +# Issue Session: #136 NIP-46 Protocol Core + +## Issue + +- Issue: #136 +- Fixed point before session: `ed9fa4a` +- Worker session: current Codex orchestrator; Grok 4.5 High reviewers +- Commits: `cf3819b`, `baa2bf2`, `03a6652`, `7b5a7d0`, `2d4fc6f`, `d983bae`, `bc342cd`, `9e029be`, `1c38f2c` +- Status: PR #145 merged into `staging` as `8b970e4`; follow-up hosted and local findings fixed; all local and hosted gates green + +## Inputs + +- Spec issue: #130 +- Ticket: #136 +- Relevant glossary terms: Nostr Event, Relay, Subscription Filter, NIP-46 +- Relevant ADRs: ADR 0002 for compatible diagnostic consolidation +- Prototype answer and source branch, if any: none + +## Implementation + +- Public interface used: supported simple and advanced NIP-46 client and bunker facades +- Behaviors covered: request correlation, encryption/decryption, timeout, dispatch, lifecycle, redacted diagnostics, and reconnect/shutdown behavior +- `tdd` used: yes, as characterization-first refactoring; the new public seam suite was green against the duplicated baseline, then protected success, protocol failure, timeout, reconnect, client shutdown, bunker shutdown, and concurrent bunker lifecycle behavior throughout consolidation +- Commands run during implementation: focused Jest/Bun protocol-core, facade-seam, diagnostic-redaction, validation, and performance suites; NIP-46 regression suite; strict TypeScript; targeted and full ESLint; diff checks +- Full suite command: `npm test -- --runInBand --coverage=false`; `bun test ./tests --max-concurrency 1 --timeout 30000`; repository policy, build, example, and pack gates + +## Review + +- Review fixed point: `ed9fa4a` +- Design findings: Grok mapped the two client and two bunker facades, their return-value and timeout differences, relay/subscription policies, private compatibility seams, advanced-only security behavior, simple-only permissions, and the requirement for one client engine plus one bunker engine behind thin adapters +- Standards findings: the initial Grok review found a correlator ownership bypass, dead advanced-client auth machinery, collapsed simple/advanced disconnect errors, and incomplete reconnect/shutdown coverage; all were fixed and the follow-up passed. Later passes required an explicit bunker publish bound, validated rejected-connect cleanup, confirmed client lifecycle serialization and restart-safe rate limiting, and preserved concurrent request cancellation rather than incorrectly serializing all RPCs +- Spec findings: final Grok review passed every acceptance criterion with no remaining findings +- Worthy fixes applied: distinct request/response envelope validation, extension-method compatibility, duplicate correlation-ID rejection, facade-specific disconnect errors, mandatory pre-dispatch envelope validation, serialized idempotent bunker transitions, serialized client connect/disconnect transitions, restart-safe rate-limiter cleanup, typed simple-client protocol errors, bounded client/bunker publishes, browser-safe logger initialization, portable correlator cleanup tests, deterministic synthetic wire keys, and advanced-client cleanup after rejected connect responses +- Findings ignored with reasons: raw `params`/`result` logging remains safe because `NIP46DiagnosticLogger` redacts those field names and leak tests cover the boundary. Serializing every request behind the lifecycle queue was rejected because concurrent RPCs are intentional and disconnect must promptly cancel pending correlation rather than wait for timeouts + +## Verification + +- Focused: NIP-46 10/10 suites and 185/185 tests; focused protocol-core, public seam, performance-security, and diagnostic suites green +- Full Jest: 85/85 suites and 1096/1096 tests +- Full Bun: 85 files and 1096/1096 tests +- Repository gates: command and package-manager policy, ESLint, strict TypeScript, CJS/ESM builds, examples, pack verification, and diff checks all green + +## Risks + +- Simple and advanced compatibility differences are explicit engine policies rather than parallel transport implementations. +- Existing private-shape compatibility getters remain only where the current suite depends on them; issue #138 owns moving those tests to public behavior and can then remove the getters. diff --git a/docs/agents/runs/issue-137-review-packet.md b/docs/agents/runs/issue-137-review-packet.md new file mode 100644 index 00000000..a5ef5364 --- /dev/null +++ b/docs/agents/runs/issue-137-review-packet.md @@ -0,0 +1,52 @@ +# Review Packet: #137 Default Test Feedback Loop + +## Issue + +- Issue: #137 +- Slice type: AFK test-infrastructure cleanup +- Acceptance criteria: reproducible timing evidence; deterministic removal of avoidable waits; materially faster routine lane; explicit slow security/performance lane; complete Node and Bun CI assurance +- Baseline: `8b970e4` +- Current diff: working branch against `8b970e4` + +## Implementation Summary + +The default Jest and Bun commands now run a canonical routine inventory, while two explicitly named security/performance load suites run in a slow lane. A small CommonJS lane module owns discovery and membership for Node 16 compatibility. CI runs routine and slow steps on Node 16, 18, 20, and Bun; Node 20 coverage uses the complete inventory. Parser-only NIP-46 validation cases no longer pay public-client teardown delays, cutting the hosted-fix default Jest wall time from 58.793s to 32.356s (45.0%). + +## Implementation Evidence + +- `implement` session: `issue-137-session.md` +- `tdd` used: lane-contract test failed before the module and wiring existed +- Routine Jest: 84 suites / 1063 tests / 32.356s +- Slow Jest: 2 suites / 40 tests / 43.154s +- Routine Bun: 84 files / 1063 tests / 190.68s +- Slow Bun: 2 files / 40 tests / 40.42s +- Full union: 86 suites/files / 1103 tests in each runtime +- Complete Jest coverage command: 86 suites / 1103 tests / 49.078s; 80.51% statement coverage + +## Review Instructions + +Review only issue #137 unless a severe cross-slice regression appears. Verify that the lane inventory is canonical and complete; routine and slow sets are disjoint; targeted Jest paths still work; command forwarding and exit codes are preserved; CI runs both sets for every supported runtime; coverage includes all tests; input-validation changes retain public integration coverage while parser-only cases remain direct; and the before/after evidence is reproducible. + +## Reviewer Output + +```text +STANDARDS_STATUS: pass +STANDARDS_FINDINGS: +- P2 documentation command mismatch corrected in AGENTS.md and CLAUDE.md + +SPEC_STATUS: pass +SPEC_FINDINGS: +- none; final pinned-Bun Grok follow-up passed standards and spec + +CODERABBIT_STATUS: clean committed rerun with zero findings; all hosted findings fixed and verified +CODERABBIT_FINDINGS: +- major: include Jest-compatible .spec.* files in canonical discovery — fixed with a red/green regression test +- minor: assert routine and complete coverage wiring — fixed +- minor: replace the ledger baseline hash with the implementation commit — fixed +- hosted minor: synchronize the top-level run status — fixed +- hosted minor: dynamically discover new routine tests during Bun watch without admitting slow paths — fixed with a red/green pure argument-builder test +- hosted minor: document standalone Jest and Bun slow commands — fixed +- hosted major: directly cover spawned Bun argument construction for dynamic watch and fixed non-watch modes — fixed +- Grok P1: replace Bun 1.3.11-only path ignores with pinned-1.3.9-compatible `[slow]` name filtering — fixed and exercised with the Bun 1.3.9 binary +- Grok P2: record hosted-fix commit `295d114` in the ledger — fixed +``` diff --git a/docs/agents/runs/issue-137-session.md b/docs/agents/runs/issue-137-session.md new file mode 100644 index 00000000..0021db32 --- /dev/null +++ b/docs/agents/runs/issue-137-session.md @@ -0,0 +1,87 @@ +# Issue Session: #137 Default Test Feedback Loop + +## Issue + +- Issue: #137 +- Fixed point before session: `8b970e4` +- Worker session: current Codex orchestrator; Grok 4.5 High reviewers +- Implementation commits: `0de11d9`, `91ffafa`, `295d114`, `d8de419`; supporting review records continue through the current branch HEAD +- Status: complete; implementation, final Grok review, clean CodeRabbit rerun, local gates, and hosted CI are green; PR #146 merged + +## Inputs + +- Spec issue: #130 +- Ticket: #137 +- Relevant glossary terms: none; this slice changes test execution policy, not the domain model +- Relevant ADRs: none +- Prototype answer and source branch, if any: none + +## Baseline + +Reproducible command at `8b970e4`: + +```bash +npx jest --json --outputFile=/tmp/snstr-issue-137-baseline.json +``` + +- Default Jest: 85 suites, 1096 tests, 58.793 seconds. +- Slowest suites: `input-validation` 58.171s; NIP-46 `performance-security` 47.215s; `connection-failures` 32.528s; `permissions` 28.841s; `core-functionality` 25.902s; NIP-47 `nip44-encryption` 25.519s. + +## Implementation + +- Public interface used: `npm test`, `npm run test:slow`, `npm run test:all`, and matching Bun commands +- Behaviors covered: one canonical slow-lane inventory; complete disjoint routine/slow partition; targeted Jest invocation compatibility; explicit routine and slow CI steps on Node 16/18/20 and Bun; complete coverage on Node 20 +- `tdd` used: yes; `tests/scripts/test-lanes.test.ts` failed before the lane module and script/CI wiring existed, then passed after the runner was implemented +- Slow lane: only `tests/nip44/nip44-performance-security.test.ts` and `tests/nip46/performance-security.test.ts` +- Deterministic cleanup: parser-only NIP-46 input-validation cases now invoke `parseConnectionString` directly, and teardown disconnects only clients that successfully connected +- Compatibility: Jest routine selection uses a generated ignore pattern, preserving `npm test -- path/to/test` targeting; Bun receives an explicit routine file inventory + +## Timing Evidence + +| Lane | Suites | Tests | Time | +| --- | ---: | ---: | ---: | +| Baseline default Jest | 85 | 1096 | 58.793s | +| Routine Jest | 84 | 1063 | 32.356s | +| Slow Jest | 2 | 40 | 43.154s | +| Routine Bun | 84 | 1063 | 190.68s | +| Slow Bun | 2 | 40 | 40.42s | + +- The hosted-fix routine Jest run improved by 45.0%, exceeding the 40% target. +- `input-validation` dropped from 58.171s in the baseline parallel run to 29.015s in the post-review routine run; its isolated Jest run is 25.294s. +- Routine plus slow remains the complete 86-suite, 1103-test assurance set in both runtimes. + +## Review + +- Review fixed point: `8b970e4` +- Design findings: Grok selected a two-file named security/performance lane, required full CI union coverage, and identified parser validation as the largest deterministic low-hanging wait removal +- Standards findings: passed; the documentation command contract was corrected so complete coverage consistently names `test:coverage:all` +- Spec findings: passed with no P0/P1 findings +- Worthy fixes applied: aligned `AGENTS.md` and `CLAUDE.md` with the routine-versus-complete coverage contract; expanded discovery to Jest-compatible `.spec.*` files; pinned routine and complete coverage wiring in tests; recorded the actual implementation commit in the ledger +- Findings ignored with reasons: none; all three local CodeRabbit findings were valid and fixed +- CodeRabbit result: clean committed rerun with zero findings after fixes +- Grok follow-up: standards and spec both passed after the CodeRabbit delta with no findings +- Hosted CodeRabbit findings: accepted all four; synchronized the run status, documented standalone slow commands, and made routine Bun watch discovery dynamic through a tested pure argument builder while preserving fixed non-watch inventory +- Final Grok findings: the first hosted fix used a Bun 1.3.11-only ignore flag and the ledger omitted `295d114`; both were valid. The watch lane now uses Bun 1.3.9-supported name filtering, the slow inventory contract enforces `[slow]` on every top-level slow suite, and the ledger records the hosted-fix commit. +- Final Grok result: standards and spec pass with the pinned Bun 1.3.9 evidence verified + +## Verification + +- Focused Jest/Bun input-validation: 33/33 in each runtime +- Lane contract: 7/7 in Jest and Bun +- Routine Jest: 84/84 suites, 1063/1063 tests +- Slow Jest: 2/2 suites, 40/40 tests +- Routine Bun: 84 files, 1063/1063 tests +- Slow Bun: 2 files, 40/40 tests +- Pinned Bun 1.3.9 compatibility: routine name filter skipped all 40 slow tests across both slow files in 104 ms while the routine lane contract completed 7/7 checks +- Complete coverage: 86/86 suites, 1103/1103 tests in 49.078s; 80.51% statements, 68.41% branches, 82.80% functions, 81.01% lines +- Repository gates: command/package-manager policy, ESLint, strict TypeScript, CommonJS script syntax, build, examples, pack, and diff integrity all green + +## Risks + +- `npm test` is now intentionally routine rather than complete; README and CI make `test:all` the explicit full assurance command. +- Focused NIP scripts remain unchanged and may include a slow file; this preserves existing contributor expectations. +- Slow membership is enforced by a tested canonical inventory so new files cannot silently become orphaned. + +| Issue | Why parked | Blocks | Required human action | Final PR decision | +| ----- | ---------- | ------ | --------------------- | ----------------- | +| None | — | — | — | — | diff --git a/docs/agents/runs/issue-138-session.md b/docs/agents/runs/issue-138-session.md new file mode 100644 index 00000000..6a0474e0 --- /dev/null +++ b/docs/agents/runs/issue-138-session.md @@ -0,0 +1,54 @@ +# Issue 138 Session: Public Behavior Test Seams + +## Fixed Point + +- Base branch: `staging` +- Fixed point: `b33f31f` +- Feature branch: `feature/public-behavior-test-seams` +- Issue: #138 — Move behavior tests off private shapes + +## Scope + +Remove the shared `Nostr` and `Relay` private-shape adapters, rewrite tests around public behavior where possible, and place only irreducible protocol injection or fault control behind narrow exports owned by `src/testing`. + +## Test-First Plan + +1. Add a source contract test that rejects the legacy adapters and named broad private-shape interfaces in the targeted Relay, Nostr, NIP-46, and NIP-47 clusters. +2. Replace Nostr state reads and key mutation with `addRelay`, `getRelay`, `removeRelay`, `setPrivateKey`, and `getPublicKey`; retain a narrow relay-replacement seam only where a deterministic mock must be injected. +3. Replace Relay field access with public getters/setters, observable callbacks, mock WebSocket behavior, and narrow protocol-message/fault seams in `src/testing`. +4. Replace NIP-46 and NIP-47 broad casts with public lifecycle behavior or focused testing-entrypoint controls. +5. Run focused Jest/Bun suites, the routine and slow lanes, complete coverage, build/package gates, Grok standards/spec review, and CodeRabbit local/hosted review. + +## Guardrails + +- Do not expose new production API solely to make an assertion convenient. +- A testing seam must name one behavior or fault; it must not return a shadow object containing arbitrary private state. +- Preserve failure sensitivity: cleanup tests must still prove cleanup and protocol tests must still traverse the production handler. + +## Status + +- Local implementation and review completed; all hosted gates passed and PR #147 merged into `staging` as `3c1e905`. + +## Implementation Result + +- Removed the shared `NostrInternals`, `getNostrInternals`, `RelayTestAccess`, and `asTestRelay` private-shape adapters. +- Reworked the targeted Nostr, Relay, NIP-46, and NIP-47 suites around public lifecycle, callbacks, relay wire behavior, or focused controls exported by `src/testing`. +- Added a source contract that prevents the removed adapters and broad private-shape patterns from returning in the targeted suites. +- Extracted the NIP-46 replay guard into an internal module and exercised replay rejection, lifecycle reset, timer cleanup, relay reconnect policy, socket replacement, and replaceable-event ordering through behavior-sensitive tests. +- Kept NIP-04 permissions out of the shared core fixture and granted them only in the encryption compatibility test. + +## Review Result + +- Grok design, standards, and specification review: approved after the behavior-sensitivity fixes. +- CodeRabbit local review: clean, 0 findings on the final full diff after five earlier minor findings were fixed. +- No public replay-window option was added: exposing product configuration only to support a test would violate this issue's guardrail, while the internal replay guard remains directly testable. + +## Verification Result + +- Routine Jest: 86 suites, 1,061 tests passed. +- Slow Jest: 35 tests passed. +- Routine Bun: 1,061 tests passed. +- Slow Bun: 35 tests passed. +- Complete Jest coverage lane: 88 suites, 1,096 tests passed; 80.72% statements, 68.59% branches, 82.63% functions, and 81.20% lines. +- Commands policy, package-manager policy, lint, strict TypeScript, build, examples build, and package verification passed. +- Final NIP-04 permission adjustment passed its focused Jest and Bun test plus lint and strict TypeScript. diff --git a/docs/agents/runs/issue-139-coderabbit-local.md b/docs/agents/runs/issue-139-coderabbit-local.md new file mode 100644 index 00000000..95ff582b --- /dev/null +++ b/docs/agents/runs/issue-139-coderabbit-local.md @@ -0,0 +1,31 @@ +# CodeRabbit Local Review: Issue #139 + +## Scope + +- Branch: `feature/ephemeral-relay-internals` +- Base: `staging` / fixed point `3c1e905` +- Full-diff command: `coderabbit review --agent --type all --base staging` +- Incremental command: `coderabbit review --agent --type uncommitted --base staging` + +## Findings Addressed + +| Severity | Finding | Resolution | +| --- | --- | --- | +| Minor | CLOSE coverage did not prove server-side subscription removal | Wait for `NostrRelay.subs` to empty, publish again, and assert no stale delivery | +| Major | NIP-46 routing re-derived the subscription ID from the map key | Route with the subscription owner's typed `subscriptionId` | +| Major | Rejected session shutdown could skip transport cleanup | Put timeout, listener, and owned-state cleanup under `finally` while preserving the original rejection | +| Major | Native server lost its only `error` listener after startup | Retain a runtime error handler for native and in-memory servers | +| Major | Timed-out test polling could continue scheduling callbacks | Make polling cancellation explicit and clear all timeout handles | + +## Findings Not Addressed + +| Severity | Finding | Reason | +| --- | --- | --- | +| Minor | Send an event only once when multiple filters in one subscription match | Pre-existing observable wire behavior; changing it is outside this compatibility-preserving structural ticket | +| Minor | Add a maximum connection count in transport acceptance | New product/security policy with no specified limit or existing shared policy; outside issue #139 | + +## Result + +- Final incremental review of the last polling fix: 0 findings. +- All production findings from the full-diff reviews were fixed except the two explicitly rejected scope changes above. +- Focused Jest/Bun regression checks and the complete project gate matrix passed after production fixes. diff --git a/docs/agents/runs/issue-139-review-packet.md b/docs/agents/runs/issue-139-review-packet.md new file mode 100644 index 00000000..6f8f0cdf --- /dev/null +++ b/docs/agents/runs/issue-139-review-packet.md @@ -0,0 +1,38 @@ +# Review Packet: Issue #139 Split Ephemeral Relay Internals + +## Fixed Point and Spec + +- Fixed point: `3c1e905` +- Diff: `git diff 3c1e905...HEAD` +- Spec: GitHub issue #139, child of cleanup run #130 +- Standards: `AGENTS.md`, `CONTRIBUTING.md`, and the code-review smell baseline + +## Delivered Shape + +- `src/utils/ephemeral-relay.ts` remains the stable public facade and composition root. +- `transport.ts` owns native/in-memory selection, connection acceptance, fallback, runtime errors, and ordered shutdown. +- `client-session.ts` owns per-client wire protocol, subscriptions, NIP-46 routing, and socket cleanup through a narrow host interface. +- `filter-match.ts` owns pure Subscription Filter matching. +- Cache/replacement behavior stays in the facade; no new package entrypoint or production root export was added. + +## Review Decisions + +- Added missing JSDoc and camelCase names for the new private interfaces. +- Reused the shared diagnostics owner and moved server-client traversal behind transport. +- Added happy/error session messaging, filter semantics, transport-failure cleanup, late-error, lifecycle, and package-surface coverage. +- Retained the private transport factory: it keeps the implementation class hidden and is not accidental middle-man surface. +- Retained the cohesive client-session owner despite its size: splitting NIP-46 or wire-protocol behavior again is outside this ticket. +- Rejected a new connection cap and a legacy multi-filter delivery change because neither belongs to the compatibility-preserving structural scope. + +## Verification + +- Focused public/internal contracts: Jest and Bun 50/50 after CodeRabbit production fixes; final polling-only check 2/2 in both. +- Routine lanes: Jest/Bun 1,074 tests each. +- Slow lanes: Jest/Bun 35 tests each. +- Complete Jest coverage inventory: 1,109 tests. +- Hosted CI: Bun and Node 16/18/20 all passed on final head `d5b7cf5`. +- Integration: PR #148 merged into `staging` as `9c9e14c`; issue #139 closed. +- Green: command policy, package-manager policy, ESLint, strict TypeScript, CJS/ESM build, web entry checks, examples, and packed-consumer verification. +- Pack result: 19 referenced targets, 376 packed files, 55 web modules, 3 guarded Node fallbacks. +- CodeRabbit: all actionable in-scope findings fixed; final incremental review clean. +- Grok 4.5 final review: Standards approved with 0 hard / 0 actionable judgement findings; Spec approved with 0 code/spec findings. diff --git a/docs/agents/runs/issue-139-session.md b/docs/agents/runs/issue-139-session.md new file mode 100644 index 00000000..fa27b63d --- /dev/null +++ b/docs/agents/runs/issue-139-session.md @@ -0,0 +1,60 @@ +# Issue Session: #139 Split Ephemeral Relay Internals + +## Issue + +- Issue: #139 — Split ephemeral Relay internals +- Fixed point before session: `3c1e905` (`staging` merge of PR #147) +- Worker session: current Codex orchestrator; Grok 4.5 High is the exclusive delegated read-only reviewer +- Commits: `d6504f6`, `7a48586`, `f8e9aac`, `96dd79f` plus review artifacts +- Status: complete; PR #148 merged into `staging` as `9c9e14c`; issue #139 and parent spec #130 closed + +## Inputs + +- Spec issue: #130 — Complete the high-impact cleanup chain +- Ticket: #139 +- Relevant glossary terms: Relay, Nostr Event, Subscription Filter +- Relevant ADRs: ADR 0001 centralizes Nostr Event validation and keeps Relay behavior at the public interface +- Prototype answer and source branch, if any: none + +## Implementation + +- Public interface used: `NostrRelay` through `src/testing`, plus the compatible `snstr/testing` and `snstr/utils/ephemeral-relay` package subpaths +- Behaviors covered: native and in-memory connection lifecycle, session wire messages and cleanup, Subscription Filter matching, restart state reset, exact-URL disconnect observation, and package export compatibility +- `tdd` used: yes; tests stay at public `NostrRelay`/wire seams, with a compact pure matcher contract at its internal module interface +- Commands run during implementation: focused Jest/Bun public-behavior baseline 25/25; final focused owner/lifecycle/session/filter set 50/50 after production review fixes; final polling-only confirmation 2/2 in both runtimes +- Full suite command: `npm test`, `npm run test:slow`, `npm run test:bun`, `npm run test:bun:slow`, and `npm run test:coverage:all` — green at 1,074 routine + 35 slow = 1,109 tests + +## Review + +- Review fixed point: `3c1e905` +- Standards findings: initial Grok pass found missing JSDoc, new snake_case ownership, duplicated diagnostic coercion, and transport client-set traversal; all were fixed. Final Grok re-review approved with 0 hard and 0 actionable judgement findings. +- Spec findings: initial Grok pass found final gates/PR pending and session coverage error-only; gates are green and focused session coverage now proves REQ, EOSE, EVENT, CLOSE, malformed messages, and stale-route removal. Final Grok re-review approved with 0 code/spec findings and named the PR as the expected next action. +- Worthy fixes applied: shared diagnostic coercion/protection, transport-owned broadcast, camelCase subscription state, post-start server error handling, guaranteed shutdown cleanup, direct NIP-46 subscription routing, and cancellable test polling. +- Findings ignored with reasons: CodeRabbit's connection cap requires a new unspecified product policy; changing duplicate delivery for overlapping filters changes pre-existing observable behavior. Both are outside the structural compatibility scope. +- CodeRabbit: two full-diff passes completed; five in-scope findings fixed; final incremental review 0 findings. See `issue-139-coderabbit-local.md`. + +## Design Result + +- Keep `src/utils/ephemeral-relay.ts` as the stable public facade and composition root. +- Extract private connection/transport lifecycle, client-session protocol state, and pure Subscription Filter matching owners under `src/utils/ephemeral-relay/`. +- Keep cache and replaceable-event storage in the facade; do not add a shallow fourth store module. +- Do not expose internal modules through the package root or testing entrypoint. +- Give the session owner a narrow host interface for cache, subscriptions, storage, connection count, and broadcast behavior; do not leak `WebSocketServer` across that seam. + +## Risks + +- Close ordering is load-bearing: late connections must be rejected, sessions must drain, and the public `Relay` disconnect must be observable before `NostrRelay.close()` resolves. +- Native and in-memory transports have intentionally different shutdown mechanics that must retain identical observable behavior. +- NIP-46 kind `24133` routing currently has special `#p` behavior and must not be silently unified with general Subscription Filter matching in this structural slice. +- `cache`, `subs`, `conn`, `wss`, `store`, both package subpaths, and the legacy numeric purge option remain compatible public behavior. + +## Verification Result + +- Routine Jest/Bun: 1,074/1,074 each. +- Slow Jest/Bun: 35/35 each. +- Complete Jest coverage inventory: 1,109/1,109. +- Hosted verification: Bun and Node 16/18/20 all green on the final head. An earlier Node 20 run had one isolated loaded-suite NIP-46 timeout while the same inventory passed on Node 16/18 and locally; the clean rerun passed unchanged production code. +- Hosted CodeRabbit: remained pending for the full 20-minute Feature Dev window without posting findings. The documented fallback was applied after two full local passes, one zero-finding incremental pass, final Grok approval, and four green hosted CI jobs. +- Integrated: PR #148 merged into `staging` at `9c9e14c` on 2026-07-19. +- Policy, lint, strict types, CJS/ESM and examples builds, web exports, and pack verification: green. +- Pack verification: 19 referenced targets, 376 packed files, 55 web modules, 3 guarded Node fallbacks. diff --git a/examples/basic-example.ts b/examples/basic-example.ts index f7b057e9..9f05a36f 100644 --- a/examples/basic-example.ts +++ b/examples/basic-example.ts @@ -6,7 +6,7 @@ import { ParsedOkReason, NostrOkCallback, } from "../src"; -import { NostrRelay } from "../src/utils/ephemeral-relay"; +import { NostrRelay } from "../src/testing"; import { createEvent, createSignedEvent, diff --git a/examples/client/validation-flow.ts b/examples/client/validation-flow.ts index fd3a370e..21717427 100644 --- a/examples/client/validation-flow.ts +++ b/examples/client/validation-flow.ts @@ -6,7 +6,7 @@ */ import { Nostr, Relay, RelayEvent } from "../../src"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; import { validateRelayIngressEvent } from "../../src/nip01/validation"; // Type for debug-only message injection in this example. diff --git a/examples/nip01/event/addressable-events.ts b/examples/nip01/event/addressable-events.ts index 0bf60633..c9d34e43 100644 --- a/examples/nip01/event/addressable-events.ts +++ b/examples/nip01/event/addressable-events.ts @@ -22,7 +22,7 @@ import { createAddressableEvent, createSignedEvent, } from "../../../src/nip01/event"; -import { NostrRelay } from "../../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../../src/testing"; // Create an ephemeral relay for the example const USE_EPHEMERAL = process.env.USE_PUBLIC_RELAYS !== "true"; diff --git a/examples/nip01/event/event-ordering-demo.ts b/examples/nip01/event/event-ordering-demo.ts index e1e7a29c..bbb280f2 100644 --- a/examples/nip01/event/event-ordering-demo.ts +++ b/examples/nip01/event/event-ordering-demo.ts @@ -1,5 +1,5 @@ import { Nostr, NostrEvent, RelayEvent } from "../../../src"; -import { NostrRelay } from "../../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../../src/testing"; import { createSignedEvent } from "../../../src/nip01/event"; /** diff --git a/examples/nip01/event/replaceable-events.ts b/examples/nip01/event/replaceable-events.ts index fa1ad773..827b8628 100644 --- a/examples/nip01/event/replaceable-events.ts +++ b/examples/nip01/event/replaceable-events.ts @@ -17,7 +17,7 @@ import { Nostr, NostrEvent, generateKeypair } from "../../../src"; import { createEvent, createSignedEvent } from "../../../src/nip01/event"; -import { NostrRelay } from "../../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../../src/testing"; // Create an ephemeral relay for the example const USE_EPHEMERAL = process.env.USE_PUBLIC_RELAYS !== "true"; diff --git a/examples/nip01/relay/auto-unsubscribe-example.ts b/examples/nip01/relay/auto-unsubscribe-example.ts index fd45a0de..6ebe581c 100644 --- a/examples/nip01/relay/auto-unsubscribe-example.ts +++ b/examples/nip01/relay/auto-unsubscribe-example.ts @@ -13,7 +13,7 @@ */ import { Relay } from "../../../src/nip01/relay"; -import { NostrRelay } from "../../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../../src/testing"; import type { NostrEvent } from "../../../src/types/nostr"; async function main() { diff --git a/examples/nip01/relay/filter-types-example.ts b/examples/nip01/relay/filter-types-example.ts index 73094518..368eb13a 100644 --- a/examples/nip01/relay/filter-types-example.ts +++ b/examples/nip01/relay/filter-types-example.ts @@ -13,7 +13,7 @@ import { Nostr } from "../../../src/nip01/nostr"; import { NostrFilter, Filter, NostrEvent } from "../../../src/types/nostr"; -import { NostrRelay } from "../../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../../src/testing"; import { generateKeypair } from "../../../src/utils/crypto"; import { createSignedEvent } from "../../../src/nip01/event"; diff --git a/examples/nip01/relay/relay-connection-example.ts b/examples/nip01/relay/relay-connection-example.ts index 03d8bfca..6bdfb7f6 100644 --- a/examples/nip01/relay/relay-connection-example.ts +++ b/examples/nip01/relay/relay-connection-example.ts @@ -1,6 +1,6 @@ import { Relay } from "../../../src/nip01/relay"; import { RelayEvent, NostrEvent } from "../../../src/types/nostr"; -import { NostrRelay } from "../../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../../src/testing"; import { createEvent, createSignedEvent } from "../../../src/nip01/event"; import { validateRelayIngressEvent } from "../../../src/nip01/validation"; import { getPublicKey } from "../../../src/utils/crypto"; diff --git a/examples/nip01/relay/relay-pool-example.ts b/examples/nip01/relay/relay-pool-example.ts index 92ff10b7..e9ac5b41 100644 --- a/examples/nip01/relay/relay-pool-example.ts +++ b/examples/nip01/relay/relay-pool-example.ts @@ -17,7 +17,7 @@ import { RelayPool } from "../../../src"; import { createTextNote, createSignedEvent } from "../../../src/nip01/event"; import { generateKeypair } from "../../../src/utils/crypto"; -import { NostrRelay } from "../../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../../src/testing"; import type { NostrEvent } from "../../../src/types/nostr"; const USE_EPHEMERAL = process.env.USE_PUBLIC_RELAYS !== "true"; diff --git a/examples/nip01/relay/relay-query-example.ts b/examples/nip01/relay/relay-query-example.ts index a57c54a9..dd569f6d 100644 --- a/examples/nip01/relay/relay-query-example.ts +++ b/examples/nip01/relay/relay-query-example.ts @@ -1,5 +1,5 @@ import { Nostr } from "../../../src/nip01/nostr"; -import { NostrRelay } from "../../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../../src/testing"; /** * Relay Query Example diff --git a/examples/nip01/relay/relay-reconnect-example.ts b/examples/nip01/relay/relay-reconnect-example.ts index b76567bb..049a8841 100644 --- a/examples/nip01/relay/relay-reconnect-example.ts +++ b/examples/nip01/relay/relay-reconnect-example.ts @@ -14,7 +14,7 @@ */ import { Relay, RelayEvent } from "../../../src/index"; -import { NostrRelay } from "../../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../../src/testing"; const USE_EPHEMERAL = process.env.USE_PUBLIC_RELAYS !== "true"; const RELAY_PORT = 0; diff --git a/examples/nip02/nip02-demo.ts b/examples/nip02/nip02-demo.ts index 03db21b0..a6f253b2 100644 --- a/examples/nip02/nip02-demo.ts +++ b/examples/nip02/nip02-demo.ts @@ -18,7 +18,7 @@ import { ContactsEvent, } from "../../src/types/nostr"; import { parseContactsFromEvent, Contact } from "../../src/nip02"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; const USER_PUBKEY = "6260f29fa75c91aaa292f082e5e87b438d2ab4fdf96af398567b01802ee2fcd4"; diff --git a/examples/nip44/README.md b/examples/nip44/README.md index bec517da..fe532163 100644 --- a/examples/nip44/README.md +++ b/examples/nip44/README.md @@ -9,7 +9,7 @@ This directory contains examples demonstrating how to use NIP-44 encrypted direc **Implementation Details**: - Uses ChaCha20 for encryption and HMAC-SHA256 for authentication - Includes a version byte to support future algorithm upgrades and backward compatibility -- Supports decryption of messages encrypted with versions 0, 1, and 2 +- Accepts defined version 2 payloads and rejects reserved, undefined, or unknown versions - Provides proper key derivation using HKDF - Implements message length hiding with a custom padding scheme - Uses secure 32-byte nonces @@ -38,7 +38,7 @@ The [`nip44-version-compatibility.ts`](./nip44-version-compatibility.ts) example - Attempting to encrypt with NIP-44 versions 0 and 1 (which correctly results in errors as per NIP-44 spec). - Successful encryption with NIP-44 version 2 (the current standard). -- Automatic decryption of messages from any supported version (0, 1, and 2). +- Public decryption rejection for reserved version 0, undefined version 1, and unknown versions. - Error handling for unsupported versions for encryption. ### Test Vector Verification @@ -84,7 +84,7 @@ npm run example:nip44:compliance ## Key Concepts - **Versioned Encryption**: NIP-44 includes a version byte in the payload for future protocol upgrades -- **Backward Compatibility**: Support for decrypting messages encrypted with older versions (0, 1) +- **Version Safety**: Reserved, undefined, and unknown versions are rejected before decryption - **Authenticated Encryption**: Uses HMAC-SHA256 to prevent message tampering - **Message Length Hiding**: Custom padding scheme to conceal the exact length of messages - **Proper Key Derivation**: Uses HKDF instead of raw ECDH output for better security @@ -104,11 +104,11 @@ npm run example:nip44:compliance ## API Functions Used - `encrypt()`: Encrypt a message using NIP-44 with optional version specification -- `decrypt()`: Decrypt a message using NIP-44 (automatically handles any supported version) +- `decrypt()`: Decrypt a defined NIP-44 v2 message - `getSharedSecret()`: Get the shared secret between two keys - `generateKeypair()`: Generate a new Nostr keypair ## Related NIPs - [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md): Encrypted Direct Message (older, less secure method) -- [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md): Basic Protocol (events and structure) \ No newline at end of file +- [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md): Basic Protocol (events and structure) diff --git a/examples/nip44/nip44-compliance-demo.ts b/examples/nip44/nip44-compliance-demo.ts index 816c08bc..e596ef6c 100644 --- a/examples/nip44/nip44-compliance-demo.ts +++ b/examples/nip44/nip44-compliance-demo.ts @@ -12,8 +12,6 @@ import { decodePayload, - NONCE_SIZE_V0, - MAC_SIZE_V0, NONCE_SIZE_V2, MAC_SIZE_V2, } from "../../src/nip44"; @@ -28,9 +26,8 @@ function createMinimalValidPayload( ): string { const versionByte = new Uint8Array([version]); - // Use version-specific constants for nonce and MAC sizes - const nonceSize = version === 2 ? NONCE_SIZE_V2 : NONCE_SIZE_V0; - const macSize = version === 2 ? MAC_SIZE_V2 : MAC_SIZE_V0; + const nonceSize = NONCE_SIZE_V2; + const macSize = MAC_SIZE_V2; // Create non-zero nonce with version-specific size const nonce = new Uint8Array(nonceSize); @@ -150,7 +147,7 @@ console.log("-".repeat(30)); try { // Create a minimal valid payload: version(1) + nonce(32) + ciphertext(33) + mac(32) = 98 bytes // But we need 99 bytes minimum, so use 34 bytes ciphertext = 99 bytes total - const minLengthPayload = createMinimalValidPayload(0, 34); // Creates exactly 99 bytes when decoded + const minLengthPayload = createMinimalValidPayload(2, 34); // Creates exactly 99 bytes when decoded const result = decodePayload(minLengthPayload); console.log("✅ Accepted minimum length valid payload"); console.log( @@ -192,7 +189,9 @@ console.log("-".repeat(30)); console.log("✅ # Prefix Detection: Implemented (NIP-44 Decryption Step 1)"); console.log("✅ Base64 Length Validation: 132 to 87,472 characters"); console.log("✅ Decoded Length Validation: 99 to 65,603 bytes"); -console.log("✅ Version Support: 0, 1, 2 (decryption only for 0 & 1)"); +console.log( + "✅ Version Support: v2; reserved, undefined, and unknown versions rejected", +); console.log("✅ Error Messages: Clear and informative"); console.log("\n🎉 All NIP-44 compliance checks passed!"); diff --git a/examples/nip44/nip44-demo.ts b/examples/nip44/nip44-demo.ts index 8b5e822b..713be822 100644 --- a/examples/nip44/nip44-demo.ts +++ b/examples/nip44/nip44-demo.ts @@ -172,7 +172,7 @@ async function main() { "NIP-44 includes a version byte. This implementation encrypts with version 2 (current standard).", ); console.log( - "Decryption works automatically with any supported version (0, 1, 2), ensuring backward compatibility.", + "Decryption accepts defined version 2 payloads and rejects reserved, undefined, or unknown versions.", ); const versionCompatMessage = @@ -203,7 +203,7 @@ async function main() { "- This library encrypts all new messages using NIP-44 Version 2.", ); console.log( - "- It can successfully decrypt messages created with NIP-44 Version 0, 1, or 2.", + "- It decrypts Version 2 payloads and rejects reserved, undefined, and unknown versions.", ); console.log( "- NIP-44 Specification: Clients MUST NOT encrypt new messages with Version 0 (Reserved) or Version 1 (Deprecated).", @@ -223,7 +223,7 @@ async function main() { "6. NIP-44 payload is versioned, allowing future encryption improvements", ); console.log( - "7. NIP-44 supports multiple versions (0, 1, 2) for decryption, ensuring backward compatibility", + "7. NIP-44 rejects reserved, undefined, and unknown versions before decryption", ); // Demonstrate secure constant-time comparison diff --git a/examples/nip44/nip44-version-compatibility.ts b/examples/nip44/nip44-version-compatibility.ts index ad0e02e4..49f8af8c 100644 --- a/examples/nip44/nip44-version-compatibility.ts +++ b/examples/nip44/nip44-version-compatibility.ts @@ -1,10 +1,11 @@ /** * NIP-44 Version Compatibility Demo * - * This example demonstrates how to use the NIP-44 version compatibility features: - * - Encrypting with different versions (0, 1, 2) - * - Automatic decryption of messages from any supported version - * - Using version options for compatibility with older clients + * This example demonstrates NIP-44's version registry: + * - Version 2 is the only defined encryption algorithm + * - Version 0 is reserved + * - Version 1 is deprecated and undefined + * - Unknown versions are rejected before decryption */ import { generateKeypair, encryptNIP44, decryptNIP44 } from "../../src"; @@ -54,6 +55,34 @@ async function main() { console.log(`Decrypted: "${decryptedV2}"`); console.log(`Successful decryption: ${message === decryptedV2}\n`); + console.log("Reserved, Undefined, and Unknown Decryption:"); + console.log("------------------------------------------------"); + for (const unsupportedVersion of [0, 1, 3]) { + const bytes = Buffer.from(encryptedV2, "base64"); + bytes[0] = unsupportedVersion; + try { + decryptNIP44( + bytes.toString("base64"), + bobKeypair.privateKey, + aliceKeypair.publicKey, + ); + throw new Error( + `Version ${unsupportedVersion} decryption unexpectedly succeeded`, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const expectedError = `NIP-44: Unsupported version: ${unsupportedVersion}. This implementation supports version 2.`; + if (errorMessage !== expectedError) { + throw error; + } + console.log( + `✅ Version ${unsupportedVersion} rejected: ${errorMessage}`, + ); + } + } + console.log("\n"); + // Demonstrate explicit v1 encryption console.log("Attempting Version 1 Encryption (should fail):"); console.log("-----------------------------------------------"); @@ -114,15 +143,9 @@ async function main() { } console.log("\n"); - // Cross-version compatibility test - console.log("Cross-Version Compatibility:"); + console.log("Version 2 Interoperability:"); console.log("---------------------------"); - console.log( - "Testing if Alice and Bob can communicate with different versions", - ); - - // Alice uses v2, Bob uses v0 - console.log("\nScenario 1: Alice (v2) → Bob"); + console.log("\nAlice (v2) → Bob (v2)"); const aliceMessage = "Hello Bob, I'm using NIP-44 v2!"; const aliceToBob = encryptNIP44( aliceMessage, @@ -140,14 +163,6 @@ async function main() { console.log(`Bob decrypts: "${bobDecrypts}"`); console.log(`Successful: ${aliceMessage === bobDecrypts}`); - // Bob uses v0, Alice uses v2 - console.log( - "\nScenario 2: Bob receiving a V0/V1 message (e.g., from an older client) and Alice (V2) decrypting it.", - ); - console.log( - " (Decryption of V0/V1 is supported, but sending V0/V1 is not. This scenario is covered by general decryption tests.)", - ); - // Demonstrate invalid version handling console.log("\nInvalid Version Handling:"); console.log("------------------------"); @@ -175,7 +190,9 @@ async function main() { console.log("\nSummary:"); console.log("--------"); console.log("- Default encryption uses NIP-44 v2 (most secure)"); - console.log("- Decryption automatically works with versions 0, 1, and 2"); + console.log( + "- Decryption accepts version 2 and rejects reserved, undefined, and unknown versions", + ); console.log( "- NIP-44 compliant clients MUST NOT encrypt new messages with versions 0 or 1.", ); @@ -186,7 +203,7 @@ async function main() { "- This implementation complies with NIP-44 requirement that clients:", ); console.log(" * MUST include a version byte in encrypted payloads"); - console.log(" * MUST be able to decrypt versions 0 and 1"); + console.log(" * MUST report reserved or undefined versions as unsupported"); console.log(" * MUST NOT encrypt with version 0 (Reserved)"); console.log(" * MUST NOT encrypt with version 1 (Deprecated and undefined)"); } diff --git a/examples/nip46/advanced/remote-signing-demo.ts b/examples/nip46/advanced/remote-signing-demo.ts index 86d27aba..991359d4 100644 --- a/examples/nip46/advanced/remote-signing-demo.ts +++ b/examples/nip46/advanced/remote-signing-demo.ts @@ -5,7 +5,7 @@ import { } from "../../../src"; import { NIP46Error } from "../../../src/nip46/types"; import { LogLevel } from "../../../src/utils/logger"; -import { NostrRelay } from "../../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../../src/testing"; async function main() { console.log("=== NIP-46 Remote Signing Advanced Demo ==="); diff --git a/examples/nip46/basic-example.ts b/examples/nip46/basic-example.ts index 4b423b92..bd74ceae 100644 --- a/examples/nip46/basic-example.ts +++ b/examples/nip46/basic-example.ts @@ -5,7 +5,7 @@ import { verifySignature, } from "../../src"; import { NIP46Error } from "../../src/nip46/types"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; async function main() { console.log("NIP-46 Basic Remote Signing Example"); diff --git a/examples/nip46/minimal.ts b/examples/nip46/minimal.ts index 9409794b..3e51a914 100644 --- a/examples/nip46/minimal.ts +++ b/examples/nip46/minimal.ts @@ -4,7 +4,7 @@ import { generateKeypair, } from "../../src"; import { LogLevel } from "../../src/utils/logger"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; /** * This minimal example demonstrates NIP-46 remote signing functionality. diff --git a/examples/nip46/simple/simple-client-test.ts b/examples/nip46/simple/simple-client-test.ts index 6104096c..255f60b6 100644 --- a/examples/nip46/simple/simple-client-test.ts +++ b/examples/nip46/simple/simple-client-test.ts @@ -5,7 +5,7 @@ import { verifySignature, } from "../../../src"; import { NIP46Error } from "../../../src/nip46/types"; -import { NostrRelay } from "../../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../../src/testing"; async function main() { console.log("Testing Simple NIP-46 Implementation"); diff --git a/examples/nip46/simple/simple-example.ts b/examples/nip46/simple/simple-example.ts index cb4613b4..da7faf0d 100644 --- a/examples/nip46/simple/simple-example.ts +++ b/examples/nip46/simple/simple-example.ts @@ -5,7 +5,7 @@ import { verifySignature, } from "../../../src"; import { NIP46Error } from "../../../src/nip46/types"; -import { NostrRelay } from "../../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../../src/testing"; async function main() { console.log("============================="); diff --git a/examples/nip46/unified-example.ts b/examples/nip46/unified-example.ts index fc78ba59..b93b0dc3 100644 --- a/examples/nip46/unified-example.ts +++ b/examples/nip46/unified-example.ts @@ -22,7 +22,7 @@ import { verifySignature, } from "../../src"; import { LogLevel } from "../../src/utils/logger"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; async function main() { console.log("=== NIP-46 Remote Signing Example ==="); diff --git a/examples/nip47/basic-client-service.ts b/examples/nip47/basic-client-service.ts index 193ae224..515dfab8 100644 --- a/examples/nip47/basic-client-service.ts +++ b/examples/nip47/basic-client-service.ts @@ -19,7 +19,7 @@ import { SignMessageResponseResult, NIP47Error, } from "../../src/nip47/types"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; import { signEvent, sha256Hex } from "../../src/utils/crypto"; /** diff --git a/examples/nip47/basic-example.ts b/examples/nip47/basic-example.ts index 8c4ae141..556b4f60 100644 --- a/examples/nip47/basic-example.ts +++ b/examples/nip47/basic-example.ts @@ -19,7 +19,7 @@ import { SignMessageResponseResult, NIP47Error, } from "../../src/nip47/types"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; import { signEvent, getPublicKey } from "../../src/utils/crypto"; import { getEventHash } from "../../src/nip01/event"; diff --git a/examples/nip47/error-handling-example.ts b/examples/nip47/error-handling-example.ts index 33cd8ed2..02da8e4a 100644 --- a/examples/nip47/error-handling-example.ts +++ b/examples/nip47/error-handling-example.ts @@ -16,7 +16,7 @@ import { MakeInvoiceResponseResult, SignMessageResponseResult, } from "../../src/nip47/types"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; import { signEvent, sha256Hex } from "../../src/utils/crypto"; /** diff --git a/examples/nip47/request-expiration-example.ts b/examples/nip47/request-expiration-example.ts index 7e0fc8ca..5aea91f5 100644 --- a/examples/nip47/request-expiration-example.ts +++ b/examples/nip47/request-expiration-example.ts @@ -15,7 +15,7 @@ import { PaymentResponseResult, MakeInvoiceResponseResult, } from "../../src/nip47/types"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; import { signEvent, sha256Hex } from "../../src/utils/crypto"; /** diff --git a/examples/nip50/search-demo.ts b/examples/nip50/search-demo.ts index 7e8ffdd7..d95bc95c 100644 --- a/examples/nip50/search-demo.ts +++ b/examples/nip50/search-demo.ts @@ -1,7 +1,7 @@ import { Nostr } from "../../src/nip01/nostr"; import { generateKeypair } from "../../src/utils/crypto"; import { createSignedEvent } from "../../src/nip01/event"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; import { createSearchFilter } from "../../src/nip50"; async function main() { diff --git a/examples/nip57/basic-example.ts b/examples/nip57/basic-example.ts index 888a41fe..f12f925a 100644 --- a/examples/nip57/basic-example.ts +++ b/examples/nip57/basic-example.ts @@ -19,7 +19,7 @@ import { import { createSignedEvent, UnsignedEvent } from "../../src/nip01/event"; // For a real ephemeral relay implementation -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; async function main() { console.log("NIP-57 Lightning Zaps Example"); diff --git a/examples/nip57/lnurl-server-simulation.ts b/examples/nip57/lnurl-server-simulation.ts index 182a5e6f..571390f4 100644 --- a/examples/nip57/lnurl-server-simulation.ts +++ b/examples/nip57/lnurl-server-simulation.ts @@ -24,7 +24,7 @@ import { import { parseLnurlPayResponse } from "../../src/nip57"; import { createSignedEvent, UnsignedEvent } from "../../src/nip01/event"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; import { createServer, Server, IncomingMessage, ServerResponse } from "http"; import { parse as parseUrl } from "url"; import { parse as parseQuery } from "querystring"; diff --git a/examples/nip57/zap-client-example.ts b/examples/nip57/zap-client-example.ts index 394af4bc..90b1aebc 100644 --- a/examples/nip57/zap-client-example.ts +++ b/examples/nip57/zap-client-example.ts @@ -21,7 +21,7 @@ import { } from "../../src"; import { createSignedEvent } from "../../src/nip01/event"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; import { fetchLnurlPayMetadata, buildZapCallbackUrl, diff --git a/package.json b/package.json index 58e72d46..9a1d0df0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "snstr", "version": "0.5.0", + "packageManager": "npm@9.8.1", "description": "Secure Nostr Software Toolkit for Renegades - A comprehensive TypeScript library for Nostr protocol implementation", "files": [ "dist/src/**", @@ -47,6 +48,12 @@ "require": "./dist/src/nip04/index.js", "default": "./dist/src/nip04/index.js" }, + "./testing": { + "types": "./dist/src/testing/index.d.ts", + "import": "./dist/esm/src/testing/index.js", + "require": "./dist/src/testing/index.js", + "default": "./dist/src/testing/index.js" + }, "./utils/ephemeral-relay": { "types": "./dist/src/utils/ephemeral-relay.d.ts", "import": "./dist/esm/src/utils/ephemeral-relay.js", @@ -60,6 +67,7 @@ "build:cjs": "tsc -p tsconfig.build.json", "build:esm": "tsc -p tsconfig.esm.json && node scripts/postbuild-esm.js", "pack:verify": "node scripts/verify-pack.js", + "package-manager:verify": "node scripts/verify-package-manager.js", "commands:verify": "node scripts/verify-commands.js", "prepack": "npm run build && npm run pack:verify", "build:examples": "tsc -p examples/tsconfig.json", @@ -67,13 +75,17 @@ "lint": "eslint . --ext .ts", "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\" \"examples/**/*.ts\"", "// Test Main": "-------------- Main Test Commands --------------", - "test": "jest", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", + "test": "node scripts/run-test-lane.js jest routine", + "test:watch": "node scripts/run-test-lane.js jest routine --watch", + "test:coverage": "node scripts/run-test-lane.js jest routine --coverage", + "test:coverage:all": "node scripts/run-test-lane.js jest all --coverage", + "test:slow": "node scripts/run-test-lane.js jest slow", "test:integration": "jest tests/integration.test.ts", "// Bun Migration": "-------------- Bun Test Migration --------------", - "test:bun": "bun test ./tests --max-concurrency 1 --timeout 30000", - "test:bun:watch": "bun test ./tests --watch --max-concurrency 1 --timeout 30000", + "test:bun": "node scripts/run-test-lane.js bun routine", + "test:bun:watch": "node scripts/run-test-lane.js bun routine --watch", + "test:bun:slow": "node scripts/run-test-lane.js bun slow", + "test:bun:all": "bun run test:bun && bun run test:bun:slow", "// Test Core": "-------------- Core Component Tests --------------", "test:nip01": "jest tests/nip01", "test:nip01:event": "jest tests/nip01/event", @@ -113,7 +125,7 @@ "test:nip70": "jest tests/nip70", "test:nip86": "jest tests/nip86", "// Test Groups": "-------------- Test Category Groups --------------", - "test:all": "npm test", + "test:all": "npm test && npm run test:slow", "test:crypto": "jest tests/utils/crypto.test.ts tests/nip04 tests/nip44", "test:identity": "jest tests/nip05 tests/nip07 tests/nip19", "test:protocols": "jest tests/nip46 tests/nip47 tests/nip57", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 01b57c57..00000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,3303 +0,0 @@ -lockfileVersion: '6.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -dependencies: - '@noble/ciphers': - specifier: ^0.4.1 - version: 0.4.1 - '@noble/curves': - specifier: ^1.8.1 - version: 1.9.7 - '@noble/hashes': - specifier: ^1.3.3 - version: 1.8.0 - '@scure/base': - specifier: ^1.2.4 - version: 1.2.6 - crypto-js: - specifier: ^4.2.0 - version: 4.2.0 - light-bolt11-decoder: - specifier: ^3.2.0 - version: 3.2.0 - websocket-polyfill: - specifier: ^0.0.3 - version: 0.0.3 - ws: - specifier: ^8.21.0 - version: 8.21.0 - -devDependencies: - '@types/crypto-js': - specifier: ^4.2.2 - version: 4.2.2 - '@types/jest': - specifier: ^29.5.11 - version: 29.5.14 - '@types/node': - specifier: ^20.10.6 - version: 20.19.17 - '@types/ws': - specifier: ^8.18.0 - version: 8.18.1 - '@typescript-eslint/eslint-plugin': - specifier: ^6.17.0 - version: 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.57.1)(typescript@5.9.2) - '@typescript-eslint/parser': - specifier: ^6.17.0 - version: 6.21.0(eslint@8.57.1)(typescript@5.9.2) - eslint: - specifier: ^8.56.0 - version: 8.57.1 - jest: - specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2) - prettier: - specifier: ^3.1.1 - version: 3.6.2 - rimraf: - specifier: ^3.0.2 - version: 3.0.2 - ts-jest: - specifier: ^29.1.1 - version: 29.4.4(@babel/core@7.28.4)(jest@29.7.0)(typescript@5.9.2) - ts-node: - specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.17)(typescript@5.9.2) - typescript: - specifier: ^5.3.3 - version: 5.9.2 - -packages: - - /@babel/code-frame@7.27.1: - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-validator-identifier': 7.27.1 - js-tokens: 4.0.0 - picocolors: 1.1.1 - dev: true - - /@babel/compat-data@7.28.4: - resolution: {integrity: sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==} - engines: {node: '>=6.9.0'} - dev: true - - /@babel/core@7.28.4: - resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.3 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) - '@babel/helpers': 7.28.4 - '@babel/parser': 7.28.4 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - dev: true - - /@babel/generator@7.28.3: - resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - dev: true - - /@babel/helper-compilation-targets@7.27.2: - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/compat-data': 7.28.4 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.26.2 - lru-cache: 5.1.1 - semver: 6.3.1 - dev: true - - /@babel/helper-globals@7.28.0: - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - dev: true - - /@babel/helper-module-imports@7.27.1: - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 - transitivePeerDependencies: - - supports-color - dev: true - - /@babel/helper-module-transforms@7.28.3(@babel/core@7.28.4): - resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color - dev: true - - /@babel/helper-plugin-utils@7.27.1: - resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} - engines: {node: '>=6.9.0'} - dev: true - - /@babel/helper-string-parser@7.27.1: - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - dev: true - - /@babel/helper-validator-identifier@7.27.1: - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} - engines: {node: '>=6.9.0'} - dev: true - - /@babel/helper-validator-option@7.27.1: - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - dev: true - - /@babel/helpers@7.28.4: - resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.4 - dev: true - - /@babel/parser@7.28.4: - resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==} - engines: {node: '>=6.0.0'} - hasBin: true - dependencies: - '@babel/types': 7.28.4 - dev: true - - /@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.4): - resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - - /@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.4): - resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - - /@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.4): - resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - - /@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.4): - resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - - /@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.4): - resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - - /@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.4): - resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - - /@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.4): - resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - - /@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.4): - resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - - /@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.4): - resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - - /@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.4): - resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - - /@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.4): - resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - - /@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.4): - resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - - /@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.4): - resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - - /@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.4): - resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - - /@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.4): - resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - - /@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.4): - resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - - /@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.4): - resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - - /@babel/template@7.27.2: - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 - dev: true - - /@babel/traverse@7.28.4: - resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.3 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.4 - '@babel/template': 7.27.2 - '@babel/types': 7.28.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - dev: true - - /@babel/types@7.28.4: - resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - dev: true - - /@bcoe/v8-coverage@0.2.3: - resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - dev: true - - /@cspotcode/source-map-support@0.8.1: - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - dev: true - - /@eslint-community/eslint-utils@4.9.0(eslint@8.57.1): - resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - dependencies: - eslint: 8.57.1 - eslint-visitor-keys: 3.4.3 - dev: true - - /@eslint-community/regexpp@4.12.1: - resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - dev: true - - /@eslint/eslintrc@2.1.4: - resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - ajv: 6.12.6 - debug: 4.4.3 - espree: 9.6.1 - globals: 13.24.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.0 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - dev: true - - /@eslint/js@8.57.1: - resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dev: true - - /@humanwhocodes/config-array@0.13.0: - resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} - engines: {node: '>=10.10.0'} - deprecated: Use @eslint/config-array instead - dependencies: - '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color - dev: true - - /@humanwhocodes/module-importer@1.0.1: - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - dev: true - - /@humanwhocodes/object-schema@2.0.3: - resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} - deprecated: Use @eslint/object-schema instead - dev: true - - /@istanbuljs/load-nyc-config@1.1.0: - resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} - engines: {node: '>=8'} - dependencies: - camelcase: 5.3.1 - find-up: 4.1.0 - get-package-type: 0.1.0 - js-yaml: 3.14.1 - resolve-from: 5.0.0 - dev: true - - /@istanbuljs/schema@0.1.3: - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} - engines: {node: '>=8'} - dev: true - - /@jest/console@29.7.0: - resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/types': 29.6.3 - '@types/node': 20.19.17 - chalk: 4.1.2 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - slash: 3.0.0 - dev: true - - /@jest/core@29.7.0(ts-node@10.9.2): - resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.17 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - dev: true - - /@jest/environment@29.7.0: - resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.17 - jest-mock: 29.7.0 - dev: true - - /@jest/expect-utils@29.7.0: - resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - jest-get-type: 29.6.3 - dev: true - - /@jest/expect@29.7.0: - resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - expect: 29.7.0 - jest-snapshot: 29.7.0 - transitivePeerDependencies: - - supports-color - dev: true - - /@jest/fake-timers@29.7.0: - resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/types': 29.6.3 - '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.19.17 - jest-message-util: 29.7.0 - jest-mock: 29.7.0 - jest-util: 29.7.0 - dev: true - - /@jest/globals@29.7.0: - resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/types': 29.6.3 - jest-mock: 29.7.0 - transitivePeerDependencies: - - supports-color - dev: true - - /@jest/reporters@29.7.0: - resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - dependencies: - '@bcoe/v8-coverage': 0.2.3 - '@jest/console': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 20.19.17 - chalk: 4.1.2 - collect-v8-coverage: 1.0.2 - exit: 0.1.2 - glob: 7.2.3 - graceful-fs: 4.2.11 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-instrument: 6.0.3 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 4.0.1 - istanbul-reports: 3.2.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - jest-worker: 29.7.0 - slash: 3.0.0 - string-length: 4.0.2 - strip-ansi: 6.0.1 - v8-to-istanbul: 9.3.0 - transitivePeerDependencies: - - supports-color - dev: true - - /@jest/schemas@29.6.3: - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@sinclair/typebox': 0.27.8 - dev: true - - /@jest/source-map@29.6.3: - resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - callsites: 3.1.0 - graceful-fs: 4.2.11 - dev: true - - /@jest/test-result@29.7.0: - resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/console': 29.7.0 - '@jest/types': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - collect-v8-coverage: 1.0.2 - dev: true - - /@jest/test-sequencer@29.7.0: - resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/test-result': 29.7.0 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - slash: 3.0.0 - dev: true - - /@jest/transform@29.7.0: - resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@babel/core': 7.28.4 - '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.31 - babel-plugin-istanbul: 6.1.1 - chalk: 4.1.2 - convert-source-map: 2.0.0 - fast-json-stable-stringify: 2.1.0 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-regex-util: 29.6.3 - jest-util: 29.7.0 - micromatch: 4.0.8 - pirates: 4.0.7 - slash: 3.0.0 - write-file-atomic: 4.0.2 - transitivePeerDependencies: - - supports-color - dev: true - - /@jest/types@29.6.3: - resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/schemas': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 20.19.17 - '@types/yargs': 17.0.33 - chalk: 4.1.2 - dev: true - - /@jridgewell/gen-mapping@0.3.13: - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - dev: true - - /@jridgewell/remapping@2.3.5: - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - dev: true - - /@jridgewell/resolve-uri@3.1.2: - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - dev: true - - /@jridgewell/sourcemap-codec@1.5.5: - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - dev: true - - /@jridgewell/trace-mapping@0.3.31: - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - dev: true - - /@jridgewell/trace-mapping@0.3.9: - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - dev: true - - /@noble/ciphers@0.4.1: - resolution: {integrity: sha512-QCOA9cgf3Rc33owG0AYBB9wszz+Ul2kramWN8tXG44Gyciud/tbkEqvxRF/IpqQaBpRBNi9f4jdNxqB2CQCIXg==} - dev: false - - /@noble/curves@1.9.7: - resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} - engines: {node: ^14.21.3 || >=16} - dependencies: - '@noble/hashes': 1.8.0 - dev: false - - /@noble/hashes@1.8.0: - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} - dev: false - - /@nodelib/fs.scandir@2.1.5: - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - dev: true - - /@nodelib/fs.stat@2.0.5: - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - dev: true - - /@nodelib/fs.walk@1.2.8: - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 - dev: true - - /@scure/base@1.1.1: - resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==} - dev: false - - /@scure/base@1.2.6: - resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} - dev: false - - /@sinclair/typebox@0.27.8: - resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - dev: true - - /@sinonjs/commons@3.0.1: - resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} - dependencies: - type-detect: 4.0.8 - dev: true - - /@sinonjs/fake-timers@10.3.0: - resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - dependencies: - '@sinonjs/commons': 3.0.1 - dev: true - - /@tsconfig/node10@1.0.11: - resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} - dev: true - - /@tsconfig/node12@1.0.11: - resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} - dev: true - - /@tsconfig/node14@1.0.3: - resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} - dev: true - - /@tsconfig/node16@1.0.4: - resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - dev: true - - /@types/babel__core@7.20.5: - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - dev: true - - /@types/babel__generator@7.27.0: - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - dependencies: - '@babel/types': 7.28.4 - dev: true - - /@types/babel__template@7.4.4: - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 - dev: true - - /@types/babel__traverse@7.28.0: - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - dependencies: - '@babel/types': 7.28.4 - dev: true - - /@types/crypto-js@4.2.2: - resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} - dev: true - - /@types/graceful-fs@4.1.9: - resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} - dependencies: - '@types/node': 20.19.17 - dev: true - - /@types/istanbul-lib-coverage@2.0.6: - resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} - dev: true - - /@types/istanbul-lib-report@3.0.3: - resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} - dependencies: - '@types/istanbul-lib-coverage': 2.0.6 - dev: true - - /@types/istanbul-reports@3.0.4: - resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - dependencies: - '@types/istanbul-lib-report': 3.0.3 - dev: true - - /@types/jest@29.5.14: - resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} - dependencies: - expect: 29.7.0 - pretty-format: 29.7.0 - dev: true - - /@types/json-schema@7.0.15: - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - dev: true - - /@types/node@20.19.17: - resolution: {integrity: sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==} - dependencies: - undici-types: 6.21.0 - dev: true - - /@types/semver@7.7.1: - resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - dev: true - - /@types/stack-utils@2.0.3: - resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - dev: true - - /@types/ws@8.18.1: - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - dependencies: - '@types/node': 20.19.17 - dev: true - - /@types/yargs-parser@21.0.3: - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - dev: true - - /@types/yargs@17.0.33: - resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} - dependencies: - '@types/yargs-parser': 21.0.3 - dev: true - - /@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.57.1)(typescript@5.9.2): - resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - dependencies: - '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.2) - '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.1)(typescript@5.9.2) - '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.2) - '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.3 - eslint: 8.57.1 - graphemer: 1.4.0 - ignore: 5.3.2 - natural-compare: 1.4.0 - semver: 7.7.2 - ts-api-utils: 1.4.3(typescript@5.9.2) - typescript: 5.9.2 - transitivePeerDependencies: - - supports-color - dev: true - - /@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2): - resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - dependencies: - '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.2) - '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.3 - eslint: 8.57.1 - typescript: 5.9.2 - transitivePeerDependencies: - - supports-color - dev: true - - /@typescript-eslint/scope-manager@6.21.0: - resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} - engines: {node: ^16.0.0 || >=18.0.0} - dependencies: - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/visitor-keys': 6.21.0 - dev: true - - /@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.9.2): - resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - dependencies: - '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.2) - '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.2) - debug: 4.4.3 - eslint: 8.57.1 - ts-api-utils: 1.4.3(typescript@5.9.2) - typescript: 5.9.2 - transitivePeerDependencies: - - supports-color - dev: true - - /@typescript-eslint/types@6.21.0: - resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} - engines: {node: ^16.0.0 || >=18.0.0} - dev: true - - /@typescript-eslint/typescript-estree@6.21.0(typescript@5.9.2): - resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - dependencies: - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.3 - globby: 11.1.0 - is-glob: 4.0.3 - minimatch: 9.0.3 - semver: 7.7.2 - ts-api-utils: 1.4.3(typescript@5.9.2) - typescript: 5.9.2 - transitivePeerDependencies: - - supports-color - dev: true - - /@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@5.9.2): - resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) - '@types/json-schema': 7.0.15 - '@types/semver': 7.7.1 - '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.2) - eslint: 8.57.1 - semver: 7.7.2 - transitivePeerDependencies: - - supports-color - - typescript - dev: true - - /@typescript-eslint/visitor-keys@6.21.0: - resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} - engines: {node: ^16.0.0 || >=18.0.0} - dependencies: - '@typescript-eslint/types': 6.21.0 - eslint-visitor-keys: 3.4.3 - dev: true - - /@ungap/structured-clone@1.3.0: - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - dev: true - - /acorn-jsx@5.3.2(acorn@8.15.0): - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - dependencies: - acorn: 8.15.0 - dev: true - - /acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} - engines: {node: '>=0.4.0'} - dependencies: - acorn: 8.15.0 - dev: true - - /acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - dev: true - - /ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - dev: true - - /ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - dependencies: - type-fest: 0.21.3 - dev: true - - /ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - dev: true - - /ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - dependencies: - color-convert: 2.0.1 - dev: true - - /ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - dev: true - - /anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - dev: true - - /arg@4.1.3: - resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - dev: true - - /argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - dependencies: - sprintf-js: 1.0.3 - dev: true - - /argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - dev: true - - /array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - dev: true - - /babel-jest@29.7.0(@babel/core@7.28.4): - resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@babel/core': ^7.8.0 - dependencies: - '@babel/core': 7.28.4 - '@jest/transform': 29.7.0 - '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.28.4) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 - transitivePeerDependencies: - - supports-color - dev: true - - /babel-plugin-istanbul@6.1.1: - resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} - engines: {node: '>=8'} - dependencies: - '@babel/helper-plugin-utils': 7.27.1 - '@istanbuljs/load-nyc-config': 1.1.0 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-instrument: 5.2.1 - test-exclude: 6.0.0 - transitivePeerDependencies: - - supports-color - dev: true - - /babel-plugin-jest-hoist@29.6.3: - resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.4 - '@types/babel__core': 7.20.5 - '@types/babel__traverse': 7.28.0 - dev: true - - /babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.4): - resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} - peerDependencies: - '@babel/core': ^7.0.0 || ^8.0.0-0 - dependencies: - '@babel/core': 7.28.4 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.4) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.4) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.4) - '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.4) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.4) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.4) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.4) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.4) - dev: true - - /babel-preset-jest@29.6.3(@babel/core@7.28.4): - resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@babel/core': ^7.0.0 - dependencies: - '@babel/core': 7.28.4 - babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.4) - dev: true - - /balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - dev: true - - /baseline-browser-mapping@2.8.6: - resolution: {integrity: sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==} - hasBin: true - dev: true - - /brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - dev: true - - /brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - dependencies: - balanced-match: 1.0.2 - dev: true - - /braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - dependencies: - fill-range: 7.1.1 - dev: true - - /browserslist@4.26.2: - resolution: {integrity: sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - dependencies: - baseline-browser-mapping: 2.8.6 - caniuse-lite: 1.0.30001743 - electron-to-chromium: 1.5.222 - node-releases: 2.0.21 - update-browserslist-db: 1.1.3(browserslist@4.26.2) - dev: true - - /bs-logger@0.2.6: - resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} - engines: {node: '>= 6'} - dependencies: - fast-json-stable-stringify: 2.1.0 - dev: true - - /bser@2.1.1: - resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} - dependencies: - node-int64: 0.4.0 - dev: true - - /buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - dev: true - - /bufferutil@4.0.9: - resolution: {integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==} - engines: {node: '>=6.14.2'} - requiresBuild: true - dependencies: - node-gyp-build: 4.8.4 - dev: false - - /callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - dev: true - - /camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} - dev: true - - /camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} - dev: true - - /caniuse-lite@1.0.30001743: - resolution: {integrity: sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==} - dev: true - - /chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - dev: true - - /char-regex@1.0.2: - resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} - engines: {node: '>=10'} - dev: true - - /ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} - dev: true - - /cjs-module-lexer@1.4.3: - resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} - dev: true - - /cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - dev: true - - /co@4.6.0: - resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} - engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - dev: true - - /collect-v8-coverage@1.0.2: - resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} - dev: true - - /color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - dependencies: - color-name: 1.1.4 - dev: true - - /color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - dev: true - - /concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - dev: true - - /convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - dev: true - - /create-jest@29.7.0(@types/node@20.19.17)(ts-node@10.9.2): - resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - dev: true - - /create-require@1.1.1: - resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - dev: true - - /cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - dev: true - - /crypto-js@4.2.0: - resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} - dev: false - - /d@1.0.2: - resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} - engines: {node: '>=0.12'} - dependencies: - es5-ext: 0.10.64 - type: 2.7.3 - dev: false - - /debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - dependencies: - ms: 2.0.0 - dev: false - - /debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - dependencies: - ms: 2.1.3 - dev: true - - /dedent@1.7.0: - resolution: {integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==} - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - dev: true - - /deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - dev: true - - /deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - dev: true - - /detect-newline@3.1.0: - resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} - engines: {node: '>=8'} - dev: true - - /diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dev: true - - /diff@4.0.2: - resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} - engines: {node: '>=0.3.1'} - dev: true - - /dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - dependencies: - path-type: 4.0.0 - dev: true - - /doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} - dependencies: - esutils: 2.0.3 - dev: true - - /electron-to-chromium@1.5.222: - resolution: {integrity: sha512-gA7psSwSwQRE60CEoLz6JBCQPIxNeuzB2nL8vE03GK/OHxlvykbLyeiumQy1iH5C2f3YbRAZpGCMT12a/9ih9w==} - dev: true - - /emittery@0.13.1: - resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} - engines: {node: '>=12'} - dev: true - - /emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - dev: true - - /error-ex@1.3.4: - resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - dependencies: - is-arrayish: 0.2.1 - dev: true - - /es5-ext@0.10.64: - resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==} - engines: {node: '>=0.10'} - requiresBuild: true - dependencies: - es6-iterator: 2.0.3 - es6-symbol: 3.1.4 - esniff: 2.0.1 - next-tick: 1.1.0 - dev: false - - /es6-iterator@2.0.3: - resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==} - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - es6-symbol: 3.1.4 - dev: false - - /es6-symbol@3.1.4: - resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==} - engines: {node: '>=0.12'} - dependencies: - d: 1.0.2 - ext: 1.7.0 - dev: false - - /escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - dev: true - - /escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} - dev: true - - /escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - dev: true - - /eslint-scope@7.2.2: - resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - dev: true - - /eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dev: true - - /eslint@8.57.1: - resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. - hasBin: true - dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) - '@eslint-community/regexpp': 4.12.1 - '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.57.1 - '@humanwhocodes/config-array': 0.13.0 - '@humanwhocodes/module-importer': 1.0.1 - '@nodelib/fs.walk': 1.2.8 - '@ungap/structured-clone': 1.3.0 - ajv: 6.12.6 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - doctrine: 3.0.0 - escape-string-regexp: 4.0.0 - eslint-scope: 7.2.2 - eslint-visitor-keys: 3.4.3 - espree: 9.6.1 - esquery: 1.6.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 6.0.1 - find-up: 5.0.0 - glob-parent: 6.0.2 - globals: 13.24.0 - graphemer: 1.4.0 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - is-path-inside: 3.0.3 - js-yaml: 4.1.0 - json-stable-stringify-without-jsonify: 1.0.1 - levn: 0.4.1 - lodash.merge: 4.6.2 - minimatch: 3.1.2 - natural-compare: 1.4.0 - optionator: 0.9.4 - strip-ansi: 6.0.1 - text-table: 0.2.0 - transitivePeerDependencies: - - supports-color - dev: true - - /esniff@2.0.1: - resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==} - engines: {node: '>=0.10'} - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - event-emitter: 0.3.5 - type: 2.7.3 - dev: false - - /espree@9.6.1: - resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) - eslint-visitor-keys: 3.4.3 - dev: true - - /esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - dev: true - - /esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} - engines: {node: '>=0.10'} - dependencies: - estraverse: 5.3.0 - dev: true - - /esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - dependencies: - estraverse: 5.3.0 - dev: true - - /estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - dev: true - - /esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - dev: true - - /event-emitter@0.3.5: - resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - dev: false - - /execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - dev: true - - /exit@0.1.2: - resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} - engines: {node: '>= 0.8.0'} - dev: true - - /expect@29.7.0: - resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/expect-utils': 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - dev: true - - /ext@1.7.0: - resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} - dependencies: - type: 2.7.3 - dev: false - - /fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - dev: true - - /fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - dev: true - - /fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - dev: true - - /fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - dev: true - - /fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - dependencies: - reusify: 1.1.0 - dev: true - - /fb-watchman@2.0.2: - resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - dependencies: - bser: 2.1.1 - dev: true - - /file-entry-cache@6.0.1: - resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} - engines: {node: ^10.12.0 || >=12.0.0} - dependencies: - flat-cache: 3.2.0 - dev: true - - /fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - dependencies: - to-regex-range: 5.0.1 - dev: true - - /find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 - dev: true - - /find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - dev: true - - /flat-cache@3.2.0: - resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} - engines: {node: ^10.12.0 || >=12.0.0} - dependencies: - flatted: 3.3.3 - keyv: 4.5.4 - rimraf: 3.0.2 - dev: true - - /flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - dev: true - - /fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - dev: true - - /fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - dev: true - - /gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - dev: true - - /get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - dev: true - - /get-package-type@0.1.0: - resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} - engines: {node: '>=8.0.0'} - dev: true - - /get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - dev: true - - /glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - dependencies: - is-glob: 4.0.3 - dev: true - - /glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - dependencies: - is-glob: 4.0.3 - dev: true - - /glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - dev: true - - /globals@13.24.0: - resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} - engines: {node: '>=8'} - dependencies: - type-fest: 0.20.2 - dev: true - - /globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.3 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 - dev: true - - /graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - dev: true - - /graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - dev: true - - /handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} - engines: {node: '>=0.4.7'} - hasBin: true - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 - dev: true - - /has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - dev: true - - /hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - dependencies: - function-bind: 1.1.2 - dev: true - - /html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - dev: true - - /human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} - dev: true - - /ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - dev: true - - /import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - dev: true - - /import-local@3.2.0: - resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} - engines: {node: '>=8'} - hasBin: true - dependencies: - pkg-dir: 4.2.0 - resolve-cwd: 3.0.0 - dev: true - - /imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - dev: true - - /inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - dev: true - - /inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - dev: true - - /is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - dev: true - - /is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - dependencies: - hasown: 2.0.2 - dev: true - - /is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - dev: true - - /is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - dev: true - - /is-generator-fn@2.1.0: - resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} - engines: {node: '>=6'} - dev: true - - /is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - dependencies: - is-extglob: 2.1.1 - dev: true - - /is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - dev: true - - /is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - dev: true - - /is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - dev: true - - /is-typedarray@1.0.0: - resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} - dev: false - - /isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - dev: true - - /istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - dev: true - - /istanbul-lib-instrument@5.2.1: - resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} - engines: {node: '>=8'} - dependencies: - '@babel/core': 7.28.4 - '@babel/parser': 7.28.4 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - dev: true - - /istanbul-lib-instrument@6.0.3: - resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} - engines: {node: '>=10'} - dependencies: - '@babel/core': 7.28.4 - '@babel/parser': 7.28.4 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 7.7.2 - transitivePeerDependencies: - - supports-color - dev: true - - /istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} - dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 - dev: true - - /istanbul-lib-source-maps@4.0.1: - resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} - engines: {node: '>=10'} - dependencies: - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - source-map: 0.6.1 - transitivePeerDependencies: - - supports-color - dev: true - - /istanbul-reports@3.2.0: - resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} - engines: {node: '>=8'} - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - dev: true - - /jest-changed-files@29.7.0: - resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - execa: 5.1.1 - jest-util: 29.7.0 - p-limit: 3.1.0 - dev: true - - /jest-circus@29.7.0: - resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.17 - chalk: 4.1.2 - co: 4.6.0 - dedent: 1.7.0 - is-generator-fn: 2.1.0 - jest-each: 29.7.0 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - p-limit: 3.1.0 - pretty-format: 29.7.0 - pure-rand: 6.1.0 - slash: 3.0.0 - stack-utils: 2.0.6 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - dev: true - - /jest-cli@29.7.0(@types/node@20.19.17)(ts-node@10.9.2): - resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - dev: true - - /jest-config@29.7.0(@types/node@20.19.17)(ts-node@10.9.2): - resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@types/node': '*' - ts-node: '>=9.0.0' - peerDependenciesMeta: - '@types/node': - optional: true - ts-node: - optional: true - dependencies: - '@babel/core': 7.28.4 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.17 - babel-jest: 29.7.0(@babel/core@7.28.4) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - ts-node: 10.9.2(@types/node@20.19.17)(typescript@5.9.2) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - dev: true - - /jest-diff@29.7.0: - resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - chalk: 4.1.2 - diff-sequences: 29.6.3 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - dev: true - - /jest-docblock@29.7.0: - resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - detect-newline: 3.1.0 - dev: true - - /jest-each@29.7.0: - resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - jest-get-type: 29.6.3 - jest-util: 29.7.0 - pretty-format: 29.7.0 - dev: true - - /jest-environment-node@29.7.0: - resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/environment': 29.7.0 - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.17 - jest-mock: 29.7.0 - jest-util: 29.7.0 - dev: true - - /jest-get-type@29.6.3: - resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dev: true - - /jest-haste-map@29.7.0: - resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/types': 29.6.3 - '@types/graceful-fs': 4.1.9 - '@types/node': 20.19.17 - anymatch: 3.1.3 - fb-watchman: 2.0.2 - graceful-fs: 4.2.11 - jest-regex-util: 29.6.3 - jest-util: 29.7.0 - jest-worker: 29.7.0 - micromatch: 4.0.8 - walker: 1.0.8 - optionalDependencies: - fsevents: 2.3.3 - dev: true - - /jest-leak-detector@29.7.0: - resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - dev: true - - /jest-matcher-utils@29.7.0: - resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - chalk: 4.1.2 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - dev: true - - /jest-message-util@29.7.0: - resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@babel/code-frame': 7.27.1 - '@jest/types': 29.6.3 - '@types/stack-utils': 2.0.3 - chalk: 4.1.2 - graceful-fs: 4.2.11 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - stack-utils: 2.0.6 - dev: true - - /jest-mock@29.7.0: - resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/types': 29.6.3 - '@types/node': 20.19.17 - jest-util: 29.7.0 - dev: true - - /jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): - resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} - engines: {node: '>=6'} - peerDependencies: - jest-resolve: '*' - peerDependenciesMeta: - jest-resolve: - optional: true - dependencies: - jest-resolve: 29.7.0 - dev: true - - /jest-regex-util@29.6.3: - resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dev: true - - /jest-resolve-dependencies@29.7.0: - resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - jest-regex-util: 29.6.3 - jest-snapshot: 29.7.0 - transitivePeerDependencies: - - supports-color - dev: true - - /jest-resolve@29.7.0: - resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - chalk: 4.1.2 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - resolve: 1.22.10 - resolve.exports: 2.0.3 - slash: 3.0.0 - dev: true - - /jest-runner@29.7.0: - resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/console': 29.7.0 - '@jest/environment': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.17 - chalk: 4.1.2 - emittery: 0.13.1 - graceful-fs: 4.2.11 - jest-docblock: 29.7.0 - jest-environment-node: 29.7.0 - jest-haste-map: 29.7.0 - jest-leak-detector: 29.7.0 - jest-message-util: 29.7.0 - jest-resolve: 29.7.0 - jest-runtime: 29.7.0 - jest-util: 29.7.0 - jest-watcher: 29.7.0 - jest-worker: 29.7.0 - p-limit: 3.1.0 - source-map-support: 0.5.13 - transitivePeerDependencies: - - supports-color - dev: true - - /jest-runtime@29.7.0: - resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/environment': 29.7.0 - '@jest/fake-timers': 29.7.0 - '@jest/globals': 29.7.0 - '@jest/source-map': 29.6.3 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.17 - chalk: 4.1.2 - cjs-module-lexer: 1.4.3 - collect-v8-coverage: 1.0.2 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-mock: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - slash: 3.0.0 - strip-bom: 4.0.0 - transitivePeerDependencies: - - supports-color - dev: true - - /jest-snapshot@29.7.0: - resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@babel/core': 7.28.4 - '@babel/generator': 7.28.3 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.4) - '@babel/types': 7.28.4 - '@jest/expect-utils': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.4) - chalk: 4.1.2 - expect: 29.7.0 - graceful-fs: 4.2.11 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - natural-compare: 1.4.0 - pretty-format: 29.7.0 - semver: 7.7.2 - transitivePeerDependencies: - - supports-color - dev: true - - /jest-util@29.7.0: - resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/types': 29.6.3 - '@types/node': 20.19.17 - chalk: 4.1.2 - ci-info: 3.9.0 - graceful-fs: 4.2.11 - picomatch: 2.3.1 - dev: true - - /jest-validate@29.7.0: - resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/types': 29.6.3 - camelcase: 6.3.0 - chalk: 4.1.2 - jest-get-type: 29.6.3 - leven: 3.1.0 - pretty-format: 29.7.0 - dev: true - - /jest-watcher@29.7.0: - resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.17 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - emittery: 0.13.1 - jest-util: 29.7.0 - string-length: 4.0.2 - dev: true - - /jest-worker@29.7.0: - resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@types/node': 20.19.17 - jest-util: 29.7.0 - merge-stream: 2.0.0 - supports-color: 8.1.1 - dev: true - - /jest@29.7.0(@types/node@20.19.17)(ts-node@10.9.2): - resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - dev: true - - /js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - dev: true - - /js-yaml@3.14.1: - resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} - hasBin: true - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - dev: true - - /js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - dependencies: - argparse: 2.0.1 - dev: true - - /jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - dev: true - - /json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - dev: true - - /json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - dev: true - - /json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - dev: true - - /json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - dev: true - - /json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - dev: true - - /keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - dependencies: - json-buffer: 3.0.1 - dev: true - - /kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} - dev: true - - /leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} - engines: {node: '>=6'} - dev: true - - /levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - dev: true - - /light-bolt11-decoder@3.2.0: - resolution: {integrity: sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==} - dependencies: - '@scure/base': 1.1.1 - dev: false - - /lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - dev: true - - /locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} - dependencies: - p-locate: 4.1.0 - dev: true - - /locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - dependencies: - p-locate: 5.0.0 - dev: true - - /lodash.memoize@4.1.2: - resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - dev: true - - /lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - dev: true - - /lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - dependencies: - yallist: 3.1.1 - dev: true - - /make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} - dependencies: - semver: 7.7.2 - dev: true - - /make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - dev: true - - /makeerror@1.0.12: - resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} - dependencies: - tmpl: 1.0.5 - dev: true - - /merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - dev: true - - /merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - dev: true - - /micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - dev: true - - /mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - dev: true - - /minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - dependencies: - brace-expansion: 1.1.12 - dev: true - - /minimatch@9.0.3: - resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} - engines: {node: '>=16 || 14 >=14.17'} - dependencies: - brace-expansion: 2.0.2 - dev: true - - /minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: true - - /ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - dev: false - - /ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - dev: true - - /natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - dev: true - - /neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - dev: true - - /next-tick@1.1.0: - resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} - dev: false - - /node-gyp-build@4.8.4: - resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} - hasBin: true - dev: false - - /node-int64@0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - dev: true - - /node-releases@2.0.21: - resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==} - dev: true - - /normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - dev: true - - /npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - dependencies: - path-key: 3.1.1 - dev: true - - /once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - dependencies: - wrappy: 1.0.2 - dev: true - - /onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - dependencies: - mimic-fn: 2.1.0 - dev: true - - /optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - dev: true - - /p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} - dependencies: - p-try: 2.2.0 - dev: true - - /p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - dependencies: - yocto-queue: 0.1.0 - dev: true - - /p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} - dependencies: - p-limit: 2.3.0 - dev: true - - /p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - dependencies: - p-limit: 3.1.0 - dev: true - - /p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - dev: true - - /parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - dependencies: - callsites: 3.1.0 - dev: true - - /parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} - dependencies: - '@babel/code-frame': 7.27.1 - error-ex: 1.3.4 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - dev: true - - /path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - dev: true - - /path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - dev: true - - /path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - dev: true - - /path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - dev: true - - /path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - dev: true - - /picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - dev: true - - /picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - dev: true - - /pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - dev: true - - /pkg-dir@4.2.0: - resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} - engines: {node: '>=8'} - dependencies: - find-up: 4.1.0 - dev: true - - /prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - dev: true - - /prettier@3.6.2: - resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} - engines: {node: '>=14'} - hasBin: true - dev: true - - /pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/schemas': 29.6.3 - ansi-styles: 5.2.0 - react-is: 18.3.1 - dev: true - - /prompts@2.4.2: - resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} - engines: {node: '>= 6'} - dependencies: - kleur: 3.0.3 - sisteransi: 1.0.5 - dev: true - - /punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - dev: true - - /pure-rand@6.1.0: - resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - dev: true - - /queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - dev: true - - /react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - dev: true - - /require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - dev: true - - /resolve-cwd@3.0.0: - resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} - engines: {node: '>=8'} - dependencies: - resolve-from: 5.0.0 - dev: true - - /resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - dev: true - - /resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - dev: true - - /resolve.exports@2.0.3: - resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} - engines: {node: '>=10'} - dev: true - - /resolve@1.22.10: - resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} - engines: {node: '>= 0.4'} - hasBin: true - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - dev: true - - /reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - dev: true - - /rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - dependencies: - glob: 7.2.3 - dev: true - - /run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - dependencies: - queue-microtask: 1.2.3 - dev: true - - /semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - dev: true - - /semver@7.7.2: - resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} - engines: {node: '>=10'} - hasBin: true - dev: true - - /shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - dependencies: - shebang-regex: 3.0.0 - dev: true - - /shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - dev: true - - /signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - dev: true - - /sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - dev: true - - /slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - dev: true - - /source-map-support@0.5.13: - resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - dev: true - - /source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - dev: true - - /sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - dev: true - - /stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} - dependencies: - escape-string-regexp: 2.0.0 - dev: true - - /string-length@4.0.2: - resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} - engines: {node: '>=10'} - dependencies: - char-regex: 1.0.2 - strip-ansi: 6.0.1 - dev: true - - /string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - dev: true - - /strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - dependencies: - ansi-regex: 5.0.1 - dev: true - - /strip-bom@4.0.0: - resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} - engines: {node: '>=8'} - dev: true - - /strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - dev: true - - /strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - dev: true - - /supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - dependencies: - has-flag: 4.0.0 - dev: true - - /supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - dependencies: - has-flag: 4.0.0 - dev: true - - /supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - dev: true - - /test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} - engines: {node: '>=8'} - dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 7.2.3 - minimatch: 3.1.2 - dev: true - - /text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - dev: true - - /tmpl@1.0.5: - resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} - dev: true - - /to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - dependencies: - is-number: 7.0.0 - dev: true - - /ts-api-utils@1.4.3(typescript@5.9.2): - resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} - engines: {node: '>=16'} - peerDependencies: - typescript: '>=4.2.0' - dependencies: - typescript: 5.9.2 - dev: true - - /ts-jest@29.4.4(@babel/core@7.28.4)(jest@29.7.0)(typescript@5.9.2): - resolution: {integrity: sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw==} - engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@babel/core': '>=7.0.0-beta.0 <8' - '@jest/transform': ^29.0.0 || ^30.0.0 - '@jest/types': ^29.0.0 || ^30.0.0 - babel-jest: ^29.0.0 || ^30.0.0 - esbuild: '*' - jest: ^29.0.0 || ^30.0.0 - jest-util: ^29.0.0 || ^30.0.0 - typescript: '>=4.3 <6' - peerDependenciesMeta: - '@babel/core': - optional: true - '@jest/transform': - optional: true - '@jest/types': - optional: true - babel-jest: - optional: true - esbuild: - optional: true - jest-util: - optional: true - dependencies: - '@babel/core': 7.28.4 - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.8 - jest: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2) - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.2 - type-fest: 4.41.0 - typescript: 5.9.2 - yargs-parser: 21.1.1 - dev: true - - /ts-node@10.9.2(@types/node@20.19.17)(typescript@5.9.2): - resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} - hasBin: true - peerDependencies: - '@swc/core': '>=1.2.50' - '@swc/wasm': '>=1.2.50' - '@types/node': '*' - typescript: '>=2.7' - peerDependenciesMeta: - '@swc/core': - optional: true - '@swc/wasm': - optional: true - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.19.17 - acorn: 8.15.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.9.2 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - dev: true - - /tstl@2.5.16: - resolution: {integrity: sha512-+O2ybLVLKcBwKm4HymCEwZIT0PpwS3gCYnxfSDEjJEKADvIFruaQjd3m7CAKNU1c7N3X3WjVz87re7TA2A5FUw==} - dev: false - - /type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - dependencies: - prelude-ls: 1.2.1 - dev: true - - /type-detect@4.0.8: - resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} - engines: {node: '>=4'} - dev: true - - /type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} - dev: true - - /type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - dev: true - - /type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - dev: true - - /type@2.7.3: - resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} - dev: false - - /typedarray-to-buffer@3.1.5: - resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} - dependencies: - is-typedarray: 1.0.0 - dev: false - - /typescript@5.9.2: - resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} - engines: {node: '>=14.17'} - hasBin: true - dev: true - - /uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true - requiresBuild: true - dev: true - optional: true - - /undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - dev: true - - /update-browserslist-db@1.1.3(browserslist@4.26.2): - resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - dependencies: - browserslist: 4.26.2 - escalade: 3.2.0 - picocolors: 1.1.1 - dev: true - - /uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - dependencies: - punycode: 2.3.1 - dev: true - - /utf-8-validate@5.0.10: - resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==} - engines: {node: '>=6.14.2'} - requiresBuild: true - dependencies: - node-gyp-build: 4.8.4 - dev: false - - /v8-compile-cache-lib@3.0.1: - resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - dev: true - - /v8-to-istanbul@9.3.0: - resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} - engines: {node: '>=10.12.0'} - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - '@types/istanbul-lib-coverage': 2.0.6 - convert-source-map: 2.0.0 - dev: true - - /walker@1.0.8: - resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} - dependencies: - makeerror: 1.0.12 - dev: true - - /websocket-polyfill@0.0.3: - resolution: {integrity: sha512-pF3kR8Uaoau78MpUmFfzbIRxXj9PeQrCuPepGE6JIsfsJ/o/iXr07Q2iQNzKSSblQJ0FiGWlS64N4pVSm+O3Dg==} - dependencies: - tstl: 2.5.16 - websocket: 1.0.35 - transitivePeerDependencies: - - supports-color - dev: false - - /websocket@1.0.35: - resolution: {integrity: sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==} - engines: {node: '>=4.0.0'} - dependencies: - bufferutil: 4.0.9 - debug: 2.6.9 - es5-ext: 0.10.64 - typedarray-to-buffer: 3.1.5 - utf-8-validate: 5.0.10 - yaeti: 0.0.6 - transitivePeerDependencies: - - supports-color - dev: false - - /which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - dependencies: - isexe: 2.0.0 - dev: true - - /word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - dev: true - - /wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - dev: true - - /wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - dev: true - - /wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - dev: true - - /write-file-atomic@4.0.2: - resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} - engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} - dependencies: - imurmurhash: 0.1.4 - signal-exit: 3.0.7 - dev: true - - /ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - dev: false - - /y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - dev: true - - /yaeti@0.0.6: - resolution: {integrity: sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==} - engines: {node: '>=0.10.32'} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - dev: false - - /yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - dev: true - - /yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - dev: true - - /yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - dev: true - - /yn@3.1.1: - resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} - engines: {node: '>=6'} - dev: true - - /yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - dev: true diff --git a/scripts/run-test-lane.js b/scripts/run-test-lane.js new file mode 100644 index 00000000..ec44d884 --- /dev/null +++ b/scripts/run-test-lane.js @@ -0,0 +1,68 @@ +const path = require("path"); +const { spawnSync } = require("child_process"); +const { + SLOW_TEST_NAME_PREFIX, + getJestArgsForLane, + getTestFilesForLane, +} = require("./test-lanes"); + +const repoRoot = path.resolve(__dirname, ".."); +const [, , runtime, lane, ...extraArgs] = process.argv; + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: repoRoot, + env: process.env, + stdio: "inherit", + }); + if (result.error) throw result.error; + process.exit(result.status === null ? 1 : result.status); +} + +function getBunArgsForLane(lane, extraArgs, root = repoRoot) { + const testFiles = getTestFilesForLane(lane, root); + const isRoutineWatch = lane === "routine" && extraArgs.includes("--watch"); + const escapedSlowPrefix = SLOW_TEST_NAME_PREFIX.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&", + ); + const testSelection = isRoutineWatch + ? [ + "./tests", + `--test-name-pattern=^(?!${escapedSlowPrefix})`, + ] + : testFiles; + + return [ + "test", + ...testSelection, + "--max-concurrency", + "1", + "--timeout", + "30000", + ...extraArgs, + ]; +} + +function main() { + if (runtime === "jest") { + getTestFilesForLane(lane, repoRoot); + const jestPackage = require.resolve("jest/package.json"); + const jestBinary = path.join(path.dirname(jestPackage), "bin", "jest.js"); + run(process.execPath, [ + jestBinary, + ...getJestArgsForLane(lane), + ...extraArgs, + ]); + } + + if (runtime === "bun") { + run("bun", getBunArgsForLane(lane, extraArgs)); + } + + throw new Error(`Unknown test runtime: ${runtime}`); +} + +if (require.main === module) main(); + +module.exports = { getBunArgsForLane }; diff --git a/scripts/test-lanes.js b/scripts/test-lanes.js new file mode 100644 index 00000000..6b694943 --- /dev/null +++ b/scripts/test-lanes.js @@ -0,0 +1,71 @@ +const fs = require("fs"); +const path = require("path"); + +const SLOW_TEST_PATHS = Object.freeze([ + "tests/nip44/nip44-performance-security.test.ts", + "tests/nip46/performance-security.test.ts", +]); +const SLOW_TEST_NAME_PREFIX = "[slow]"; + +function toPosixPath(filePath) { + return filePath.split(path.sep).join("/"); +} + +function discoverTestFiles(repoRoot) { + const testsRoot = path.join(repoRoot, "tests"); + const files = []; + + function visit(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const absolutePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + visit(absolutePath); + } else if (/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(entry.name)) { + files.push(toPosixPath(path.relative(repoRoot, absolutePath))); + } + } + } + + visit(testsRoot); + return files.sort(); +} + +function getTestFilesForLane(lane, repoRoot) { + const allFiles = discoverTestFiles(repoRoot); + const slowFiles = new Set(SLOW_TEST_PATHS); + const missingSlowFiles = SLOW_TEST_PATHS.filter( + (filePath) => !allFiles.includes(filePath), + ); + if (missingSlowFiles.length > 0) { + throw new Error( + `Slow test lane references missing files: ${missingSlowFiles.join(", ")}`, + ); + } + + if (lane === "all") return allFiles; + if (lane === "slow") return [...SLOW_TEST_PATHS]; + if (lane === "routine") { + return allFiles.filter((filePath) => !slowFiles.has(filePath)); + } + throw new Error(`Unknown test lane: ${lane}`); +} + +function getJestArgsForLane(lane) { + if (lane === "all") return []; + if (lane === "slow") return [...SLOW_TEST_PATHS]; + if (lane === "routine") { + const ignorePattern = SLOW_TEST_PATHS.map((filePath) => + filePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + ).join("|"); + return [`--testPathIgnorePatterns=${ignorePattern}`]; + } + throw new Error(`Unknown test lane: ${lane}`); +} + +module.exports = { + SLOW_TEST_NAME_PREFIX, + SLOW_TEST_PATHS, + discoverTestFiles, + getJestArgsForLane, + getTestFilesForLane, +}; diff --git a/scripts/verify-pack.js b/scripts/verify-pack.js index d16cd85b..5e331355 100644 --- a/scripts/verify-pack.js +++ b/scripts/verify-pack.js @@ -10,6 +10,7 @@ */ const fs = require("fs"); +const os = require("os"); const path = require("path"); const { execFileSync } = require("child_process"); const { builtinModules } = require("module"); @@ -54,6 +55,230 @@ function fail(msg) { process.exit(1); } +function verifyNodeSubpathResolution(packageRoot) { + const checks = [ + { + label: "CommonJS snstr/testing", + args: [ + "-e", + 'const api = require("snstr/testing"); if (typeof api.NostrRelay !== "function") process.exit(1);', + ], + }, + { + label: "ESM snstr/testing", + args: [ + "--input-type=module", + "-e", + 'const api = await import("snstr/testing"); if (typeof api.NostrRelay !== "function") process.exit(1);', + ], + }, + { + label: "legacy CommonJS ephemeral relay alias", + args: [ + "-e", + 'const api = require("snstr/utils/ephemeral-relay"); if (typeof api.NostrRelay !== "function") process.exit(1);', + ], + }, + { + label: "legacy ESM ephemeral relay alias", + args: [ + "--input-type=module", + "-e", + 'const api = await import("snstr/utils/ephemeral-relay"); if (typeof api.NostrRelay !== "function") process.exit(1);', + ], + }, + ]; + + for (const { label, args } of checks) { + try { + execFileSync(process.execPath, args, { cwd: packageRoot, stdio: "pipe" }); + } catch (err) { + throw new Error( + `${label} did not resolve through package exports. ${err?.message ?? String(err)}`, + ); + } + } +} + +function verifyPackedTypeConsumer(tempDir) { + const fixturePath = path.join(tempDir, "consumer.mts"); + fs.writeFileSync( + fixturePath, + [ + 'import { RelayEvent, type NostrEvent, type RelayInterface } from "snstr";', + 'import { NostrRelay, type RelayTestContext, type RelayTestMock } from "snstr/testing";', + "", + "const mock: RelayTestMock = (id: string, accepted: boolean) => `${id}:${accepted}`;", + "declare const relay: RelayInterface;", + "declare const event: NostrEvent;", + "const context: RelayTestContext = {", + " relay,", + " originals: {},", + " mocks: { send: mock, handlers: { [RelayEvent.OK]: mock } },", + " capturedCallbacks: {},", + "};", + "void [mock, context, event, NostrRelay];", + "", + ].join("\n"), + ); + + const program = ts.createProgram({ + rootNames: [fixturePath], + options: { + strict: true, + noEmit: true, + skipLibCheck: false, + types: [], + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + lib: ["lib.es2020.d.ts", "lib.dom.d.ts"], + }, + }); + const diagnostics = ts.getPreEmitDiagnostics(program); + if (diagnostics.length === 0) return; + + const formatted = ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => tempDir, + getNewLine: () => "\n", + }); + throw new Error(`Packed no-Jest type consumer failed:\n${formatted}`); +} + +function declarationFiles(root) { + const found = []; + const pending = [root]; + while (pending.length > 0) { + const current = pending.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const entryPath = path.join(current, entry.name); + if (entry.isDirectory()) { + pending.push(entryPath); + } else if (entry.name.endsWith(".d.ts")) { + found.push(entryPath); + } + } + } + return found; +} + +function verifyPublishedDeclarationPurity(packageRoot) { + const forbiddenJestDeclarationPatterns = [ + /\bjest\s*\./, + /\btypeof\s+jest\b/, + /\bnamespace\s+jest\b/, + /@types\/jest/, + / { + const declaration = fs.readFileSync(declarationPath, "utf8"); + return forbiddenJestDeclarationPatterns.some((pattern) => + pattern.test(declaration), + ); + }); + if (polluted.length === 0) return; + + throw new Error( + `Published declarations reference Jest: ${polluted + .map((declarationPath) => path.relative(packageRoot, declarationPath)) + .sort() + .map((declarationPath) => JSON.stringify(declarationPath)) + .join(", ")}`, + ); +} + +function verifyPackedNodeSubpathResolution(cacheDir) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "snstr-pack-verify-")); + let failure; + + try { + const packOutput = execFileSync( + "npm", + [ + "pack", + "--ignore-scripts", + "--json", + "--cache", + cacheDir, + "--pack-destination", + tempDir, + ], + { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }, + ); + const parsed = JSON.parse(packOutput); + const packInfo = Array.isArray(parsed) ? parsed[0] : parsed; + const tarballPath = path.join(tempDir, packInfo.filename); + + fs.writeFileSync( + path.join(tempDir, "package.json"), + JSON.stringify({ private: true }), + ); + const packageManifest = readJson(path.join(repoRoot, "package.json")); + const requiredConsumerTypes = [ + "@types/crypto-js", + "@types/node", + "@types/ws", + ]; + for (const dependency of requiredConsumerTypes) { + const version = packageManifest.devDependencies?.[dependency]; + if (typeof version !== "string" || version.trim().length === 0) { + throw new Error( + `Packed consumer dependency ${JSON.stringify(dependency)} is missing from devDependencies`, + ); + } + } + try { + execFileSync( + "npm", + [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--cache", + cacheDir, + tarballPath, + ...requiredConsumerTypes.map( + (dependency) => + `${dependency}@${packageManifest.devDependencies[dependency]}`, + ), + ], + { cwd: tempDir, stdio: "pipe" }, + ); + } catch (err) { + const detail = err?.stderr + ? err.stderr.toString().trim() + : (err?.message ?? String(err)); + throw new Error(`Packed consumer npm install failed. ${detail}`); + } + const packageRoot = path.join(tempDir, "node_modules", "snstr"); + for (const forbiddenPackage of ["jest", "@types/jest"]) { + if (fs.existsSync(path.join(tempDir, "node_modules", forbiddenPackage))) { + throw new Error( + `Packed consumer unexpectedly installed ${forbiddenPackage}`, + ); + } + } + verifyNodeSubpathResolution(packageRoot); + verifyPublishedDeclarationPurity(packageRoot); + verifyPackedTypeConsumer(tempDir); + } catch (err) { + failure = err; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + + if (failure) { + fail( + `Packed package subpath verification failed. ${failure?.message ?? String(failure)}`, + ); + } +} + function packageName(specifier) { if (specifier.startsWith("@")) { return specifier.split("/").slice(0, 2).join("/"); @@ -97,16 +322,23 @@ function collectDependencyReferences(source) { function verifyPlatformConditionOrder(exportsMap) { for (const [subpath, conditions] of Object.entries(exportsMap || {})) { - if (!conditions || typeof conditions !== "object" || Array.isArray(conditions)) { + if ( + !conditions || + typeof conditions !== "object" || + Array.isArray(conditions) + ) { continue; } - const platforms = ["react-native", "browser"].filter((key) => key in conditions); + const platforms = ["react-native", "browser"].filter( + (key) => key in conditions, + ); if (platforms.length === 0) continue; if ( platforms.length === 2 && - JSON.stringify(conditions["react-native"]) !== JSON.stringify(conditions.browser) + JSON.stringify(conditions["react-native"]) !== + JSON.stringify(conditions.browser) ) { fail(`${subpath} resolves browser and React Native to different targets`); } @@ -201,7 +433,9 @@ function verifyWebEntryGraph(entryTarget) { for (const { kind, specifier } of collectDependencyReferences(source)) { if (specifier.startsWith(".")) { const resolved = path.resolve(path.dirname(filePath), specifier); - const relativeModule = path.relative(sourceRoot, resolved).replaceAll(path.sep, "/"); + const relativeModule = path + .relative(sourceRoot, resolved) + .replaceAll(path.sep, "/"); if ( relativeModule.startsWith("../") || forbiddenModules.some((forbidden) => @@ -225,7 +459,9 @@ function verifyWebEntryGraph(entryTarget) { builtins.has(dependency) || forbiddenPackages.has(dependency) ) { - const relativeFile = path.relative(sourceRoot, filePath).replaceAll(path.sep, "/"); + const relativeFile = path + .relative(sourceRoot, filePath) + .replaceAll(path.sep, "/"); const exceptionKey = `${relativeFile}|${kind}|${specifier}`; if (allowedGuardedNodeReferences.has(exceptionKey)) { observedGuardedNodeReferences.add(exceptionKey); @@ -242,11 +478,15 @@ function verifyWebEntryGraph(entryTarget) { (exception) => !observedGuardedNodeReferences.has(exception), ); if (staleExceptions.length > 0) { - violations.push(`stale guarded dependency exceptions: ${staleExceptions.join(", ")}`); + violations.push( + `stale guarded dependency exceptions: ${staleExceptions.join(", ")}`, + ); } if (violations.length > 0) { - fail(`Web entry dependency graph is not platform-safe: ${violations.join("; ")}`); + fail( + `Web entry dependency graph is not platform-safe: ${violations.join("; ")}`, + ); } return { @@ -273,13 +513,15 @@ collectExportTargets(pkg.exports, referenced); const referencedFiles = [...referenced].filter((p) => !p.endsWith("/")); // 1) Ensure the referenced targets exist on disk (after build). -const missingOnDisk = referencedFiles.filter((p) => !fs.existsSync(path.join(repoRoot, p))); +const missingOnDisk = referencedFiles.filter( + (p) => !fs.existsSync(path.join(repoRoot, p)), +); if (missingOnDisk.length) { fail( `Missing files referenced by package.json: ${missingOnDisk .sort() .map((p) => JSON.stringify(p)) - .join(", ")}` + .join(", ")}`, ); } @@ -319,10 +561,12 @@ try { packJson = execFileSync( "npm", ["pack", "--dry-run", "--ignore-scripts", "--json", "--cache", cacheDir], - { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] } + { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }, ); } catch (err) { - fail(`npm pack --dry-run failed. ${err && err.message ? err.message : String(err)}`); + fail( + `npm pack --dry-run failed. ${err && err.message ? err.message : String(err)}`, + ); } let packInfo; @@ -330,20 +574,48 @@ try { const parsed = JSON.parse(packJson); packInfo = Array.isArray(parsed) ? parsed[0] : parsed; } catch (err) { - fail(`Failed to parse npm pack JSON output. ${err && err.message ? err.message : String(err)}`); + fail( + `Failed to parse npm pack JSON output. ${err && err.message ? err.message : String(err)}`, + ); } const packedPaths = new Set((packInfo.files || []).map((f) => f.path)); +const forbiddenTestSupportPaths = [ + "dist/src/utils/test-helpers.js", + "dist/src/utils/test-helpers.d.ts", + "dist/esm/src/utils/test-helpers.js", + "dist/esm/src/utils/test-helpers.d.ts", + "dist/src/types/globals.d.ts", + "dist/esm/src/types/globals.d.ts", +]; +const accidentallyPacked = forbiddenTestSupportPaths.filter((p) => + packedPaths.has(p), +); +if (accidentallyPacked.length) { + fail( + `Private test helpers are included in the npm tarball: ${accidentallyPacked + .sort() + .map((p) => JSON.stringify(p)) + .join(", ")}`, + ); +} const missingInTarball = referencedFiles.filter((p) => !packedPaths.has(p)); if (missingInTarball.length) { fail( `Referenced files are not included in the npm tarball: ${missingInTarball .sort() .map((p) => JSON.stringify(p)) - .join(", ")}` + .join(", ")}`, ); } +try { + verifyNodeSubpathResolution(repoRoot); +} catch (err) { + fail(`Checkout subpath verification failed. ${err?.message ?? String(err)}`); +} +verifyPackedNodeSubpathResolution(cacheDir); + console.log( - `[pack:verify] OK (${referencedFiles.length} referenced targets, ${packedPaths.size} packed files, ${webGraph.modules} web modules, ${webGraph.guardedNodeReferences} guarded Node fallbacks)` + `[pack:verify] OK (${referencedFiles.length} referenced targets, ${packedPaths.size} packed files, ${webGraph.modules} web modules, ${webGraph.guardedNodeReferences} guarded Node fallbacks)`, ); diff --git a/scripts/verify-package-manager.js b/scripts/verify-package-manager.js new file mode 100644 index 00000000..f31afad8 --- /dev/null +++ b/scripts/verify-package-manager.js @@ -0,0 +1,180 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ + +const fs = require("fs"); +const path = require("path"); + +const CANONICAL_PACKAGE_MANAGER = "npm@9.8.1"; +const COMPATIBILITY_BUN_VERSION = "1.3.9"; +const FORBIDDEN_ROOT_LOCKFILES = [ + "npm-shrinkwrap.json", + "pnpm-lock.yaml", + "yarn.lock", +]; + +function verifyRepository(repoRoot) { + const errors = []; + const readText = (file) => { + try { + return fs.readFileSync(path.join(repoRoot, file), "utf8"); + } catch (error) { + errors.push(`${file} could not be read: ${error.message}`); + return undefined; + } + }; + const readJson = (file) => { + const content = readText(file); + if (content === undefined) return undefined; + try { + const parsed = JSON.parse(content); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { + errors.push(`${file} must contain a JSON object`); + return undefined; + } + return parsed; + } catch (error) { + errors.push(`${file} is not valid JSON: ${error.message}`); + return undefined; + } + }; + const packageJson = readJson("package.json"); + + if (!packageJson) return errors.sort(); + + if (packageJson.packageManager !== CANONICAL_PACKAGE_MANAGER) { + errors.push( + `package.json packageManager must be ${CANONICAL_PACKAGE_MANAGER}; found ${JSON.stringify(packageJson.packageManager)}`, + ); + } + + const packageLockPath = path.join(repoRoot, "package-lock.json"); + if (!fs.existsSync(packageLockPath)) { + errors.push("package-lock.json is required"); + } else { + const packageLock = readJson("package-lock.json"); + if (!packageLock) return errors.sort(); + if (packageLock.lockfileVersion !== 3) { + errors.push( + `package-lock.json must use lockfileVersion 3; found ${packageLock.lockfileVersion}`, + ); + } + const rootPackage = packageLock.packages?.[""]; + for (const field of ["name", "version"]) { + if (rootPackage?.[field] !== packageJson[field]) { + errors.push(`package-lock.json root ${field} must match package.json`); + } + } + } + + if (!fs.existsSync(path.join(repoRoot, "bun.lock"))) { + errors.push("bun.lock is required for the Bun compatibility lane"); + } + const bunVersionPath = path.join(repoRoot, ".bun-version"); + if (!fs.existsSync(bunVersionPath)) { + errors.push(".bun-version is required for the Bun compatibility lane"); + } else { + const bunVersion = readText(".bun-version"); + if ( + bunVersion !== undefined && + bunVersion.trim() !== COMPATIBILITY_BUN_VERSION + ) { + errors.push(`.bun-version must pin Bun ${COMPATIBILITY_BUN_VERSION}`); + } + } + for (const file of FORBIDDEN_ROOT_LOCKFILES) { + if (fs.existsSync(path.join(repoRoot, file))) { + errors.push(`${file} is not allowed at the repository root`); + } + } + + const workflow = readText(".github/workflows/build-test.yml"); + if (workflow === undefined) return errors.sort(); + const commandsByJob = extractRunCommands(workflow); + const requiredCommands = { + "build-and-test-node": [ + `corepack prepare ${CANONICAL_PACKAGE_MANAGER} --activate`, + "npm ci", + ], + "build-and-test-bun": ["bun install --frozen-lockfile"], + }; + for (const [job, commands] of Object.entries(requiredCommands)) { + const jobCommands = commandsByJob.get(job) ?? new Set(); + for (const command of commands) { + if (!jobCommands.has(command)) { + errors.push( + `build-test workflow job ${job} must run ${JSON.stringify(command)}`, + ); + } + } + } + + return errors.sort(); +} + +function extractRunCommands(workflow) { + const commandsByJob = new Map(); + const lines = workflow.split(/\r?\n/); + let job; + for (let index = 0; index < lines.length; index += 1) { + const jobMatch = lines[index].match(/^ ([A-Za-z0-9_-]+):\s*$/); + if (jobMatch) { + job = jobMatch[1]; + commandsByJob.set(job, new Set()); + continue; + } + if (!job) continue; + const match = lines[index].match(/^(\s*)(?:-\s+)?run:\s*(.*?)\s*$/); + if (!match) continue; + const [, indentation, value] = match; + if (value && value !== "|" && value !== ">") { + const quote = value[0]; + commandsByJob + .get(job) + .add( + (quote === '"' || quote === "'") && value.at(-1) === quote + ? value.slice(1, -1) + : value, + ); + continue; + } + for (index += 1; index < lines.length; index += 1) { + const commandLine = lines[index]; + if (!commandLine.trim()) continue; + const commandIndent = commandLine.match(/^\s*/)[0].length; + if (commandIndent <= indentation.length) { + index -= 1; + break; + } + const command = commandLine.trim(); + if (!command.startsWith("#")) commandsByJob.get(job).add(command); + } + } + return commandsByJob; +} + +function runCli(repoRoot = path.resolve(__dirname, "..")) { + const errors = verifyRepository(repoRoot); + if (errors.length > 0) { + console.error( + "[package-manager:verify] Package-manager policy verification failed:", + ); + for (const error of errors) console.error(`- ${error}`); + return 1; + } + console.log( + "[package-manager:verify] npm and Bun metadata, lockfiles, and CI commands are consistent.", + ); + return 0; +} + +module.exports = { extractRunCommands, runCli, verifyRepository }; + +if (require.main === module) { + process.exitCode = runCli( + process.argv[2] ? path.resolve(process.argv[2]) : undefined, + ); +} diff --git a/src/index.ts b/src/index.ts index 44c564dd..19b7750e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,24 @@ export type { // Export types export * from "./types/nostr"; +export type { + NostrClientToServerEventMessage, + NostrServerToClientEventMessage, + NostrEventMessage, + NostrReqMessage, + NostrCloseMessage, + NostrOkMessage, + NostrEoseMessage, + NostrClosedMessage, + NostrNoticeMessage, + NostrRelayAuthMessage, + NostrClientAuthMessage, + NostrAuthMessage, + NostrClientMessage, + NostrRelayMessage, + NostrMessage, + RelayConnectionOptions, +} from "./types/protocol"; // Export utilities export { diff --git a/src/nip01/event.ts b/src/nip01/event.ts index f5f5c026..4a9c6025 100644 --- a/src/nip01/event.ts +++ b/src/nip01/event.ts @@ -11,7 +11,10 @@ import { import { getPublicKey, signEvent as signEventCrypto } from "../utils/crypto"; import { getUnixTime } from "../utils/time"; import { isValidRelayUrl } from "../nip19"; -import { isValidPrivateKey, isValidPublicKeyPoint } from "../nip44"; +import { + isValidPrivateKey, + isValidPublicKeyPoint, +} from "../utils/key-validation"; import { getRegisteredNIP04 } from "../nip04/registry"; import { calculateEventHash } from "./serialization"; import { @@ -24,11 +27,12 @@ import { import { validateEventContent, validateTags, - SECURITY_LIMITS, SecurityValidationError, validateArrayAccess, safeArrayAccess, } from "../utils/security-validator"; +import { SECURITY_LIMITS } from "../utils/security-limits"; +import { utf8ByteLength } from "../utils/wire-validation"; export { NostrValidationError } from "./validation"; @@ -146,7 +150,7 @@ export function createTextNote( const validatedTags = validateTags(tags); // Calculate actual UTF-8 byte length for proper size validation - const contentByteLength = new TextEncoder().encode(validatedContent).length; + const contentByteLength = utf8ByteLength(validatedContent); if (contentByteLength > SECURITY_LIMITS.MAX_CONTENT_SIZE) { throw new SecurityValidationError( `Content too large: ${contentByteLength} bytes (max ${SECURITY_LIMITS.MAX_CONTENT_SIZE})`, @@ -218,7 +222,7 @@ export async function createDirectMessage( const validatedTags = validateTags(tags); // Calculate actual UTF-8 byte length for proper size validation - const contentByteLength = new TextEncoder().encode(validatedContent).length; + const contentByteLength = utf8ByteLength(validatedContent); if (contentByteLength > SECURITY_LIMITS.MAX_CONTENT_SIZE) { throw new SecurityValidationError( `Content too large: ${contentByteLength} bytes (max ${SECURITY_LIMITS.MAX_CONTENT_SIZE})`, @@ -343,7 +347,7 @@ export function createAddressableEvent( const validatedTags = validateTags(additionalTags); // Calculate actual UTF-8 byte length for proper size validation - const contentByteLength = new TextEncoder().encode(validatedContent).length; + const contentByteLength = utf8ByteLength(validatedContent); if (contentByteLength > SECURITY_LIMITS.MAX_CONTENT_SIZE) { throw new SecurityValidationError( `Content too large: ${contentByteLength} bytes (max ${SECURITY_LIMITS.MAX_CONTENT_SIZE})`, diff --git a/src/nip01/nostr.ts b/src/nip01/nostr.ts index ee87869a..e3c9e29e 100644 --- a/src/nip01/nostr.ts +++ b/src/nip01/nostr.ts @@ -11,7 +11,6 @@ import { } from "../types/nostr"; import type { RelayConnectionOptions } from "../types/protocol"; import { getPublicKey, generateKeypair } from "../utils/crypto"; -import { isValidRelayUrl } from "../nip19"; import { createSignedAuthEvent } from "../nip42"; import { createSignedEvent, @@ -19,12 +18,13 @@ import { createDirectMessage, createMetadataEvent, } from "./event"; +import type { DiagnosticLogger } from "../utils/logger"; import { - preprocessRelayUrl as preprocessRelayUrlUtil, - normalizeRelayUrl as normalizeRelayUrlUtil, - RelayUrlValidationError, -} from "../utils/relayUrl"; -import { Logger, LogLevel } from "../utils/logger"; + createDefaultDiagnosticLogger, + diagnosticFailureType, + protectDiagnosticLogger, + safeRelayDiagnostic, +} from "../utils/diagnostics"; import { validateFilters, validateNumber, @@ -33,6 +33,7 @@ import { RateLimitState, } from "../utils/security-validator"; import { getRegisteredNIP04 } from "../nip04/registry"; +import { RelayRegistry } from "./relayRegistry"; /** * Rate limit configuration for different operation types @@ -60,6 +61,8 @@ export interface NostrRateLimits { * Options for Nostr client configuration */ export interface NostrOptions { + /** Optional canonical logger used by the client and, by default, its Relays. */ + logger?: DiagnosticLogger; /** Options to pass to each Relay instance */ relayOptions?: RelayConnectionOptions; /** Rate limiting configuration for different operations */ @@ -81,10 +84,7 @@ export type NostrClosedCallback = ( subscriptionId: string, message: string, ) => void; -export type NostrAuthCallback = ( - relay: string, - challenge: string, -) => void; +export type NostrAuthCallback = (relay: string, challenge: string) => void; export type NostrEventCallback = | NostrConnectCallback @@ -119,12 +119,12 @@ function formatRetryAfterSeconds(retryAfter?: number): number { } export class Nostr { - private relays: Map = new Map(); + private relays: RelayRegistry; private privateKey?: string; private publicKey?: string; private relayOptions?: RelayConnectionOptions; private eventCallbacks: Map> = new Map(); - private logger: Logger; + protected logger: DiagnosticLogger; // Rate limiting state private subscribeRateLimit: RateLimitState = { @@ -160,7 +160,21 @@ export class Nostr { * @param options.rateLimits Rate limiting configuration for different operations */ constructor(relayUrls: string[] = [], options?: NostrOptions) { - this.relayOptions = options?.relayOptions; + const logger = options?.logger ?? options?.relayOptions?.logger; + this.logger = logger + ? protectDiagnosticLogger(logger) + : createDefaultDiagnosticLogger({ + prefix: "Nostr", + includeTimestamp: false, + silent: process.env.NODE_ENV === "test", + }); + const relayLogger = + options?.relayOptions?.logger ?? options?.logger ?? this.logger; + this.relayOptions = { + ...(options?.relayOptions ?? {}), + logger: relayLogger, + }; + this.relays = new RelayRegistry(this.relayOptions); // Initialize rate limits with defaults or user-provided values this.RATE_LIMITS = { @@ -172,39 +186,9 @@ export class Nostr { FETCH: options?.rateLimits?.fetch || { limit: 200, windowMs: 60000 }, // 200 fetches per minute }; - this.logger = new Logger({ - prefix: "Nostr", - level: LogLevel.WARN, - includeTimestamp: false, - silent: process.env.NODE_ENV === "test", - }); relayUrls.forEach((url) => this.addRelay(url)); } - /** - * Normalize a relay URL by lowercasing only the scheme and host, - * while preserving the case of path, query, and fragment parts. - * This is the correct behavior per URL standards. - */ - private normalizeRelayUrl(url: string): string { - // Delegate to shared utility for consistent canonicalization - return normalizeRelayUrlUtil(url); - } - - /** - * Preprocesses a relay URL before normalization and validation. - * Adds wss:// prefix only to URLs without any scheme. - * Throws an error for URLs with incompatible schemes. - * - * @param url - The input URL string to preprocess - * @returns The preprocessed URL with appropriate scheme - * @throws Error if URL has an incompatible scheme - */ - private preprocessRelayUrl(url: string): string { - // Delegate to shared utility for consistent preprocessing - return preprocessRelayUrlUtil(url); - } - // Helper function to create the event handler wrapper private _createRelayEventHandler( relayUrl: string, @@ -248,9 +232,9 @@ export class Nostr { }; default: // Should not happen if RelayEvent enum is comprehensive - console.warn( - `Unhandled RelayEvent type for handler creation: ${event}`, - ); + this.logger.warn("Unhandled RelayEvent type for handler creation", { + event, + }); // This case should ideally be impossible if RelayEvent is exhaustive // and all cases are handled. Return a no-op that matches a common handler signature. return () => {}; @@ -258,18 +242,10 @@ export class Nostr { } public addRelay(url: string): Relay { - url = this.preprocessRelayUrl(url); - url = this.normalizeRelayUrl(url); - if (!isValidRelayUrl(url)) { - throw new Error(`Invalid relay URL: ${url}`); - } - - if (this.relays.has(url)) { - return this.relays.get(url)!; - } - - const relay = new Relay(url, this.relayOptions); - this.relays.set(url, relay); + const registration = this.relays.register(url); + if (!registration.created) return registration.relay; + const { relay } = registration; + url = registration.url; // Attach stored callbacks to the new relay this.eventCallbacks.forEach((callbacksSet, eventType) => { @@ -308,46 +284,11 @@ export class Nostr { } public getRelay(url: string): Relay | undefined { - // Re-use the same normalisation logic as addRelay() - try { - url = this.preprocessRelayUrl(url); - url = this.normalizeRelayUrl(url); - if (!isValidRelayUrl(url)) { - return undefined; - } - return this.relays.get(url); - } catch (error) { - // Handle RelayUrlValidationError and other URL processing errors gracefully - if (error instanceof RelayUrlValidationError) { - return undefined; - } - // For other unexpected errors, also return undefined for graceful handling - return undefined; - } + return this.relays.lookup(url); } public removeRelay(url: string): void { - // Re-use the same normalisation logic as addRelay() and getRelay() - try { - url = this.preprocessRelayUrl(url); - url = this.normalizeRelayUrl(url); - if (!isValidRelayUrl(url)) { - return; - } - - const relay = this.relays.get(url); - if (relay) { - relay.disconnect(); - this.relays.delete(url); - } - } catch (error) { - // Handle RelayUrlValidationError and other URL processing errors gracefully - if (error instanceof RelayUrlValidationError) { - return; // Silently ignore invalid URLs as intended - } - // For other unexpected errors, also return void for graceful handling - return; - } + this.relays.remove(url); } public async connectToRelays(): Promise { @@ -498,16 +439,15 @@ export class Nostr { relayResults, }; } else { - // Get reasons for failures and log them for diagnostics - const failureReasons = Array.from(relayResults.entries()) + const failedRelays = Array.from(relayResults.entries()) .filter(([_, result]) => !result.success) - .map(([url, result]) => `${url}: ${result.reason || "unknown"}`); + .map(([url]) => safeRelayDiagnostic(url)); this.logger.warn("Failed to publish event to any relay", { eventId: event.id, eventKind: event.kind, - failureCount: failureReasons.length, - failures: failureReasons, + failedRelays, + failureCount: failedRelays.length, }); return { @@ -517,13 +457,10 @@ export class Nostr { }; } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "unknown error"; - this.logger.error("Failed to publish event", { eventId: event.id, eventKind: event.kind, - error: errorMessage, + failureType: diagnosticFailureType(error), }); return { @@ -625,14 +562,11 @@ export class Nostr { const { decrypt } = getRegisteredNIP04(); return decrypt(this.privateKey, senderPubkey, event.content); } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "unknown error"; - this.logger.error("Failed to decrypt direct message", { eventId: event.id, - senderPubkey, + failureType: diagnosticFailureType(error), recipientPubkey, - error: errorMessage, + senderPubkey, }); throw new Error( @@ -1203,9 +1137,7 @@ export class Nostr { ): Promise { this.enforcePublishRateLimit(); - const normalizedRelayUrl = this.normalizeRelayUrl( - this.preprocessRelayUrl(relayUrl), - ); + const normalizedRelayUrl = this.relays.canonicalUrl(relayUrl); const relay = this.getRelayByUrl(normalizedRelayUrl); const { createdAt, ...publishOptions } = options; @@ -1239,15 +1171,7 @@ export class Nostr { } private getRelayByUrl(relayUrl: string): Relay { - const preprocessedUrl = this.preprocessRelayUrl(relayUrl); - const normalizedUrl = this.normalizeRelayUrl(preprocessedUrl); - const relay = this.relays.get(normalizedUrl); - - if (!relay) { - throw new Error(`Relay not found: ${normalizedUrl}`); - } - - return relay; + return this.relays.require(relayUrl); } private enforcePublishRateLimit(): void { diff --git a/src/nip01/relay.ts b/src/nip01/relay.ts index 5fb0f228..ffb3f086 100644 --- a/src/nip01/relay.ts +++ b/src/nip01/relay.ts @@ -16,15 +16,23 @@ import { ParsedOkReason, SubscriptionOptions, } from "../types/nostr"; -import { RelayConnectionOptions } from "../types/protocol"; +import { + NostrClientMessage, + NostrCloseMessage, + NostrReqMessage, + RelayConnectionOptions, +} from "../types/protocol"; import { NostrValidationError, validateRelayIngressEvent } from "./validation"; -import { Logger, LogLevel } from "../utils/logger"; +import type { DiagnosticLogger } from "../utils/logger"; import { - SECURITY_LIMITS, - getSecureRandom, - secureRandomHex, -} from "../utils/security-validator"; + createDefaultDiagnosticLogger, + diagnosticFailureType, + protectDiagnosticLogger, + safeRelayDiagnostic, +} from "../utils/diagnostics"; +import { getSecureRandom, secureRandomHex } from "../utils/security-validator"; import { maybeUnref } from "../utils/timers"; +import { isLowercaseHexOfLength } from "../utils/wire-validation"; import { BivariantHandler, OpenEventLike, @@ -32,6 +40,7 @@ import { ErrorEventLike, MessageEventLike, } from "../utils/websocket-types"; +import { RelayEventStore } from "./relayEventStore"; type WebSocketLike = { readyState: number; @@ -44,12 +53,6 @@ type WebSocketLike = { terminate?: () => void; }; -type ClientMessage = - | ["EVENT", NostrEvent] - | ["AUTH", NostrEvent] - | ["REQ", string, ...Filter[]] - | ["CLOSE", string]; - const WS_READY_STATE = { CONNECTING: 0, OPEN: 1, @@ -58,7 +61,8 @@ const WS_READY_STATE = { } as const; export class Relay { - private static readonly noopSocketHandler: BivariantHandler = () => {}; + private static readonly noopSocketHandler: BivariantHandler = + () => {}; private url: string; private ws: WebSocketLike | null = null; private connected = false; @@ -78,12 +82,10 @@ export class Relay { finalize: (userInitiated?: boolean) => void; } | null = null; private connectionTimeout = 10000; // Default timeout of 10 seconds - private logger: Logger; - // Event buffers with memory limits - private eventBuffers: Map = new Map(); - private eventBufferAccessTimes: Map = new Map(); // For LRU eviction - private maxEventBuffers = SECURITY_LIMITS.MAX_RELAY_EVENT_BUFFERS; - private maxEventsPerBuffer = SECURITY_LIMITS.MAX_EVENTS_PER_BUFFER; + private logger: DiagnosticLogger; + private readonly eventStore = new RelayEventStore({ + onEviction: (message) => this.logger.debug(message), + }); private bufferFlushInterval: NodeJS.Timeout | null = null; private bufferFlushDelay = 50; // ms to wait before flushing event buffer // Reconnection parameters @@ -94,27 +96,17 @@ export class Relay { private maxReconnectDelay = 30000; // Maximum delay between reconnect attempts (ms) private maxFutureTimestampDrift = 60 * 60; // Maximum accepted future event timestamp drift (s) private maxPastTimestampDrift = 0; // Maximum accepted past event timestamp drift (s); 0 disables this check - // Track replaceable and addressable events according to NIP-01 with memory limits - private replaceableEvents: Map> = new Map(); - private replaceableEventAccessTimes: Map = new Map(); // For LRU eviction - private maxReplaceableEventPubkeys = - SECURITY_LIMITS.MAX_REPLACEABLE_EVENT_PUBKEYS; - private maxReplaceableEventsPerPubkey = - SECURITY_LIMITS.MAX_REPLACEABLE_EVENTS_PER_PUBKEY; - - private addressableEvents: Map = new Map(); - private addressableEventAccessTimes: Map = new Map(); // For LRU eviction - private maxAddressableEvents = SECURITY_LIMITS.MAX_ADDRESSABLE_EVENTS; private pendingValidationCounts: Map = new Map(); private pendingEoseSubscriptions: Set = new Set(); constructor(url: string, options: RelayConnectionOptions = {}) { this.url = url; - this.logger = new Logger({ - prefix: `Relay(${url})`, - level: LogLevel.WARN, // Default to WARN level for production use - includeTimestamp: false, - }); + this.logger = options.logger + ? protectDiagnosticLogger(options.logger) + : createDefaultDiagnosticLogger({ + prefix: `Relay(${safeRelayDiagnostic(url)})`, + includeTimestamp: false, + }); if (options.connectionTimeout !== undefined) { this.connectionTimeout = options.connectionTimeout; @@ -141,6 +133,11 @@ export class Relay { } } + /** Replace the diagnostic sink without changing Relay behavior. */ + public setLogger(logger: DiagnosticLogger): void { + this.logger = protectDiagnosticLogger(logger); + } + public async connect(): Promise { if (this.connected) return true; if (this.connectionPromise) return this.connectionPromise; @@ -212,7 +209,10 @@ export class Relay { readyState?: number; close?: () => void; terminate?: () => void; - once?: (event: string, cb: (...args: unknown[]) => void) => void; + once?: ( + event: string, + cb: (...args: unknown[]) => void, + ) => void; }; if (typeof nodeWs.once === "function") { nodeWs.once("error", () => {}); @@ -282,9 +282,8 @@ export class Relay { this.clearRelayDisconnectObserver(); this.relayDisconnectObserver = { attemptId, - unregister: registerRelayDisconnectObserver( - this.url, - () => finalizeClose(), + unregister: registerRelayDisconnectObserver(this.url, () => + finalizeClose(), ), }; this.triggerEvent(RelayEvent.Connect, this.url); @@ -345,7 +344,10 @@ export class Relay { } // Ensure we return false if connection fails but don't re-throw - this.logger.error(`Connection failed:`, _error); + this.logger.error("Connection failed", { + failureType: diagnosticFailureType(_error), + relay: safeRelayDiagnostic(this.url), + }); // Schedule reconnection if auto-reconnect is enabled if (this.autoReconnect) { @@ -389,10 +391,14 @@ export class Relay { // Cancel any scheduled reconnection this.cancelReconnect(); + this.subscriptions.clear(); + this.eventStore.clear(); + this.pendingValidationCounts.clear(); + this.pendingEoseSubscriptions.clear(); + this.clearBufferFlush(); if (!this.ws) { this.connected = false; - this.clearBufferFlush(); return; } @@ -400,23 +406,6 @@ export class Relay { // Detach handlers first so any late socket events don't fire callbacks after teardown. this.detachSocketHandlers(this.ws); - // Clear all subscriptions to prevent further processing - this.subscriptions.clear(); - - // Clear event buffers and access times - this.eventBuffers.clear(); - this.eventBufferAccessTimes.clear(); - - // Clear replaceable event storage and access times - this.replaceableEvents.clear(); - this.replaceableEventAccessTimes.clear(); - - // Clear addressable event storage and access times - this.addressableEvents.clear(); - this.addressableEventAccessTimes.clear(); - - this.clearBufferFlush(); // Clear the buffer flush interval - // Close the WebSocket if it's open or connecting if ( this.ws && @@ -454,7 +443,10 @@ export class Relay { } } } catch (error) { - console.error(`Error closing WebSocket for ${this.url}:`, error); + this.logger.error("Failed to close WebSocket", { + failureType: diagnosticFailureType(error), + relay: safeRelayDiagnostic(this.url), + }); } finally { // Reset all state variables this.ws = null; @@ -470,9 +462,12 @@ export class Relay { try { // Keep handlers callable for implementations that dispatch socket callbacks without type checks. - socket.onopen = Relay.noopSocketHandler as BivariantHandler; - socket.onclose = Relay.noopSocketHandler as BivariantHandler; - socket.onerror = Relay.noopSocketHandler as BivariantHandler; + socket.onopen = + Relay.noopSocketHandler as BivariantHandler; + socket.onclose = + Relay.noopSocketHandler as BivariantHandler; + socket.onerror = + Relay.noopSocketHandler as BivariantHandler; socket.onmessage = Relay.noopSocketHandler as BivariantHandler; } catch { @@ -509,9 +504,10 @@ export class Relay { this.maxReconnectAttempts > 0 && this.reconnectAttempts >= this.maxReconnectAttempts ) { - console.warn( - `Maximum reconnection attempts (${this.maxReconnectAttempts}) reached for ${this.url}`, - ); + this.logger.warn("Maximum reconnection attempts reached", { + attempts: this.maxReconnectAttempts, + relay: safeRelayDiagnostic(this.url), + }); return; } @@ -543,10 +539,11 @@ export class Relay { if (reconnectGeneration !== this.disconnectGeneration) return; this.reconnectAttempts++; this.connect().catch((error) => { - console.error( - `Reconnection attempt ${this.reconnectAttempts} failed for ${this.url}:`, - error, - ); + this.logger.error("Reconnection attempt failed", { + attempt: this.reconnectAttempts, + failureType: diagnosticFailureType(error), + relay: safeRelayDiagnostic(this.url), + }); // The next reconnection will be scheduled in the connect() method's catch handler }); }, reconnectDelay); @@ -683,7 +680,7 @@ export class Relay { } private async sendClientMessage( - message: ClientMessage, + message: NostrClientMessage, options: PublishOptions = {}, ackEventId?: string, ): Promise { @@ -739,7 +736,11 @@ export class Relay { return error.details.eventId; } - if (error instanceof Error && ackEventId && error.message.includes(ackEventId)) { + if ( + error instanceof Error && + ackEventId && + error.message.includes(ackEventId) + ) { return ackEventId; } @@ -816,7 +817,10 @@ export class Relay { } catch (error) { const errorMessage = error instanceof Error ? error.message : "unknown error"; - console.error(`Error sending message to ${this.url}:`, errorMessage); + this.logger.error("Failed to send relay message", { + failureType: diagnosticFailureType(error), + relay: safeRelayDiagnostic(this.url), + }); return { success: false, reason: `error: ${errorMessage}`, @@ -870,11 +874,11 @@ export class Relay { } this.subscriptions.set(id, subscription); - this.eventBuffers.set(id, []); // Initialize an empty event buffer for this subscription + this.eventStore.initializeBuffer(id); if (this.connected && this.ws) { - const message = JSON.stringify(["REQ", id, ...filters]); - this.ws.send(message); + const message: NostrReqMessage = ["REQ", id, ...filters]; + this.ws.send(JSON.stringify(message)); } return id; @@ -887,14 +891,13 @@ export class Relay { clearTimeout(subscription.eoseTimer); } this.subscriptions.delete(id); - this.eventBuffers.delete(id); // Clean up the event buffer for this subscription - this.eventBufferAccessTimes.delete(id); // Clean up the access time tracking + this.eventStore.deleteBuffer(id); this.pendingEoseSubscriptions.delete(id); this.pendingValidationCounts.delete(id); if (this.connected && this.ws) { - const message = JSON.stringify(["CLOSE", id]); - this.ws.send(message); + const message: NostrCloseMessage = ["CLOSE", id]; + this.ws.send(JSON.stringify(message)); } } @@ -916,10 +919,18 @@ export class Relay { break; } + const subscriptionAtIngress = this.subscriptions.get(subscriptionId); + if (!subscriptionAtIngress) break; + this.incrementPendingValidation(subscriptionId); this.validateInboundEvent(event) .then((validatedEvent) => { + if ( + this.subscriptions.get(subscriptionId) !== subscriptionAtIngress + ) { + return; + } this.processValidatedEvent(validatedEvent, subscriptionId); }) .catch((error) => { @@ -1000,16 +1011,18 @@ export class Relay { this.triggerEvent( RelayEvent.Error, this.url, - new Error( - `Invalid AUTH challenge: ${JSON.stringify(challenge)}`, - ), + new Error(`Invalid AUTH challenge: ${JSON.stringify(challenge)}`), ); } break; } default: // Unknown message type, ignore or log - console.warn(`Relay(${this.url}): Unknown message type:`, type, rest); + this.logger.warn("Unknown relay message type", { + itemCount: rest.length, + messageType: "unknown", + relay: safeRelayDiagnostic(this.url), + }); break; } } @@ -1021,6 +1034,9 @@ export class Relay { event: NostrEvent, subscriptionId: string, ): void { + const subscription = this.subscriptions.get(subscriptionId); + if (!subscription) return; + // Process replaceable events (kinds 0, 3, 10000-19999) if ( event.kind === 0 || @@ -1034,12 +1050,8 @@ export class Relay { this.processAddressableEvent(event); } - const subscription = this.subscriptions.get(subscriptionId); - if (subscription) { - // Use the proper buffer management method that enforces memory limits and LRU eviction - this.addToEventBuffer(subscriptionId, event); - this.maybeFinalizeEOSE(subscriptionId); - } + this.addToEventBuffer(subscriptionId, event); + this.maybeFinalizeEOSE(subscriptionId); } private validateInboundEvent(event: unknown): Promise { @@ -1078,10 +1090,7 @@ export class Relay { subscriptionId: string, message: string, ): void; - private triggerEvent( - event: RelayEvent.Auth, - challenge: string, - ): void; + private triggerEvent(event: RelayEvent.Auth, challenge: string): void; private triggerEvent(event: RelayEvent, ...args: unknown[]): void { const callbacks = this.eventHandlers[event]; @@ -1093,7 +1102,11 @@ export class Relay { // The type assertion in Relay.on ensures the callback matches the event. (callback as (...args: unknown[]) => void)(...args); } catch (e) { - console.error(`Relay(${this.url}): Error in ${event} callback:`, e); + this.logger.error("Relay callback failed", { + event, + failureType: diagnosticFailureType(e), + relay: safeRelayDiagnostic(this.url), + }); } } }); @@ -1161,7 +1174,7 @@ export class Relay { * Flush all event buffers for all subscriptions */ private flushAllBuffers(): void { - for (const subscriptionId of this.eventBuffers.keys()) { + for (const subscriptionId of this.eventStore.bufferIds()) { this.flushSubscriptionBuffer(subscriptionId); } } @@ -1218,219 +1231,40 @@ export class Relay { * Flush the event buffer for a specific subscription */ private flushSubscriptionBuffer(subscriptionId: string): void { - const buffer = this.eventBuffers.get(subscriptionId); - if (!buffer || buffer.length === 0) return; + const events = this.eventStore.drainBuffer(subscriptionId); + if (events.length === 0) return; const subscription = this.subscriptions.get(subscriptionId); if (!subscription) { - // If the subscription has been removed, clear the buffer - this.eventBuffers.delete(subscriptionId); return; } - // Sort the events according to NIP-01: newest first, then by lexical order of ID if same timestamp - const sortedEvents = this.sortEvents(buffer); - - // Remove the buffer from the map instead of keeping an empty array - this.eventBuffers.delete(subscriptionId); - this.eventBufferAccessTimes.delete(subscriptionId); - // Process all events - for (const event of sortedEvents) { + for (const event of events) { try { subscription.onEvent(event); } catch (error) { - console.error( - `Error in subscription handler for ${subscriptionId}:`, - error, - ); + this.logger.error("Subscription handler failed", { + failureType: diagnosticFailureType(error), + subscriptionId, + }); } } } - /** - * Sort events according to NIP-01 specification: - * 1. created_at timestamp (descending - newer events first) - * 2. event id (lexical ascending) if timestamps are the same - */ - private sortEvents(events: NostrEvent[]): NostrEvent[] { - return [...events].sort((a, b) => { - // Sort by created_at (descending - newer events first) - if (a.created_at !== b.created_at) { - return b.created_at - a.created_at; - } - // If created_at is the same, sort by id (ascending lexical order) - // This ensures lower IDs win when timestamps match, consistent with NIP-01 replaceable/addressable events. - return a.id.localeCompare(b.id); - }); - } - // Add event to buffer with memory limits private addToEventBuffer(subscriptionId: string, event: NostrEvent): void { - // Update access time for LRU - this.eventBufferAccessTimes.set(subscriptionId, Date.now()); - - // Ensure we don't exceed max number of buffers - if ( - !this.eventBuffers.has(subscriptionId) && - this.eventBuffers.size >= this.maxEventBuffers - ) { - this.evictOldestEventBuffer(); - } - - // Get or create buffer - if (!this.eventBuffers.has(subscriptionId)) { - this.eventBuffers.set(subscriptionId, []); - } - - const buffer = this.eventBuffers.get(subscriptionId)!; - - // Ensure we don't exceed max events per buffer - if (buffer.length >= this.maxEventsPerBuffer) { - buffer.shift(); // Remove oldest event - } - - buffer.push(event); - } - - // Evict oldest accessed event buffer - private evictOldestEventBuffer(): void { - let oldestTime = Infinity; - let oldestId = ""; - - for (const [id, time] of this.eventBufferAccessTimes) { - if (time < oldestTime) { - oldestTime = time; - oldestId = id; - } - } - - if (oldestId) { - this.eventBuffers.delete(oldestId); - this.eventBufferAccessTimes.delete(oldestId); - this.logger.debug(`Evicted event buffer for subscription: ${oldestId}`); - } + this.eventStore.addToBuffer(subscriptionId, event); } // Process replaceable event with memory limits private processReplaceableEvent(event: NostrEvent): void { - const pubkey = event.pubkey; - - // Update access time for LRU - this.replaceableEventAccessTimes.set(pubkey, Date.now()); - - // Ensure we don't exceed max pubkeys - if ( - !this.replaceableEvents.has(pubkey) && - this.replaceableEvents.size >= this.maxReplaceableEventPubkeys - ) { - this.evictOldestReplaceablePubkey(); - } - - // Get or create pubkey map - if (!this.replaceableEvents.has(pubkey)) { - this.replaceableEvents.set(pubkey, new Map()); - } - - const kindMap = this.replaceableEvents.get(pubkey)!; - const existingEvent = kindMap.get(event.kind); - - // Only store if newer or first of this kind - if (!existingEvent || event.created_at > existingEvent.created_at) { - // Ensure we don't exceed max events per pubkey - if ( - kindMap.size >= this.maxReplaceableEventsPerPubkey && - !kindMap.has(event.kind) - ) { - // Remove oldest event by created_at - let oldestKind = -1; - let oldestTime = Infinity; - for (const [kind, evt] of kindMap) { - if (evt.created_at < oldestTime) { - oldestTime = evt.created_at; - oldestKind = kind; - } - } - if (oldestKind !== -1) { - kindMap.delete(oldestKind); - this.logger.debug( - `Evicted replaceable event kind ${oldestKind} for pubkey: ${pubkey}`, - ); - } - } - - kindMap.set(event.kind, event); - } - } - - // Evict oldest accessed replaceable event pubkey - private evictOldestReplaceablePubkey(): void { - let oldestTime = Infinity; - let oldestPubkey = ""; - - for (const [pubkey, time] of this.replaceableEventAccessTimes) { - if (time < oldestTime) { - oldestTime = time; - oldestPubkey = pubkey; - } - } - - if (oldestPubkey) { - this.replaceableEvents.delete(oldestPubkey); - this.replaceableEventAccessTimes.delete(oldestPubkey); - this.logger.debug( - `Evicted replaceable events for pubkey: ${oldestPubkey}`, - ); - } + this.eventStore.storeReplaceable(event); } // Process addressable event with memory limits private processAddressableEvent(event: NostrEvent): void { - const dTag = event.tags.find((tag) => tag[0] === "d"); - const dValue = dTag ? dTag[1] : ""; - const addressId = `${event.kind}:${event.pubkey}:${dValue}`; - - // Update access time for LRU - this.addressableEventAccessTimes.set(addressId, Date.now()); - - // Ensure we don't exceed max addressable events - if ( - !this.addressableEvents.has(addressId) && - this.addressableEvents.size >= this.maxAddressableEvents - ) { - this.evictOldestAddressableEvent(); - } - - const existingEvent = this.addressableEvents.get(addressId); - - // Only store if newer, first, or tie-breaker with smaller ID - if ( - !existingEvent || - event.created_at > existingEvent.created_at || - (event.created_at === existingEvent.created_at && - event.id < existingEvent.id) - ) { - this.addressableEvents.set(addressId, event); - } - } - - // Evict oldest accessed addressable event - private evictOldestAddressableEvent(): void { - let oldestTime = Infinity; - let oldestId = ""; - - for (const [id, time] of this.addressableEventAccessTimes) { - if (time < oldestTime) { - oldestTime = time; - oldestId = id; - } - } - - if (oldestId) { - this.addressableEvents.delete(oldestId); - this.addressableEventAccessTimes.delete(oldestId); - this.logger.debug(`Evicted addressable event: ${oldestId}`); - } + this.eventStore.storeAddressable(event); } // Update getLatestReplaceableEvent to use access tracking @@ -1438,11 +1272,7 @@ export class Relay { pubkey: string, kind: number, ): NostrEvent | undefined { - // Update access time - this.replaceableEventAccessTimes.set(pubkey, Date.now()); - - const kindMap = this.replaceableEvents.get(pubkey); - return kindMap?.get(kind); + return this.eventStore.getReplaceable(pubkey, kind); } // Update getLatestAddressableEvent to use access tracking @@ -1451,12 +1281,7 @@ export class Relay { pubkey: string, dTagValue: string = "", ): NostrEvent | undefined { - const addressId = `${kind}:${pubkey}:${dTagValue}`; - - // Update access time - this.addressableEventAccessTimes.set(addressId, Date.now()); - - return this.addressableEvents.get(addressId); + return this.eventStore.getAddressable(kind, pubkey, dTagValue); } /** @@ -1466,9 +1291,7 @@ export class Relay { * @returns Array of addressable events */ public getAddressableEventsByPubkey(pubkey: string): NostrEvent[] { - return Array.from(this.addressableEvents.values()).filter( - (event) => event.pubkey === pubkey, - ); + return this.eventStore.getAddressableByPubkey(pubkey); } /** @@ -1478,9 +1301,7 @@ export class Relay { * @returns Array of addressable events */ public getAddressableEventsByKind(kind: number): NostrEvent[] { - return Array.from(this.addressableEvents.values()).filter( - (event) => event.kind === kind, - ); + return this.eventStore.getAddressableByKind(kind); } // Helper function to parse NIP-20 prefixes from OK messages @@ -1504,10 +1325,6 @@ export class Relay { // New private helper method for validating NIP-01 filter identifiers private _isValidNip01FilterIdentifier(value: unknown): value is string { - if (typeof value !== "string") { - return false; - } - // Must be 64 characters, lowercase hex - return /^[0-9a-f]{64}$/.test(value); + return isLowercaseHexOfLength(value, 64); } } diff --git a/src/nip01/relayEventStore.ts b/src/nip01/relayEventStore.ts new file mode 100644 index 00000000..8ce3d24c --- /dev/null +++ b/src/nip01/relayEventStore.ts @@ -0,0 +1,280 @@ +import { NostrEvent } from "../types/nostr"; +import { SECURITY_LIMITS } from "../utils/security-limits"; + +export interface RelayEventStoreOptions { + maxEventBuffers?: number; + maxEventsPerBuffer?: number; + maxReplaceableEventPubkeys?: number; + maxReplaceableEventsPerPubkey?: number; + maxAddressableEvents?: number; + now?: () => number; + onEviction?: (message: string) => void; +} + +/** + * Owns deterministic, in-memory Relay buffering and replaceable/addressable + * event retention policy. Connection lifecycle and callback delivery remain in + * Relay; this module only decides what is retained and in which order. + */ +export class RelayEventStore { + private readonly eventBuffers = new Map(); + private readonly eventBufferAccessTimes = new Map(); + private readonly replaceableEvents = new Map< + string, + Map + >(); + private readonly replaceableEventAccessTimes = new Map(); + private readonly addressableEvents = new Map(); + private readonly addressableEventAccessTimes = new Map(); + + private readonly maxEventBuffers: number; + private readonly maxEventsPerBuffer: number; + private readonly maxReplaceableEventPubkeys: number; + private readonly maxReplaceableEventsPerPubkey: number; + private readonly maxAddressableEvents: number; + private readonly now: () => number; + private readonly onEviction: (message: string) => void; + + constructor(options: RelayEventStoreOptions = {}) { + this.maxEventBuffers = RelayEventStore.capacity( + "maxEventBuffers", + options.maxEventBuffers, + SECURITY_LIMITS.MAX_RELAY_EVENT_BUFFERS, + ); + this.maxEventsPerBuffer = RelayEventStore.capacity( + "maxEventsPerBuffer", + options.maxEventsPerBuffer, + SECURITY_LIMITS.MAX_EVENTS_PER_BUFFER, + ); + this.maxReplaceableEventPubkeys = RelayEventStore.capacity( + "maxReplaceableEventPubkeys", + options.maxReplaceableEventPubkeys, + SECURITY_LIMITS.MAX_REPLACEABLE_EVENT_PUBKEYS, + ); + this.maxReplaceableEventsPerPubkey = RelayEventStore.capacity( + "maxReplaceableEventsPerPubkey", + options.maxReplaceableEventsPerPubkey, + SECURITY_LIMITS.MAX_REPLACEABLE_EVENTS_PER_PUBKEY, + ); + this.maxAddressableEvents = RelayEventStore.capacity( + "maxAddressableEvents", + options.maxAddressableEvents, + SECURITY_LIMITS.MAX_ADDRESSABLE_EVENTS, + ); + this.now = options.now ?? Date.now; + this.onEviction = options.onEviction ?? (() => {}); + } + + clear(): void { + this.eventBuffers.clear(); + this.eventBufferAccessTimes.clear(); + this.replaceableEvents.clear(); + this.replaceableEventAccessTimes.clear(); + this.addressableEvents.clear(); + this.addressableEventAccessTimes.clear(); + } + + initializeBuffer(subscriptionId: string): void { + if (this.eventBuffers.has(subscriptionId)) return; + this.ensureBufferCapacity(); + this.eventBuffers.set(subscriptionId, []); + this.eventBufferAccessTimes.set(subscriptionId, this.now()); + } + + deleteBuffer(subscriptionId: string): void { + this.eventBuffers.delete(subscriptionId); + this.eventBufferAccessTimes.delete(subscriptionId); + } + + bufferIds(): IterableIterator { + return this.eventBuffers.keys(); + } + + addToBuffer(subscriptionId: string, event: NostrEvent): void { + this.initializeBuffer(subscriptionId); + this.eventBufferAccessTimes.set(subscriptionId, this.now()); + const buffer = this.eventBuffers.get(subscriptionId)!; + if (buffer.length >= this.maxEventsPerBuffer) { + buffer.shift(); + } + buffer.push(event); + } + + drainBuffer(subscriptionId: string): NostrEvent[] { + const buffer = this.eventBuffers.get(subscriptionId); + if (!buffer || buffer.length === 0) return []; + this.deleteBuffer(subscriptionId); + return RelayEventStore.sortEvents(buffer); + } + + static sortEvents(events: readonly NostrEvent[]): NostrEvent[] { + return [...events].sort((a, b) => { + if (a.created_at !== b.created_at) { + return b.created_at - a.created_at; + } + return a.id.localeCompare(b.id); + }); + } + + storeReplaceable(event: NostrEvent): void { + const pubkey = event.pubkey; + if ( + !this.replaceableEvents.has(pubkey) && + this.replaceableEvents.size >= this.maxReplaceableEventPubkeys + ) { + this.evictOldestReplaceablePubkey(); + } + this.replaceableEventAccessTimes.set(pubkey, this.now()); + + let kindMap = this.replaceableEvents.get(pubkey); + if (!kindMap) { + kindMap = new Map(); + this.replaceableEvents.set(pubkey, kindMap); + } + const existing = kindMap.get(event.kind); + if (existing && !RelayEventStore.shouldReplace(existing, event)) return; + + if ( + !kindMap.has(event.kind) && + kindMap.size >= this.maxReplaceableEventsPerPubkey + ) { + const oldest = [...kindMap.entries()].sort( + ([kindA, eventA], [kindB, eventB]) => + eventA.created_at - eventB.created_at || + eventB.id.localeCompare(eventA.id) || + kindA - kindB, + )[0]; + if (oldest) { + kindMap.delete(oldest[0]); + this.onEviction( + `Evicted replaceable event kind ${oldest[0]} for pubkey: ${pubkey}`, + ); + } + } + kindMap.set(event.kind, event); + } + + getReplaceable(pubkey: string, kind: number): NostrEvent | undefined { + const event = this.replaceableEvents.get(pubkey)?.get(kind); + if (event) { + this.replaceableEventAccessTimes.set(pubkey, this.now()); + } + return event; + } + + storeAddressable(event: NostrEvent): void { + const address = RelayEventStore.addressFor(event); + if ( + !this.addressableEvents.has(address) && + this.addressableEvents.size >= this.maxAddressableEvents + ) { + this.evictOldestAddressableEvent(); + } + this.addressableEventAccessTimes.set(address, this.now()); + const existing = this.addressableEvents.get(address); + if (!existing || RelayEventStore.shouldReplace(existing, event)) { + this.addressableEvents.set(address, event); + } + } + + getAddressable( + kind: number, + pubkey: string, + dTagValue = "", + ): NostrEvent | undefined { + const address = `${kind}:${pubkey}:${dTagValue}`; + const event = this.addressableEvents.get(address); + if (event) { + this.addressableEventAccessTimes.set(address, this.now()); + } + return event; + } + + getAddressableByPubkey(pubkey: string): NostrEvent[] { + return this.collectAddressable((event) => event.pubkey === pubkey); + } + + getAddressableByKind(kind: number): NostrEvent[] { + return this.collectAddressable((event) => event.kind === kind); + } + + private collectAddressable( + matches: (event: NostrEvent) => boolean, + ): NostrEvent[] { + const events: NostrEvent[] = []; + for (const [address, event] of this.addressableEvents) { + if (matches(event)) { + this.addressableEventAccessTimes.set(address, this.now()); + events.push(event); + } + } + return events; + } + + private static capacity( + name: string, + value: number | undefined, + fallback: number, + ): number { + const capacity = value ?? fallback; + if (!Number.isSafeInteger(capacity) || capacity <= 0) { + throw new RangeError(`${name} must be a positive safe integer`); + } + return capacity; + } + + private static shouldReplace( + existing: NostrEvent, + candidate: NostrEvent, + ): boolean { + return ( + candidate.created_at > existing.created_at || + (candidate.created_at === existing.created_at && + candidate.id < existing.id) + ); + } + + private static addressFor(event: NostrEvent): string { + const dValue = event.tags.find((tag) => tag[0] === "d")?.[1] ?? ""; + return `${event.kind}:${event.pubkey}:${dValue}`; + } + + private ensureBufferCapacity(): void { + if (this.eventBuffers.size < this.maxEventBuffers) return; + const oldest = this.oldestAccess(this.eventBufferAccessTimes); + if (oldest) { + this.deleteBuffer(oldest); + this.onEviction(`Evicted event buffer for subscription: ${oldest}`); + } + } + + private evictOldestReplaceablePubkey(): void { + const oldest = this.oldestAccess(this.replaceableEventAccessTimes); + if (oldest) { + this.replaceableEvents.delete(oldest); + this.replaceableEventAccessTimes.delete(oldest); + this.onEviction(`Evicted replaceable events for pubkey: ${oldest}`); + } + } + + private evictOldestAddressableEvent(): void { + const oldest = this.oldestAccess(this.addressableEventAccessTimes); + if (oldest) { + this.addressableEvents.delete(oldest); + this.addressableEventAccessTimes.delete(oldest); + this.onEviction(`Evicted addressable event: ${oldest}`); + } + } + + private oldestAccess(accessTimes: ReadonlyMap): string { + let oldestKey = ""; + let oldestTime = Infinity; + for (const [key, time] of accessTimes) { + if (time < oldestTime || (time === oldestTime && key < oldestKey)) { + oldestKey = key; + oldestTime = time; + } + } + return oldestKey; + } +} diff --git a/src/nip01/relayPool.ts b/src/nip01/relayPool.ts index 13bfde05..88a12010 100644 --- a/src/nip01/relayPool.ts +++ b/src/nip01/relayPool.ts @@ -7,6 +7,13 @@ import { } from "../types/nostr"; import { RelayConnectionOptions } from "../types/protocol"; import { normalizeRelayUrl as normalizeRelayUrlUtil } from "../utils/relayUrl"; +import type { DiagnosticLogger } from "../utils/logger"; +import { + createDefaultDiagnosticLogger, + diagnosticFailureType, + protectDiagnosticLogger, + safeRelayDiagnostic, +} from "../utils/diagnostics"; /** * Result enum for removeRelay operations to provide clear error diagnostics @@ -23,23 +30,33 @@ export enum RemoveRelayResult { export class RelayPool { private relays: Map = new Map(); private relayOptions?: RelayConnectionOptions; + private logger: DiagnosticLogger; constructor( relayUrls: string[] = [], - options?: { relayOptions?: RelayConnectionOptions }, + options?: { + relayOptions?: RelayConnectionOptions; + /** Optional canonical logger used by the pool and child Relays. */ + logger?: DiagnosticLogger; + }, ) { - this.relayOptions = options?.relayOptions; + this.logger = options?.logger + ? protectDiagnosticLogger(options.logger) + : createDefaultDiagnosticLogger({ prefix: "RelayPool" }); + const relayLogger = + options?.relayOptions?.logger ?? options?.logger ?? this.logger; + this.relayOptions = { + ...(options?.relayOptions ?? {}), + logger: relayLogger, + }; relayUrls.forEach((url) => { try { this.addRelay(url); } catch (error) { - // Log the error but continue processing remaining URLs - const errorMessage = - error instanceof Error ? error.message : String(error); - console.warn( - `Failed to add relay "${url}" during pool construction:`, - errorMessage, - ); + this.logger.warn("Failed to add relay during pool construction", { + failureType: diagnosticFailureType(error), + relay: safeRelayDiagnostic(url), + }); } }); } @@ -91,10 +108,19 @@ export class RelayPool { const normalizedUrl = this.normalizeRelayUrl(url); let relay = this.relays.get(normalizedUrl); if (!relay) { - relay = new Relay(normalizedUrl, options || this.relayOptions); + const relayOptions = options + ? { + ...options, + logger: options.logger ?? this.relayOptions?.logger, + } + : this.relayOptions; + relay = new Relay(normalizedUrl, relayOptions); this.relays.set(normalizedUrl, relay); } else if (options) { // Merge the new options into the existing relay's configuration + if (options.logger !== undefined) { + relay.setLogger(options.logger); + } if (options.connectionTimeout !== undefined) { relay.setConnectionTimeout(options.connectionTimeout); } @@ -120,9 +146,10 @@ export class RelayPool { normalizedUrl = this.normalizeRelayUrl(url); } catch (error) { // URL normalization failed - this is a user input error, not a programmer bug - const errorMessage = - error instanceof Error ? error.message : String(error); - console.warn(`Invalid relay URL "${url}":`, errorMessage); + this.logger.warn("Invalid relay URL", { + failureType: diagnosticFailureType(error), + relay: safeRelayDiagnostic(url), + }); return RemoveRelayResult.InvalidUrl; } @@ -169,9 +196,10 @@ export class RelayPool { if (relay) relay.disconnect(); } catch (error) { // Log the error for debugging purposes, but continue processing other URLs - const errorMessage = - error instanceof Error ? error.message : String(error); - console.warn(`Failed to close relay "${url}":`, errorMessage); + this.logger.warn("Failed to close relay", { + failureType: diagnosticFailureType(error), + relay: safeRelayDiagnostic(url), + }); } }); } else { @@ -212,11 +240,12 @@ export class RelayPool { try { onEvent(event, relayUrl); } catch (eventError) { - console.warn( - `Error processing event from ${relayUrl}:`, - eventError, - event, - ); + this.logger.warn("RelayPool event callback failed", { + eventId: event.id, + eventKind: event.kind, + failureType: diagnosticFailureType(eventError), + relay: safeRelayDiagnostic(relayUrl), + }); } }; @@ -235,7 +264,10 @@ export class RelayPool { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.warn(`Failed to subscribe to relay ${url}:`, errorMessage); + this.logger.warn("Failed to subscribe to relay", { + failureType: diagnosticFailureType(error), + relay: safeRelayDiagnostic(url), + }); return { success: false, url, error: errorMessage }; } }, @@ -264,7 +296,9 @@ export class RelayPool { eoseSent = true; onEOSE(); } catch (eoseError) { - console.warn("Error in EOSE callback:", eoseError); + this.logger.warn("RelayPool EOSE callback failed", { + failureType: diagnosticFailureType(eoseError), + }); } } }; @@ -282,7 +316,10 @@ export class RelayPool { const subscription = { relay, id, ready: true }; subscriptions.push(subscription); } catch (error) { - console.warn(`Failed to create subscription for relay ${url}:`, error); + this.logger.warn("Failed to create relay subscription", { + failureType: diagnosticFailureType(error), + relay: safeRelayDiagnostic(url), + }); // Decrease successful count since this relay actually failed, but prevent going negative if (successfulRelayCount > 0) { successfulRelayCount--; @@ -297,9 +334,9 @@ export class RelayPool { eoseSent = true; onEOSE(); } catch (eoseError) { - console.warn( - "Error in EOSE callback (after subscription failure):", - eoseError, + this.logger.warn( + "RelayPool EOSE callback failed after subscription failure", + { failureType: diagnosticFailureType(eoseError) }, ); } } @@ -313,7 +350,12 @@ export class RelayPool { eoseSent = true; onEOSE(); } catch (eoseError) { - console.warn("Error in EOSE callback (all relays failed):", eoseError); + this.logger.warn( + "RelayPool EOSE callback failed after all relays failed", + { + failureType: diagnosticFailureType(eoseError), + }, + ); } } @@ -328,7 +370,9 @@ export class RelayPool { try { relay.unsubscribe(id); } catch (unsubError) { - console.warn("Error during unsubscribe:", unsubError); + this.logger.warn("RelayPool unsubscribe failed", { + failureType: diagnosticFailureType(unsubError), + }); } } }); @@ -364,7 +408,9 @@ export class RelayPool { sub.close(); } catch (cleanupError) { // Ignore cleanup errors, but log them if needed - console.warn("Error during subscription cleanup:", cleanupError); + this.logger.warn("RelayPool subscription cleanup failed", { + failureType: diagnosticFailureType(cleanupError), + }); } sub = null; } @@ -396,7 +442,11 @@ export class RelayPool { try { events.push(ev); } catch (eventError) { - console.warn("Error processing event:", eventError, ev); + this.logger.warn("RelayPool query event processing failed", { + eventId: ev.id, + eventKind: ev.kind, + failureType: diagnosticFailureType(eventError), + }); // Continue processing other events instead of failing completely } }, diff --git a/src/nip01/relayRegistry.ts b/src/nip01/relayRegistry.ts new file mode 100644 index 00000000..8108fc16 --- /dev/null +++ b/src/nip01/relayRegistry.ts @@ -0,0 +1,99 @@ +import type { RelayConnectionOptions } from "../types/protocol"; +import { isValidRelayUrl } from "../nip19"; +import { normalizeRelayUrl, preprocessRelayUrl } from "../utils/relayUrl"; +import { Relay } from "./relay"; + +export interface RelayRegistration { + url: string; + relay: Relay; + created: boolean; +} + +/** Owns canonical relay identity, instances, and removal lifecycle. */ +export class RelayRegistry { + private readonly relays = new Map(); + + constructor(private readonly relayOptions?: RelayConnectionOptions) {} + + canonicalUrl(url: string): string { + const canonical = normalizeRelayUrl(preprocessRelayUrl(url)); + if (!isValidRelayUrl(canonical)) { + throw new Error(`Invalid relay URL: ${canonical}`); + } + return canonical; + } + + register(url: string): RelayRegistration { + const canonical = this.canonicalUrl(url); + const existing = this.relays.get(canonical); + if (existing) return { url: canonical, relay: existing, created: false }; + + const relay = new Relay(canonical, this.relayOptions); + this.relays.set(canonical, relay); + return { url: canonical, relay, created: true }; + } + + lookup(url: string): Relay | undefined { + try { + return this.relays.get(this.canonicalUrl(url)); + } catch { + return undefined; + } + } + + require(url: string): Relay { + const canonical = this.canonicalUrl(url); + const relay = this.relays.get(canonical); + if (!relay) throw new Error(`Relay not found: ${canonical}`); + return relay; + } + + remove(url: string): void { + this.delete(url); + } + + get size(): number { + return this.relays.size; + } + + get(url: string): Relay | undefined { + return this.lookup(url); + } + + set(url: string, relay: Relay): this { + const canonical = this.canonicalUrl(url); + const displaced = this.relays.get(canonical); + if (displaced && displaced !== relay) displaced.disconnect(); + this.relays.set(canonical, relay); + return this; + } + + has(url: string): boolean { + return this.lookup(url) !== undefined; + } + + delete(url: string): boolean { + let canonical: string; + try { + canonical = this.canonicalUrl(url); + } catch { + return false; + } + const relay = this.relays.get(canonical); + if (!relay) return false; + relay.disconnect(); + return this.relays.delete(canonical); + } + + values(): IterableIterator { + return this.relays.values(); + } + + entries(): IterableIterator<[string, Relay]> { + return this.relays.entries(); + } + + forEach(callback: (relay: Relay, url: string) => void): void { + this.relays.forEach(callback); + } +} diff --git a/src/nip01/validation.ts b/src/nip01/validation.ts index 78e55d98..517ea038 100644 --- a/src/nip01/validation.ts +++ b/src/nip01/validation.ts @@ -6,7 +6,11 @@ import { } from "../types/nostr"; import { verifySignature } from "../utils/crypto"; import { getUnixTime } from "../utils/time"; -import { isValidPublicKeyPoint } from "../nip44"; +import { isValidPublicKeyPoint } from "../utils/key-validation"; +import { + isHexOfLength, + isLowercaseHexOfLength, +} from "../utils/wire-validation"; import { calculateEventHash } from "./serialization"; type ValidationInvalidData = @@ -68,19 +72,11 @@ export function isValidLowercasePublicKeyFormat(publicKey: string): boolean { } function isLowercaseHexString(value: unknown, length: number): value is string { - return ( - typeof value === "string" && - /^[0-9a-f]+$/.test(value) && - value.length === length - ); + return isLowercaseHexOfLength(value, length); } function isHexString(value: unknown, length: number): value is string { - return ( - typeof value === "string" && - /^[0-9a-fA-F]+$/.test(value) && - value.length === length - ); + return isHexOfLength(value, length); } function isValidCreatedAtTimestamp(value: unknown): value is number { diff --git a/src/nip02/index.ts b/src/nip02/index.ts index bd23b752..735ceee6 100644 --- a/src/nip02/index.ts +++ b/src/nip02/index.ts @@ -2,7 +2,7 @@ import { NostrEvent, ContactsEvent } from "../types/nostr"; import { getUnixTime } from "../utils/time"; -import { isValidPublicKeyPoint } from "../nip44"; +import { isValidPublicKeyPoint } from "../utils/key-validation"; import { isValidRelayUrl } from "../nip19"; import { normalizeRelayUrl as canonicalizeRelayUrl } from "../utils/relayUrl"; import { diff --git a/src/nip07/adapter.ts b/src/nip07/adapter.ts index b2e6d044..d2bb1221 100644 --- a/src/nip07/adapter.ts +++ b/src/nip07/adapter.ts @@ -1,7 +1,8 @@ import { NostrEvent } from "../types/nostr"; -import { Nostr } from "../nip01/nostr"; +import { Nostr, type NostrOptions } from "../nip01/nostr"; import * as nip07 from "./index"; import { getUnixTime } from "../utils/time"; +import { diagnosticFailureType } from "../utils/diagnostics"; /** * NIP-07 enabled Nostr client that uses browser extension for signing @@ -16,9 +17,9 @@ export class Nip07Nostr extends Nostr { * @param relayUrls Array of relay URLs to connect to * @throws Error if NIP-07 extension is not available */ - constructor(relayUrls: string[] = []) { + constructor(relayUrls: string[] = [], options?: NostrOptions) { // Initialize with default constructor - super(relayUrls); + super(relayUrls, options); if (!nip07.hasNip07Support()) { throw new Error("NIP-07 extension not available in this browser"); @@ -98,7 +99,9 @@ export class Nip07Nostr extends Nostr { await this.publishEvent(signedEvent); return signedEvent; } catch (error) { - console.error("Failed to publish text note:", error); + this.logger.error("Failed to publish text note", { + failureType: diagnosticFailureType(error), + }); return null; } } @@ -150,7 +153,9 @@ export class Nip07Nostr extends Nostr { await this.publishEvent(signedEvent); return signedEvent; } catch (error) { - console.error("Failed to publish direct message:", error); + this.logger.error("Failed to publish direct message", { + failureType: diagnosticFailureType(error), + }); return null; } } diff --git a/src/nip09/index.ts b/src/nip09/index.ts index 12de43b7..e829120e 100644 --- a/src/nip09/index.ts +++ b/src/nip09/index.ts @@ -13,6 +13,14 @@ import { safeArrayAccess, SecurityValidationError, } from "../utils/security-validator"; +import type { DiagnosticLogger } from "../utils/logger"; +import { + createDefaultDiagnosticLogger, + diagnosticFailureType, + reportDiagnostic, +} from "../utils/diagnostics"; + +const defaultLogger = createDefaultDiagnosticLogger({ prefix: "NIP-09" }); export interface DeletionRequestOptions { ids?: string[]; // event ids referenced with 'e' tags @@ -58,7 +66,10 @@ export interface DeletionTargets { /** * Extract referenced ids, addresses and kinds from a deletion event */ -export function parseDeletionTargets(event: NostrEvent): DeletionTargets { +export function parseDeletionTargets( + event: NostrEvent, + logger: DiagnosticLogger = defaultLogger, +): DeletionTargets { const result: DeletionTargets = { ids: [], addresses: [], @@ -92,11 +103,14 @@ export function parseDeletionTargets(event: NostrEvent): DeletionTargets { } catch (error) { if (error instanceof SecurityValidationError) { // Log bounds checking error but continue processing - if (typeof console !== "undefined" && console.warn) { - console.warn( - `NIP-09: Bounds checking error in tag processing: ${error.message}`, - ); - } + reportDiagnostic( + logger, + "warn", + "Bounds checking failed in deletion tag", + { + failureType: diagnosticFailureType(error), + }, + ); } } } @@ -110,10 +124,11 @@ export function parseDeletionTargets(event: NostrEvent): DeletionTargets { export function isDeletionRequestForEvent( deletion: NostrEvent, event: NostrEvent, + logger: DiagnosticLogger = defaultLogger, ): boolean { if (deletion.kind !== NostrKind.Deletion) return false; if (deletion.pubkey !== event.pubkey) return false; - const targets = parseDeletionTargets(deletion); + const targets = parseDeletionTargets(deletion, logger); if (targets.ids.includes(event.id)) return true; // Safe access to d-tag with bounds checking @@ -135,11 +150,14 @@ export function isDeletionRequestForEvent( } } catch (error) { if (error instanceof SecurityValidationError) { - if (typeof console !== "undefined" && console.warn) { - console.warn( - `NIP-09: Bounds checking error in d-tag processing: ${error.message}`, - ); - } + reportDiagnostic( + logger, + "warn", + "Bounds checking failed in deletion d-tag", + { + failureType: diagnosticFailureType(error), + }, + ); } } diff --git a/src/nip10/index.ts b/src/nip10/index.ts index 54b44661..a3e6a97d 100644 --- a/src/nip10/index.ts +++ b/src/nip10/index.ts @@ -11,6 +11,14 @@ import { safeArrayAccess, SecurityValidationError, } from "../utils/security-validator"; +import type { DiagnosticLogger } from "../utils/logger"; +import { + createDefaultDiagnosticLogger, + diagnosticFailureType, + reportDiagnostic, +} from "../utils/diagnostics"; + +const defaultLogger = createDefaultDiagnosticLogger({ prefix: "NIP-10" }); /** Event pointer used in thread references */ export interface ThreadPointer { @@ -82,7 +90,10 @@ export function createQuoteTag(pointer: ThreadPointer): string[] { * Parse thread references (e tags) from an event. Handles both * marked and deprecated positional e tags. */ -export function parseThreadReferences(event: NostrEvent): ThreadReferences { +export function parseThreadReferences( + event: NostrEvent, + logger: DiagnosticLogger = defaultLogger, +): ThreadReferences { const eTags = event.tags.filter((t) => { try { return validateArrayAccess(t, 0) && safeArrayAccess(t, 0) === "e"; @@ -128,8 +139,13 @@ export function parseThreadReferences(event: NostrEvent): ThreadReferences { } } catch (error) { if (error instanceof SecurityValidationError) { - console.warn( - `NIP-10: Bounds checking error in thread parsing: ${error.message}`, + reportDiagnostic( + logger, + "warn", + "Bounds checking failed in thread tag", + { + failureType: diagnosticFailureType(error), + }, ); } continue; // Skip invalid tags @@ -152,8 +168,11 @@ export function parseThreadReferences(event: NostrEvent): ThreadReferences { } } catch (error) { if (error instanceof SecurityValidationError) { - console.warn( - `NIP-10: Bounds checking error in positional parsing: ${error.message}`, + reportDiagnostic( + logger, + "warn", + "Bounds checking failed in positional thread parsing", + { failureType: diagnosticFailureType(error) }, ); } } diff --git a/src/nip11/index.ts b/src/nip11/index.ts index 931952c6..f8910695 100644 --- a/src/nip11/index.ts +++ b/src/nip11/index.ts @@ -18,6 +18,22 @@ export type { // Import types for internal use import type { RelayInfo } from "../types/nostr"; +import type { DiagnosticLogger } from "../utils/logger"; +import { + createDefaultDiagnosticLogger, + diagnosticFailureType, + reportDiagnostic, + safeRelayDiagnostic, +} from "../utils/diagnostics"; + +const defaultLogger = createDefaultDiagnosticLogger({ prefix: "NIP-11" }); + +export interface FetchRelayInformationOptions { + useCache?: boolean; + timeoutMs?: number; + /** Optional canonical diagnostic logger. */ + logger?: DiagnosticLogger; +} // Cache for relay information to avoid repeated requests const relayInfoCache: Record< @@ -61,14 +77,16 @@ function isValidWebSocketURL(url: string): boolean { */ export async function fetchRelayInformation( url: string, - options: { useCache?: boolean; timeoutMs?: number } = {}, + options: FetchRelayInformationOptions = {}, ): Promise { // Default options - const { useCache = true, timeoutMs = 5000 } = options; + const { useCache = true, timeoutMs = 5000, logger = defaultLogger } = options; // Validate the URL if (!isValidWebSocketURL(url)) { - console.error(`Invalid WebSocket URL: ${url}`); + reportDiagnostic(logger, "error", "Invalid WebSocket URL", { + relay: safeRelayDiagnostic(url), + }); return null; } @@ -120,7 +138,10 @@ export async function fetchRelayInformation( return null; } catch (error) { - console.error(`Failed to fetch relay information from ${url}:`, error); + reportDiagnostic(logger, "error", "Failed to fetch relay information", { + failureType: diagnosticFailureType(error), + relay: safeRelayDiagnostic(url), + }); // Cache negative result if (useCache) { @@ -147,8 +168,11 @@ export function clearRelayInfoCache() { * @param url - WebSocket URL of the relay (ws:// or wss://) * @returns Promise resolving to boolean indicating if NIP-11 is supported */ -export async function supportsNIP11(url: string): Promise { - const info = await fetchRelayInformation(url); +export async function supportsNIP11( + url: string, + options: FetchRelayInformationOptions = {}, +): Promise { + const info = await fetchRelayInformation(url, options); return info !== null; } @@ -162,8 +186,9 @@ export async function supportsNIP11(url: string): Promise { export async function relaySupportsNIPs( url: string, nipNumbers: number[], + options: FetchRelayInformationOptions = {}, ): Promise { - const info = await fetchRelayInformation(url); + const info = await fetchRelayInformation(url, options); if (!info || !info.supported_nips) { return false; @@ -179,8 +204,11 @@ export async function relaySupportsNIPs( * @param url - WebSocket URL of the relay (ws:// or wss://) * @returns Promise resolving to payment URL if available, null otherwise */ -export async function getRelayPaymentInfo(url: string): Promise { - const info = await fetchRelayInformation(url); +export async function getRelayPaymentInfo( + url: string, + options: FetchRelayInformationOptions = {}, +): Promise { + const info = await fetchRelayInformation(url, options); return info?.payments_url || null; } @@ -190,7 +218,10 @@ export async function getRelayPaymentInfo(url: string): Promise { * @param url - WebSocket URL of the relay (ws:// or wss://) * @returns Promise resolving to boolean indicating if payments are required */ -export async function relayRequiresPayment(url: string): Promise { - const info = await fetchRelayInformation(url); +export async function relayRequiresPayment( + url: string, + options: FetchRelayInformationOptions = {}, +): Promise { + const info = await fetchRelayInformation(url, options); return !!info?.limitation?.payments_required; } diff --git a/src/nip19/index.ts b/src/nip19/index.ts index 0638f835..c793f78d 100644 --- a/src/nip19/index.ts +++ b/src/nip19/index.ts @@ -13,6 +13,13 @@ import { Bech32Result, SimpleBech32Result, } from "./types"; +import type { DiagnosticLogger } from "../utils/logger"; +import { + createDefaultDiagnosticLogger, + reportDiagnostic, +} from "../utils/diagnostics"; + +const defaultLogger = createDefaultDiagnosticLogger({ prefix: "NIP-19" }); // Re-export types export * from "./types"; @@ -414,7 +421,10 @@ export function encodeProfile(data: ProfileData): Bech32String { /** * Decodes an nprofile to profile data */ -export function decodeProfile(nprofile: Bech32String): ProfileData { +export function decodeProfile( + nprofile: Bech32String, + logger: DiagnosticLogger = defaultLogger, +): ProfileData { try { // Decode the bech32 string const { prefix, words } = decodeBech32WithLimit(nprofile); @@ -450,8 +460,11 @@ export function decodeProfile(nprofile: Bech32String): ProfileData { // Warn about invalid relay URLs but still include them if (!isValidRelayUrl(relay)) { - console.warn( - `Warning: Invalid relay URL format found while decoding: ${relay}`, + reportDiagnostic( + logger, + "warn", + "Invalid relay URL found while decoding nprofile", + { reason: "invalid-relay-url" }, ); } } @@ -542,7 +555,10 @@ export function encodeEvent(data: EventData): Bech32String { /** * Decodes an nevent to event data */ -export function decodeEvent(nevent: Bech32String): EventData { +export function decodeEvent( + nevent: Bech32String, + logger: DiagnosticLogger = defaultLogger, +): EventData { try { // Decode the bech32 string const { prefix, words } = decodeBech32WithLimit(nevent); @@ -579,8 +595,11 @@ export function decodeEvent(nevent: Bech32String): EventData { // Warn about invalid relay URLs but still include them if (!isValidRelayUrl(relay)) { - console.warn( - `Warning: Invalid relay URL format found while decoding: ${relay}`, + reportDiagnostic( + logger, + "warn", + "Invalid relay URL found while decoding nevent", + { reason: "invalid-relay-url" }, ); } } else if (entry.type === TLVType.Author) { @@ -702,7 +721,10 @@ export function encodeAddress(data: AddressData): Bech32String { /** * Decodes an naddr to address data */ -export function decodeAddress(naddr: Bech32String): AddressData { +export function decodeAddress( + naddr: Bech32String, + logger: DiagnosticLogger = defaultLogger, +): AddressData { try { // Decode the bech32 string const { prefix, words } = decodeBech32WithLimit(naddr); @@ -737,8 +759,11 @@ export function decodeAddress(naddr: Bech32String): AddressData { // Warn about invalid relay URLs but still include them if (!isValidRelayUrl(relay)) { - console.warn( - `Warning: Invalid relay URL format found while decoding: ${relay}`, + reportDiagnostic( + logger, + "warn", + "Invalid relay URL found while decoding naddr", + { reason: "invalid-relay-url" }, ); } } else if (entry.type === TLVType.Author) { @@ -834,7 +859,10 @@ function bytesToHex(bytes: Uint8Array): HexString { /** * Universal decoder for any NIP-19 entity */ -export function decode(bech32Str: Bech32String): DecodedEntity { +export function decode( + bech32Str: Bech32String, + logger: DiagnosticLogger = defaultLogger, +): DecodedEntity { // Basic validation for bech32 format if (!bech32Str.includes("1")) { throw new Error( @@ -864,17 +892,17 @@ export function decode(bech32Str: Bech32String): DecodedEntity { case Prefix.Profile: return { type: Prefix.Profile, - data: decodeProfile(bech32Str), + data: decodeProfile(bech32Str, logger), }; case Prefix.Event: return { type: Prefix.Event, - data: decodeEvent(bech32Str), + data: decodeEvent(bech32Str, logger), }; case Prefix.Address: return { type: Prefix.Address, - data: decodeAddress(bech32Str), + data: decodeAddress(bech32Str, logger), }; default: throw new Error(`Unknown prefix: ${prefix}`); diff --git a/src/nip29/index.ts b/src/nip29/index.ts index 09ebc411..1831f6c0 100644 --- a/src/nip29/index.ts +++ b/src/nip29/index.ts @@ -2,6 +2,7 @@ import { createAddressableEvent, createEvent, UnsignedEvent } from "../nip01/eve import { NostrEvent, Filter } from "../types/nostr"; import { getPublicKey } from "../utils/crypto"; import { getUnixTime } from "../utils/time"; +import { isLowercaseHexOfLength } from "../utils/wire-validation"; export const GROUP_METADATA_KIND = 39000; export const GROUP_ADMINS_KIND = 39001; @@ -105,7 +106,7 @@ function assertGroupId(groupId: string): void { } function assertPubkey(pubkey: string, field = "pubkey"): void { - if (!/^[0-9a-f]{64}$/.test(pubkey)) { + if (!isLowercaseHexOfLength(pubkey, 64)) { throw new Error(`${field} must be a 64-character lowercase hex string`); } } diff --git a/src/nip42/index.ts b/src/nip42/index.ts index 4d49c889..1fa6057a 100644 --- a/src/nip42/index.ts +++ b/src/nip42/index.ts @@ -2,7 +2,8 @@ import { createSignedEvent, validateEvent } from "../nip01/event"; import { EventTemplate, NIP20Prefix, NostrEvent } from "../types/nostr"; import { getPublicKey } from "../utils/crypto"; import { normalizeRelayUrl } from "../utils/relayUrl"; -import { sanitizeString, SECURITY_LIMITS } from "../utils/security-validator"; +import { sanitizeString } from "../utils/security-validator"; +import { SECURITY_LIMITS } from "../utils/security-limits"; export const AUTH_EVENT_KIND = 22242; diff --git a/src/nip44/README.md b/src/nip44/README.md index 62157cad..e8e2a2e1 100644 --- a/src/nip44/README.md +++ b/src/nip44/README.md @@ -12,7 +12,8 @@ NIP-44 replaces the older NIP-04 encryption with a more secure approach using Ch - Proper key derivation using HKDF - Message length hiding via a custom padding scheme - Secure nonce handling with 32-byte nonces -- **Full version compatibility** supporting decryption of v0, v1, and v2 messages +- Strict version handling that accepts defined v2 payloads and rejects reserved, + undefined, or unknown versions ## Basic Usage @@ -72,7 +73,9 @@ Decrypts a message using NIP-44 encryption. - Returns: Decrypted message - Throws: Error if decryption fails (wrong keys, tampered message, etc.) -**Note**: This implementation automatically detects and handles payload versions 0, 1, and 2, providing seamless backward compatibility. +**Note**: This implementation accepts version 2 payloads. Version 0 is reserved, +version 1 is deprecated and undefined, and every other version is reported as +unsupported. ### `getNIP44SharedSecret(privateKey, publicKey)` @@ -98,40 +101,39 @@ Performs constant-time comparison of two byte arrays to prevent timing attacks. This function is used internally to validate MACs securely, but is also exposed for use in other security-critical comparisons. Using constant-time comparison is important when comparing sensitive values like MACs, signatures, or hashes to prevent timing side-channel attacks. -## Version Compatibility +## Version Handling -This implementation follows the NIP-44 specification requirement that clients: +This implementation follows the NIP-44 version registry: - **MUST** include a version byte in encrypted payloads -- **MUST** be able to decrypt versions 0 and 1 -- **MUST NOT** encrypt with version 0 ("Reserved") -- **MUST NOT** encrypt with version 1 ("Deprecated and undefined") +- Treat version 0 as reserved +- Treat version 1 as deprecated and undefined +- Use version 2 for the currently defined encryption algorithm +- Report any version other than 2 as unsupported ### Version Support | Version | Encryption | Decryption | Notes | |---------|------------|------------|-------| -| 0 | ❌ Not Supported | ✅ Supported | Per NIP-44: "Reserved. Implementations MUST NOT encrypt with this version." Decryption is supported. | -| 1 | ❌ Not Supported | ✅ Supported | Per NIP-44: "Deprecated and undefined. Implementations MUST NOT encrypt with this version." Decryption is supported. | +| 0 | ❌ Not Supported | ❌ Not Supported | Reserved; no algorithm is defined. | +| 1 | ❌ Not Supported | ❌ Not Supported | Deprecated and undefined; no algorithm is defined. | | 2 | ✅ Supported (Default) | ✅ Supported | Current version, used by default for all encryption. | ### Default Behavior - **Encryption**: All messages are encrypted with NIP-44 version 2 (the current standard). -- **Decryption**: Automatically detects and handles versions 0, 1, and 2 - -**Note on V0/V1 Decryption Compatibility:** -NIP-44 (Section: Decryption, Point 4) specifies that for decrypting versions 0 and 1: *"Implementations MUST be able to decrypt versions 0 and 1 for compatibility, **using the same algorithms as above** [i.e., version 2's algorithms] with the respective version byte. The `message_nonce` is 32 bytes, `mac` is 32 bytes. Clients MAY refuse to decrypt messages with these versions."* -This implementation adheres to this directive by applying the NIP-44 v2 cryptographic pipeline (including key derivation with the "nip44-v2" salt, ChaCha20, and HMAC-SHA256) when attempting to decrypt payloads marked as v0 or v1. Therefore, successful decryption of v0/v1 payloads implies they were constructed in a manner compatible with the v2 cryptographic scheme, as guided by the NIP-44 decryption instructions. +- **Decryption**: Rejects reserved, undefined, and unknown versions before key + derivation or decryption. ### Version Differences -Currently, all versions use the same cryptographic primitives: +Version 2 uses these cryptographic primitives: - ChaCha20 for encryption - HMAC-SHA256 for authentication - 32-byte nonces - 32-byte MAC tags -The primary difference is the version byte in the payload, which enables future protocol upgrades. +The version byte enables future protocol upgrades without assigning algorithms +to reserved or undefined versions. ## Differences from NIP-04 @@ -149,8 +151,6 @@ This implementation follows the NIP-44 v2 specification exactly, with careful at ### Key Constants - `CURRENT_VERSION = 2` - Current NIP-44 version for encryption -- `MIN_SUPPORTED_VERSION = 0` - Minimum supported version for decryption -- `MAX_SUPPORTED_VERSION = 2` - Maximum supported version for decryption - `NONCE_SIZE_V2 = 32` - 32-byte nonce as required by NIP-44 v2 - `KEY_SIZE = 32` - 32-byte key for ChaCha20 - `MAC_SIZE_V2 = 32` - 32-byte MAC from HMAC-SHA256 @@ -194,9 +194,9 @@ This implementation follows the NIP-44 v2 specification exactly, with careful at - Validate decoded payload length (99 to 65,603 bytes) 2. **Parse Payload** (NIP-44 spec section "Decryption" step 2) - - Decode base64 - - Extract version byte, validate it's between 0-2 - - Extract nonce, ciphertext, and MAC based on version-specific sizes + - Decode base64 + - Extract the version byte and require the defined version 2 + - Extract the v2 nonce, ciphertext, and MAC 3. **Key Derivation** (Same as encryption) - Calculate the same conversation key and message keys @@ -247,7 +247,7 @@ This implementation has been thoroughly validated against the official [NIP-44 t - End-to-end encryption/decryption validation with various message lengths - Edge case handling (minimum and maximum message sizes) - Error handling for invalid inputs and tampered messages -- Version compatibility tests across versions 0, 1, and 2 +- Version handling tests for v2 plus reserved, undefined, and unknown versions The official test vectors provide cryptographic certainty that our implementation correctly follows the NIP-44 specification and will interoperate with other compliant implementations. @@ -312,7 +312,7 @@ This implementation has been validated against the [official NIP-44 test vectors Passing these test vectors ensures that this implementation correctly handles: - Key derivation according to the NIP-44 specification -- Encryption and decryption across all supported versions (v0, v1, v2) +- Version 2 encryption/decryption and unsupported-version rejection - Message authentication and padding as specified ## Implementation Details @@ -343,4 +343,4 @@ The implementation properly handles x-only public keys (the standard for Nostr) 3. Constructing the correct compressed format (with 02/03 prefix) for ECDH operations 4. Performing constant-time operations to avoid timing side-channels -This approach ensures compatibility with Nostr's x-only public key format while maintaining the security properties required by the NIP-44 specification. \ No newline at end of file +This approach ensures compatibility with Nostr's x-only public key format while maintaining the security properties required by the NIP-44 specification. diff --git a/src/nip44/index.ts b/src/nip44/index.ts index 053a750b..aa691f84 100644 --- a/src/nip44/index.ts +++ b/src/nip44/index.ts @@ -7,6 +7,16 @@ import { import { hmac } from "@noble/hashes/hmac"; import { secp256k1 } from "@noble/curves/secp256k1"; import { chacha20 } from "@noble/ciphers/chacha"; +import { + isValidPrivateKey, + isValidPublicKeyPoint, +} from "../utils/key-validation"; + +export { + isValidPrivateKey, + isValidPublicKeyFormat, + isValidPublicKeyPoint, +} from "../utils/key-validation"; // NIP-44 constants as specified in https://github.com/nostr-protocol/nips/blob/master/44.md // const VERSION = 2; // Original hardcoded version @@ -14,29 +24,8 @@ import { chacha20 } from "@noble/ciphers/chacha"; // const KEY_SIZE = 32; // 32-byte key for ChaCha20 // const MAC_SIZE = 32; // HMAC-SHA256 produces 32-byte tags -// Current supported version for encryption const CURRENT_VERSION = 2; -// Minimum supported version for decryption -const MIN_SUPPORTED_VERSION = 0; -// Maximum supported version for decryption (for future extensibility) -const MAX_SUPPORTED_VERSION = 2; - -// NIP-44 Version-specific constants -// Per NIP-44 specification (Decryption, point 4): -// "Implementations MUST be able to decrypt versions 0 and 1 for compatibility, -// using the same algorithms as above [i.e., version 2's algorithms] with the respective version byte. -// The `message_nonce` is 32 bytes, `mac` is 32 bytes." -// -// This confirms that all versions (0, 1, 2) use: -// - 32-byte nonces -// - 32-byte MAC tags -// - Same cryptographic algorithms (ChaCha20, HMAC-SHA256, HKDF with "nip44-v2" salt) -export const NONCE_SIZE_V0 = 32; -export const NONCE_SIZE_V1 = 32; export const NONCE_SIZE_V2 = 32; - -export const MAC_SIZE_V0 = 32; -export const MAC_SIZE_V1 = 32; export const MAC_SIZE_V2 = 32; const KEY_SIZE = 32; // 32-byte key for ChaCha20 (consistent across versions) @@ -292,109 +281,6 @@ function unpad(padded: Uint8Array): string { } } -/** - * Validate if a string is a valid hex format public key - * This function validates the FORMAT (64 lowercase hex characters) and - * rejects problematic edge cases that are cryptographically invalid. - * It does NOT validate if the hex string represents a valid curve point. - * For full cryptographic validation, use isValidPublicKeyPoint. - */ -// secp256k1 field prime (P) as BigInt, defined once -const FIELD_PRIME = BigInt( - "0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f", -); - -export function isValidPublicKeyFormat(publicKey: string): boolean { - // Check format: must be 64 hex characters (case-insensitive) - if (!/^[0-9a-f]{64}$/i.test(publicKey)) { - return false; - } - - // Reject problematic edge cases that are invalid for cryptographic use - - // All zeros - invalid public key (point at infinity) - if ( - publicKey === - "0000000000000000000000000000000000000000000000000000000000000000" - ) { - return false; - } - - // All 'f's - invalid public key (field prime - 1, not a valid x-coordinate) - if ( - publicKey === - "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" - ) { - return false; - } - - // Any value ≥ field prime is invalid as an x-coordinate - try { - const keyValue = BigInt("0x" + publicKey); - if (keyValue >= FIELD_PRIME) { - return false; - } - } catch { - // If BigInt conversion fails, it's not a valid hex string anyway - return false; - } - - return true; -} - -/** - * Validate if a hex string represents a valid point on the secp256k1 curve - * This function does cryptographic validation in addition to format validation. - * Use this when you need to ensure the public key is actually usable for cryptographic operations. - * - * This implementation uses efficient point validation instead of expensive ECDH operations, - * significantly improving performance while maintaining the same validation behavior. - */ -export function isValidPublicKeyPoint(publicKey: string): boolean { - // First check format - if (!isValidPublicKeyFormat(publicKey)) { - return false; - } - - // For Nostr x-only public keys, we need to check if the x-coordinate - // represents a valid point on the secp256k1 curve. - // We try both possible y-coordinates (even and odd) efficiently using - // ProjectivePoint.fromHex which validates curve membership without - // performing expensive ECDH operations. - - const prefixes = ["02", "03"]; - for (const prefix of prefixes) { - try { - secp256k1.ProjectivePoint.fromHex(prefix + publicKey); - return true; - } catch { - // Continue to next prefix - } - } - - return false; -} - -/** - * Validate if a string is a valid hex format private key - * A valid private key must be a 32-byte hex string with a value less than the curve order - */ -export function isValidPrivateKey(privateKey: string): boolean { - // Check format: must be 64 hex characters (case-insensitive) - if (!/^[0-9a-f]{64}$/i.test(privateKey)) { - return false; - } - - try { - // Check that the value is a valid scalar (less than curve order) - // This will throw if the private key is invalid - secp256k1.getPublicKey(privateKey); - return true; - } catch { - return false; - } -} - /** * Get conversation key (shared secret) between two users * According to NIP-44 v2 spec, this is: @@ -508,12 +394,9 @@ export function getMessageKeys( ); } - if (nonce.length < NONCE_SIZE_V0) { - // Assuming V0 has the smallest possible nonce, check against it. - // This check might need refinement if V0/V1 nonces are smaller and still valid inputs here. - // However, getMessageKeys is currently only called by encrypt/decrypt which would use version-specific nonce sizes. + if (nonce.length !== NONCE_SIZE_V2) { throw new Error( - `NIP-44: Nonce too short for key derivation. Min expected: ${NONCE_SIZE_V0}, got: ${nonce.length}`, + `NIP-44: Nonce must be ${NONCE_SIZE_V2} bytes for key derivation, got: ${nonce.length}`, ); } @@ -564,11 +447,9 @@ export function hmacWithAAD( message: Uint8Array, aad: Uint8Array, // aad is the NIP-44 nonce (e.g. 32 bytes for v2) ): Uint8Array { - if (aad.length < NONCE_SIZE_V0) { - // Allow for potentially smaller nonces from older versions. - // This check needs to be correct for the SMALLEST valid nonce size. + if (aad.length !== NONCE_SIZE_V2) { throw new Error( - `NIP-44: AAD (nonce) too short. Min expected: ${NONCE_SIZE_V0} bytes, got: ${aad.length}`, + `NIP-44: AAD (nonce) must be ${NONCE_SIZE_V2} bytes, got: ${aad.length}`, ); } @@ -694,32 +575,6 @@ function encryptV2( return base64Encode(payload); } -// Placeholder for NIP-44 v1 encryption -- REMOVED as per NIP-44 spec (MUST NOT encrypt with v1) -/* -function encryptV1( - plaintext: string, - privateKey: string, - publicKey: string, - nonce?: Uint8Array, -): string { - // ... (original implementation removed) ... - throw new Error("NIP-44: Encryption with version 1 is not permitted by the NIP-44 specification."); -} -*/ - -// Placeholder for NIP-44 v0 encryption -- REMOVED as per NIP-44 spec (MUST NOT encrypt with v0) -/* -function encryptV0( - plaintext: string, - privateKey: string, - publicKey: string, - nonce?: Uint8Array, -): string { - // ... (original implementation removed) ... - throw new Error("NIP-44: Encryption with version 0 is not permitted by the NIP-44 specification."); -} -*/ - /** * Encrypt a message using NIP-44 v2 (ChaCha20 + HMAC-SHA256) * @@ -756,31 +611,20 @@ export function encrypt( // NIP-44 spec (version 2): This is the current and recommended version for encryption. if (versionToEncryptWith === 0) { throw new Error( - "NIP-44: Encryption with version 0 is not permitted by the NIP-44 specification. Only decryption is supported for v0.", + "NIP-44: Encryption with version 0 is not permitted because the version is reserved.", ); } if (versionToEncryptWith === 1) { throw new Error( - "NIP-44: Encryption with version 1 is not permitted by the NIP-44 specification. Only decryption is supported for v1.", + "NIP-44: Encryption with version 1 is not permitted because the version is deprecated and undefined.", ); } if (versionToEncryptWith !== CURRENT_VERSION) { - // Currently CURRENT_VERSION is 2 - // This condition will catch any other non-current versions if CURRENT_VERSION changes - // or if a version > 2 is somehow passed and not caught by MAX_SUPPORTED_VERSION check. throw new Error( `NIP-44: Unsupported encryption version: ${versionToEncryptWith}. Only version ${CURRENT_VERSION} is supported for encryption.`, ); } - // Further check if it's in the decryptable range just in case (MAX_SUPPORTED_VERSION for future extensibility). - // MIN_SUPPORTED_VERSION for encryption is effectively CURRENT_VERSION due to above checks. - if (versionToEncryptWith > MAX_SUPPORTED_VERSION) { - throw new Error( - `NIP-44: Encryption version ${versionToEncryptWith} is outside the maximum supported range [${MIN_SUPPORTED_VERSION}-${MAX_SUPPORTED_VERSION}].`, - ); - } - // At this point, versionToEncryptWith must be CURRENT_VERSION (e.g., 2) try { if (versionToEncryptWith === 2) { @@ -874,23 +718,16 @@ export function decodePayload(payload: string): { } const version = data[0]; - // Validate version is in supported range for decryption - if (version < MIN_SUPPORTED_VERSION || version > MAX_SUPPORTED_VERSION) { + // NIP-44 defines only v2. Version 0 is reserved and version 1 is deprecated + // and undefined, so both must be treated like any other unknown version. + if (version !== CURRENT_VERSION) { throw new Error( - `NIP-44: Unsupported version: ${version}. This implementation supports versions ${MIN_SUPPORTED_VERSION}-${MAX_SUPPORTED_VERSION}.`, + `NIP-44: Unsupported version: ${version}. This implementation supports version ${CURRENT_VERSION}.`, ); } - // Determine nonce and MAC sizes based on version - const nonceSize = - version === 0 - ? NONCE_SIZE_V0 - : version === 1 - ? NONCE_SIZE_V1 - : NONCE_SIZE_V2; - - const macSize = - version === 0 ? MAC_SIZE_V0 : version === 1 ? MAC_SIZE_V1 : MAC_SIZE_V2; + const nonceSize = NONCE_SIZE_V2; + const macSize = MAC_SIZE_V2; // Verify minimum payload length for the detected version const minVersionedPayloadSize = VERSION_BYTE_SIZE + nonceSize + 1 + macSize; // version + nonce + min_ciphertext (1 byte) + mac @@ -953,70 +790,6 @@ function decryptV2( return unpad(padded); } -// Placeholder for NIP-44 v1 decryption -// TODO: Implement actual NIP-44 v1 decryption logic -// NIP-44 v1 Decryption: Implementations MUST be able to decrypt this version if they can decrypt v2. -// It is assumed that v1 decryption uses the same underlying crypto as v2, but with version byte 1. -// The primary difference is that the conversation key KDF uses the "nip44-v2" salt, -// as v1 spec for KDF is undefined and NIP-44 mandates v1 decryption. -// NIP-44 (Decryption, point 2) mandates that v1 payloads be decrypted using the same -// algorithms as v2, including the "nip44-v2" KDF salt and 32-byte nonce/MAC. -// This function adheres to that by utilizing decryptV2. -function decryptV1( - encryptedData: Uint8Array, - nonce: Uint8Array, - mac: Uint8Array, - privateKey: string, - publicKey: string, -): string { - // For now, V1 uses V2 logic as a placeholder. - // This needs to be replaced with actual V1 specification if different. - // The NIP-44 spec says "Implementations MUST be able to decrypt versions 0 and 1" - // but doesn't detail if their algorithms differ from v2 beyond version byte. - // Assuming key derivation ("nip44-v2" salt in getSharedSecret) and crypto primitives are the same unless specified. - // console.warn("NIP-44: decryptV1 is using V2 logic as a placeholder. Verify V1 specification."); - // NIP-44 specifies using v2 algorithms for v1 decryption. - try { - return decryptV2(encryptedData, nonce, mac, privateKey, publicKey); - } catch (error) { - if (error instanceof Error && error.message.includes("NIP-44 (v2)")) { - throw new Error(error.message.replace("NIP-44 (v2)", "NIP-44 (v1)")); - } - throw error; - } -} - -// Placeholder for NIP-44 v0 decryption -// TODO: Implement actual NIP-44 v0 decryption logic -// NIP-44 v0 Decryption: Implementations MUST be able to decrypt this version if they can decrypt v2. -// It is assumed that v0 decryption uses the same underlying crypto as v2, but with version byte 0. -// The primary difference is that the conversation key KDF uses the "nip44-v2" salt, -// as v0 spec for KDF is undefined and NIP-44 mandates v0 decryption. -// NIP-44 (Decryption, point 2) mandates that v0 payloads be decrypted using the same -// algorithms as v2, including the "nip44-v2" KDF salt and 32-byte nonce/MAC. -// This function adheres to that by utilizing decryptV2. -function decryptV0( - encryptedData: Uint8Array, - nonce: Uint8Array, - mac: Uint8Array, - privateKey: string, - publicKey: string, -): string { - // For now, V0 uses V2 logic as a placeholder. - // This needs to be replaced with actual V0 specification. - // There's no official NIP for v0, it was an early experimental version. - // console.warn("NIP-44: decryptV0 is using V2 logic as a placeholder. Verify V0 specification if possible."); - // NIP-44 specifies using v2 algorithms for v0 decryption. - try { - return decryptV2(encryptedData, nonce, mac, privateKey, publicKey); - } catch (error) { - if (error instanceof Error && error.message.includes("NIP-44 (v2)")) { - throw new Error(error.message.replace("NIP-44 (v2)", "NIP-44 (v0)")); - } - throw error; - } -} - /** * Decrypt a message using NIP-44 (ChaCha20 + HMAC-SHA256) * @@ -1044,23 +817,12 @@ export function decrypt( try { // Decode and extract the payload components const { - version, nonce, ciphertext: encryptedData, mac, } = decodePayload(ciphertext); - // Call the appropriate version-specific decrypt function - if (version === 0) { - return decryptV0(encryptedData, nonce, mac, privateKey, publicKey); - } else if (version === 1) { - return decryptV1(encryptedData, nonce, mac, privateKey, publicKey); - } else if (version === 2) { - return decryptV2(encryptedData, nonce, mac, privateKey, publicKey); - } else { - // This case should ideally be caught by decodePayload's version check - throw new Error(`NIP-44: Unexpected version ${version} after decoding.`); - } + return decryptV2(encryptedData, nonce, mac, privateKey, publicKey); } catch (error) { // Enhance error messages for better debugging if (error instanceof Error) { diff --git a/src/nip46/bunker.ts b/src/nip46/bunker.ts index b55ddbbb..1b48f82c 100644 --- a/src/nip46/bunker.ts +++ b/src/nip46/bunker.ts @@ -1,7 +1,6 @@ -import { Nostr } from "../nip01/nostr"; import { encrypt as encryptNIP44, decrypt as decryptNIP44 } from "../nip44"; import { encrypt as encryptNIP04, decrypt as decryptNIP04 } from "../nip04"; -import { NostrEvent, NostrFilter } from "../types/nostr"; +import { NostrEvent } from "../types/nostr"; import { createSignedEvent } from "../nip01/event"; import { getUnixTime } from "../utils/time"; import { generateRequestId } from "./utils/request-response"; @@ -12,7 +11,6 @@ import { NIP46BunkerOptions, NIP46AuthChallenge, NIP46Metadata, - NIP46EncryptionResult, NIP46ClientSession, NIP46KeyPair, NIP46UnsignedEventData, @@ -31,17 +29,19 @@ import { validateKeypairForCrypto, securePermissionCheck, } from "./utils/security"; -import { Logger, LogLevel } from "../utils/logger"; +import { LogLevel } from "../utils/logger"; +import { NIP46DiagnosticLogger } from "./utils/diagnostics"; +import { NIP46BunkerEngine } from "./internal/bunker-engine"; +import { NIP46ReplayGuard } from "./internal/replay-guard"; export class NostrRemoteSignerBunker { - private nostr: Nostr; + private readonly engine: NIP46BunkerEngine; private userKeypair: NIP46KeyPair; private signerKeypair: NIP46KeyPair; private options: NIP46BunkerOptions; private connectedClients: Map; private pendingAuthChallenges: Map; - private subId: string | null; - private logger: Logger; + private logger: NIP46DiagnosticLogger; private rateLimiter: NIP46RateLimiter; private permissionHandler: | (( @@ -50,22 +50,21 @@ export class NostrRemoteSignerBunker { params: string[], ) => boolean | null) | null = null; - private usedRequestIds: Map = new Map(); // Request ID -> timestamp + private readonly replayGuard = new NIP46ReplayGuard(); private cleanupInterval: NodeJS.Timeout | null = null; // For cleanup interval management constructor(options: NIP46BunkerOptions) { this.options = options; - this.nostr = new Nostr(options.relays || []); this.connectedClients = new Map(); this.pendingAuthChallenges = new Map(); - this.subId = null; // Initialize logger - this.logger = new Logger({ + this.logger = NIP46DiagnosticLogger.create(options.logger, { level: options.debug ? LogLevel.DEBUG : LogLevel.INFO, prefix: "NIP46-BUNKER", includeTimestamp: true, - silent: process.env.NODE_ENV === "test", // Silent in test environment + silent: + typeof process !== "undefined" && process.env?.NODE_ENV === "test", }); // Initialize rate limiter with configurable options @@ -96,6 +95,97 @@ export class NostrRemoteSignerBunker { privateKey: "", }; + this.engine = new NIP46BunkerEngine({ + relays: options.relays || [], + logger: this.logger, + signerKeys: () => this.signerKeypair, + validateStart: () => + validateSecureInitialization({ + userKeypair: this.userKeypair, + signerKeypair: this.signerKeypair, + }), + validateEnvelope: (event) => + validateBeforeDecryption( + this.signerKeypair, + event.pubkey, + event.content, + "NIP-44", + ), + beforeEvent: (event) => { + const result = this.rateLimiter.isAllowed(event.pubkey); + if (result.allowed) return { action: "continue" }; + return { + action: "respond", + response: { + id: "unknown", + error: + NIP46ErrorUtils.getErrorDescription(NIP46ErrorCode.RATE_LIMITED) + + (result.retryAfter + ? ` Retry after ${result.retryAfter} seconds.` + : ""), + }, + }; + }, + beforeRequest: (request) => { + const replay = this.replayGuard.isReplay(request.id); + if (replay) { + this.logger.warn("Replay attack detected", { + requestId: request.id, + }); + } + return replay ? { action: "drop" } : { action: "continue" }; + }, + handlers: { + [NIP46Method.CONNECT]: (request, clientPubkey) => + this.handleConnect(request, clientPubkey), + [NIP46Method.SIGN_EVENT]: (request, clientPubkey) => + this.handleSignEvent(request, clientPubkey), + [NIP46Method.GET_PUBLIC_KEY]: (request) => + this.handleGetPublicKey(request), + [NIP46Method.PING]: async (request) => ({ + id: request.id, + result: "pong", + }), + [NIP46Method.DISCONNECT]: (request, clientPubkey) => + this.handleDisconnect(request, clientPubkey), + [NIP46Method.NIP04_ENCRYPT]: (request, clientPubkey) => + this.handleEncryption(request, clientPubkey), + [NIP46Method.NIP04_DECRYPT]: (request, clientPubkey) => + this.handleEncryption(request, clientPubkey), + [NIP46Method.NIP44_ENCRYPT]: (request, clientPubkey) => + this.handleEncryption(request, clientPubkey), + [NIP46Method.NIP44_DECRYPT]: (request, clientPubkey) => + this.handleEncryption(request, clientPubkey), + }, + unknownMethod: (request) => + NIP46ErrorUtils.createErrorResponse( + request.id, + NIP46ErrorCode.METHOD_NOT_SUPPORTED, + `Method ${request.method} is not supported`, + ), + afterStart: async () => { + if (this.options.metadata) { + await this.publishMetadata(this.options.metadata); + } + this.rateLimiter.start(); + this.cleanupInterval = setInterval(() => this.cleanup(), 60000).unref(); + }, + beforeStop: () => { + if (this.cleanupInterval) clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + }, + afterStop: () => { + try { + this.rateLimiter.destroy(); + } catch (error) { + this.logger.error("Failed to destroy rate limiter", { error }); + } + this.connectedClients.clear(); + this.pendingAuthChallenges.clear(); + this.replayGuard.clear(); + }, + }); + this.logger.info("Bunker initialized", { userPubkey: options.userPubkey, signerPubkey: options.signerPubkey || options.userPubkey, @@ -113,82 +203,13 @@ export class NostrRemoteSignerBunker { public async start(): Promise { this.logger.info("Starting bunker"); - - // Validate that private keys are properly set before starting - validateSecureInitialization({ - userKeypair: this.userKeypair, - signerKeypair: this.signerKeypair, - }); - - // Connect to relays - await this.nostr.connectToRelays(); + await this.engine.start(); this.logger.info("Connected to relays successfully"); - - // Subscribe to requests - const filter: NostrFilter = { - kinds: [24133], - "#p": [this.signerKeypair.publicKey], - }; - - // Clean up any existing subscription - if (this.subId) { - this.nostr.unsubscribe([this.subId]); - } - - // Subscribe to incoming requests - this.subId = this.nostr.subscribe([filter], (event: NostrEvent) => - this.handleRequest(event), - )[0]; - - // Publish metadata if needed - if (this.options.metadata) { - await this.publishMetadata(this.options.metadata); - } - - // Start cleanup interval - this.cleanupInterval = setInterval(() => this.cleanup(), 60000).unref(); // Run cleanup every 1 minute for better security, don't keep process alive } public async stop(): Promise { this.logger.info("Stopping bunker"); - - // Clear the cleanup interval FIRST to prevent race conditions - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); - this.cleanupInterval = null; - } - - // Clean up subscription - if (this.subId) { - try { - await this.nostr.unsubscribe([this.subId]); - this.subId = null; - } catch (error) { - this.logger.error("Failed to unsubscribe", { error }); - } - } - - // Disconnect from relays - if (this.nostr) { - try { - await this.nostr.disconnectFromRelays(); - } catch (error) { - this.logger.error("Failed to disconnect from relays", { error }); - } - } - - // Clean up rate limiter - try { - this.rateLimiter.destroy(); - } catch (error) { - this.logger.error("Failed to destroy rate limiter", { error }); - } - - // Clear all data structures - this.connectedClients.clear(); - this.pendingAuthChallenges.clear(); - this.usedRequestIds.clear(); - + await this.engine.stop(); this.logger.info("Bunker stopped successfully"); } @@ -244,51 +265,6 @@ export class NostrRemoteSignerBunker { this.logger.debug("Custom permission handler cleared"); } - /** - * Check if a request ID has been used before (replay attack prevention) - * @private - */ - private isReplayAttack(requestId: string): boolean { - const now = Date.now(); - const requestTime = this.usedRequestIds.get(requestId); - - if (requestTime !== undefined) { - // This request ID has been seen before - this.logger.warn("Replay attack detected", { - requestId, - originalTime: new Date(requestTime).toISOString(), - attemptTime: new Date(now).toISOString(), - }); - return true; - } - - // Store the request ID with current timestamp - this.usedRequestIds.set(requestId, now); - - return false; - } - - /** - * Clean up old request IDs to prevent memory leaks - * @private - */ - private cleanupOldRequestIds(): void { - const now = Date.now(); - const maxAge = 120000; // 2 minutes - reduced from 1 hour for better security - let cleaned = 0; - - for (const [requestId, timestamp] of this.usedRequestIds.entries()) { - if (now - timestamp > maxAge) { - this.usedRequestIds.delete(requestId); - cleaned++; - } - } - - if (cleaned > 0) { - this.logger.debug("Cleaned up old request IDs", { count: cleaned }); - } - } - /** * Resolve an auth challenge by marking it as resolved * @param pubkey The client pubkey that completed authentication @@ -374,7 +350,12 @@ export class NostrRemoteSignerBunker { * Periodic cleanup of expired data */ private cleanup(): void { - this.cleanupOldRequestIds(); + const cleanedRequestIds = this.replayGuard.cleanup(); + if (cleanedRequestIds > 0) { + this.logger.debug("Cleaned up old request IDs", { + count: cleanedRequestIds, + }); + } // Clean up expired auth challenges const now = Date.now(); @@ -394,147 +375,6 @@ export class NostrRemoteSignerBunker { } } - /** - * Handle incoming request events - * @private - */ - private async handleRequest(event: NostrEvent): Promise { - try { - const clientPubkey = event.pubkey; - - this.logger.debug("Received request event", { - eventId: event.id, - clientPubkey, - eventKind: event.kind, - }); - - // Check rate limiting FIRST - Critical DoS protection - const rateLimitResult = this.rateLimiter.isAllowed(clientPubkey); - if (!rateLimitResult.allowed) { - this.logger.warn("Request rate limited", { - clientPubkey, - retryAfter: rateLimitResult.retryAfter, - remainingRequests: rateLimitResult.remainingRequests, - }); - - // Send rate limit error response - await this.sendResponse( - clientPubkey, - "unknown", // We don't have the request ID yet - null, - NIP46ErrorUtils.getErrorDescription(NIP46ErrorCode.RATE_LIMITED) + - (rateLimitResult.retryAfter - ? ` Retry after ${rateLimitResult.retryAfter} seconds.` - : ""), - ); - return; - } - - // Decrypt and parse the request - const decryptResult = await this.decryptContent( - event.content, - clientPubkey, - ); - if (!decryptResult.success) { - this.logger.error("Failed to decrypt request", { - clientPubkey, - error: decryptResult.error, - }); - return; - } - - let request: NIP46Request; - try { - request = JSON.parse(decryptResult.data!) as NIP46Request; - } catch (error) { - this.logger.error("Failed to parse request JSON", { - clientPubkey, - error: error instanceof Error ? error.message : String(error), - }); - return; - } - - // Validate request structure - if (!request.id || !request.method) { - this.logger.error("Invalid request structure", { - clientPubkey, - hasId: !!request.id, - hasMethod: !!request.method, - }); - return; - } - - // Check for replay attacks - if (this.isReplayAttack(request.id)) { - this.logger.warn("Ignoring replay attack", { - requestId: request.id, - clientPubkey, - }); - return; - } - - // Route the request to the appropriate handler - let response: NIP46Response; - - const method = request.method; - this.logger.debug("Processing request", { - requestId: request.id, - method, - clientPubkey, - paramsCount: request.params?.length || 0, - }); - - switch (method) { - case NIP46Method.CONNECT: - response = await this.handleConnect(request, clientPubkey); - break; - case NIP46Method.SIGN_EVENT: - response = await this.handleSignEvent(request, clientPubkey); - break; - case NIP46Method.GET_PUBLIC_KEY: - response = await this.handleGetPublicKey(request); - break; - case NIP46Method.PING: - response = { id: request.id, result: "pong" }; - break; - case NIP46Method.DISCONNECT: - response = await this.handleDisconnect(request, clientPubkey); - break; - case NIP46Method.NIP04_ENCRYPT: - case NIP46Method.NIP04_DECRYPT: - case NIP46Method.NIP44_ENCRYPT: - case NIP46Method.NIP44_DECRYPT: - response = await this.handleEncryption(request, clientPubkey); - break; - default: - this.logger.warn("Unknown method requested", { - method, - clientPubkey, - }); - response = NIP46ErrorUtils.createErrorResponse( - request.id, - NIP46ErrorCode.METHOD_NOT_SUPPORTED, - `Method ${method} is not supported`, - ); - } - - // Send the response back to the client - await this.sendResponse( - clientPubkey, - response.id, - response.result || null, - response.error, - response.auth_url, - ); - } catch (error) { - this.logger.error("Error handling request", { - error: error instanceof Error ? error.message : String(error), - eventId: event.id, - clientPubkey: event.pubkey, - }); - } - } - /** * Handle connect requests * @private @@ -882,61 +722,6 @@ export class NostrRemoteSignerBunker { } } - /** - * Send a response back to the client - * @private - */ - private async sendResponse( - clientPubkey: string, - id: string, - result: string | null = null, - error?: string, - auth_url?: string, - ): Promise { - try { - const response: NIP46Response = { - id, - result: result || undefined, - error, - auth_url, - }; - - const responseJson = JSON.stringify(response); - const encryptedContent = await encryptNIP44( - responseJson, - this.signerKeypair.privateKey, - clientPubkey, - ); - - const responseEvent: NostrEvent = await createSignedEvent( - { - kind: 24133, - pubkey: this.signerKeypair.publicKey, - content: encryptedContent, - created_at: getUnixTime(), - tags: [["p", clientPubkey]], - }, - this.signerKeypair.privateKey, - ); - - await this.nostr.publishEvent(responseEvent); - - this.logger.debug("Response sent to client", { - responseId: id, - clientPubkey, - hasResult: !!result, - hasError: !!error, - hasAuthUrl: !!auth_url, - }); - } catch (error) { - this.logger.error("Failed to send response", { - responseId: id, - clientPubkey, - error: error instanceof Error ? error.message : String(error), - }); - } - } - /** * Check if a client is authorized (has completed auth challenge) * @private @@ -979,7 +764,7 @@ export class NostrRemoteSignerBunker { this.signerKeypair.privateKey, ); - await this.nostr.publishEvent(metadataEvent); + await this.engine.publishEvent(metadataEvent); this.logger.info("Metadata published successfully"); return metadataEvent; } catch (error) { @@ -990,49 +775,6 @@ export class NostrRemoteSignerBunker { } } - /** - * Decrypt content from a client - * @private - */ - private async decryptContent( - content: string, - authorPubkey: string, - ): Promise { - try { - // Security validation before decryption - validateBeforeDecryption( - this.signerKeypair, - authorPubkey, - content, - "NIP-44", - ); - - const decrypted = await decryptNIP44( - content, - this.signerKeypair.privateKey, - authorPubkey, - ); - - return { - success: true, - method: "nip44", - data: decrypted, - }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - this.logger.error("NIP-44 decryption failed", { - error: errorMessage, - authorPubkey, - }); - return { - success: false, - method: "nip44", - error: errorMessage, - }; - } - } - private hasPermission( clientPubkey: string, permission: string, diff --git a/src/nip46/client.ts b/src/nip46/client.ts index 0ff55de6..d50cd3d3 100644 --- a/src/nip46/client.ts +++ b/src/nip46/client.ts @@ -1,59 +1,27 @@ -import { Nostr } from "../nip01/nostr"; -import { generateKeypair } from "../utils/crypto"; -import { getUnixTime } from "../utils/time"; -import { encrypt as encryptNIP44, decrypt as decryptNIP44 } from "../nip44"; -import { NostrEvent, NostrFilter } from "../types/nostr"; -import { createSignedEvent } from "../nip01/event"; -import { parseConnectionString } from "./utils/connection"; -import { Logger, LogLevel } from "../utils/logger"; +import { NostrEvent } from "../types/nostr"; +import { LogLevel } from "../utils/logger"; +import { NIP46DiagnosticLogger } from "./utils/diagnostics"; import { generateRequestId } from "./utils/request-response"; -import { isValidAuthUrl } from "./utils/auth"; +import { NIP46ClientEngine } from "./internal/client-engine"; import { - NIP46Method, - NIP46Request, - NIP46Response, NIP46ClientOptions, - NIP46EncryptionResult, - NIP46Error, NIP46ConnectionError, - NIP46TimeoutError, - NIP46EncryptionError, NIP46DecryptionError, + NIP46EncryptionError, + NIP46Error, + NIP46Method, NIP46SigningError, - NIP46KeyPair, + NIP46TimeoutError, NIP46UnsignedEventData, } from "./types"; -const DEFAULT_TIMEOUT = 30000; // 30 seconds +const DEFAULT_TIMEOUT = 30000; +/** Full-featured public facade over the canonical NIP-46 client engine. */ export class NostrRemoteSignerClient { - private nostr: Nostr; - private clientKeypair: NIP46KeyPair | null = null; - private signerPubkey: string | null = null; - private userPubkey: string | null = null; - private pendingRequests = new Map< - string, - { - resolve: (response: NIP46Response) => void; - reject: (reason: Error) => void; - timeout: NodeJS.Timeout; - } - >(); - private options: NIP46ClientOptions; - private authWindow: Window | null; - private connected = false; - private subId: string | null = null; - private logger: Logger; - private debug: boolean; - private pendingAuthChallenges = new Map< - string, - { - originalRequestId: string; - authUrl: string; - timeout: ReturnType; - timestamp: number; - } - >(); + private readonly options: NIP46ClientOptions; + private readonly logger: NIP46DiagnosticLogger; + private readonly engine: NIP46ClientEngine; constructor(options: NIP46ClientOptions = {}) { this.options = { @@ -66,809 +34,217 @@ export class NostrRemoteSignerClient { image: "", ...options, }; - this.nostr = new Nostr(this.options.relays); - this.authWindow = null; - this.debug = options.debug || false; - - // Initialize logger - this.logger = new Logger({ + this.logger = NIP46DiagnosticLogger.create(options.logger, { level: options.debug ? LogLevel.DEBUG : LogLevel.INFO, prefix: "NIP46-CLIENT", includeTimestamp: true, silent: - typeof process !== "undefined" && process.env?.NODE_ENV === "test", // Silent in test environment + typeof process !== "undefined" && process.env?.NODE_ENV === "test", }); - } - - /** - * Set up subscription to receive responses from the signer - */ - private async setupSubscription(): Promise { - this.logger.debug("Setting up subscription"); - - if (this.subId) { - this.logger.debug("Cleaning up previous subscription", { - subId: this.subId, - }); - this.nostr.unsubscribe([this.subId]); - } - - if (!this.clientKeypair) { - throw new NIP46ConnectionError("Client keypair not initialized"); - } - - const filter: NostrFilter = { - kinds: [24133], - "#p": [this.clientKeypair.publicKey], - }; - - // Add authors filter if we know the signer's pubkey - if (this.signerPubkey) { - filter.authors = [this.signerPubkey]; - } - - this.logger.debug("Subscribing with filter", { - filter: JSON.stringify(filter), - clientPubkey: this.clientKeypair.publicKey, + this.engine = new NIP46ClientEngine({ + relays: this.options.relays || [], + timeout: this.options.timeout || DEFAULT_TIMEOUT, + logger: this.logger, + relayStrategy: "replace", + parseBeforeInitialConnect: false, + regenerateKeysOnConnect: false, + filterResponsesBySigner: true, + rejectProtocolErrors: false, + requireConnectedForRequests: true, + inspectPublishResult: false, + connectDelayMs: 0, + disconnectDelayMs: 0, + buildConnectParams: (info) => { + const params = [info.pubkey]; + if (info.secret) params.push(info.secret); + if (info.permissions?.length) params.push(info.permissions.join(",")); + return params; + }, + timeoutError: (_method, requestId) => + new NIP46TimeoutError(`Request ${requestId} timed out`), + disconnectError: () => new NIP46ConnectionError("Client disconnected"), + wrapPublishError: (error) => + new NIP46ConnectionError( + `Failed to send request: ${this.errorMessage(error)}`, + ), }); - - this.subId = this.nostr.subscribe([filter], (event) => - this.handleResponse(event), - )[0]; - - this.logger.debug("Subscription created", { subId: this.subId }); } - /** - * Clean up resources and reset state - */ - private async cleanup(): Promise { - // Set disconnected state FIRST to prevent new requests - this.connected = false; - - // Clean up pending requests BEFORE unsubscribing to prevent race conditions - this.pendingRequests.forEach((request) => { - clearTimeout(request.timeout); - request.reject(new NIP46ConnectionError("Client disconnected")); - }); - this.pendingRequests.clear(); - - // Clean up pending auth challenges - this.pendingAuthChallenges.forEach((challenge) => { - clearTimeout(challenge.timeout); - }); - this.pendingAuthChallenges.clear(); - - // Close auth window if open - if (this.authWindow && !this.authWindow.closed) { - this.authWindow.close(); - this.authWindow = null; - } - - // Now safely clean up subscriptions and connections - if (this.subId) { - try { - await this.nostr.unsubscribe([this.subId]); - this.subId = null; - } catch (error) { - this.logger.error("Unsubscription failed", { error }); - } - } - - try { - await this.nostr.disconnectFromRelays(); - } catch (error) { - this.logger.error("Relay disconnection failed", { error }); - } - - // Clear other state - this.signerPubkey = null; - this.userPubkey = null; - } - - /** - * Connect to a remote signer - * @throws {Error} If connection fails or validation fails - * @returns {string} "ack" or required secret value (NOT the user pubkey) - */ + /** Connect and retain the advanced facade's `ack`/secret return contract. */ public async connect(connectionString: string): Promise { this.logger.info("Connecting to signer", { connectionString }); try { - // Generate client keypair if needed - if (!this.clientKeypair) { - this.clientKeypair = await generateKeypair(); - this.logger.debug("Generated client keypair", { - publicKey: this.clientKeypair.publicKey, - }); - } - - // Connect to relays - this.logger.debug("Connecting to relays", { - relays: this.options.relays, - }); - await this.nostr.connectToRelays(); - this.logger.info("Connected to relays"); - - // Parse connection info - const connectionInfo = parseConnectionString(connectionString); - this.signerPubkey = connectionInfo.pubkey; - this.logger.info("Parsed connection info", { - signerPubkey: this.signerPubkey, - type: connectionInfo.type, - relays: connectionInfo.relays, - hasSecret: !!connectionInfo.secret, - }); - - // Connect to signer's relays if provided - if (connectionInfo.relays?.length) { - this.logger.debug("Connecting to signer relays", { - relays: connectionInfo.relays, - }); - - // Clean up existing Nostr instance if it exists - if (this.nostr) { - this.logger.debug("Cleaning up existing Nostr instance"); - try { - await this.nostr.unsubscribeAll(); - await this.nostr.disconnectFromRelays(); - } catch (error) { - this.logger.warn("Error during Nostr instance cleanup", { - error: error instanceof Error ? error.message : String(error), - }); - } - } - - // Create a new Nostr instance with combined relays - const allRelays = [ - ...(this.options.relays || []), - ...connectionInfo.relays, - ]; - this.nostr = new Nostr(Array.from(new Set(allRelays))); - await this.nostr.connectToRelays(); - this.logger.info("Connected to combined relays"); - } - - // Set up subscription to receive responses - await this.setupSubscription(); - - // Send connect request - const params = [this.signerPubkey]; - if (connectionInfo.secret) { - params.push(connectionInfo.secret); - } - if (connectionInfo.permissions?.length) { - params.push(connectionInfo.permissions.join(",")); - } - - const response = await this.sendRequest(NIP46Method.CONNECT, params); - + const { response } = await this.engine.connect(connectionString); if (response.error) { throw new NIP46ConnectionError(`Connection failed: ${response.error}`); } - - // SPEC COMPLIANCE: connect() returns "ack" or secret, NOT user pubkey - this.connected = true; - this.logger.info("Connected to signer successfully", { - signerPubkey: this.signerPubkey, - connectResult: response.result, - }); - - // Return the connect result (should be "ack" or secret) return response.result || "ack"; } catch (error) { - await this.cleanup(); - if (error instanceof NIP46Error) { - throw error; - } - const message = error instanceof Error ? error.message : String(error); - throw new NIP46ConnectionError(`Failed to connect: ${message}`); + await this.engine.disconnect(); + if (error instanceof NIP46Error) throw error; + throw new NIP46ConnectionError( + `Failed to connect: ${this.errorMessage(error)}`, + ); } } - /** - * Disconnect from the remote signer - */ public async disconnect(): Promise { - this.logger.info("Disconnecting from signer"); - try { - if (this.connected && this.signerPubkey) { - this.logger.debug("Sending disconnect request"); - await this.sendRequest(NIP46Method.DISCONNECT, []); - this.logger.info("Disconnect request sent"); - } - } catch (error) { - this.logger.error("Error during disconnect", { - error: error instanceof Error ? error.message : String(error), - }); - } finally { - await this.cleanup(); - this.logger.info("Client cleanup completed"); - } + await this.engine.disconnect(); } - /** - * Sign an event - * @throws {Error} If signing fails - */ async signEvent(eventData: NIP46UnsignedEventData): Promise { - this.logger.debug("Signing event", { eventData }); - - if (!this.connected) { - throw new NIP46ConnectionError("Not connected to signer"); - } - - const response = await this.sendRequest(NIP46Method.SIGN_EVENT, [ + this.assertConnected(); + const response = await this.engine.request(NIP46Method.SIGN_EVENT, [ JSON.stringify(eventData), ]); - if (response.error) { - this.logger.error("Event signing failed", { error: response.error }); throw new NIP46SigningError(`Event signing failed: ${response.error}`); } - - const signedEvent = JSON.parse(response.result!); - this.logger.info("Event signed successfully", { eventId: signedEvent.id }); - return signedEvent; + return JSON.parse(response.result!) as NostrEvent; } - /** - * Get the user's public key (must be called after connect()) - * This is required by NIP-46 spec - clients must differentiate between - * remote-signer-pubkey and user-pubkey - */ public async getUserPublicKey(): Promise { - if (!this.connected) { - throw new NIP46ConnectionError("Not connected to signer"); - } - - if (this.userPubkey) { - return this.userPubkey; - } - - this.logger.debug("Getting user public key from signer"); - const response = await this.sendRequest(NIP46Method.GET_PUBLIC_KEY, []); + this.assertConnected(); + if (this.engine.cachedUserPubkey) return this.engine.cachedUserPubkey; + const response = await this.engine.request(NIP46Method.GET_PUBLIC_KEY, []); if (response.error) { throw new NIP46ConnectionError( `Failed to get public key: ${response.error}`, ); } - - this.userPubkey = response.result!; - this.logger.info("User public key retrieved", { - userPubkey: this.userPubkey, - }); - - return this.userPubkey; + this.engine.cachedUserPubkey = response.result!; + return response.result!; } - /** - * @deprecated Use getUserPublicKey() instead. This method name doesn't clearly - * indicate it's getting the USER's public key, not the signer's public key. - */ + /** @deprecated Use getUserPublicKey() instead. */ async getPublicKey(): Promise { return this.getUserPublicKey(); } - /** - * Ping the signer - * @throws {Error} If ping fails - */ async ping(): Promise { - this.logger.debug("Sending ping"); - - if (!this.connected) { - throw new NIP46ConnectionError("Not connected to signer"); - } - - const response = await this.sendRequest(NIP46Method.PING, []); - - if (response.error) { - this.logger.error("Ping failed", { error: response.error }); - throw new NIP46Error(`Ping failed: ${response.error}`); - } - - this.logger.debug("Ping successful", { result: response.result }); + this.assertConnected(); + const response = await this.engine.request(NIP46Method.PING, []); + if (response.error) throw new NIP46Error(`Ping failed: ${response.error}`); return response.result!; } - /** - * Encrypt data with NIP-44 - * @throws {Error} If encryption fails - */ async nip44Encrypt( thirdPartyPubkey: string, plaintext: string, ): Promise { - this.logger.debug("NIP-44 encryption request", { thirdPartyPubkey }); - - if (!this.connected) { - throw new NIP46ConnectionError("Not connected to signer"); - } - - const response = await this.sendRequest(NIP46Method.NIP44_ENCRYPT, [ + return this.encryptionRequest( + NIP46Method.NIP44_ENCRYPT, thirdPartyPubkey, plaintext, - ]); - - if (response.error) { - this.logger.error("NIP-44 encryption failed", { error: response.error }); - throw new NIP46EncryptionError( - `NIP-44 encryption failed: ${response.error}`, - ); - } - - this.logger.debug("NIP-44 encryption successful"); - return response.result!; + "NIP-44", + ); } - /** - * Decrypt data with NIP-44 - * @throws {Error} If decryption fails - */ async nip44Decrypt( thirdPartyPubkey: string, ciphertext: string, ): Promise { - this.logger.debug("NIP-44 decryption request", { thirdPartyPubkey }); - - if (!this.connected) { - throw new NIP46ConnectionError("Not connected to signer"); - } - - const response = await this.sendRequest(NIP46Method.NIP44_DECRYPT, [ + return this.decryptionRequest( + NIP46Method.NIP44_DECRYPT, thirdPartyPubkey, ciphertext, - ]); - - if (response.error) { - this.logger.error("NIP-44 decryption failed", { error: response.error }); - throw new NIP46DecryptionError( - `NIP-44 decryption failed: ${response.error}`, - ); - } - - this.logger.debug("NIP-44 decryption successful"); - return response.result!; + "NIP-44", + ); } - /** - * Get relay list - * @throws {Error} If request fails - */ async getRelays(): Promise { - this.logger.debug("Getting relay list"); - - if (!this.connected) { - throw new NIP46ConnectionError("Not connected to signer"); - } - - const response = await this.sendRequest(NIP46Method.GET_RELAYS, []); - + this.assertConnected(); + const response = await this.engine.request(NIP46Method.GET_RELAYS, []); if (response.error) { - this.logger.error("Get relays failed", { error: response.error }); throw new NIP46Error(`Get relays failed: ${response.error}`); } - - const relays = JSON.parse(response.result!); - this.logger.debug("Relay list retrieved", { relays }); - return relays; + return JSON.parse(response.result!) as string[]; } - /** - * Encrypt data with NIP-04 - * @throws {Error} If encryption fails - */ async nip04Encrypt( thirdPartyPubkey: string, plaintext: string, ): Promise { - this.logger.debug("NIP-04 encryption request", { thirdPartyPubkey }); - - if (!this.connected) { - throw new NIP46ConnectionError("Not connected to signer"); - } - - const response = await this.sendRequest(NIP46Method.NIP04_ENCRYPT, [ + return this.encryptionRequest( + NIP46Method.NIP04_ENCRYPT, thirdPartyPubkey, plaintext, - ]); - - if (response.error) { - this.logger.error("NIP-04 encryption failed", { error: response.error }); - throw new NIP46EncryptionError( - `NIP-04 encryption failed: ${response.error}`, - ); - } - - this.logger.debug("NIP-04 encryption successful"); - return response.result!; + "NIP-04", + ); } - /** - * Decrypt data with NIP-04 - * @throws {Error} If decryption fails - */ async nip04Decrypt( thirdPartyPubkey: string, ciphertext: string, ): Promise { - this.logger.debug("NIP-04 decryption request", { thirdPartyPubkey }); - - if (!this.connected) { - throw new NIP46ConnectionError("Not connected to signer"); - } - - const response = await this.sendRequest(NIP46Method.NIP04_DECRYPT, [ + return this.decryptionRequest( + NIP46Method.NIP04_DECRYPT, thirdPartyPubkey, ciphertext, - ]); + "NIP-04", + ); + } + private async encryptionRequest( + method: NIP46Method, + pubkey: string, + plaintext: string, + label: string, + ): Promise { + this.assertConnected(); + const response = await this.engine.request(method, [pubkey, plaintext]); if (response.error) { - this.logger.error("NIP-04 decryption failed", { error: response.error }); - throw new NIP46DecryptionError( - `NIP-04 decryption failed: ${response.error}`, + throw new NIP46EncryptionError( + `${label} encryption failed: ${response.error}`, ); } - - this.logger.debug("NIP-04 decryption successful"); return response.result!; } - /** - * Send a request to the signer and wait for response - * @private - */ - private async sendRequest( + private async decryptionRequest( method: NIP46Method, - params: string[], - ): Promise { - if (!this.connected && method !== NIP46Method.CONNECT) { - throw new NIP46ConnectionError("Client is not connected"); - } - - const id = this.generateRequestId(); - const request: NIP46Request = { id, method, params }; - - this.logger.debug("Sending request", { requestId: id, method, params }); - - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.pendingRequests.delete(id); - this.logger.error("Request timeout", { requestId: id, method }); - reject(new NIP46TimeoutError(`Request ${id} timed out`)); - }, this.options.timeout); - - // Use unref to prevent this timeout from keeping the process alive - timeout.unref(); - - this.pendingRequests.set(id, { resolve, reject, timeout }); - - this.sendEncryptedRequest(request).catch((error) => { - this.pendingRequests.delete(id); - clearTimeout(timeout); - this.logger.error("Failed to send encrypted request", { - requestId: id, - error: error instanceof Error ? error.message : String(error), - }); - reject(error); - }); - }); - } - - /** - * Send encrypted request to signer - * @private - */ - private async sendEncryptedRequest(request: NIP46Request): Promise { - if (!this.clientKeypair || !this.signerPubkey) { - throw new NIP46ConnectionError("Client keypair or signer pubkey not set"); - } - - try { - const requestJson = JSON.stringify(request); - const encryptedContent = await encryptNIP44( - requestJson, - this.clientKeypair.privateKey, - this.signerPubkey, - ); - - const requestEvent: NostrEvent = await createSignedEvent( - { - kind: 24133, - content: encryptedContent, - created_at: getUnixTime(), - tags: [["p", this.signerPubkey]], - pubkey: this.clientKeypair.publicKey, // Add missing pubkey field - }, - this.clientKeypair.privateKey, - ); - - await this.nostr.publishEvent(requestEvent); - this.logger.debug("Encrypted request sent", { requestId: request.id }); - } catch (error) { - this.logger.error("Failed to send encrypted request", { - requestId: request.id, - error: error instanceof Error ? error.message : String(error), - }); - throw new NIP46ConnectionError( - `Failed to send request: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - /** - * Handle incoming response from signer - * @private - */ - private async handleResponse(event: NostrEvent): Promise { - this.logger.debug("Received response event", { eventId: event.id }); - - try { - // Decrypt the response - const decryptResult = await this.decryptContent( - event.content, - event.pubkey, - ); - - if (!decryptResult.success) { - this.logger.error("Failed to decrypt response", { - error: decryptResult.error, - eventId: event.id, - }); - return; - } - - this.logger.debug("Decrypted response data", { - data: decryptResult.data, - }); - - let response: NIP46Response; - try { - response = JSON.parse(decryptResult.data!); - this.logger.debug("Parsed response", { response }); - } catch (error) { - this.logger.error("Failed to parse response JSON", { - error: error instanceof Error ? error.message : String(error), - data: decryptResult.data, - }); - return; - } - - // Handle pending request - const pendingRequest = this.pendingRequests.get(response.id); - if (pendingRequest) { - this.logger.debug("Resolving pending request", { - requestId: response.id, - }); - clearTimeout(pendingRequest.timeout); - this.pendingRequests.delete(response.id); - pendingRequest.resolve(response); - } else { - this.logger.warn("Received response for unknown request", { - requestId: response.id, - }); - } - } catch (error) { - this.logger.error("Error handling response", { - eventId: event.id, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - /** - * Decrypt response content - * @private - */ - private async decryptContent( - content: string, - authorPubkey: string, - ): Promise { - if (!this.clientKeypair) { - return { - success: false, - method: "nip44", - error: "Client keypair not available", - }; - } - - try { - this.logger.debug("Attempting NIP-44 decryption", { authorPubkey }); - const decrypted = await decryptNIP44( - content, - this.clientKeypair.privateKey, - authorPubkey, - ); - - return { - success: true, - method: "nip44", - data: decrypted, - }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - this.logger.error("NIP-44 decryption failed", { - error: errorMessage, - authorPubkey, - }); - return { - success: false, - method: "nip44", - error: errorMessage, - }; - } - } - - /** - * Handle auth challenge from signer - * @private - */ - private handleAuthChallenge(response: NIP46Response): void { - if (!response.auth_url) { - this.logger.error("Auth challenge missing auth_url"); - return; - } - - // Validate auth URL - if ( - !isValidAuthUrl(response.auth_url, { - authDomainWhitelist: this.options.authDomainWhitelist, - logger: this.logger, - }) - ) { - this.logger.error("Invalid auth URL received", { - authUrl: response.auth_url, - }); - return; - } - - const requestId = response.id; - this.logger.info("Handling auth challenge", { - requestId, - authUrl: response.auth_url, - }); - - // Store the auth challenge - const timeout = setTimeout(() => { - this.handleAuthTimeout(requestId); - }, this.options.timeout || DEFAULT_TIMEOUT); - - // Only call unref in Node.js environments - if (typeof timeout === "object" && "unref" in timeout) { - (timeout as NodeJS.Timeout).unref(); - } - - this.pendingAuthChallenges.set(requestId, { - originalRequestId: requestId, - authUrl: response.auth_url, - timeout, - timestamp: Date.now(), - }); - - // For browser environment, open the auth URL - if (typeof window !== "undefined") { - this.authWindow = window.open( - response.auth_url, - "_blank", - "width=600,height=700", + pubkey: string, + ciphertext: string, + label: string, + ): Promise { + this.assertConnected(); + const response = await this.engine.request(method, [pubkey, ciphertext]); + if (response.error) { + throw new NIP46DecryptionError( + `${label} decryption failed: ${response.error}`, ); - if (this.authWindow) { - this.monitorAuthWindow(requestId); - } else { - this.logger.error("Failed to open auth window"); - } - } else { - this.logger.info("Auth URL for manual opening", { - authUrl: response.auth_url, - }); } + return response.result!; } - /** - * Monitor auth window for completion - * @private - */ - private monitorAuthWindow(requestId: string): void { - if (!this.authWindow) return; - - const checkClosed = () => { - if (this.authWindow?.closed) { - this.logger.info("Auth window closed", { requestId }); - this.authWindow = null; - - // Clean up the auth challenge - const challenge = this.pendingAuthChallenges.get(requestId); - if (challenge) { - clearTimeout(challenge.timeout); - this.pendingAuthChallenges.delete(requestId); - } - } else { - // Check again in 1 second - setTimeout(checkClosed, 1000); - } - }; - - checkClosed(); - } - - /** - * Handle auth timeout - * @private - */ - private handleAuthTimeout(requestId: string): void { - this.logger.error("Auth challenge timed out", { requestId }); - - const challenge = this.pendingAuthChallenges.get(requestId); - if (challenge) { - this.pendingAuthChallenges.delete(requestId); - - // Close auth window if open - if (this.authWindow && !this.authWindow.closed) { - this.authWindow.close(); - this.authWindow = null; - } - } - - // Reject the original request - const pendingRequest = this.pendingRequests.get(requestId); - if (pendingRequest) { - clearTimeout(pendingRequest.timeout); - this.pendingRequests.delete(requestId); - pendingRequest.reject(new NIP46TimeoutError("Auth challenge timed out")); + private assertConnected(): void { + if (!this.engine.connected) { + throw new NIP46ConnectionError("Not connected to signer"); } } - /** - * Generate a unique request ID - * @private - */ - private generateRequestId(): string { - // Use the secure utility function - return generateRequestId(); + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } - /** - * Generate a connection string for this client - * @static - */ static generateConnectionString( clientPubkey: string, options: NIP46ClientOptions = {}, ): string { - // Validate pubkey if (!clientPubkey || clientPubkey.trim() === "") { throw new NIP46ConnectionError("Client pubkey cannot be empty"); } const params = new URLSearchParams(); - - if (options.relays?.length) { - options.relays.forEach((relay) => params.append("relay", relay)); - } - - // Always include a secret if not provided - const secret = options.secret || generateRequestId(); - params.append("secret", secret); - + options.relays?.forEach((relay) => params.append("relay", relay)); + params.append("secret", options.secret || generateRequestId()); if (options.permissions?.length) { params.append("perms", options.permissions.join(",")); } - - if (options.name) { - params.append("name", options.name); - } - - if (options.url) { - params.append("url", options.url); - } - - if (options.image) { - params.append("image", options.image); - } - - const queryString = params.toString(); - return `nostrconnect://${clientPubkey}?${queryString}`; + if (options.name) params.append("name", options.name); + if (options.url) params.append("url", options.url); + if (options.image) params.append("image", options.image); + return `nostrconnect://${clientPubkey}?${params.toString()}`; } } diff --git a/src/nip46/internal/bunker-engine.ts b/src/nip46/internal/bunker-engine.ts new file mode 100644 index 00000000..48925a33 --- /dev/null +++ b/src/nip46/internal/bunker-engine.ts @@ -0,0 +1,207 @@ +import { Nostr } from "../../nip01/nostr"; +import { NostrEvent, NostrFilter } from "../../types/nostr"; +import { NIP46DiagnosticLogger } from "../utils/diagnostics"; +import { + NIP46KeyPair, + NIP46Method, + NIP46Request, + NIP46Response, +} from "../types"; +import { NIP46_EVENT_KIND, NIP46Wire } from "./wire"; + +const DEFAULT_PUBLISH_TIMEOUT = 10000; + +export type NIP46RequestHandler = ( + request: NIP46Request, + clientPubkey: string, +) => Promise; + +export type NIP46DispatchGuardResult = + | { action: "continue" } + | { action: "drop" } + | { action: "respond"; response: NIP46Response }; + +export interface NIP46BunkerEngineProfile { + relays: string[]; + logger: NIP46DiagnosticLogger; + signerKeys: () => NIP46KeyPair; + validateStart: () => void; + validateEnvelope: (event: NostrEvent) => void; + beforeEvent?: ( + event: NostrEvent, + ) => Promise | NIP46DispatchGuardResult; + beforeRequest?: ( + request: NIP46Request, + clientPubkey: string, + ) => Promise | NIP46DispatchGuardResult; + handlers: Partial>; + unknownMethod: (request: NIP46Request) => NIP46Response; + failureResponse?: (error: unknown) => NIP46Response; + afterStart?: () => Promise | void; + beforeStop?: () => Promise | void; + afterStop?: () => Promise | void; +} + +/** Canonical bunker-side NIP-46 transport, dispatch, and lifecycle owner. */ +export class NIP46BunkerEngine { + private readonly nostr: Nostr; + private readonly profile: NIP46BunkerEngineProfile; + private subId: string | null = null; + private lifecycleQueue: Promise = Promise.resolve(); + private running = false; + + constructor(profile: NIP46BunkerEngineProfile) { + this.profile = profile; + this.nostr = new Nostr(profile.relays); + } + + async start(): Promise { + return this.enqueueLifecycle(async () => { + if (this.running) return; + this.profile.validateStart(); + + try { + await this.nostr.connectToRelays(); + await this.removeSubscription(); + + const filter: NostrFilter = { + kinds: [NIP46_EVENT_KIND], + "#p": [this.profile.signerKeys().publicKey], + }; + this.subId = this.nostr.subscribe([filter], (event) => { + this.handleEvent(event); + })[0]; + await this.profile.afterStart?.(); + this.running = true; + } catch (error) { + await this.removeSubscription(); + try { + await this.nostr.disconnectFromRelays(); + } catch { + // Preserve the original start failure. + } + throw error; + } + }); + } + + async stop(): Promise { + return this.enqueueLifecycle(async () => { + if (!this.running) return; + await this.profile.beforeStop?.(); + await this.removeSubscription(); + try { + await this.nostr.disconnectFromRelays(); + } catch (error) { + this.profile.logger.warn("Failed to disconnect bunker relays", { + error, + }); + } + await this.profile.afterStop?.(); + this.running = false; + }); + } + + async publishEvent(event: NostrEvent): Promise { + await this.nostr.publishEvent(event, { timeout: DEFAULT_PUBLISH_TIMEOUT }); + } + + private async handleEvent(event: NostrEvent): Promise { + try { + this.profile.validateEnvelope(event); + const eventGuard = await this.profile.beforeEvent?.(event); + if (eventGuard && (await this.applyGuard(eventGuard, event.pubkey))) { + return; + } + + const request = NIP46Wire.decryptRequest( + event, + this.profile.signerKeys().privateKey, + ); + if (!request.id || !request.method) { + throw new Error("Invalid request structure"); + } + + this.profile.logger.debug("Dispatching NIP-46 request", { + requestId: request.id, + method: request.method, + params: request.params, + }); + + const requestGuard = await this.profile.beforeRequest?.( + request, + event.pubkey, + ); + if (requestGuard && (await this.applyGuard(requestGuard, event.pubkey))) { + return; + } + + const handler = this.profile.handlers[request.method]; + const response = handler + ? await handler(request, event.pubkey) + : this.profile.unknownMethod(request); + await this.sendResponse(response, event.pubkey); + } catch (error) { + this.profile.logger.error("Failed to process NIP-46 request", { + error, + }); + const response = this.profile.failureResponse?.(error); + if (response) await this.sendResponse(response, event.pubkey); + } + } + + private async applyGuard( + guard: NIP46DispatchGuardResult, + clientPubkey: string, + ): Promise { + if (guard.action === "continue") return false; + if (guard.action === "respond") { + await this.sendResponse(guard.response, clientPubkey); + } + return true; + } + + private async sendResponse( + response: NIP46Response, + clientPubkey: string, + ): Promise { + try { + this.profile.logger.debug("Sending NIP-46 response", { + requestId: response.id, + result: response.result, + error: response.error, + authUrl: response.auth_url, + }); + const event = await NIP46Wire.createResponseEvent( + response, + this.profile.signerKeys(), + clientPubkey, + ); + await this.nostr.publishEvent(event, { + timeout: DEFAULT_PUBLISH_TIMEOUT, + }); + } catch (error) { + this.profile.logger.error("Failed to send NIP-46 response", { error }); + } + } + + private async removeSubscription(): Promise { + if (!this.subId) return; + try { + await this.nostr.unsubscribe([this.subId]); + } catch (error) { + this.profile.logger.warn("Failed to unsubscribe bunker", { error }); + } finally { + this.subId = null; + } + } + + private enqueueLifecycle(transition: () => Promise): Promise { + const result = this.lifecycleQueue.then(transition, transition); + this.lifecycleQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} diff --git a/src/nip46/internal/client-engine.ts b/src/nip46/internal/client-engine.ts new file mode 100644 index 00000000..f92e0598 --- /dev/null +++ b/src/nip46/internal/client-engine.ts @@ -0,0 +1,341 @@ +import { Nostr } from "../../nip01/nostr"; +import { NostrEvent, NostrFilter } from "../../types/nostr"; +import { generateKeypair } from "../../utils/crypto"; +import { NIP46DiagnosticLogger } from "../utils/diagnostics"; +import { parseConnectionString } from "../utils/connection"; +import { generateRequestId } from "../utils/request-response"; +import { + NIP46ConnectionError, + NIP46ConnectionInfo, + NIP46Error, + NIP46KeyPair, + NIP46Method, + NIP46Request, + NIP46Response, + NIP46TimeoutError, +} from "../types"; +import { NIP46RequestCorrelator } from "./request-correlator"; +import { NIP46_EVENT_KIND, NIP46Wire } from "./wire"; + +type RelayStrategy = "add" | "replace"; + +export interface NIP46ClientEngineProfile { + relays: string[]; + timeout: number; + logger: NIP46DiagnosticLogger; + relayStrategy: RelayStrategy; + parseBeforeInitialConnect: boolean; + regenerateKeysOnConnect: boolean; + filterResponsesBySigner: boolean; + rejectProtocolErrors: boolean; + requireConnectedForRequests: boolean; + inspectPublishResult: boolean; + connectDelayMs: number; + disconnectDelayMs: number; + buildConnectParams(info: NIP46ConnectionInfo): string[]; + timeoutError(method: NIP46Method, requestId: string): NIP46TimeoutError; + disconnectError(): Error; + wrapPublishError(error: unknown): Error; + onCleanup?: () => void; +} + +export interface NIP46ClientConnectResult { + info: NIP46ConnectionInfo; + response: NIP46Response; +} + +/** Canonical client-side NIP-46 transport, correlation, and lifecycle owner. */ +export class NIP46ClientEngine { + private nostr: Nostr; + private readonly profile: NIP46ClientEngineProfile; + private readonly correlator = new NIP46RequestCorrelator(); + private clientKeypair: NIP46KeyPair = { publicKey: "", privateKey: "" }; + private signerPubkey: string | null = null; + private userPubkey: string | null = null; + private subId: string | null = null; + private lifecycleQueue: Promise = Promise.resolve(); + private isConnected = false; + + constructor(profile: NIP46ClientEngineProfile) { + this.profile = profile; + this.nostr = new Nostr(profile.relays); + } + + get clientKeys(): NIP46KeyPair { + return this.clientKeypair; + } + + get connected(): boolean { + return this.isConnected; + } + + get pendingRequests(): NIP46RequestCorrelator["pending"] { + return this.correlator.pending; + } + + get cachedUserPubkey(): string | null { + return this.userPubkey; + } + + set cachedUserPubkey(pubkey: string | null) { + this.userPubkey = pubkey; + } + + async connect(connectionString: string): Promise { + return this.enqueueLifecycle(async () => { + try { + let info: NIP46ConnectionInfo; + + if (this.profile.parseBeforeInitialConnect) { + info = parseConnectionString(connectionString); + await this.prepareConnection(info); + } else { + await this.ensureClientKeys(); + await this.nostr.connectToRelays(); + info = parseConnectionString(connectionString); + await this.applyConnectionRelays(info); + } + + this.signerPubkey = info.pubkey; + await this.setupSubscription(); + + const response = await this.request( + NIP46Method.CONNECT, + this.profile.buildConnectParams(info), + ); + if (!response.error) this.isConnected = true; + + return { info, response }; + } catch (error) { + await this.cleanup(); + throw error; + } + }); + } + + async disconnect(): Promise { + return this.enqueueLifecycle(async () => { + try { + if (this.canSendDisconnect()) { + await this.request(NIP46Method.DISCONNECT, []); + } + } catch (error) { + this.profile.logger.warn("Failed to send disconnect request", { + error, + }); + } finally { + await this.cleanup(); + await this.delay(this.profile.disconnectDelayMs); + } + }); + } + + async request(method: NIP46Method, params: string[]): Promise { + this.assertCanRequest(method); + + const request: NIP46Request = { + id: generateRequestId(), + method, + params, + }; + this.profile.logger.debug("Sending NIP-46 request", { + requestId: request.id, + method, + params, + }); + const responsePromise = this.correlator.register( + request.id, + this.profile.timeout, + () => this.profile.timeoutError(method, request.id), + ); + + try { + const event = await NIP46Wire.createRequestEvent( + request, + this.clientKeypair, + this.signerPubkey!, + ); + const result = await this.nostr.publishEvent(event, { + timeout: this.profile.timeout, + }); + if (this.profile.inspectPublishResult && !result.success) { + throw new NIP46ConnectionError( + `Relay rejected event: ${this.firstRelayFailure(result.relayResults)}`, + ); + } + } catch (error) { + this.correlator.reject(request.id, this.profile.wrapPublishError(error)); + } + + const response = await responsePromise; + if (this.profile.rejectProtocolErrors && response.error) { + throw new NIP46Error(response.error); + } + return response; + } + + private async prepareConnection(info: NIP46ConnectionInfo): Promise { + this.signerPubkey = info.pubkey; + await this.ensureClientKeys(); + await this.applyConnectionRelays(info); + await this.nostr.connectToRelays(); + await this.delay(this.profile.connectDelayMs); + } + + private async ensureClientKeys(): Promise { + if ( + this.profile.regenerateKeysOnConnect || + !this.clientKeypair.privateKey + ) { + this.clientKeypair = await generateKeypair(); + } + } + + private async applyConnectionRelays( + info: NIP46ConnectionInfo, + ): Promise { + if (!info.relays.length) return; + + if (this.profile.relayStrategy === "add") { + for (const relay of info.relays) { + try { + this.nostr.addRelay(relay); + } catch (error) { + this.profile.logger.warn("Failed to add connection relay", { + relay, + error, + }); + } + } + return; + } + + await this.removeSubscription(); + await this.nostr.unsubscribeAll(); + await this.nostr.disconnectFromRelays(); + const relays = Array.from( + new Set([...this.profile.relays, ...info.relays]), + ); + this.nostr = new Nostr(relays); + await this.nostr.connectToRelays(); + } + + private async setupSubscription(): Promise { + await this.removeSubscription(); + + const filter: NostrFilter = { + kinds: [NIP46_EVENT_KIND], + "#p": [this.clientKeypair.publicKey], + }; + if (this.profile.filterResponsesBySigner && this.signerPubkey) { + filter.authors = [this.signerPubkey]; + } + + this.subId = this.nostr.subscribe([filter], (event) => { + this.handleResponse(event); + })[0]; + } + + private handleResponse(event: NostrEvent): void { + if ( + this.profile.filterResponsesBySigner && + this.signerPubkey && + event.pubkey !== this.signerPubkey + ) { + return; + } + + try { + const response = NIP46Wire.decryptResponse( + event, + this.clientKeypair.privateKey, + ); + this.profile.logger.debug("Received NIP-46 response", { + requestId: response.id, + }); + if (!this.correlator.settle(response)) { + this.profile.logger.warn("Received response for unknown request", { + requestId: response.id, + }); + } + } catch (error) { + this.profile.logger.error("Failed to process response", { error }); + } + } + + private assertCanRequest(method: NIP46Method): void { + if (!this.clientKeypair.privateKey) { + throw new NIP46ConnectionError("Client private key not set"); + } + if (!this.signerPubkey) { + throw new NIP46ConnectionError("Signer public key not set"); + } + if ( + this.profile.requireConnectedForRequests && + !this.isConnected && + method !== NIP46Method.CONNECT + ) { + throw new NIP46ConnectionError("Client is not connected"); + } + } + + private canSendDisconnect(): boolean { + if (!this.clientKeypair.privateKey || !this.signerPubkey) return false; + return !this.profile.requireConnectedForRequests || this.isConnected; + } + + private async cleanup(): Promise { + this.isConnected = false; + this.correlator.cancelAll(this.profile.disconnectError()); + this.profile.onCleanup?.(); + await this.removeSubscription(); + + try { + await this.nostr.disconnectFromRelays(); + } catch (error) { + this.profile.logger.warn("Relay disconnection failed", { error }); + } + + this.signerPubkey = null; + this.userPubkey = null; + } + + private async removeSubscription(): Promise { + if (!this.subId) return; + try { + await this.nostr.unsubscribe([this.subId]); + } catch (error) { + this.profile.logger.warn("Unsubscription failed", { error }); + } finally { + this.subId = null; + } + } + + // Serialize session transitions only. Ordinary requests remain concurrent, + // and disconnect cleanup deliberately cancels them through the correlator. + private enqueueLifecycle(transition: () => Promise): Promise { + const result = this.lifecycleQueue.then(transition, transition); + this.lifecycleQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private firstRelayFailure( + relayResults: Map, + ): string { + for (const result of relayResults.values()) { + if (!result.success && result.reason) return result.reason; + } + return "unknown reason"; + } + + private async delay(ms: number): Promise { + if (ms <= 0) return; + await new Promise((resolve) => { + const timeout = setTimeout(resolve, ms); + if (typeof timeout === "object" && "unref" in timeout) timeout.unref(); + }); + } +} diff --git a/src/nip46/internal/replay-guard.ts b/src/nip46/internal/replay-guard.ts new file mode 100644 index 00000000..129dd281 --- /dev/null +++ b/src/nip46/internal/replay-guard.ts @@ -0,0 +1,46 @@ +const DEFAULT_REPLAY_WINDOW_MS = 120_000; + +export interface NIP46ReplayGuardOptions { + windowMs?: number; + now?: () => number; +} + +/** Owns the bounded replay window for NIP-46 request identifiers. */ +export class NIP46ReplayGuard { + private readonly seenAt = new Map(); + private readonly windowMs: number; + private readonly now: () => number; + + constructor(options: NIP46ReplayGuardOptions = {}) { + this.windowMs = options.windowMs ?? DEFAULT_REPLAY_WINDOW_MS; + this.now = options.now ?? Date.now; + } + + /** Return true for an already-seen ID, otherwise record it. */ + isReplay(requestId: string): boolean { + if (this.seenAt.has(requestId)) return true; + this.seenAt.set(requestId, this.now()); + return false; + } + + /** Remove IDs older than the configured replay window. */ + cleanup(): number { + const now = this.now(); + let cleaned = 0; + for (const [requestId, timestamp] of this.seenAt) { + if (now - timestamp > this.windowMs) { + this.seenAt.delete(requestId); + cleaned += 1; + } + } + return cleaned; + } + + clear(): void { + this.seenAt.clear(); + } + + get size(): number { + return this.seenAt.size; + } +} diff --git a/src/nip46/internal/request-correlator.ts b/src/nip46/internal/request-correlator.ts new file mode 100644 index 00000000..23d1502f --- /dev/null +++ b/src/nip46/internal/request-correlator.ts @@ -0,0 +1,64 @@ +import { NIP46Response } from "../types"; + +export interface PendingNIP46Request { + resolve: (response: NIP46Response) => void; + reject: (error: Error) => void; + timeout: ReturnType; +} + +/** Owns request registration, timeout, response correlation, and cancellation. */ +export class NIP46RequestCorrelator { + readonly pending = new Map(); + + register( + requestId: string, + timeoutMs: number, + timeoutError: () => Error, + ): Promise { + if (this.pending.has(requestId)) { + return Promise.reject( + new Error(`NIP-46 request '${requestId}' is already pending`), + ); + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(requestId); + reject(timeoutError()); + }, timeoutMs); + + if (typeof timeout === "object" && "unref" in timeout) { + timeout.unref(); + } + + this.pending.set(requestId, { resolve, reject, timeout }); + }); + } + + settle(response: NIP46Response): boolean { + const pending = this.pending.get(response.id); + if (!pending) return false; + + clearTimeout(pending.timeout); + this.pending.delete(response.id); + pending.resolve(response); + return true; + } + + reject(requestId: string, error: Error): void { + const pending = this.pending.get(requestId); + if (!pending) return; + + clearTimeout(pending.timeout); + this.pending.delete(requestId); + pending.reject(error); + } + + cancelAll(error: Error): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timeout); + pending.reject(error); + } + this.pending.clear(); + } +} diff --git a/src/nip46/internal/wire.ts b/src/nip46/internal/wire.ts new file mode 100644 index 00000000..24335921 --- /dev/null +++ b/src/nip46/internal/wire.ts @@ -0,0 +1,140 @@ +import { createSignedEvent } from "../../nip01/event"; +import { decrypt as decryptNIP44, encrypt as encryptNIP44 } from "../../nip44"; +import { NostrEvent } from "../../types/nostr"; +import { getUnixTime } from "../../utils/time"; +import { NIP46KeyPair, NIP46Request, NIP46Response } from "../types"; +import { + MAX_CONTENT_SIZE, + MAX_ID_LENGTH, + MAX_PARAMS_COUNT, + validatePubkey, +} from "../utils/validator"; + +export const NIP46_EVENT_KIND = 24133; + +/** Canonical NIP-44 event codec shared by every NIP-46 facade. */ +export class NIP46Wire { + static async createRequestEvent( + request: NIP46Request, + sender: NIP46KeyPair, + recipientPubkey: string, + ): Promise { + return this.createEvent(request, sender, recipientPubkey); + } + + static async createResponseEvent( + response: NIP46Response, + sender: NIP46KeyPair, + recipientPubkey: string, + ): Promise { + return this.createEvent(response, sender, recipientPubkey); + } + + static decryptRequest( + event: NostrEvent, + recipientPrivateKey: string, + ): NIP46Request { + const payload = this.decryptPayload(event, recipientPrivateKey); + if (!this.isRequest(payload)) throw new Error("Invalid NIP-46 request"); + return payload; + } + + static decryptResponse( + event: NostrEvent, + recipientPrivateKey: string, + ): NIP46Response { + const payload = this.decryptPayload(event, recipientPrivateKey); + if (!this.isResponse(payload)) throw new Error("Invalid NIP-46 response"); + return payload; + } + + static decryptContent( + content: string, + recipientPrivateKey: string, + authorPubkey: string, + ): string { + return decryptNIP44(content, recipientPrivateKey, authorPubkey); + } + + private static async createEvent( + payload: NIP46Request | NIP46Response, + sender: NIP46KeyPair, + recipientPubkey: string, + ): Promise { + const content = await encryptNIP44( + JSON.stringify(payload), + sender.privateKey, + recipientPubkey, + ); + + return createSignedEvent( + { + kind: NIP46_EVENT_KIND, + content, + created_at: getUnixTime(), + tags: [["p", recipientPubkey]], + pubkey: sender.publicKey, + }, + sender.privateKey, + ); + } + + private static decryptPayload( + event: NostrEvent, + recipientPrivateKey: string, + ): unknown { + const decrypted = this.decryptContent( + event.content, + recipientPrivateKey, + event.pubkey, + ); + return JSON.parse(decrypted) as unknown; + } + + private static isRequest(payload: unknown): payload is NIP46Request { + if (!this.isRecord(payload)) return false; + if (!this.isId(payload.id)) return false; + if ( + typeof payload.method !== "string" || + payload.method.length === 0 || + payload.method.length > 64 + ) { + return false; + } + if ( + !Array.isArray(payload.params) || + payload.params.length > MAX_PARAMS_COUNT || + !payload.params.every((param) => typeof param === "string") + ) { + return false; + } + const pubkey = payload.pubkey; + return ( + pubkey === undefined || + (typeof pubkey === "string" && validatePubkey(pubkey)) + ); + } + + private static isResponse(payload: unknown): payload is NIP46Response { + if (!this.isRecord(payload) || !this.isId(payload.id)) return false; + const fields = [payload.result, payload.error, payload.auth_url]; + if (!fields.some((field) => field !== undefined)) return false; + return fields.every( + (field) => + field === undefined || + (typeof field === "string" && field.length <= MAX_CONTENT_SIZE), + ); + } + + private static isId(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= MAX_ID_LENGTH + ); + } + + private static isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); + } +} diff --git a/src/nip46/simple-bunker.ts b/src/nip46/simple-bunker.ts index c7fd55e8..3c063c0d 100644 --- a/src/nip46/simple-bunker.ts +++ b/src/nip46/simple-bunker.ts @@ -1,8 +1,5 @@ -import { NostrEvent, NostrFilter } from "../types/nostr"; -import { Nostr } from "../nip01/nostr"; import { encrypt as encryptNIP44, decrypt as decryptNIP44 } from "../nip44"; import { encrypt as encryptNIP04, decrypt as decryptNIP04 } from "../nip04"; -import { getUnixTime } from "../utils/time"; import { createSignedEvent, UnsignedEvent } from "../nip01/event"; import { NIP46Request, @@ -14,13 +11,18 @@ import { NIP46ConnectionError, NIP46Method, } from "./types"; -import { Logger, LogLevel } from "../utils/logger"; +import { LogLevel } from "../utils/logger"; +import { NIP46DiagnosticLogger } from "./utils/diagnostics"; import { createSuccessResponse, createErrorResponse, } from "./utils/request-response"; import { buildConnectionString } from "./utils/connection"; -import { validatePrivateKeySecure } from "./utils/security"; +import { + validateBeforeDecryption, + validatePrivateKeySecure, +} from "./utils/security"; +import { NIP46BunkerEngine } from "./internal/bunker-engine"; // Session data for connected clients interface ClientSession { @@ -35,16 +37,14 @@ interface ClientSession { * It is designed to be lightweight and easy to use. */ export class SimpleNIP46Bunker { - private nostr: Nostr; - private relays: string[]; + private readonly engine: NIP46BunkerEngine; + private readonly relays: string[]; private userKeys: NIP46KeyPair; private signerKeys: NIP46KeyPair; private clients: Map; private defaultPermissions: Set; - private subId: string | null; private secret?: string; - private logger: Logger; - private debug: boolean; + private logger: NIP46DiagnosticLogger; /** * Create a new SimpleNIP46Bunker @@ -61,23 +61,75 @@ export class SimpleNIP46Bunker { options: SimpleNIP46BunkerOptions = {}, ) { this.relays = relays; - this.nostr = new Nostr(relays); this.userKeys = { publicKey: userPubkey, privateKey: "" }; this.signerKeys = { publicKey: signerPubkey || userPubkey, privateKey: "" }; this.clients = new Map(); this.defaultPermissions = new Set(options.defaultPermissions || []); - this.subId = null; this.secret = options.secret; - this.debug = options.debug || false; + const debug = options.debug || false; // For backward compatibility, set the logger level based on debug flag if not explicitly set const logLevel = - options.logLevel || (this.debug ? LogLevel.DEBUG : LogLevel.INFO); + options.logLevel || (debug ? LogLevel.DEBUG : LogLevel.INFO); - this.logger = new Logger({ + this.logger = NIP46DiagnosticLogger.create(options.logger, { prefix: "Bunker", level: logLevel, - silent: process.env.NODE_ENV === "test", // Silent in test environment + silent: + typeof process !== "undefined" && process.env?.NODE_ENV === "test", + }); + this.engine = new NIP46BunkerEngine({ + relays, + logger: this.logger, + signerKeys: () => this.signerKeys, + validateStart: () => { + if (!this.userKeys.publicKey) { + throw new NIP46ConnectionError("User public key not set"); + } + if (!this.signerKeys.publicKey) { + throw new NIP46ConnectionError("Signer public key not set"); + } + if (!this.signerKeys.privateKey) { + throw new NIP46ConnectionError("Signer private key not set"); + } + }, + validateEnvelope: (event) => + validateBeforeDecryption( + this.signerKeys, + event.pubkey, + event.content, + "NIP-44", + ), + handlers: { + [NIP46Method.CONNECT]: (request, clientPubkey) => + this.handleConnect(request, clientPubkey), + [NIP46Method.GET_PUBLIC_KEY]: (request, clientPubkey) => + this.handleGetPublicKey(request, clientPubkey), + [NIP46Method.PING]: (request, clientPubkey) => + this.handlePing(request, clientPubkey), + [NIP46Method.SIGN_EVENT]: (request, clientPubkey) => + this.handleSignEvent(request, clientPubkey), + [NIP46Method.NIP44_ENCRYPT]: (request, clientPubkey) => + this.handleNIP44Encrypt(request, clientPubkey), + [NIP46Method.NIP44_DECRYPT]: (request, clientPubkey) => + this.handleNIP44Decrypt(request, clientPubkey), + [NIP46Method.NIP04_ENCRYPT]: (request, clientPubkey) => + this.handleNIP04Encrypt(request, clientPubkey), + [NIP46Method.NIP04_DECRYPT]: (request, clientPubkey) => + this.handleNIP04Decrypt(request, clientPubkey), + [NIP46Method.GET_RELAYS]: (request, clientPubkey) => + this.handleGetRelays(request, clientPubkey), + [NIP46Method.DISCONNECT]: (request, clientPubkey) => + this.handleDisconnect(request, clientPubkey), + }, + unknownMethod: (request) => + createErrorResponse(request.id, `Unknown method: ${request.method}`), + failureResponse: (error) => + createErrorResponse( + "unknown", + `Failed to process request: ${this.errorMessage(error)}`, + ), + afterStop: () => this.clients.clear(), }); } @@ -85,42 +137,13 @@ export class SimpleNIP46Bunker { * Start the bunker and listen for requests */ async start(): Promise { - // Validate keys - if (!this.userKeys.publicKey) { - throw new NIP46ConnectionError("User public key not set"); - } - - if (!this.signerKeys.publicKey) { - throw new NIP46ConnectionError("Signer public key not set"); - } - - // Also require the signer's _private_ key to be available up-front - if (!this.signerKeys.privateKey) { - throw new NIP46ConnectionError("Signer private key not set"); - } - try { - // Connect to relays - await this.nostr.connectToRelays(); - - // Subscribe to requests - const filter: NostrFilter = { - kinds: [24133], - "#p": [this.signerKeys.publicKey], - }; - - const subIds = this.nostr.subscribe([filter], (event) => - this.handleRequest(event), - ); - this.subId = subIds[0]; - + await this.engine.start(); this.logger.info( `Bunker started for ${this.signerKeys.publicKey} on ${this.relays.length} relay(s)`, ); - return; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = this.errorMessage(error); this.logger.error(`Failed to start bunker:`, errorMessage); throw error instanceof NIP46Error ? error @@ -132,27 +155,13 @@ export class SimpleNIP46Bunker { * Stop the bunker */ async stop(): Promise { - if (this.subId) { - try { - this.nostr.unsubscribe([this.subId]); - } catch (e) { - // Ignore unsubscribe errors - } - this.subId = null; - } - try { - await this.nostr.disconnectFromRelays(); + await this.engine.stop(); this.logger.info(`Bunker stopped`); } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = this.errorMessage(error); this.logger.warn(`Error disconnecting from relays:`, errorMessage); - // Continue despite errors } - - // Clear client sessions - this.clients.clear(); } /** @@ -212,135 +221,6 @@ export class SimpleNIP46Bunker { return false; } - /** - * Handle an incoming request event - */ - private async handleRequest(event: NostrEvent): Promise { - try { - this.logger.info(`Received request from ${event.pubkey}`); - - // Check if we have the signer private key - if (!this.signerKeys.privateKey) { - this.logger.error(`Signer private key not set`); - return; - } - - // Decrypt with the signer's private key and client's public key - try { - const decrypted = decryptNIP44( - event.content, - this.signerKeys.privateKey, - event.pubkey, - ); - - this.logger.debug(`Decrypted content: ${decrypted}`); - - // Parse the request - const request: NIP46Request = JSON.parse(decrypted); - const clientPubkey = event.pubkey; - - this.logger.debug( - `Processing request: ${request.method} (${request.id})`, - ); - - // Handle the request based on method - let response: NIP46Response; - - switch (request.method) { - case NIP46Method.CONNECT: - response = await this.handleConnect(request, clientPubkey); - break; - - case NIP46Method.GET_PUBLIC_KEY: - if (!this.isClientAuthorized(clientPubkey)) { - response = createErrorResponse(request.id, "Unauthorized"); - } else { - this.logger.debug(`Sending pubkey: ${this.userKeys.publicKey}`); - response = createSuccessResponse( - request.id, - this.userKeys.publicKey, - ); - } - break; - - case NIP46Method.PING: - if (!this.isClientAuthorized(clientPubkey)) { - response = createErrorResponse(request.id, "Unauthorized"); - } else { - this.logger.debug(`Ping-pong`); - response = createSuccessResponse(request.id, "pong"); - } - break; - - case NIP46Method.SIGN_EVENT: - response = await this.handleSignEvent(request, clientPubkey); - break; - - case NIP46Method.NIP44_ENCRYPT: - response = await this.handleNIP44Encrypt(request, clientPubkey); - break; - - case NIP46Method.NIP44_DECRYPT: - response = await this.handleNIP44Decrypt(request, clientPubkey); - break; - - case NIP46Method.NIP04_ENCRYPT: - response = await this.handleNIP04Encrypt(request, clientPubkey); - break; - - case NIP46Method.NIP04_DECRYPT: - response = await this.handleNIP04Decrypt(request, clientPubkey); - break; - - case NIP46Method.GET_RELAYS: - response = await this.handleGetRelays(request, clientPubkey); - break; - - case NIP46Method.DISCONNECT: - response = await this.handleDisconnect(request, clientPubkey); - break; - - default: - response = createErrorResponse( - request.id, - `Unknown method: ${request.method}`, - ); - } - - // Send the response - await this.sendResponse(response, clientPubkey); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to process request:`, errorMessage); - - // Send error response to client even when we couldn't parse the request - const response = createErrorResponse( - "unknown", // cannot recover id - using convention for failed parse - `Failed to process request: ${errorMessage}`, - ); - await this.sendResponse(response, event.pubkey); - } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - this.logger.error(`Error handling request:`, errorMessage); - - try { - // Attempt to send a generic error response for the outer handler as well - const response = createErrorResponse( - "unknown", // cannot recover id - `Failed to handle request: ${errorMessage}`, - ); - await this.sendResponse(response, event.pubkey); - } catch (err) { - // Just log if we can't send the response in this case - const errMessage = err instanceof Error ? err.message : String(err); - this.logger.error(`Could not send error response: ${errMessage}`); - } - } - } - /** * Handle a connect request */ @@ -394,14 +274,34 @@ export class SimpleNIP46Bunker { this.clients.set(clientPubkey, session); this.logger.info(`Client ${clientPubkey.slice(0, 8)}... connected`); - this.logger.debug( - `Client permissions: ${Array.from(session.permissions).join(", ")}`, - ); + this.logger.debug("Client permissions configured", { + permissionCount: session.permissions.size, + }); // Respond with "ack" or the secret if provided return createSuccessResponse(request.id, requestedSecret || "ack"); } + private async handleGetPublicKey( + request: NIP46Request, + clientPubkey: string, + ): Promise { + if (!this.isClientAuthorized(clientPubkey)) { + return createErrorResponse(request.id, "Unauthorized"); + } + return createSuccessResponse(request.id, this.userKeys.publicKey); + } + + private async handlePing( + request: NIP46Request, + clientPubkey: string, + ): Promise { + if (!this.isClientAuthorized(clientPubkey)) { + return createErrorResponse(request.id, "Unauthorized"); + } + return createSuccessResponse(request.id, "pong"); + } + /** * Handle a sign_event request */ @@ -454,9 +354,6 @@ export class SimpleNIP46Bunker { pubkey: this.userKeys.publicKey, }; - // Set the private key on the Nostr instance for signing - this.nostr.setPrivateKey(this.userKeys.privateKey); - // Create a signed event using createSignedEvent const signedEvent = await createSignedEvent( unsignedEvent, @@ -816,52 +713,6 @@ export class SimpleNIP46Bunker { } } - /** - * Send a response to a client - */ - private async sendResponse( - response: NIP46Response, - clientPubkey: string, - ): Promise { - try { - this.logger.debug( - `Sending response for request ${response.id}:`, - JSON.stringify(response), - ); - - // Encrypt the response with the signer's private key and client's public key - const encrypted = encryptNIP44( - JSON.stringify(response), - this.signerKeys.privateKey, - clientPubkey, - ); - - // Create the unsigned event - const eventData: UnsignedEvent = { - kind: 24133, - pubkey: this.signerKeys.publicKey, - created_at: getUnixTime(), - tags: [["p", clientPubkey]], - content: encrypted, - }; - - // Create a properly signed event - const signedEvent = await createSignedEvent( - eventData, - this.signerKeys.privateKey, - ); - - // Use the Nostr class to publish the event - await this.nostr.publishEvent(signedEvent); - - this.logger.debug(`Response sent for request: ${response.id}`); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to send response: ${errorMessage}`); - } - } - /** * Check if a client is authorized */ @@ -875,4 +726,8 @@ export class SimpleNIP46Bunker { setLogLevel(level: LogLevel): void { this.logger.setLevel(level); } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } } diff --git a/src/nip46/simple-client.ts b/src/nip46/simple-client.ts index 163c0481..410f5844 100644 --- a/src/nip46/simple-client.ts +++ b/src/nip46/simple-client.ts @@ -1,571 +1,225 @@ -import { NostrEvent, NostrFilter } from "../types/nostr"; -import { Nostr } from "../nip01/nostr"; -import { generateKeypair } from "../utils/crypto"; -import { getUnixTime } from "../utils/time"; -import { encrypt as encryptNIP44, decrypt as decryptNIP44 } from "../nip44"; -import { createSignedEvent } from "../nip01/event"; -import { generateRequestId } from "./utils/request-response"; -import { Logger, LogLevel } from "../utils/logger"; -import { parseConnectionString } from "./utils/connection"; +import { NostrEvent } from "../types/nostr"; +import { LogLevel } from "../utils/logger"; +import { NIP46ClientEngine } from "./internal/client-engine"; +import { NIP46DiagnosticLogger } from "./utils/diagnostics"; import { - NIP46KeyPair, - NIP46UnsignedEventData, - SimpleNIP46ClientOptions, - NIP46Error, NIP46ConnectionError, - NIP46TimeoutError, - NIP46EncryptionError, NIP46DecryptionError, - NIP46SigningError, - NIP46Request, - NIP46Response, + NIP46EncryptionError, + NIP46Error, NIP46Method, + NIP46SigningError, + NIP46TimeoutError, + NIP46UnsignedEventData, + SimpleNIP46ClientOptions, } from "./types"; -/** - * Simple implementation of a NIP-46 client - * - * This class implements the client-side of the NIP-46 Remote Signing protocol. - * It is designed to be lightweight and easy to use. - */ +/** Lightweight public facade over the canonical NIP-46 client engine. */ export class SimpleNIP46Client { - private nostr: Nostr; - private clientKeys: NIP46KeyPair; - private signerPubkey: string | null; - private userPubkey: string | null; - private pendingRequests: Map void>; - private subId: string | null = null; - private timeout: number; - private logger: Logger; - private debug: boolean; + private readonly logger: NIP46DiagnosticLogger; + private readonly engine: NIP46ClientEngine; - /** - * Create a new SimpleNIP46Client - * - * @param relays - Array of relay URLs to connect to - * @param options - Client options - */ constructor(relays: string[], options: SimpleNIP46ClientOptions = {}) { - this.nostr = new Nostr(relays); - this.clientKeys = { publicKey: "", privateKey: "" }; - this.signerPubkey = null; - this.userPubkey = null; - this.pendingRequests = new Map(); - this.timeout = options.timeout || 30000; - this.debug = options.debug || false; - - // For backward compatibility, set the logger level based on debug flag if not explicitly set + const timeout = options.timeout || 30000; + const debug = options.debug || false; const logLevel = - options.logLevel || (this.debug ? LogLevel.DEBUG : LogLevel.INFO); + options.logLevel || (debug ? LogLevel.DEBUG : LogLevel.INFO); - this.logger = new Logger({ + this.logger = NIP46DiagnosticLogger.create(options.logger, { prefix: "Client", level: logLevel, - silent: process.env.NODE_ENV === "test", // Silent in test environment + silent: + typeof process !== "undefined" && process.env?.NODE_ENV === "test", + }); + this.engine = new NIP46ClientEngine({ + relays, + timeout, + logger: this.logger, + relayStrategy: "add", + parseBeforeInitialConnect: true, + regenerateKeysOnConnect: true, + filterResponsesBySigner: true, + rejectProtocolErrors: false, + requireConnectedForRequests: false, + inspectPublishResult: true, + connectDelayMs: 1000, + disconnectDelayMs: 500, + buildConnectParams: (info) => [ + info.pubkey, + info.secret || "", + (info.permissions || []).join(","), + ], + timeoutError: (method) => + new NIP46TimeoutError(`Request timed out: ${method}`), + disconnectError: () => new NIP46Error("Client disconnected"), + wrapPublishError: (error) => + new NIP46ConnectionError( + `Failed to sign or publish event: ${this.errorMessage(error)}`, + ), }); } - /** - * Connect to a remote signer - * - * @param connectionString - The bunker:// connection string - * @returns The user's public key - */ + /** Connect and retain the simple facade's user-pubkey return contract. */ async connect(connectionString: string): Promise { + this.logger.info("Connecting to signer", { connectionString }); try { - // Parse connection string and validate - const info = parseConnectionString(connectionString); - this.signerPubkey = info.pubkey; - this.logger.info(`Connecting to signer: ${this.signerPubkey}`); - - // Add relays from connection string to the client - if (info.relays && info.relays.length > 0) { - this.logger.debug( - `Adding relays from connection string: ${info.relays.join(", ")}`, - ); - info.relays.forEach((relay) => { - try { - this.nostr.addRelay(relay); - } catch (error) { - this.logger.warn( - `Failed to add relay ${relay}:`, - error instanceof Error ? error.message : String(error), - ); - } - }); - } - - // Generate client keypair - this.clientKeys = await generateKeypair(); - this.logger.debug( - `Generated client keypair: ${this.clientKeys.publicKey}`, - ); - - // Connect to relays - await this.nostr.connectToRelays(); - - // Give a moment for the connection to fully establish - await new Promise((resolve) => setTimeout(resolve, 1000).unref()); - this.logger.debug(`Connected to relays`); - - // Subscribe to responses - const filter: NostrFilter = { - kinds: [24133], - "#p": [this.clientKeys.publicKey], - }; - - const subIds = this.nostr.subscribe([filter], (event) => - this.handleResponse(event), - ); - this.subId = subIds[0]; - this.logger.debug( - `Subscribed to responses with filter: p=${this.clientKeys.publicKey}`, - ); - - // Send connect request with proper parameters per NIP-46 spec - // Ensure signerPubkey is not null before constructing params array - if (!this.signerPubkey) { - throw new NIP46ConnectionError( - "Signer public key is not set. Connection string parsing may have failed.", - ); + const { info, response } = await this.engine.connect(connectionString); + if (response.error) { + throw new NIP46ConnectionError(`Connection failed: ${response.error}`); } - - const connectParams = [ - this.signerPubkey, - info.secret || "", // optional_secret - (info.permissions || []).join(","), // optional_requested_permissions - ]; - - const connectResponse = await this.sendRequest( - NIP46Method.CONNECT, - connectParams, - ); - this.logger.info(`Connect request sent successfully`); - - // Handle connect response per NIP-46 spec - // First check for error response - if (connectResponse.error) { - throw new NIP46ConnectionError( - `Connection failed: ${connectResponse.error}`, - ); + if ( + response.result !== "ack" && + (!info.secret || info.secret !== response.result) + ) { + throw new NIP46ConnectionError("Invalid or missing required secret"); } - - if (connectResponse.result !== "ack") { - // If not "ack", it should be a required secret value - this.logger.debug( - `Connect response requires secret: ${connectResponse.result}`, - ); - if (!info.secret || info.secret !== connectResponse.result) { - throw new NIP46ConnectionError("Invalid or missing required secret"); - } + if (response.result !== "ack") { + this.logger.debug("Connect response requires secret", { + hasSecret: true, + }); } - // Get and store user public key (required after connect per NIP-46 spec) try { - this.userPubkey = await this.getPublicKey(); - this.logger.debug(`Got user pubkey: ${this.userPubkey}`); - return this.userPubkey; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to get user public key:`, errorMessage); + const userPubkey = await this.getPublicKey(); + this.engine.cachedUserPubkey = userPubkey; + return userPubkey; + } catch { throw new NIP46ConnectionError( "Failed to get user public key after connect", ); } } catch (error) { - // Clean up on error - await this.disconnect(); - if (error instanceof NIP46Error) { - throw error; - } else { - const errorMessage = - error instanceof Error ? error.message : String(error); - throw new NIP46ConnectionError(`Connection failed: ${errorMessage}`); - } + await this.engine.disconnect(); + if (error instanceof NIP46Error) throw error; + throw new NIP46ConnectionError( + `Connection failed: ${this.errorMessage(error)}`, + ); } } - /** - * Get the user's public key - */ async getPublicKey(): Promise { - const response = await this.sendRequest(NIP46Method.GET_PUBLIC_KEY, []); + const response = await this.engine.request(NIP46Method.GET_PUBLIC_KEY, []); + if (response.error) { + throw new NIP46ConnectionError( + `Failed to get public key: ${response.error}`, + ); + } return response.result!; } - /** - * Ping the bunker to check connectivity - */ async ping(): Promise { try { - // Check if client is connected first - if (!this.signerPubkey || !this.clientKeys.privateKey) { - return false; - } - - // Add timeout to prevent hanging - const timeoutPromise = new Promise((_, reject) => { - setTimeout( - () => reject(new NIP46TimeoutError("Ping timed out")), - this.timeout, - ).unref(); // Don't keep process alive - }); - - const pingPromise = this.sendRequest(NIP46Method.PING, []).then( - (response) => response.result === "pong", - ); - - // Race the ping request against the timeout - return await Promise.race([pingPromise, timeoutPromise]); - } catch (error) { - // Return false for any error (timeout, connection issues, etc.) + if (!this.engine.connected) return false; + const response = await this.engine.request(NIP46Method.PING, []); + return response.result === "pong"; + } catch { return false; } } - /** - * Sign an event remotely - * @param eventData - Event data to sign - * @returns Signed event - */ async signEvent(eventData: NIP46UnsignedEventData): Promise { - const response = await this.sendRequest(NIP46Method.SIGN_EVENT, [ + const response = await this.engine.request(NIP46Method.SIGN_EVENT, [ JSON.stringify(eventData), ]); - - // Handle error response - if (response.error) { - throw new NIP46SigningError(response.error); - } - - // Parse the result - return JSON.parse(response.result!); + if (response.error) throw new NIP46SigningError(response.error); + return JSON.parse(response.result!) as NostrEvent; } - /** - * Encrypt a message using NIP-44 (preferred) - */ async nip44Encrypt( thirdPartyPubkey: string, plaintext: string, ): Promise { - const response = await this.sendRequest(NIP46Method.NIP44_ENCRYPT, [ + return this.encryptionRequest( + NIP46Method.NIP44_ENCRYPT, thirdPartyPubkey, plaintext, - ]); - - if (response.error) { - throw new NIP46EncryptionError( - `NIP-44 encryption failed: ${response.error}`, - ); - } - - return response.result!; + "NIP-44", + ); } - /** - * Decrypt a message using NIP-44 (preferred) - */ async nip44Decrypt( thirdPartyPubkey: string, ciphertext: string, ): Promise { - const response = await this.sendRequest(NIP46Method.NIP44_DECRYPT, [ + return this.decryptionRequest( + NIP46Method.NIP44_DECRYPT, thirdPartyPubkey, ciphertext, - ]); - - if (response.error) { - throw new NIP46DecryptionError( - `NIP-44 decryption failed: ${response.error}`, - ); - } - - return response.result!; + "NIP-44", + ); } - /** - * Encrypt a message using NIP-04 (legacy support) - */ async nip04Encrypt( thirdPartyPubkey: string, plaintext: string, ): Promise { - const response = await this.sendRequest(NIP46Method.NIP04_ENCRYPT, [ + return this.encryptionRequest( + NIP46Method.NIP04_ENCRYPT, thirdPartyPubkey, plaintext, - ]); - - if (response.error) { - throw new NIP46EncryptionError( - `NIP-04 encryption failed: ${response.error}`, - ); - } - - return response.result!; + "NIP-04", + ); } - /** - * Decrypt a message using NIP-04 (legacy support) - */ async nip04Decrypt( thirdPartyPubkey: string, ciphertext: string, ): Promise { - const response = await this.sendRequest(NIP46Method.NIP04_DECRYPT, [ + return this.decryptionRequest( + NIP46Method.NIP04_DECRYPT, thirdPartyPubkey, ciphertext, - ]); - - if (response.error) { - throw new NIP46DecryptionError( - `NIP-04 decryption failed: ${response.error}`, - ); - } - - return response.result!; + "NIP-04", + ); } - /** - * Get the relay list from the remote signer - */ async getRelays(): Promise { - const response = await this.sendRequest(NIP46Method.GET_RELAYS, []); - return JSON.parse(response.result!); + const response = await this.engine.request(NIP46Method.GET_RELAYS, []); + if (response.error) { + throw new NIP46Error(`Failed to get relays: ${response.error}`); + } + return JSON.parse(response.result!) as string[]; } - /** - * Disconnect from the remote signer - */ async disconnect(): Promise { - // Send disconnect request to bunker if connected - if (this.signerPubkey && this.clientKeys.privateKey) { - try { - await this.sendRequest(NIP46Method.DISCONNECT, []); - this.logger.debug("Disconnect request sent to bunker"); - } catch (e) { - // Ignore disconnect request errors - continue with cleanup - this.logger.warn( - "Failed to send disconnect request:", - e instanceof Error ? e.message : String(e), - ); - } - } - - // First cancel subscription - if (this.subId) { - try { - this.nostr.unsubscribe([this.subId]); - } catch (e) { - // Ignore unsubscribe errors - } - this.subId = null; - } - - // Cancel any pending requests with errors - for (const [id, handler] of this.pendingRequests.entries()) { - try { - handler({ - id, - error: "Client disconnected", - }); - } catch (e) { - // Ignore errors during cleanup - } - } - this.pendingRequests.clear(); - - try { - // Disconnect from relays - await this.nostr.disconnectFromRelays(); - } catch (e) { - const errorMessage = e instanceof Error ? e.message : String(e); - this.logger.warn("Error disconnecting from relays:", errorMessage); - // Continue despite disconnection errors - } - - // Reset connection state - this.userPubkey = null; - this.signerPubkey = null; - - // Add a small delay to ensure all connections are closed - await new Promise((resolve) => setTimeout(resolve, 500).unref()); + await this.engine.disconnect(); } - /** - * Send a request to the remote signer - * - * @param method - The request method - * @param params - The request parameters - * @returns A promise that resolves with the response - */ - private async sendRequest( - method: NIP46Method, - params: string[], - ): Promise { - return new Promise((resolve, reject) => { - // Check if we have valid keys - if (!this.clientKeys.privateKey) { - reject(new NIP46ConnectionError("Client private key not set")); - return; - } - - if (!this.signerPubkey) { - reject(new NIP46ConnectionError("Signer public key not set")); - return; - } - - // Create the request - const request: NIP46Request = { - id: generateRequestId(), - method, - params, - }; - - this.logger.debug(`Sending ${method} request: ${request.id}`); - this.logger.trace(`JSON payload: ${JSON.stringify(request)}`); - - // Set up timeout - const timeoutId = setTimeout(() => { - this.pendingRequests.delete(request.id); - reject(new NIP46TimeoutError(`Request timed out: ${method}`)); - }, this.timeout).unref(); // Don't keep process alive - - // Store the promise handlers with timeout cleanup - this.pendingRequests.set(request.id, (response: NIP46Response) => { - clearTimeout(timeoutId); - if (response.error) { - reject(new NIP46Error(response.error)); - } else { - resolve(response); - } - }); - - // Encrypt and send the request - try { - const encrypted = encryptNIP44( - JSON.stringify(request), - this.clientKeys.privateKey, - this.signerPubkey, - ); - - // Create the event without id and sig - const eventData: Omit = { - kind: 24133, - pubkey: this.clientKeys.publicKey, - created_at: getUnixTime(), - tags: [["p", this.signerPubkey]], - content: encrypted, - }; - - // Create a properly signed event using promises - createSignedEvent(eventData, this.clientKeys.privateKey) - .then((signedEvent: NostrEvent) => { - // Then publish the signed event - return this.nostr.publishEvent(signedEvent); - }) - .then((publishResult) => { - if (!publishResult.success) { - let reasonMessage = "unknown reason"; - if (publishResult.relayResults) { - for (const relayResult of publishResult.relayResults.values()) { - if (!relayResult.success && relayResult.reason) { - reasonMessage = relayResult.reason; - break; - } - } - } - throw new NIP46ConnectionError( - `Relay rejected event: ${reasonMessage}`, - ); - } - }) - .catch((err) => { - clearTimeout(timeoutId); - this.pendingRequests.delete(request.id); - const errorMessage = - err instanceof Error ? err.message : String(err); - reject( - new NIP46ConnectionError( - `Failed to sign or publish event: ${errorMessage}`, - ), - ); - }); - } catch (error) { - clearTimeout(timeoutId); - this.pendingRequests.delete(request.id); - const errorMessage = - error instanceof Error ? error.message : String(error); - reject( - new NIP46EncryptionError( - `Failed to encrypt request: ${errorMessage}`, - ), - ); - } - }); + setLogLevel(level: LogLevel): void { + this.logger.setLevel(level); } - /** - * Handle a response from the remote signer - * - * @param event - The response event - */ - private handleResponse(event: NostrEvent): void { - this.logger.debug(`Received response from signer:`); - - // Check if the event is from our signer - if (this.signerPubkey && event.pubkey !== this.signerPubkey) { - this.logger.warn( - `Received response from unexpected pubkey: ${event.pubkey}`, + private async encryptionRequest( + method: NIP46Method, + pubkey: string, + plaintext: string, + label: string, + ): Promise { + const response = await this.engine.request(method, [pubkey, plaintext]); + if (response.error) { + throw new NIP46EncryptionError( + `${label} encryption failed: ${response.error}`, ); - return; - } - - // Ensure we have our client keys - if (!this.clientKeys.privateKey) { - this.logger.error(`Cannot decrypt response: client private key not set`); - return; } + return response.result!; + } - try { - // Decrypt the content using NIP-44 - const decrypted = decryptNIP44( - event.content, - this.clientKeys.privateKey, - event.pubkey, + private async decryptionRequest( + method: NIP46Method, + pubkey: string, + ciphertext: string, + label: string, + ): Promise { + const response = await this.engine.request(method, [pubkey, ciphertext]); + if (response.error) { + throw new NIP46DecryptionError( + `${label} decryption failed: ${response.error}`, ); - this.logger.debug(`Decrypted content: ${decrypted}`); - - // Parse the response - const response: NIP46Response = JSON.parse(decrypted); - - // Check if we have a handler for this response - const handler = this.pendingRequests.get(response.id); - if (handler) { - this.logger.debug(`Processing response for request: ${response.id}`); - - // Remove the handler - this.pendingRequests.delete(response.id); - - // Call the handler - handler(response); - } else { - this.logger.warn( - `Received response for unknown request: ${response.id}`, - ); - } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to process response:`, errorMessage); } + return response.result!; } - /** - * Set the log level - */ - setLogLevel(level: LogLevel): void { - this.logger.setLevel(level); + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } } diff --git a/src/nip46/types.ts b/src/nip46/types.ts index 43d8641f..46d271bb 100644 --- a/src/nip46/types.ts +++ b/src/nip46/types.ts @@ -3,6 +3,7 @@ */ import type { RateLimitConfig } from "./utils/rate-limiter"; +import type { DiagnosticLogger } from "../utils/logger"; export interface NIP46Request { id: string; @@ -95,6 +96,8 @@ export interface NIP46ClientOptions extends NIP46ConnectionOptions { debug?: boolean; authTimeout?: number; // Auth challenge timeout in milliseconds authDomainWhitelist?: string[]; // Allowed domains for auth URLs + /** Receives redacted NIP-46 diagnostics. The logger controls its own level. */ + logger?: DiagnosticLogger; } /** @@ -112,6 +115,8 @@ export interface NIP46BunkerOptions { metadata?: NIP46Metadata; debug?: boolean; rateLimitConfig?: RateLimitConfig; + /** Receives redacted NIP-46 diagnostics. The logger controls its own level. */ + logger?: DiagnosticLogger; } /** @@ -327,6 +332,8 @@ export interface SimpleNIP46BunkerOptions { defaultPermissions?: string[]; secret?: string; debug?: boolean; + /** Receives redacted NIP-46 diagnostics. The logger controls its own level. */ + logger?: DiagnosticLogger; } /** @@ -336,4 +343,6 @@ export interface SimpleNIP46ClientOptions { timeout?: number; logLevel?: number; // Using LogLevel enum debug?: boolean; + /** Receives redacted NIP-46 diagnostics. The logger controls its own level. */ + logger?: DiagnosticLogger; } diff --git a/src/nip46/utils/diagnostics.ts b/src/nip46/utils/diagnostics.ts new file mode 100644 index 00000000..6b707f29 --- /dev/null +++ b/src/nip46/utils/diagnostics.ts @@ -0,0 +1,202 @@ +import { + DiagnosticLogArgument, + DiagnosticLogger, + LogLevel, + Logger, + LoggerOptions, +} from "../../utils/logger"; + +const REDACTED = "[REDACTED]"; +const SENSITIVE_FIELD_NAMES = new Set([ + "authurl", + "ciphertext", + "connectionstring", + "connectresult", + "content", + "data", + "decrypted", + "decrypteddata", + "details", + "error", + "errormessage", + "eventdata", + "message", + "params", + "plaintext", + "privatekey", + "result", + "secret", +]); +const LEGACY_PAYLOAD_MESSAGE = + /^(.*(?:decrypted content|json payload):)[\s\S]*/i; +const LEGACY_RESPONSE_ENVELOPE_MESSAGE = /sending response for request/i; +const CONNECTION_URI = /\b(bunker|nostrconnect):\/\/[^\s"']+/gi; + +function normalizedFieldName(fieldName: string): string { + return fieldName.replace(/[^a-z0-9]/gi, "").toLowerCase(); +} + +function safeDiagnosticLabel(value: unknown, fallback: string): string { + return typeof value === "string" && /^[a-z][a-z0-9_.:-]{0,63}$/i.test(value) + ? value + : fallback; +} + +function redactDiagnosticText(value: string): string { + const withoutConnectionUris = value.replace( + CONNECTION_URI, + (_match, scheme: string) => `${scheme}://${REDACTED}`, + ); + + return withoutConnectionUris.replace( + LEGACY_PAYLOAD_MESSAGE, + (_match, prefix: string) => `${prefix} ${REDACTED}`, + ); +} + +function redactDiagnosticValue( + value: DiagnosticLogArgument, + seen: WeakSet, +): DiagnosticLogArgument { + if (typeof value === "string") { + return redactDiagnosticText(value); + } + + if (value === null || typeof value !== "object") { + return value; + } + + if (seen.has(value)) { + return "[Circular]"; + } + seen.add(value); + + try { + if (value instanceof Error) { + const errorWithMetadata = value as Error & { + code?: unknown; + type?: unknown; + }; + const sanitizedError: Record = { + name: safeDiagnosticLabel(value.name, "Error"), + }; + + if ( + typeof errorWithMetadata.code === "number" || + typeof errorWithMetadata.code === "boolean" + ) { + sanitizedError.code = errorWithMetadata.code; + } else if (typeof errorWithMetadata.code === "string") { + sanitizedError.code = safeDiagnosticLabel( + errorWithMetadata.code, + "UNKNOWN", + ); + } + + if (typeof errorWithMetadata.type === "string") { + sanitizedError.type = safeDiagnosticLabel( + errorWithMetadata.type, + "Error", + ); + } + + return sanitizedError; + } + + if (Array.isArray(value)) { + return value.map((item) => + redactDiagnosticValue(item as DiagnosticLogArgument, seen), + ); + } + + const sanitized: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + if (SENSITIVE_FIELD_NAMES.has(normalizedFieldName(key))) { + sanitized[key] = REDACTED; + continue; + } + + sanitized[key] = redactDiagnosticValue( + nestedValue as DiagnosticLogArgument, + seen, + ); + } + + return sanitized; + } finally { + seen.delete(value); + } +} + +/** + * NIP-46 diagnostic boundary that strips protocol payloads and connection + * secrets before forwarding safe operation metadata to the configured logger. + */ +export class NIP46DiagnosticLogger implements DiagnosticLogger { + private readonly delegate: DiagnosticLogger; + + constructor(delegate: DiagnosticLogger) { + this.delegate = delegate; + } + + static create( + logger: DiagnosticLogger | undefined, + defaultOptions: LoggerOptions, + ): NIP46DiagnosticLogger { + return new NIP46DiagnosticLogger(logger ?? new Logger(defaultOptions)); + } + + private write( + level: keyof DiagnosticLogger, + message: string, + args: DiagnosticLogArgument[], + ): void { + try { + const sanitizedArguments = LEGACY_RESPONSE_ENVELOPE_MESSAGE.test(message) + ? args.map(() => REDACTED) + : args.map((argument) => + (level === "error" || level === "warn") && + typeof argument === "string" + ? REDACTED + : redactDiagnosticValue(argument, new WeakSet()), + ); + this.delegate[level]( + redactDiagnosticText(message), + ...sanitizedArguments, + ); + } catch { + // Diagnostics are observational and must not alter NIP-46 behavior. + } + } + + error(message: string, ...args: DiagnosticLogArgument[]): void { + this.write("error", message, args); + } + + warn(message: string, ...args: DiagnosticLogArgument[]): void { + this.write("warn", message, args); + } + + info(message: string, ...args: DiagnosticLogArgument[]): void { + this.write("info", message, args); + } + + debug(message: string, ...args: DiagnosticLogArgument[]): void { + this.write("debug", message, args); + } + + trace(message: string, ...args: DiagnosticLogArgument[]): void { + this.write("trace", message, args); + } + + setLevel(level: LogLevel): void { + try { + const levelAwareLogger = this.delegate as DiagnosticLogger & { + setLevel?: (nextLevel: LogLevel) => void; + }; + levelAwareLogger.setLevel?.(level); + } catch { + // A custom logger controls its own filtering and cannot alter behavior. + } + } +} diff --git a/src/nip46/utils/rate-limiter.ts b/src/nip46/utils/rate-limiter.ts index 71f1267d..f6d177bf 100644 --- a/src/nip46/utils/rate-limiter.ts +++ b/src/nip46/utils/rate-limiter.ts @@ -35,8 +35,7 @@ export class NIP46RateLimiter { this.burstSize = config.burstSize ?? 10; this.cleanupIntervalMs = config.cleanupIntervalMs ?? 300000; // 5 minutes - // Start cleanup interval - this.startCleanup(); + this.start(); } /** @@ -205,10 +204,9 @@ export class NIP46RateLimiter { history.lastCleanup = now; } - /** - * Start cleanup interval to prevent memory leaks - */ - private startCleanup(): void { + /** Start periodic cleanup if it is not already running. */ + start(): void { + if (this.cleanupInterval) return; this.cleanupInterval = setInterval(() => { this.performCleanup(); }, this.cleanupIntervalMs).unref(); // Don't keep process alive diff --git a/src/nip46/utils/security.ts b/src/nip46/utils/security.ts index 08293f00..64f444e2 100644 --- a/src/nip46/utils/security.ts +++ b/src/nip46/utils/security.ts @@ -2,7 +2,8 @@ * Security validation utilities for NIP-46 */ -import { isValidPrivateKey } from "../../nip44"; +import { isValidPrivateKey } from "../../utils/key-validation"; +import { isHexOfLength } from "../../utils/wire-validation"; import { NIP46SecurityError, NIP46UnsignedEventData } from "../types"; /** @@ -158,7 +159,7 @@ export function validateKeypairForCrypto( } // Basic hex validation for public key (64 chars hex) - if (!/^[0-9a-f]{64}$/i.test(keypair.publicKey)) { + if (!isHexOfLength(keypair.publicKey, 64)) { throw new NIP46SecurityError( `${context} public key must be 64 character hex string`, ); @@ -218,7 +219,7 @@ export function validateEncryptionParams( ); } - if (!/^[0-9a-f]{64}$/i.test(thirdPartyPubkey)) { + if (!isHexOfLength(thirdPartyPubkey, 64)) { throw new NIP46SecurityError( `${operation} third party public key must be 64 character hex string`, ); @@ -370,14 +371,14 @@ export function validateBunkerInitialization(options: { ); } - if (!/^[0-9a-f]{64}$/i.test(options.userPubkey)) { + if (!isHexOfLength(options.userPubkey, 64)) { throw new NIP46SecurityError( "User public key must be 64 character hex string", ); } // Validate signer public key if provided - if (options.signerPubkey && !/^[0-9a-f]{64}$/i.test(options.signerPubkey)) { + if (options.signerPubkey && !isHexOfLength(options.signerPubkey, 64)) { throw new NIP46SecurityError( "Signer public key must be 64 character hex string", ); diff --git a/src/nip46/utils/validator.ts b/src/nip46/utils/validator.ts index 3eb70e72..32b52cd1 100644 --- a/src/nip46/utils/validator.ts +++ b/src/nip46/utils/validator.ts @@ -1,5 +1,6 @@ import { NIP46Request, NIP46Method } from "../types"; import { Logger, LogLevel } from "../../utils/logger"; +import { isHexOfLength, utf8ByteLength } from "../../utils/wire-validation"; /** * Enhanced validation utilities for NIP-46 security @@ -22,7 +23,7 @@ export function validateEventContent(content: string): boolean { } // Check content size limits using UTF-8 byte length - const contentByteLength = new TextEncoder().encode(content).length; + const contentByteLength = utf8ByteLength(content); if (contentByteLength > MAX_CONTENT_SIZE) { return false; } @@ -135,48 +136,28 @@ function validateTags(tags: unknown): boolean { * Validate public key format (strict hex validation) */ export function validatePubkey(pubkey: string): boolean { - if (!pubkey || typeof pubkey !== "string") { - return false; - } - - // Must be exactly 64 characters of hex (case-insensitive) - return /^[0-9a-f]{64}$/i.test(pubkey); + return isHexOfLength(pubkey, 64); } /** * Validate event ID format */ export function validateEventId(eventId: string): boolean { - if (!eventId || typeof eventId !== "string") { - return false; - } - - // Must be exactly 64 characters of hex (case-insensitive) - return /^[0-9a-f]{64}$/i.test(eventId); + return isHexOfLength(eventId, 64); } /** * Validate signature format */ export function validateSignature(signature: string): boolean { - if (!signature || typeof signature !== "string") { - return false; - } - - // Must be exactly 128 characters of hex (case-insensitive) - return /^[0-9a-f]{128}$/i.test(signature); + return isHexOfLength(signature, 128); } /** * Validate private key format (for internal use) */ export function validatePrivateKey(privateKey: string): boolean { - if (!privateKey || typeof privateKey !== "string") { - return false; - } - - // Must be exactly 64 characters of hex (case-insensitive) - return /^[0-9a-f]{64}$/i.test(privateKey); + return isHexOfLength(privateKey, 64); } /** diff --git a/src/nip47/client.ts b/src/nip47/client.ts index 317b87af..d5d500c0 100644 --- a/src/nip47/client.ts +++ b/src/nip47/client.ts @@ -33,73 +33,9 @@ import { import { SecurityValidationError } from "../utils/security-validator"; import { Logger, LogLevel } from "../utils/logger"; import type { DiagnosticLogger } from "../utils/logger"; +import { generateNWCURL, parseNIP47Response, parseNWCURL } from "./protocol"; -/** - * Parse a NWC URL into connection options - * - * Handles URLs like: nostr+walletconnect://pubkey?relay=wss://relay.example.com&secret=xxx - * Uses the URL class for robust parsing of relay URLs that contain "://" - */ -export function parseNWCURL(url: string): NIP47ConnectionOptions { - if (!url.startsWith("nostr+walletconnect://")) { - throw new Error("Invalid NWC URL format"); - } - - // Use URL class for robust parsing - replace custom protocol with https temporarily - // This handles relay URLs containing "://" correctly (e.g., wss://relay.example.com) - const tempUrl = url.replace("nostr+walletconnect://", "https://nwc.temp/"); - let parsed: URL; - - try { - parsed = new URL(tempUrl); - } catch { - throw new Error("Invalid NWC URL format: malformed URL structure"); - } - - // Extract pubkey from pathname (remove leading slash) - const pubkey = parsed.pathname.slice(1); - if (!pubkey) { - throw new Error("Missing pubkey in NWC URL"); - } - - // Parse query parameters using the URL's searchParams - const relays: string[] = []; - parsed.searchParams.getAll("relay").forEach((relay) => relays.push(relay)); - - const secret = parsed.searchParams.get("secret"); - if (!secret) { - throw new Error("Missing secret in NWC URL"); - } - - return { - pubkey, - secret, - relays, - }; -} - -/** - * Generate a NWC URL from connection options - */ -export function generateNWCURL(options: NIP47ConnectionOptions): string { - if (!options.pubkey) { - throw new Error("Missing pubkey in connection options"); - } - - if (!options.secret) { - throw new Error("Missing secret in connection options"); - } - - if (!options.relays || options.relays.length === 0) { - throw new Error("At least one relay must be specified"); - } - - const params = new URLSearchParams(); - options.relays.forEach((relay) => params.append("relay", relay)); - params.append("secret", options.secret); - - return `nostr+walletconnect://${options.pubkey}?${params.toString()}`; -} +export { generateNWCURL, parseNWCURL }; /** * Retry configuration @@ -495,10 +431,14 @@ export class NostrWalletConnectClient { event.kind === NIP47EventKind.NOTIFICATION || event.kind === NIP47EventKind.NOTIFICATION_NIP44 ) { - this.logger.debug(`Processing as NOTIFICATION event (kind ${event.kind})`); + this.logger.debug( + `Processing as NOTIFICATION event (kind ${event.kind})`, + ); await this.handleNotification(event); } else if (event.kind === NIP47EventKind.INFO) { - this.logger.debug(`Processing as INFO event (kind ${NIP47EventKind.INFO})`); + this.logger.debug( + `Processing as INFO event (kind ${NIP47EventKind.INFO})`, + ); this.handleInfoEvent(event); } else { this.logger.debug( @@ -507,100 +447,6 @@ export class NostrWalletConnectClient { } } - /** - * Validate that a response follows the NIP-47 specification structure - */ - private validateResponse(response: unknown): NIP47Response { - // First check if response is an object - if (!response || typeof response !== "object") { - throw new NIP47ClientError( - "Invalid response: not an object", - NIP47ErrorCode.INVALID_REQUEST, - ); - } - - // Cast to a more specific type for property access - const resp = response as Record; - - // Check if result_type exists and is a string - if (!resp.result_type || typeof resp.result_type !== "string") { - throw new NIP47ClientError( - "Invalid response: missing or invalid result_type", - NIP47ErrorCode.INVALID_REQUEST, - ); - } - - // Verify result_type is a known NIP47Method - if (!Object.values(NIP47Method).includes(resp.result_type as NIP47Method)) { - throw new NIP47ClientError( - `Invalid response: unknown result_type '${resp.result_type}'`, - NIP47ErrorCode.INVALID_REQUEST, - ); - } - - // Check error field - per NIP-47 spec it should be null on success, but some - // wallet implementations omit it entirely. Default to null if not present. - const hasErrorField = "error" in resp; - const errorValue = hasErrorField ? resp.error : null; - - // If error is not null, validate its structure - if (errorValue !== null) { - if (typeof errorValue !== "object") { - throw new NIP47ClientError( - "Invalid response: error field must be an object or null", - NIP47ErrorCode.INVALID_REQUEST, - ); - } - - const error = errorValue as Record; - - // Check error has code and message - if (!error.code || typeof error.code !== "string") { - throw new NIP47ClientError( - "Invalid response: error must have a code field", - NIP47ErrorCode.INVALID_REQUEST, - ); - } - - if (!error.message || typeof error.message !== "string") { - throw new NIP47ClientError( - "Invalid response: error must have a message field", - NIP47ErrorCode.INVALID_REQUEST, - ); - } - - // When there's an error, result should be null - if (resp.result !== null) { - throw new NIP47ClientError( - "Invalid response: when error is present, result must be null", - NIP47ErrorCode.INVALID_REQUEST, - ); - } - } - - // If no error, result should be defined and not null - if ( - errorValue === null && - (resp.result === null || resp.result === undefined) - ) { - throw new NIP47ClientError( - "Invalid response: when error is null, result must be defined and not null", - NIP47ErrorCode.INVALID_REQUEST, - ); - } - - // Normalize missing error field to null to keep runtime data aligned with - // the NIP47Response type contract and spec semantics. - if (!hasErrorField) { - return { - ...(response as Omit), - error: null, - }; - } - - return response as NIP47Response; - } - /** * Handle response events */ @@ -659,19 +505,18 @@ export class NostrWalletConnectClient { ); } - const response = JSON.parse(decrypted); - - // Validate response structure - this.validateResponse(response); - - this.logger.debug( - `Validated response of type: ${(response as NIP47Response).result_type}`, + const response = parseNIP47Response( + decrypted, + (message) => + new NIP47ClientError(message, NIP47ErrorCode.INVALID_REQUEST), ); + this.logger.debug(`Validated response of type: ${response.result_type}`); + this.logger.debug("Correlated response with a pending request"); // Resolve the pending request - pendingRequest.resolve(response as NIP47Response); + pendingRequest.resolve(response); this.pendingRequests.delete(requestId); this.logger.debug("Pending request resolved successfully"); } catch (error) { @@ -1012,8 +857,8 @@ export class NostrWalletConnectClient { info.notifications ?? this.supportedNotifications; const nextEncryption = info.encryption ? info.encryption - .map((s) => s as NIP47EncryptionScheme) - .filter((s) => Object.values(NIP47EncryptionScheme).includes(s)) + .map((s) => s as NIP47EncryptionScheme) + .filter((s) => Object.values(NIP47EncryptionScheme).includes(s)) : [NIP47EncryptionScheme.NIP04]; // Commit the capability snapshot only after every field validates. diff --git a/src/nip47/protocol.ts b/src/nip47/protocol.ts new file mode 100644 index 00000000..3075da51 --- /dev/null +++ b/src/nip47/protocol.ts @@ -0,0 +1,143 @@ +import { + NIP47ConnectionOptions, + NIP47Method, + NIP47Request, + NIP47Response, +} from "./types"; + +export function parseNWCURL(url: string): NIP47ConnectionOptions { + if (!url.startsWith("nostr+walletconnect://")) { + throw new Error("Invalid NWC URL format"); + } + + let parsed: URL; + try { + parsed = new URL( + url.replace("nostr+walletconnect://", "https://nwc.temp/"), + ); + } catch { + throw new Error("Invalid NWC URL format: malformed URL structure"); + } + + const pubkey = parsed.pathname.slice(1); + if (!pubkey) throw new Error("Missing pubkey in NWC URL"); + const secret = parsed.searchParams.get("secret"); + if (!secret) throw new Error("Missing secret in NWC URL"); + const relays = parsed.searchParams.getAll("relay"); + if (relays.length === 0) { + throw new Error("At least one relay must be specified"); + } + + return { pubkey, secret, relays }; +} + +export function generateNWCURL(options: NIP47ConnectionOptions): string { + if (!options.pubkey) throw new Error("Missing pubkey in connection options"); + if (!options.secret) throw new Error("Missing secret in connection options"); + if (!options.relays || options.relays.length === 0) { + throw new Error("At least one relay must be specified"); + } + + const params = new URLSearchParams(); + options.relays.forEach((relay) => params.append("relay", relay)); + params.append("secret", options.secret); + return `nostr+walletconnect://${options.pubkey}?${params.toString()}`; +} + +export function validateNIP47Response( + response: unknown, + invalid: (message: string) => Error, +): NIP47Response { + if (!response || typeof response !== "object") { + throw invalid("Invalid response: not an object"); + } + const value = response as Record; + if (!value.result_type || typeof value.result_type !== "string") { + throw invalid("Invalid response: missing or invalid result_type"); + } + if (!Object.values(NIP47Method).includes(value.result_type as NIP47Method)) { + throw invalid( + `Invalid response: unknown result_type '${value.result_type}'`, + ); + } + + const hasError = "error" in value; + const errorValue = hasError ? value.error : null; + if (errorValue !== null) { + if (!errorValue || typeof errorValue !== "object") { + throw invalid("Invalid response: error field must be an object or null"); + } + const error = errorValue as Record; + if (!error.code || typeof error.code !== "string") { + throw invalid("Invalid response: error must have a code field"); + } + if (!error.message || typeof error.message !== "string") { + throw invalid("Invalid response: error must have a message field"); + } + if (value.result !== null) { + throw invalid( + "Invalid response: when error is present, result must be null", + ); + } + } else if (value.result === null || value.result === undefined) { + throw invalid( + "Invalid response: when error is null, result must be defined and not null", + ); + } + + return hasError + ? (response as NIP47Response) + : ({ + ...(response as Omit), + error: null, + } as NIP47Response); +} + +export class NIP47RequestParseError extends Error { + constructor(message: string) { + super(message); + this.name = "NIP47RequestParseError"; + } +} + +export function parseNIP47Request(content: string): NIP47Request { + let request: unknown; + try { + request = JSON.parse(content); + } catch { + throw new NIP47RequestParseError("Invalid request: malformed JSON"); + } + if (!request || typeof request !== "object") { + throw new NIP47RequestParseError("Invalid request: not an object"); + } + const value = request as Record; + if (typeof value.method !== "string" || !value.method) { + throw new NIP47RequestParseError( + "Invalid request: missing or invalid method", + ); + } + if ( + !value.params || + typeof value.params !== "object" || + Array.isArray(value.params) + ) { + throw new NIP47RequestParseError( + "Invalid request: missing or invalid params", + ); + } + return request as NIP47Request; +} + +export function parseNIP47Response( + content: string, + invalid: (message: string) => Error, +): NIP47Response { + try { + return validateNIP47Response(JSON.parse(content), invalid); + } catch (error) { + if (error instanceof SyntaxError) { + throw invalid("Invalid response: malformed JSON"); + } + throw error; + } +} diff --git a/src/nip47/requestDispatcher.ts b/src/nip47/requestDispatcher.ts new file mode 100644 index 00000000..d0033ea8 --- /dev/null +++ b/src/nip47/requestDispatcher.ts @@ -0,0 +1,192 @@ +import { + ERROR_CATEGORIES, + ERROR_RECOVERY_HINTS, + ListTransactionsParams, + LookupInvoiceParams, + MakeInvoiceParams, + NIP47EncryptionScheme, + NIP47ErrorCode, + NIP47Method, + NIP47Request, + NIP47Response, + NIP47ResponseResult, + PayInvoiceParams, + SignMessageParams, + WalletImplementation, +} from "./types"; + +export interface NIP47RequestDispatcherOptions { + wallet: WalletImplementation; + supportedMethods: readonly NIP47Method[]; + supportedEncryption: readonly NIP47EncryptionScheme[]; +} + +function errorResponse( + method: NIP47Method, + code: NIP47ErrorCode, + message: string, + data?: Record, +): NIP47Response { + return { + result_type: method, + result: null, + error: { + code, + message, + category: ERROR_CATEGORIES[code], + recoveryHint: ERROR_RECOVERY_HINTS[code], + data, + }, + }; +} + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +export async function dispatchNIP47Request( + request: NIP47Request, + options: NIP47RequestDispatcherOptions, +): Promise { + const { wallet, supportedMethods, supportedEncryption } = options; + if (!supportedMethods.includes(request.method)) { + return errorResponse( + request.method, + NIP47ErrorCode.INVALID_REQUEST, + `Method ${request.method} not supported`, + ); + } + + const invalid = (message: string) => + errorResponse(request.method, NIP47ErrorCode.INVALID_REQUEST, message); + + try { + let result: NIP47ResponseResult; + switch (request.method) { + case NIP47Method.GET_INFO: + result = { + ...(await wallet.getInfo()), + encryption: [...supportedEncryption], + }; + break; + case NIP47Method.GET_BALANCE: + result = await wallet.getBalance(); + break; + case NIP47Method.PAY_INVOICE: { + const params = request.params as PayInvoiceParams; + if ( + !isObject(params) || + typeof params.invoice !== "string" || + (params.amount !== undefined && typeof params.amount !== "number") || + (params.maxfee !== undefined && typeof params.maxfee !== "number") + ) + return invalid("Invalid parameters for pay_invoice method"); + result = await wallet.payInvoice( + params.invoice, + params.amount, + params.maxfee, + ); + break; + } + case NIP47Method.MAKE_INVOICE: { + const params = request.params as MakeInvoiceParams; + if ( + !isObject(params) || + typeof params.amount !== "number" || + (params.description !== undefined && + typeof params.description !== "string") || + (params.description_hash !== undefined && + typeof params.description_hash !== "string") || + (params.expiry !== undefined && typeof params.expiry !== "number") + ) + return invalid("Invalid parameters for make_invoice method"); + result = await wallet.makeInvoice( + params.amount, + params.description, + params.description_hash, + params.expiry, + ); + break; + } + case NIP47Method.LOOKUP_INVOICE: { + const params = request.params as LookupInvoiceParams; + if ( + !isObject(params) || + (params.payment_hash !== undefined && + typeof params.payment_hash !== "string") || + (params.invoice !== undefined && + typeof params.invoice !== "string") || + (typeof params.payment_hash !== "string" && + typeof params.invoice !== "string") + ) + return invalid("Invalid parameters for lookup_invoice method"); + try { + result = await wallet.lookupInvoice({ + payment_hash: params.payment_hash, + invoice: params.invoice, + }); + } catch (error) { + const known = error as { code?: NIP47ErrorCode; message?: string }; + if (known.code !== NIP47ErrorCode.NOT_FOUND) throw error; + const lookupType = params.payment_hash ? "payment_hash" : "invoice"; + const lookupValue = params.payment_hash || params.invoice; + return errorResponse( + request.method, + NIP47ErrorCode.NOT_FOUND, + `Invoice not found: Could not find ${lookupType}: ${lookupValue} in the wallet's database`, + ); + } + break; + } + case NIP47Method.LIST_TRANSACTIONS: { + const params = request.params as ListTransactionsParams; + if ( + !isObject(params) || + (params.from !== undefined && typeof params.from !== "number") || + (params.until !== undefined && typeof params.until !== "number") || + (params.limit !== undefined && typeof params.limit !== "number") || + (params.offset !== undefined && typeof params.offset !== "number") || + (params.unpaid !== undefined && typeof params.unpaid !== "boolean") || + (params.type !== undefined && typeof params.type !== "string") + ) + return invalid("Invalid parameters for list_transactions method"); + result = { + transactions: await wallet.listTransactions( + params.from, + params.until, + params.limit, + params.offset, + params.unpaid, + params.type, + ), + }; + break; + } + case NIP47Method.SIGN_MESSAGE: { + const params = request.params as SignMessageParams; + if (!isObject(params) || typeof params.message !== "string") { + return invalid("Invalid parameters for sign_message method"); + } + if (!wallet.signMessage) { + return invalid("sign_message method not implemented by wallet"); + } + result = await wallet.signMessage(params.message); + break; + } + default: + return invalid(`Method ${request.method} not supported`); + } + return { result_type: request.method, result, error: null }; + } catch (error) { + const known = error as { + code?: NIP47ErrorCode; + message?: string; + data?: Record; + }; + return errorResponse( + request.method, + known.code || NIP47ErrorCode.INTERNAL_ERROR, + known.message || "An error occurred processing the request", + known.data, + ); + } +} diff --git a/src/nip47/service.ts b/src/nip47/service.ts index c3d65d08..bc5d1b75 100644 --- a/src/nip47/service.ts +++ b/src/nip47/service.ts @@ -17,12 +17,6 @@ import { ERROR_CATEGORIES, ERROR_RECOVERY_HINTS, NIP47NotificationType, - NIP47RequestParams, - PayInvoiceParams, - MakeInvoiceParams, - LookupInvoiceParams, - ListTransactionsParams, - SignMessageParams, NIP47ResponseResult, NIP47EncryptionScheme, } from "./types"; @@ -34,6 +28,8 @@ import { import { maybeUnref } from "../utils/timers"; import { Logger, LogLevel } from "../utils/logger"; import type { DiagnosticLogger } from "../utils/logger"; +import { dispatchNIP47Request } from "./requestDispatcher"; +import { NIP47RequestParseError, parseNIP47Request } from "./protocol"; /** * TTL Map implementation with automatic cleanup @@ -106,10 +102,15 @@ class TTLMap { } private startCleanup(): void { + if (this.cleanupInterval) return; this.cleanupInterval = setInterval(() => this.cleanup(), 30000); maybeUnref(this.cleanupInterval); } + start(): void { + this.startCleanup(); + } + destroy(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); @@ -189,6 +190,10 @@ export class NostrWalletService { private subIds: string[] = []; private authorizedClients: string[] = []; private requestEncryption: TTLMap; // Track encryption per request with TTL + private initialized = false; + private initializationPromise: Promise | null = null; + private disconnectionPromise: Promise | null = null; + private lifecycleGeneration = 0; constructor( options: NostrWalletServiceOptions, @@ -236,26 +241,124 @@ export class NostrWalletService { /** * Initialize the service, connect to relays, and publish capabilities */ - public async init(): Promise { - // Connect to relays - await this.client.connectToRelays(); - this.logger.info("Service connected to configured relays"); - - // Set up subscription to receive requests - this.setupSubscription(); - this.logger.info("Service subscribed to requests"); - - // Publish info event - await this.publishInfoEvent(); - this.logger.info( - `Service published info event with methods: ${this.supportedMethods.join(", ")}`, - ); + public init(): Promise { + if (this.initialized) return Promise.resolve(); + if (this.initializationPromise) return this.initializationPromise; + + const generation = this.lifecycleGeneration; + const initialization = this.initialize(generation).finally(() => { + if (this.initializationPromise === initialization) { + this.initializationPromise = null; + } + }); + this.initializationPromise = initialization; + return initialization; + } + + /** Perform one initialization attempt after any active disconnect completes. */ + private async initialize(generation: number): Promise { + if (this.disconnectionPromise) { + await this.disconnectionPromise; + } + this.assertInitializationCurrent(generation); + if (this.initialized) return; + + let attemptSubIds: string[] = []; + + try { + this.requestEncryption.start(); + + await this.client.connectToRelays(); + this.assertInitializationCurrent(generation); + this.logger.info("Service connected to configured relays"); + + attemptSubIds = this.setupSubscription(generation); + this.subIds = attemptSubIds; + this.assertInitializationCurrent(generation); + this.logger.info("Service subscribed to requests"); + + await this.publishInfoEvent(); + this.assertInitializationCurrent(generation); + this.logger.info( + `Service published info event with methods: ${this.supportedMethods.join(", ")}`, + ); + this.initialized = true; + } catch (error) { + if (attemptSubIds.length > 0) { + this.client.unsubscribe(attemptSubIds); + } + if (this.subIds === attemptSubIds) { + this.subIds = []; + } + if (generation === this.lifecycleGeneration) { + this.initialized = false; + this.requestEncryption.destroy(); + try { + await this.client.disconnectFromRelays(); + } catch (disconnectError) { + this.logger.error( + "Error cleaning up failed service initialization:", + disconnectError instanceof Error + ? disconnectError + : String(disconnectError), + ); + } + } + throw error; + } + } + + /** Reject an initialization attempt invalidated by disconnect. */ + private assertInitializationCurrent(generation: number): void { + if (generation !== this.lifecycleGeneration) { + throw new Error("Service initialization cancelled by disconnect"); + } } /** * Disconnect from relays */ - public async disconnect(): Promise { + public disconnect(): Promise { + if (this.disconnectionPromise) { + if (this.initializationPromise) { + this.lifecycleGeneration += 1; + const invalidatedInitialization = this.initializationPromise; + this.initializationPromise = null; + this.observeInvalidatedInitialization(invalidatedInitialization); + } + return this.disconnectionPromise; + } + + this.lifecycleGeneration += 1; + this.initialized = false; + const invalidatedInitialization = this.initializationPromise; + this.initializationPromise = null; + this.observeInvalidatedInitialization(invalidatedInitialization); + + const disconnection = this.performDisconnect(invalidatedInitialization).finally( + () => { + if (this.disconnectionPromise === disconnection) { + this.disconnectionPromise = null; + } + }, + ); + this.disconnectionPromise = disconnection; + return disconnection; + } + + /** Prevent an expected cancellation from becoming an unhandled rejection. */ + private observeInvalidatedInitialization( + initialization: Promise | null, + ): void { + void initialization?.catch(() => { + // Awaiters still receive the original rejection from initialization. + }); + } + + /** Release all service-owned lifecycle resources. */ + private async performDisconnect( + invalidatedInitialization: Promise | null, + ): Promise { try { // Clean up TTL map this.requestEncryption.destroy(); @@ -276,8 +379,16 @@ export class NostrWalletService { ); } + if (invalidatedInitialization) { + try { + await invalidatedInitialization; + } catch { + // Disconnect intentionally invalidates an in-flight initialization. + } + } + // Short delay to allow any other cleanup to complete - return new Promise((resolve) => { + await new Promise((resolve) => { const t = setTimeout(resolve, 100); maybeUnref(t); }); @@ -334,14 +445,15 @@ export class NostrWalletService { /** * Set up subscription to receive requests */ - private setupSubscription(): void { + private setupSubscription(generation: number): string[] { // Subscribe to request events directed at this service const filter = { kinds: [NIP47EventKind.REQUEST], "#p": [this.pubkey], }; - this.subIds = this.client.subscribe([filter], (event: NostrEvent) => { + return this.client.subscribe([filter], (event: NostrEvent) => { + if (generation !== this.lifecycleGeneration) return; this.handleEvent(event); }); } @@ -419,7 +531,9 @@ export class NostrWalletService { } } catch (error) { if (error instanceof SecurityValidationError) { - this.logger.warn("NIP-47: Bounds checking error in expiration parsing"); + this.logger.warn( + "NIP-47: Bounds checking error in expiration parsing", + ); } } @@ -549,9 +663,7 @@ export class NostrWalletService { event.content, ); } - this.logger.trace( - "Successfully decrypted request content", - ); + this.logger.trace("Successfully decrypted request content"); } catch (decryptError) { this.logger.error( "Failed to decrypt message:", @@ -562,7 +674,7 @@ export class NostrWalletService { return; } - nip47Request = JSON.parse(decryptedContent) as NIP47Request; + nip47Request = parseNIP47Request(decryptedContent); // Now that we have the request method, we can use it if an expiration occurs during processing @@ -640,7 +752,9 @@ export class NostrWalletService { await this.sendErrorResponse( requesterClientPubkey, this.pubkey, - NIP47ErrorCode.INTERNAL_ERROR, + error instanceof NIP47RequestParseError + ? NIP47ErrorCode.INVALID_REQUEST + : NIP47ErrorCode.INTERNAL_ERROR, error instanceof Error ? error.message : "Internal server error", event.id, methodForError, @@ -759,268 +873,13 @@ export class NostrWalletService { }; } - /** - * Type guard for PayInvoiceParams - */ - private isPayInvoiceParams( - method: NIP47Method, - params: NIP47RequestParams, - ): params is PayInvoiceParams { - return ( - method === NIP47Method.PAY_INVOICE && - typeof params === "object" && - params !== null && - typeof (params as PayInvoiceParams).invoice === "string" && - ((params as PayInvoiceParams).amount === undefined || - typeof (params as PayInvoiceParams).amount === "number") && - ((params as PayInvoiceParams).maxfee === undefined || - typeof (params as PayInvoiceParams).maxfee === "number") - ); - } - - /** - * Type guard for MakeInvoiceParams - */ - private isMakeInvoiceParams( - method: NIP47Method, - params: NIP47RequestParams, - ): params is MakeInvoiceParams { - return ( - method === NIP47Method.MAKE_INVOICE && - typeof params === "object" && - params !== null && - typeof (params as MakeInvoiceParams).amount === "number" && - ((params as MakeInvoiceParams).description === undefined || - typeof (params as MakeInvoiceParams).description === "string") && - ((params as MakeInvoiceParams).description_hash === undefined || - typeof (params as MakeInvoiceParams).description_hash === "string") && - ((params as MakeInvoiceParams).expiry === undefined || - typeof (params as MakeInvoiceParams).expiry === "number") - ); - } - - /** - * Type guard for LookupInvoiceParams - */ - private isLookupInvoiceParams( - method: NIP47Method, - params: NIP47RequestParams, - ): params is LookupInvoiceParams { - return ( - method === NIP47Method.LOOKUP_INVOICE && - typeof params === "object" && - params !== null && - // Must have at least one of payment_hash or invoice - (typeof (params as LookupInvoiceParams).payment_hash === "string" || - typeof (params as LookupInvoiceParams).invoice === "string") - ); - } - - /** - * Type guard for ListTransactionsParams - */ - private isListTransactionsParams( - method: NIP47Method, - params: NIP47RequestParams, - ): params is ListTransactionsParams { - return ( - method === NIP47Method.LIST_TRANSACTIONS && - typeof params === "object" && - params !== null && - ((params as ListTransactionsParams).from === undefined || - typeof (params as ListTransactionsParams).from === "number") && - ((params as ListTransactionsParams).until === undefined || - typeof (params as ListTransactionsParams).until === "number") && - ((params as ListTransactionsParams).limit === undefined || - typeof (params as ListTransactionsParams).limit === "number") && - ((params as ListTransactionsParams).offset === undefined || - typeof (params as ListTransactionsParams).offset === "number") && - ((params as ListTransactionsParams).unpaid === undefined || - typeof (params as ListTransactionsParams).unpaid === "boolean") && - ((params as ListTransactionsParams).type === undefined || - typeof (params as ListTransactionsParams).type === "string") - ); - } - - /** - * Type guard for SignMessageParams - */ - private isSignMessageParams( - method: NIP47Method, - params: NIP47RequestParams, - ): params is SignMessageParams { - return ( - method === NIP47Method.SIGN_MESSAGE && - typeof params === "object" && - params !== null && - typeof (params as SignMessageParams).message === "string" - ); - } - - /** - * Handle a request and produce a response - */ - private async handleRequest(request: NIP47Request): Promise { - // Check if method is supported - if (!this.supportedMethods.includes(request.method)) { - return this.createErrorResponse( - request.method, - NIP47ErrorCode.INVALID_REQUEST, - `Method ${request.method} not supported`, - ); - } - - try { - let result; - - // Execute the appropriate method - switch (request.method) { - case NIP47Method.GET_INFO: - result = await this.walletImpl.getInfo(); - // Add encryption information to the result - result = { - ...result, - encryption: this.supportedEncryption, - }; - break; - - case NIP47Method.GET_BALANCE: - result = await this.walletImpl.getBalance(); - break; - - case NIP47Method.PAY_INVOICE: - if (this.isPayInvoiceParams(request.method, request.params)) { - result = await this.walletImpl.payInvoice( - request.params.invoice, - request.params.amount, - request.params.maxfee, - ); - } else { - return this.createErrorResponse( - request.method, - NIP47ErrorCode.INVALID_REQUEST, - "Invalid parameters for pay_invoice method", - ); - } - break; - - case NIP47Method.MAKE_INVOICE: - if (this.isMakeInvoiceParams(request.method, request.params)) { - result = await this.walletImpl.makeInvoice( - request.params.amount, - request.params.description, - request.params.description_hash, - request.params.expiry, - ); - } else { - return this.createErrorResponse( - request.method, - NIP47ErrorCode.INVALID_REQUEST, - "Invalid parameters for make_invoice method", - ); - } - break; - - case NIP47Method.LOOKUP_INVOICE: - if (this.isLookupInvoiceParams(request.method, request.params)) { - try { - result = await this.walletImpl.lookupInvoice({ - payment_hash: request.params.payment_hash, - invoice: request.params.invoice, - }); - } catch (error: unknown) { - const err = error as { - code?: NIP47ErrorCode; - message?: string; - data?: Record; - }; - // Enhance NOT_FOUND errors with more context for lookupInvoice - if (err.code === NIP47ErrorCode.NOT_FOUND) { - const lookupType = request.params.payment_hash - ? "payment_hash" - : "invoice"; - const lookupValue = - request.params.payment_hash || request.params.invoice; - - return this.createErrorResponse( - request.method, - NIP47ErrorCode.NOT_FOUND, - `Invoice not found: Could not find ${lookupType}: ${lookupValue} in the wallet's database`, - ); - } - throw error; - } - } else { - return this.createErrorResponse( - request.method, - NIP47ErrorCode.INVALID_REQUEST, - "Invalid parameters for lookup_invoice method", - ); - } - break; - - case NIP47Method.LIST_TRANSACTIONS: { - if (this.isListTransactionsParams(request.method, request.params)) { - const transactions = await this.walletImpl.listTransactions( - request.params.from, - request.params.until, - request.params.limit, - request.params.offset, - request.params.unpaid, - request.params.type, - ); - result = { transactions }; - } else { - return this.createErrorResponse( - request.method, - NIP47ErrorCode.INVALID_REQUEST, - "Invalid parameters for list_transactions method", - ); - } - break; - } - - case NIP47Method.SIGN_MESSAGE: - if (this.isSignMessageParams(request.method, request.params)) { - if (!this.walletImpl.signMessage) { - return this.createErrorResponse( - request.method, - NIP47ErrorCode.INVALID_REQUEST, - "sign_message method not implemented by wallet", - ); - } - result = await this.walletImpl.signMessage(request.params.message); - } else { - return this.createErrorResponse( - request.method, - NIP47ErrorCode.INVALID_REQUEST, - "Invalid parameters for sign_message method", - ); - } - break; - - default: - return this.createErrorResponse( - request.method, - NIP47ErrorCode.INVALID_REQUEST, - `Method ${request.method} not supported`, - ); - } - - return this.createSuccessResponse(request.method, result); - } catch (error: unknown) { - const err = error as { - code?: NIP47ErrorCode; - message?: string; - data?: Record; - }; - return this.createErrorResponse( - request.method, - err.code || NIP47ErrorCode.INTERNAL_ERROR, - err.message || "An error occurred processing the request", - err.data, - ); - } + /** Delegate pure parameter validation and wallet invocation. */ + private handleRequest(request: NIP47Request): Promise { + return dispatchNIP47Request(request, { + wallet: this.walletImpl, + supportedMethods: this.supportedMethods, + supportedEncryption: this.supportedEncryption, + }); } /** diff --git a/src/nip56/index.ts b/src/nip56/index.ts index 53bbdc9c..f1f49fbb 100644 --- a/src/nip56/index.ts +++ b/src/nip56/index.ts @@ -1,9 +1,9 @@ import type { EventTemplate, NostrEvent } from "../types/nostr"; import { sanitizeString, - SECURITY_LIMITS, validateEventContent, } from "../utils/security-validator"; +import { SECURITY_LIMITS } from "../utils/security-limits"; export const REPORT_KIND = 1984; diff --git a/src/nip57/README.md b/src/nip57/README.md index ab12890f..5e756b0b 100644 --- a/src/nip57/README.md +++ b/src/nip57/README.md @@ -27,7 +27,7 @@ The protocol supports both standard zaps (signed by the sender) and anonymous za The library provides an in-memory relay implementation for testing and development: ```typescript -import { NostrRelay } from '../utils/ephemeral-relay'; +import { NostrRelay } from "snstr/testing"; // Create an ephemeral relay on port 3000 // Optional: purge events every 60 seconds diff --git a/src/nip57/client.ts b/src/nip57/client.ts index b03e47c9..2919ac05 100644 --- a/src/nip57/client.ts +++ b/src/nip57/client.ts @@ -14,6 +14,7 @@ import { LnurlSuccessAction, LnurlInvoiceResponse } from "./types"; import { Nostr } from "../nip01/nostr"; import { createSignedEvent } from "../nip01/event"; import { getUnixTime } from "../utils/time"; +import { getPublicKey } from "../utils/crypto"; import { createZapRequest, validateZapReceipt, @@ -31,6 +32,9 @@ import { Logger } from "../utils/logger"; import type { DiagnosticLogger } from "../utils/logger"; import { reportNIP57Diagnostic } from "./diagnostics"; +const LNURL_CACHE_CAPACITY = 256; +const LNURL_CALLBACK_TIMEOUT_MS = 10_000; + /** * Options for the ZapClient */ @@ -117,30 +121,40 @@ export interface ZapStats { * This client provides high-level methods for sending zaps, * fetching zap receipts, and calculating zap statistics. */ -export class NostrZapClient { +class ZapClientCore { private client: Nostr; private defaultRelays: string[]; private logger: DiagnosticLogger; + private lnurlCache: Map< + string, + { pubkey: string; lnurl: string; supportsZaps: boolean } + > = new Map(); /** * Create a new zap client * @param options Options for configuring the client */ - constructor(options: { - /** The Nostr client instance to use */ - client: Nostr; - - /** Default relay URLs to use when not specified explicitly */ - defaultRelays?: string[]; - - /** Receives NIP-57 diagnostics. Quiet by default. */ - logger?: DiagnosticLogger; - }) { - this.client = options.client; + constructor(options: ZapClientOptions) { + this.client = options.nostrClient; this.defaultRelays = options.defaultRelays || []; this.logger = options.logger ?? new Logger({ silent: true }); } + private rememberLnurlResult( + pubkey: string, + lnurl: string, + supportsZaps: boolean, + ): void { + this.lnurlCache.delete(pubkey); + this.lnurlCache.set(pubkey, { pubkey, lnurl, supportsZaps }); + + while (this.lnurlCache.size > LNURL_CACHE_CAPACITY) { + const oldestPubkey = this.lnurlCache.keys().next().value; + if (oldestPubkey === undefined) break; + this.lnurlCache.delete(oldestPubkey); + } + } + /** Collect matching zap receipts until the first EOSE or the legacy timeout. */ private collectZapReceipts(filter: Filter): Promise { return new Promise((resolve, reject) => { @@ -247,12 +261,189 @@ export class NostrZapClient { * @returns Whether the user can receive zaps */ async canReceiveZaps(pubkey: string, lnurl?: string): Promise { - const zapClient = new ZapClient({ - nostrClient: this.client, - defaultRelays: this.defaultRelays, - logger: this.logger, - }); - return zapClient.canReceiveZaps(pubkey, lnurl); + try { + const cached = this.lnurlCache.get(pubkey); + if (cached && (!lnurl || cached.lnurl === lnurl)) { + this.lnurlCache.delete(pubkey); + this.lnurlCache.set(pubkey, cached); + return cached.supportsZaps; + } + + if (!lnurl) { + return false; + } + + const metadata = await fetchLnurlPayMetadata(lnurl, this.logger); + if (!metadata) { + this.rememberLnurlResult(pubkey, lnurl, false); + return false; + } + + const supportsZaps = supportsNostrZaps(metadata); + this.rememberLnurlResult(pubkey, lnurl, supportsZaps); + return supportsZaps; + } catch (error) { + reportNIP57Diagnostic( + this.logger, + "error", + "Failed to check zap support", + { error }, + ); + return false; + } + } + + async getZapInvoice( + options: { + recipientPubkey: string; + lnurl: string; + amount: number; + comment?: string; + eventId?: string; + aTag?: string; + relays?: string[]; + anonymousZap?: boolean; + }, + privateKey: string, + ): Promise { + try { + const senderPubkey = this.client.getPublicKey(); + if (!senderPubkey && !options.anonymousZap) { + return { + invoice: "", + zapRequest: {} as NostrEvent, + error: "No public key available and not anonymous zap", + }; + } + + const metadata = await fetchLnurlPayMetadata(options.lnurl, this.logger); + if (!metadata) { + return { + invoice: "", + zapRequest: {} as NostrEvent, + error: "Invalid LNURL or failed to fetch metadata", + }; + } + + if (!supportsNostrZaps(metadata)) { + return { + invoice: "", + zapRequest: {} as NostrEvent, + error: "LNURL does not support Nostr zaps", + }; + } + + if ( + options.amount < metadata.minSendable || + options.amount > metadata.maxSendable + ) { + return { + invoice: "", + zapRequest: {} as NostrEvent, + error: `Amount out of range (${metadata.minSendable}-${metadata.maxSendable} millisats)`, + }; + } + + const zapRequestOptions: ZapRequestOptions = { + recipientPubkey: options.recipientPubkey, + amount: options.amount, + relays: options.relays || this.defaultRelays, + content: options.comment || "", + lnurl: options.lnurl, + }; + + if (options.eventId) { + zapRequestOptions.eventId = options.eventId; + } + if (options.aTag) { + zapRequestOptions.aTag = options.aTag; + } + if (options.anonymousZap && senderPubkey) { + zapRequestOptions.senderPubkey = senderPubkey; + } + + const signingPubkey = options.anonymousZap + ? getPublicKey(privateKey) + : senderPubkey || ""; + const requestTemplate = createZapRequest( + zapRequestOptions, + signingPubkey, + ); + + const signedZapRequest = await createSignedEvent( + { + ...requestTemplate, + tags: requestTemplate.tags || [], + pubkey: signingPubkey, + created_at: getUnixTime(), + }, + privateKey, + ); + + const callbackUrl = buildZapCallbackUrl( + metadata.callback, + JSON.stringify(signedZapRequest), + options.amount, + ); + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + LNURL_CALLBACK_TIMEOUT_MS, + ); + let invoiceData: LnurlInvoiceResponse; + try { + const invoiceResponse = await fetch(callbackUrl, { + signal: controller.signal, + }); + invoiceData = (await invoiceResponse.json()) as LnurlInvoiceResponse; + } catch (error) { + if (controller.signal.aborted) { + return { + invoice: "", + zapRequest: signedZapRequest, + error: "LNURL callback timed out", + }; + } + throw error; + } finally { + clearTimeout(timeoutId); + } + + if (invoiceData.status === "ERROR") { + return { + invoice: "", + zapRequest: signedZapRequest, + error: invoiceData.reason || "LNURL error", + }; + } + + if ( + typeof invoiceData.pr !== "string" || + invoiceData.pr.trim().length === 0 + ) { + return { + invoice: "", + zapRequest: signedZapRequest, + error: "LNURL server returned an invalid invoice response", + }; + } + + return { + invoice: invoiceData.pr, + zapRequest: signedZapRequest, + paymentHash: invoiceData.payment_hash, + successAction: invoiceData.successAction, + }; + } catch (error) { + reportNIP57Diagnostic(this.logger, "error", "Failed to get zap invoice", { + error, + }); + return { + invoice: "", + zapRequest: {} as NostrEvent, + error: `Error: ${error instanceof Error ? error.message : String(error)}`, + }; + } } /** @@ -302,14 +493,8 @@ export class NostrZapClient { invoice?: string; error?: string; }> { - const zapClient = new ZapClient({ - nostrClient: this.client, - defaultRelays: this.defaultRelays, - logger: this.logger, - }); - // Get the invoice - const result = await zapClient.getZapInvoice(options, privateKey); + const result = await this.getZapInvoice(options, privateKey); if (result.error) { return { @@ -328,36 +513,54 @@ export class NostrZapClient { // 2. Return the payment success/failure } - /** - * Fetch zaps received by a user - * - * @param pubkey User's public key - * @param options Filter options - * @returns Array of zap receipt events - */ - async fetchUserReceivedZaps( - pubkey: string, - options: ZapFilterOptions = {}, - ): Promise { + private buildReceiptFilter( + options: ZapFilterOptions, + target: { recipientPubkey?: string; eventId?: string } = {}, + ): Filter { const filter: Filter = { kinds: [9735], - "#p": [pubkey] as string[], - limit: options.limit || 20, + limit: options.limit ?? 20, }; - if (options.since) { + if (target.recipientPubkey) { + filter["#p"] = [target.recipientPubkey]; + } + if (target.eventId) { + filter["#e"] = [target.eventId]; + } else if ( + !target.recipientPubkey && + options.events && + options.events.length > 0 + ) { + filter["#e"] = options.events; + } + if (options.since !== undefined) { filter.since = options.since; } - - if (options.until) { + if (options.until !== undefined) { filter.until = options.until; } - if (options.authors && options.authors.length > 0) { filter.authors = options.authors; } - return this.collectZapReceipts(filter); + return filter; + } + + /** + * Fetch zaps received by a user + * + * @param pubkey User's public key + * @param options Filter options + * @returns Array of zap receipt events + */ + async fetchUserReceivedZaps( + pubkey: string, + options: ZapFilterOptions = {}, + ): Promise { + return this.collectZapReceipts( + this.buildReceiptFilter(options, { recipientPubkey: pubkey }), + ); } /** @@ -415,25 +618,9 @@ export class NostrZapClient { eventId: string, options: ZapFilterOptions = {}, ): Promise { - const filter: Filter = { - kinds: [9735], - "#e": [eventId] as string[], - limit: options.limit || 20, - }; - - if (options.since) { - filter.since = options.since; - } - - if (options.until) { - filter.until = options.until; - } - - if (options.authors && options.authors.length > 0) { - filter.authors = options.authors; - } - - return this.collectZapReceipts(filter); + return this.collectZapReceipts( + this.buildReceiptFilter(options, { eventId }), + ); } /** @@ -445,28 +632,7 @@ export class NostrZapClient { async fetchZapReceipts( options: ZapFilterOptions = {}, ): Promise { - const filter: Filter = { - kinds: [9735], - limit: options.limit || 20, - }; - - if (options.since) { - filter.since = options.since; - } - - if (options.until) { - filter.until = options.until; - } - - if (options.authors && options.authors.length > 0) { - filter.authors = options.authors; - } - - if (options.events && options.events.length > 0) { - filter["#e"] = options.events as string[]; - } - - return this.collectZapReceipts(filter); + return this.collectZapReceipts(this.buildReceiptFilter(options)); } /** @@ -493,24 +659,11 @@ export class NostrZapClient { ); } - /** - * Calculate total zaps received by a user - * - * @param pubkey User's public key - * @param options Filter options - * @returns Zap statistics - */ - async getTotalZapsReceived( - pubkey: string, - options: ZapFilterOptions = {}, - ): Promise { - const zaps = await this.fetchUserReceivedZaps(pubkey, options); - + private calculateZapStats(zaps: NostrEvent[]): ZapStats { if (zaps.length === 0) { return { total: 0, count: 0 }; } - // Initialize stats const stats: ZapStats = { total: 0, count: 0, @@ -521,50 +674,44 @@ export class NostrZapClient { latestAt: 0, }; - // Process each zap for (const zap of zaps) { const validation = this.validateZapReceipt(zap); + if (!validation.valid || !validation.amount) continue; + + stats.total += validation.amount; + stats.count++; + stats.largest = Math.max(stats.largest ?? 0, validation.amount); + stats.smallest = Math.min( + stats.smallest ?? Number.MAX_SAFE_INTEGER, + validation.amount, + ); + stats.firstAt = Math.min( + stats.firstAt ?? Number.MAX_SAFE_INTEGER, + zap.created_at, + ); + stats.latestAt = Math.max(stats.latestAt ?? 0, zap.created_at); + } - if (validation.valid && validation.amount) { - stats.total += validation.amount; - stats.count++; - - // Update largest/smallest - if (validation.amount > (stats.largest || 0)) { - stats.largest = validation.amount; - } + if (stats.count === 0) return { total: 0, count: 0 }; - if (validation.amount < (stats.smallest || Number.MAX_SAFE_INTEGER)) { - stats.smallest = validation.amount; - } + stats.average = Math.floor(stats.total / stats.count); - // Update timestamps - if (zap.created_at < stats.firstAt!) { - stats.firstAt = zap.created_at; - } + return stats; + } - if (zap.created_at > stats.latestAt!) { - stats.latestAt = zap.created_at; - } - } - } - - // Calculate average - if (stats.count > 0) { - stats.average = Math.floor(stats.total / stats.count); - } - - // Reset smallest if no valid zaps were found - if (stats.smallest === Number.MAX_SAFE_INTEGER) { - stats.smallest = undefined; - } - - // Reset timestamps if no valid zaps were found - if (stats.firstAt === Number.MAX_SAFE_INTEGER) { - stats.firstAt = undefined; - } - - return stats; + /** + * Calculate total zaps received by a user + * + * @param pubkey User's public key + * @param options Filter options + * @returns Zap statistics + */ + async getTotalZapsReceived( + pubkey: string, + options: ZapFilterOptions = {}, + ): Promise { + const zaps = await this.fetchUserReceivedZaps(pubkey, options); + return this.calculateZapStats(zaps); } /** @@ -579,66 +726,7 @@ export class NostrZapClient { options: ZapFilterOptions = {}, ): Promise { const zaps = await this.fetchEventZaps(eventId, options); - - if (zaps.length === 0) { - return { total: 0, count: 0 }; - } - - // Initialize stats - const stats: ZapStats = { - total: 0, - count: 0, - largest: 0, - smallest: Number.MAX_SAFE_INTEGER, - average: 0, - firstAt: Number.MAX_SAFE_INTEGER, - latestAt: 0, - }; - - // Process each zap - for (const zap of zaps) { - const validation = this.validateZapReceipt(zap); - - if (validation.valid && validation.amount) { - stats.total += validation.amount; - stats.count++; - - // Update largest/smallest - if (validation.amount > (stats.largest || 0)) { - stats.largest = validation.amount; - } - - if (validation.amount < (stats.smallest || Number.MAX_SAFE_INTEGER)) { - stats.smallest = validation.amount; - } - - // Update timestamps - if (zap.created_at < stats.firstAt!) { - stats.firstAt = zap.created_at; - } - - if (zap.created_at > stats.latestAt!) { - stats.latestAt = zap.created_at; - } - } - } - - // Calculate average - if (stats.count > 0) { - stats.average = Math.floor(stats.total / stats.count); - } - - // Reset smallest if no valid zaps were found - if (stats.smallest === Number.MAX_SAFE_INTEGER) { - stats.smallest = undefined; - } - - // Reset timestamps if no valid zaps were found - if (stats.firstAt === Number.MAX_SAFE_INTEGER) { - stats.firstAt = undefined; - } - - return stats; + return this.calculateZapStats(zaps); } /** @@ -666,26 +754,129 @@ export class NostrZapClient { } } +/** Comprehensive public NIP-57 facade retained for 0.x compatibility. */ +export class NostrZapClient { + private core: ZapClientCore; + + /** Create a comprehensive NIP-57 client around an existing Nostr client. */ + constructor(options: { + /** The Nostr client instance to use. */ + client: Nostr; + /** Default relay URLs to use when not specified explicitly. */ + defaultRelays?: string[]; + /** Receives NIP-57 diagnostics. Quiet by default. */ + logger?: DiagnosticLogger; + }) { + this.core = new ZapClientCore({ + nostrClient: options.client, + defaultRelays: options.defaultRelays, + logger: options.logger, + }); + } + + /** Check whether a user LNURL supports Nostr zaps. */ + async canReceiveZaps(pubkey: string, lnurl?: string): Promise { + return this.core.canReceiveZaps(pubkey, lnurl); + } + + /** Build a zap request and return the invoice needed to send the zap. */ + async sendZap( + options: { + recipientPubkey: string; + lnurl: string; + amount: number; + comment?: string; + eventId?: string; + aTag?: string; + relays?: string[]; + anonymousZap?: boolean; + }, + privateKey: string, + ): Promise<{ success: boolean; invoice?: string; error?: string }> { + return this.core.sendZap(options, privateKey); + } + + /** Fetch zap receipts received by a user. */ + async fetchUserReceivedZaps( + pubkey: string, + options: ZapFilterOptions = {}, + ): Promise { + return this.core.fetchUserReceivedZaps(pubkey, options); + } + + /** Fetch zap receipts sent by a user. */ + async fetchUserSentZaps( + pubkey: string, + options: ZapFilterOptions = {}, + ): Promise { + return this.core.fetchUserSentZaps(pubkey, options); + } + + /** Fetch zap receipts associated with an event. */ + async fetchEventZaps( + eventId: string, + options: ZapFilterOptions = {}, + ): Promise { + return this.core.fetchEventZaps(eventId, options); + } + + /** Fetch all zap receipts matching the supplied filter options. */ + async fetchZapReceipts( + options: ZapFilterOptions = {}, + ): Promise { + return this.core.fetchZapReceipts(options); + } + + /** Validate a zap receipt and extract its amount when valid. */ + validateZapReceipt( + zapReceipt: NostrEvent, + lnurlPubkey?: string, + ): ZapValidationResult { + return this.core.validateZapReceipt(zapReceipt, lnurlPubkey); + } + + /** Calculate aggregate zap statistics for a user. */ + async getTotalZapsReceived( + pubkey: string, + options: ZapFilterOptions = {}, + ): Promise { + return this.core.getTotalZapsReceived(pubkey, options); + } + + /** Calculate aggregate zap statistics for an event. */ + async getTotalZapsForEvent( + eventId: string, + options: ZapFilterOptions = {}, + ): Promise { + return this.core.getTotalZapsForEvent(eventId, options); + } + + /** Parse zap split recipients and weights from an event. */ + parseZapSplit(event: NostrEvent) { + return this.core.parseZapSplit(event); + } + + /** Calculate recipient amounts for parsed zap split information. */ + calculateZapSplitAmounts( + totalAmount: number, + splitInfo: ReturnType, + ) { + return this.core.calculateZapSplitAmounts(totalAmount, splitInfo); + } +} + /** * Client for working with NIP-57 Zaps */ export class ZapClient { - private nostrClient: Nostr; - private defaultRelays: string[]; - private logger: DiagnosticLogger; - private lnurlCache: Map< - string, - { pubkey: string; lnurl: string; supportsZaps: boolean } - > = new Map(); + private core: ZapClientCore; /** * Create a new ZapClient * @param options Options for the client */ constructor(options: ZapClientOptions) { - this.nostrClient = options.nostrClient; - this.defaultRelays = options.defaultRelays || []; - this.logger = options.logger ?? new Logger({ silent: true }); + this.core = new ZapClientCore(options); } /** @@ -698,44 +889,7 @@ export class ZapClient { pubkey: string, lnurlFromProfile?: string, ): Promise { - try { - // Check cache first - const cached = this.lnurlCache.get(pubkey); - if (cached) { - return cached.supportsZaps; - } - - // Use provided LNURL or fetch from profile - const lnurl = lnurlFromProfile; - if (!lnurl) { - // In a real implementation, we would fetch the user's profile to get their LNURL - // For now, return false as we don't have profile fetching implemented - return false; - } - - // Fetch and validate LNURL metadata - const metadata = await fetchLnurlPayMetadata(lnurl, this.logger); - if (!metadata) { - this.lnurlCache.set(pubkey, { pubkey, lnurl, supportsZaps: false }); - return false; - } - - // Check if it supports zaps - const supportsZaps = supportsNostrZaps(metadata); - - // Cache the result - this.lnurlCache.set(pubkey, { pubkey, lnurl, supportsZaps }); - - return supportsZaps; - } catch (error) { - reportNIP57Diagnostic( - this.logger, - "error", - "Failed to check zap support", - { error }, - ); - return false; - } + return this.core.canReceiveZaps(pubkey, lnurlFromProfile); } /** @@ -757,126 +911,7 @@ export class ZapClient { }, privateKey: string, ): Promise { - try { - const senderPubkey = this.nostrClient.getPublicKey(); - if (!senderPubkey && !options.anonymousZap) { - return { - invoice: "", - zapRequest: {} as NostrEvent, - error: "No public key available and not anonymous zap", - }; - } - - // Fetch LNURL metadata - const metadata = await fetchLnurlPayMetadata(options.lnurl, this.logger); - if (!metadata) { - return { - invoice: "", - zapRequest: {} as NostrEvent, - error: "Invalid LNURL or failed to fetch metadata", - }; - } - - // Check if LNURL supports zaps - if (!supportsNostrZaps(metadata)) { - return { - invoice: "", - zapRequest: {} as NostrEvent, - error: "LNURL does not support Nostr zaps", - }; - } - - // Check amount limits - if ( - options.amount < metadata.minSendable || - options.amount > metadata.maxSendable - ) { - return { - invoice: "", - zapRequest: {} as NostrEvent, - error: `Amount out of range (${metadata.minSendable}-${metadata.maxSendable} millisats)`, - }; - } - - // Create zap request - const zapRequestOptions: ZapRequestOptions = { - recipientPubkey: options.recipientPubkey, - amount: options.amount, - relays: options.relays || this.defaultRelays, - content: options.comment || "", - lnurl: options.lnurl, - }; - - // Add optional parameters - if (options.eventId) { - zapRequestOptions.eventId = options.eventId; - } - - if (options.aTag) { - zapRequestOptions.aTag = options.aTag; - } - - // For anonymous zaps - if (options.anonymousZap && senderPubkey) { - zapRequestOptions.senderPubkey = senderPubkey; - } - - // Create and sign zap request - const requestTemplate = createZapRequest( - zapRequestOptions, - options.anonymousZap - ? "00000000000000000000000000000000000000000000000000000000000000" - : senderPubkey || "", - ); - - const signedZapRequest = await createSignedEvent( - { - ...requestTemplate, - tags: requestTemplate.tags || [], - pubkey: options.anonymousZap - ? "00000000000000000000000000000000000000000000000000000000000000" - : senderPubkey || "", - created_at: getUnixTime(), - }, - privateKey, - ); - - // Build callback URL with the zap request - const callbackUrl = buildZapCallbackUrl( - metadata.callback, - JSON.stringify(signedZapRequest), - options.amount, - ); - - // Fetch invoice from LNURL - const invoiceResponse = await fetch(callbackUrl); - const invoiceData = - (await invoiceResponse.json()) as LnurlInvoiceResponse; - - if (invoiceData.status === "ERROR") { - return { - invoice: "", - zapRequest: signedZapRequest, - error: invoiceData.reason || "LNURL error", - }; - } - - return { - invoice: invoiceData.pr, - zapRequest: signedZapRequest, - paymentHash: invoiceData.payment_hash, - successAction: invoiceData.successAction, - }; - } catch (error) { - reportNIP57Diagnostic(this.logger, "error", "Failed to get zap invoice", { - error, - }); - return { - invoice: "", - zapRequest: {} as NostrEvent, - error: `Error: ${error instanceof Error ? error.message : String(error)}`, - }; - } + return this.core.getZapInvoice(options, privateKey); } /** @@ -889,7 +924,7 @@ export class ZapClient { zapReceipt: NostrEvent, lnurlPubkey: string, ): ZapValidationResult { - return validateZapReceipt(zapReceipt, lnurlPubkey, this.logger); + return this.core.validateZapReceipt(zapReceipt, lnurlPubkey); } /** @@ -899,7 +934,46 @@ export class ZapClient { * @returns Split information for each recipient */ getZapSplitAmounts(event: NostrEvent, totalAmount: number) { - const splitInfo = parseZapSplit(event); - return calculateZapSplitAmounts(totalAmount, splitInfo); + const splitInfo = this.core.parseZapSplit(event); + return this.core.calculateZapSplitAmounts(totalAmount, splitInfo); + } + + /** Fetch all zap receipts matching the filter criteria. */ + async fetchZapReceipts( + options: ZapFilterOptions = {}, + ): Promise { + return this.core.fetchZapReceipts(options); + } + + /** Fetch zap receipts received by a user. */ + async fetchUserReceivedZaps( + pubkey: string, + options: ZapFilterOptions = {}, + ): Promise { + return this.core.fetchUserReceivedZaps(pubkey, options); + } + + /** Fetch zap receipts for an event. */ + async fetchEventZaps( + eventId: string, + options: ZapFilterOptions = {}, + ): Promise { + return this.core.fetchEventZaps(eventId, options); + } + + /** Calculate zap statistics for a user. */ + async getTotalZapsReceived( + pubkey: string, + options: ZapFilterOptions = {}, + ): Promise { + return this.core.getTotalZapsReceived(pubkey, options); + } + + /** Calculate zap statistics for an event. */ + async getTotalZapsForEvent( + eventId: string, + options: ZapFilterOptions = {}, + ): Promise { + return this.core.getTotalZapsForEvent(eventId, options); } } diff --git a/src/nip65/index.ts b/src/nip65/index.ts index 06d0e5c0..8ef1a34a 100644 --- a/src/nip65/index.ts +++ b/src/nip65/index.ts @@ -1,6 +1,13 @@ import { NostrEvent } from "../types/nostr"; import { isValidRelayUrl } from "../nip19/secure"; import { getUnixTime } from "../utils/time"; +import type { DiagnosticLogger } from "../utils/logger"; +import { + createDefaultDiagnosticLogger, + reportDiagnostic, +} from "../utils/diagnostics"; + +const defaultLogger = createDefaultDiagnosticLogger({ prefix: "NIP-65" }); /** Relay list entry describing read/write preferences */ export interface RelayListEntry { @@ -28,6 +35,7 @@ export interface RelayListEvent extends NostrEvent { export function createRelayListEvent( relays: RelayListEntry[], content = "", + logger: DiagnosticLogger = defaultLogger, ): Omit { const tags: string[][] = []; @@ -35,9 +43,23 @@ export function createRelayListEvent( // skip entries with missing or invalid URL if (!r.url || !isValidRelayUrl(r.url)) { if (!r.url) { - console.warn("Skipping relay entry with missing URL", r); + reportDiagnostic( + logger, + "warn", + "Skipping relay entry with missing URL", + { + reason: "missing-url", + }, + ); } else { - console.warn(`Skipping relay entry with invalid URL: ${r.url}`, r); + reportDiagnostic( + logger, + "warn", + "Skipping relay entry with invalid URL", + { + reason: "invalid-url", + }, + ); } continue; } diff --git a/src/nip66/index.ts b/src/nip66/index.ts index b03cd85f..ed5e5862 100644 --- a/src/nip66/index.ts +++ b/src/nip66/index.ts @@ -26,6 +26,14 @@ import { RelayMonitorAnnouncementOptions, ParsedRelayMonitorAnnouncement, } from "./types"; +import type { DiagnosticLogger } from "../utils/logger"; +import { + createDefaultDiagnosticLogger, + diagnosticFailureType, + reportDiagnostic, +} from "../utils/diagnostics"; + +const defaultLogger = createDefaultDiagnosticLogger({ prefix: "NIP-66" }); export * from "./types"; @@ -322,6 +330,7 @@ export function createRelayDiscoveryEvent( */ export function parseRelayDiscoveryEvent( event: NostrEvent, + logger: DiagnosticLogger = defaultLogger, ): ParsedRelayDiscoveryEvent | null { if (event.kind !== RELAY_DISCOVERY_KIND) return null; @@ -394,12 +403,14 @@ export function parseRelayDiscoveryEvent( } } catch (error) { if (error instanceof SecurityValidationError) { - // Log bounds checking error but continue processing - if (typeof console !== "undefined" && console.warn) { - console.warn( - `NIP-66: Bounds checking error in tag processing: ${error.message}`, - ); - } + reportDiagnostic( + logger, + "warn", + "Bounds checking failed in discovery tag", + { + failureType: diagnosticFailureType(error), + }, + ); } } } @@ -547,6 +558,7 @@ export function createRelayMonitorAnnouncement( */ export function parseRelayMonitorAnnouncement( event: NostrEvent, + logger: DiagnosticLogger = defaultLogger, ): ParsedRelayMonitorAnnouncement | null { if (event.kind !== RELAY_MONITOR_KIND) return null; @@ -578,11 +590,14 @@ export function parseRelayMonitorAnnouncement( !tagValue || (typeof tagValue === "string" && tagValue.trim() === "") ) { - if (typeof console !== "undefined" && console.warn) { - console.warn( - `NIP-66: Skipping frequency tag with missing or empty value: ${JSON.stringify(tag)}`, - ); - } + reportDiagnostic( + logger, + "warn", + "Skipping invalid frequency tag", + { + reason: "missing-value", + }, + ); break; } @@ -594,11 +609,14 @@ export function parseRelayMonitorAnnouncement( // Check if parsing resulted in a valid number if (isNaN(frequencyValue)) { - if (typeof console !== "undefined" && console.warn) { - console.warn( - `NIP-66: Skipping frequency tag with invalid numeric value: "${tagValue}"`, - ); - } + reportDiagnostic( + logger, + "warn", + "Skipping invalid frequency tag", + { + reason: "invalid-number", + }, + ); break; } @@ -611,11 +629,16 @@ export function parseRelayMonitorAnnouncement( frequencyValue < MIN_FREQUENCY || frequencyValue > MAX_FREQUENCY ) { - if (typeof console !== "undefined" && console.warn) { - console.warn( - `NIP-66: Skipping frequency tag with value out of bounds (${MIN_FREQUENCY}-${MAX_FREQUENCY}s): ${frequencyValue}s`, - ); - } + reportDiagnostic( + logger, + "warn", + "Skipping invalid frequency tag", + { + maximum: MAX_FREQUENCY, + minimum: MIN_FREQUENCY, + reason: "out-of-bounds", + }, + ); break; } @@ -623,12 +646,9 @@ export function parseRelayMonitorAnnouncement( data.frequency = frequencyValue; } catch (error) { // Handle any unexpected errors during parsing - if (typeof console !== "undefined" && console.warn) { - console.warn( - `NIP-66: Error parsing frequency tag ${JSON.stringify(tag)}:`, - error, - ); - } + reportDiagnostic(logger, "warn", "Failed to parse frequency tag", { + failureType: diagnosticFailureType(error), + }); // Continue processing other tags } break; @@ -640,11 +660,9 @@ export function parseRelayMonitorAnnouncement( !tagValue || (typeof tagValue === "string" && tagValue.trim() === "") ) { - if (typeof console !== "undefined" && console.warn) { - console.warn( - `NIP-66: Skipping timeout tag with missing or empty value: ${JSON.stringify(tag)}`, - ); - } + reportDiagnostic(logger, "warn", "Skipping invalid timeout tag", { + reason: "missing-value", + }); break; } @@ -657,11 +675,9 @@ export function parseRelayMonitorAnnouncement( // Check if parsing resulted in a valid number if (isNaN(timeoutValue)) { - if (typeof console !== "undefined" && console.warn) { - console.warn( - `NIP-66: Skipping timeout tag with invalid numeric value: "${tagValue}"`, - ); - } + reportDiagnostic(logger, "warn", "Skipping invalid timeout tag", { + reason: "invalid-number", + }); break; } @@ -671,11 +687,11 @@ export function parseRelayMonitorAnnouncement( // Check bounds if (timeoutValue < MIN_TIMEOUT || timeoutValue > MAX_TIMEOUT) { - if (typeof console !== "undefined" && console.warn) { - console.warn( - `NIP-66: Skipping timeout tag with value out of bounds (${MIN_TIMEOUT}-${MAX_TIMEOUT}ms): ${timeoutValue}ms`, - ); - } + reportDiagnostic(logger, "warn", "Skipping invalid timeout tag", { + maximum: MAX_TIMEOUT, + minimum: MIN_TIMEOUT, + reason: "out-of-bounds", + }); break; } @@ -686,11 +702,14 @@ export function parseRelayMonitorAnnouncement( if (typeof testValue === "string" && testValue.trim() !== "") { testParam = testValue; } else if (testValue !== undefined) { - if (typeof console !== "undefined" && console.warn) { - console.warn( - `NIP-66: Skipping timeout tag with invalid test parameter: ${JSON.stringify(testValue)}`, - ); - } + reportDiagnostic( + logger, + "warn", + "Skipping invalid timeout tag", + { + reason: "invalid-test-parameter", + }, + ); break; } } @@ -702,12 +721,9 @@ export function parseRelayMonitorAnnouncement( }); } catch (error) { // Handle any unexpected errors during parsing - if (typeof console !== "undefined" && console.warn) { - console.warn( - `NIP-66: Error parsing timeout tag ${JSON.stringify(tag)}:`, - error, - ); - } + reportDiagnostic(logger, "warn", "Failed to parse timeout tag", { + failureType: diagnosticFailureType(error), + }); // Continue processing other tags } break; @@ -724,12 +740,14 @@ export function parseRelayMonitorAnnouncement( } } catch (error) { if (error instanceof SecurityValidationError) { - // Log bounds checking error but continue processing - if (typeof console !== "undefined" && console.warn) { - console.warn( - `NIP-66: Bounds checking error in tag processing: ${error.message}`, - ); - } + reportDiagnostic( + logger, + "warn", + "Bounds checking failed in monitor tag", + { + failureType: diagnosticFailureType(error), + }, + ); } } } diff --git a/src/testing/behavior-controls.ts b/src/testing/behavior-controls.ts new file mode 100644 index 00000000..19f23d69 --- /dev/null +++ b/src/testing/behavior-controls.ts @@ -0,0 +1,299 @@ +import type { Nostr } from "../nip01/nostr"; +import type { Relay } from "../nip01/relay"; +import type { RelayRegistry } from "../nip01/relayRegistry"; +import type { NostrRemoteSignerBunker } from "../nip46/bunker"; +import type { NIP46ClientEngine } from "../nip46/internal/client-engine"; +import type { NIP46RateLimiter } from "../nip46/utils/rate-limiter"; +import type { NIP46Request, NIP46Response } from "../nip46/types"; +import type { NostrWalletConnectClient } from "../nip47/client"; +import type { NostrWalletService } from "../nip47/service"; +import type { + NIP47EncryptionScheme, + NIP47Request, + NIP47Response, +} from "../nip47/types"; +import type { + NostrEvent, + PublishOptions, + PublishResponse, +} from "../types/nostr"; + +/** Minimal socket surface used when a test must drive Relay transport behavior. */ +export interface RelayTestSocket { + readyState: number; + onopen?: ((event: unknown) => void) | null; + onclose?: ((event: unknown) => void) | null; + onerror?: ((event: unknown) => void) | null; + onmessage?: ((event: { data: unknown }) => void) | null; + send(data: string): void; + close(code?: number, reason?: string): void; + terminate?: () => void; +} + +/** Deliver one decoded relay frame through the production message handler. */ +export function dispatchRelayMessage(relay: Relay, message: unknown[]): void { + ( + relay as unknown as { + handleMessage(value: unknown[]): void; + } + ).handleMessage(message); +} + +/** Wait until validation work triggered by a delivered EVENT frame has settled. */ +export async function waitForRelayValidation( + relay: Relay, + subscriptionId: string, + maxMicrotaskTurns = 1000, +): Promise { + const pending = ( + relay as unknown as { + pendingValidationCounts: Map; + } + ).pendingValidationCounts; + for (let turn = 0; turn < maxMicrotaskTurns; turn += 1) { + if ((pending.get(subscriptionId) ?? 0) === 0) return; + await new Promise((resolve) => { + const channel = new MessageChannel(); + channel.port1.onmessage = () => { + channel.port1.close(); + channel.port2.close(); + resolve(); + }; + channel.port2.postMessage(undefined); + }); + } + throw new Error( + `Timed out waiting for inbound EVENT validation for ${subscriptionId}`, + ); +} + +/** Replace the inbound validator to hold or fail one lifecycle-sensitive test. */ +export function replaceRelayInboundValidator( + relay: Relay, + validator: (event: unknown) => Promise, +): () => void { + const target = relay as unknown as { + validateInboundEvent(event: unknown): Promise; + }; + const original = target.validateInboundEvent; + target.validateInboundEvent = validator; + return () => { + target.validateInboundEvent = original; + }; +} + +/** Install a deterministic socket and connection flag, returning a restore hook. */ +export function installRelaySocket( + relay: Relay, + socket: RelayTestSocket | null, + connected = socket?.readyState === 1, +): () => void { + const target = relay as unknown as { + ws: RelayTestSocket | null; + connected: boolean; + }; + const originalSocket = target.ws; + const originalConnected = target.connected; + target.ws = socket; + target.connected = connected; + return () => { + target.ws = originalSocket; + target.connected = originalConnected; + }; +} + +/** Return the active socket only for tests that exercise the wire peer directly. */ +export function getRelaySocket(relay: Relay): RelayTestSocket | null { + return (relay as unknown as { ws: RelayTestSocket | null }).ws; +} + +/** Trigger scheduling without exposing Relay's other private methods. */ +export function scheduleRelayReconnect(relay: Relay): void { + (relay as unknown as { scheduleReconnect(): void }).scheduleReconnect(); +} + +/** + * Invoke only the bunker connect handler for auth-challenge protocol tests. + * This intentionally bypasses engine middleware such as replay protection. + */ +export function invokeNip46BunkerConnect( + bunker: NostrRemoteSignerBunker, + request: NIP46Request, + clientPubkey: string, +): Promise { + return ( + bunker as unknown as { + handleConnect( + value: NIP46Request, + requester: string, + ): Promise; + } + ).handleConnect(request, clientPubkey); +} + +export interface NIP46ClientEngineLifecycleHooks { + prepareConnection?: () => Promise; + setupSubscription?: () => Promise; + cleanup?: () => Promise; +} + +/** Install lifecycle gates without exposing the engine's other private state. */ +export function installNip46ClientEngineLifecycleHooks( + engine: NIP46ClientEngine, + hooks: NIP46ClientEngineLifecycleHooks, +): () => void { + const target = engine as unknown as { + prepareConnection(): Promise; + setupSubscription(): Promise; + cleanup(): Promise; + }; + const originals = { + prepareConnection: target.prepareConnection, + setupSubscription: target.setupSubscription, + cleanup: target.cleanup, + }; + if (hooks.prepareConnection) + target.prepareConnection = hooks.prepareConnection; + if (hooks.setupSubscription) + target.setupSubscription = hooks.setupSubscription; + if (hooks.cleanup) target.cleanup = hooks.cleanup; + return () => { + target.prepareConnection = originals.prepareConnection; + target.setupSubscription = originals.setupSubscription; + target.cleanup = originals.cleanup; + }; +} + +/** Replace only the rate-limiter teardown fault used by bunker stop tests. */ +export function replaceNip46RateLimiterDestroy( + bunker: NostrRemoteSignerBunker, + destroy: () => void, +): () => void { + const limiter = ( + bunker as unknown as { + rateLimiter: NIP46RateLimiter; + } + ).rateLimiter; + const original = limiter.destroy; + limiter.destroy = destroy; + return () => { + limiter.destroy = original; + }; +} + +/** Replace only the capability-discovery wait used during client initialization. */ +export function replaceNip47CapabilityDiscoveryWait( + client: NostrWalletConnectClient, + wait: () => Promise, +): () => void { + const target = client as unknown as { + waitForCapabilityDiscovery(): Promise; + }; + const original = target.waitForCapabilityDiscovery; + target.waitForCapabilityDiscovery = wait; + return () => { + target.waitForCapabilityDiscovery = original; + }; +} + +export type NIP47RequestSender = ( + request: NIP47Request, + expiration?: number, + allowDuringInitialization?: boolean, +) => Promise; + +/** Replace only request sending to exercise capability fallback outcomes. */ +export function replaceNip47RequestSender( + client: NostrWalletConnectClient, + send: NIP47RequestSender, +): () => void { + const target = client as unknown as { + sendRequest( + request: NIP47Request, + expiration?: number, + allowDuringInitialization?: boolean, + ): Promise; + }; + const original = target.sendRequest; + target.sendRequest = send; + return () => { + target.sendRequest = original; + }; +} + +/** Deliver one correlated response through the production NIP-47 response path. */ +export async function dispatchNip47ClientResponse( + client: NostrWalletConnectClient, + requestId: string, + encryptionScheme: NIP47EncryptionScheme, + event: NostrEvent, +): Promise { + const target = client as unknown as { + pendingRequests: Map< + string, + { + encryptionScheme: NIP47EncryptionScheme; + resolve: (response: NIP47Response) => void; + } + >; + handleResponse(value: NostrEvent): Promise; + }; + target.pendingRequests.set(requestId, { + encryptionScheme, + resolve: () => undefined, + }); + try { + await target.handleResponse(event); + } finally { + target.pendingRequests.delete(requestId); + } +} + +/** Deliver one request through the production NIP-47 service protocol handler. */ +export async function dispatchNip47ServiceRequest( + service: NostrWalletService, + event: NostrEvent, +): Promise { + const target = service as unknown as { + handleEvent(value: NostrEvent): Promise; + }; + await target.handleEvent(event); +} + +export interface NostrTestRelay { + publish?( + event: NostrEvent, + options?: PublishOptions, + ): Promise; + connect?(): Promise; + disconnect(): unknown; + on?(...args: unknown[]): unknown; + off?(...args: unknown[]): unknown; + subscribe?(...args: unknown[]): string; + unsubscribe?(subscriptionId: string): void; + authenticate?( + event: NostrEvent, + options?: PublishOptions, + ): Promise; + getLatestReplaceableEvent?( + pubkey: string, + kind: number, + ): NostrEvent | undefined; + getLatestAddressableEvent?( + kind: number, + pubkey: string, + dTagValue: string, + ): NostrEvent | undefined; + getAddressableEventsByPubkey?(pubkey: string): NostrEvent[]; + getAddressableEventsByKind?(kind: number): NostrEvent[]; +} + +/** Install one deterministic relay double into Nostr's owned registry. */ +export function installNostrTestRelay( + nostr: Nostr, + url: string, + relay: NostrTestRelay, +): void { + const registry = (nostr as unknown as { relays: RelayRegistry }).relays; + registry.set(url, relay as unknown as Relay); +} diff --git a/src/testing/index.ts b/src/testing/index.ts new file mode 100644 index 00000000..d2ecdd2d --- /dev/null +++ b/src/testing/index.ts @@ -0,0 +1,86 @@ +/** + * Node-only test support for integration suites and runnable examples. + * + * This subpath is supported within the 0.x release line but is not intended + * for production relay hosting. Its API may evolve between minor 0.x releases. + */ +export { NostrRelay } from "../utils/ephemeral-relay"; +export type { NostrRelayOptions } from "../utils/ephemeral-relay"; +export { + dispatchNip47ClientResponse, + dispatchNip47ServiceRequest, + dispatchRelayMessage, + getRelaySocket, + installNostrTestRelay, + installNip46ClientEngineLifecycleHooks, + installRelaySocket, + invokeNip46BunkerConnect, + replaceNip46RateLimiterDestroy, + replaceNip47CapabilityDiscoveryWait, + replaceNip47RequestSender, + replaceRelayInboundValidator, + scheduleRelayReconnect, + waitForRelayValidation, +} from "./behavior-controls"; +export type { + NIP46ClientEngineLifecycleHooks, + NIP47RequestSender, + NostrTestRelay, + RelayTestSocket, +} from "./behavior-controls"; + +import type { + RelayEvent, + RelayEventCallbacks, + RelayInterface, +} from "../types/nostr"; + +/** + * Framework-neutral callable used by relay test doubles. Test doubles need a + * deliberately permissive parameter list so specifically typed functions and + * framework mocks remain assignable under strict function variance. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type RelayTestMock = (...args: any[]) => unknown; + +/** + * Relay test state for consumers that need to replace public relay methods and + * capture callbacks without depending on one test framework's mock types. + */ +export interface RelayTestContext { + /** The relay instance being tested. */ + relay: RelayInterface; + /** Original methods or properties replaced by the test. */ + originals: { + ws?: WebSocket | null; + on?: ( + event: E, + callback: RelayEventCallbacks[E], + ) => void; + off?: ( + event: E, + callback: RelayEventCallbacks[E], + ) => void; + }; + /** Framework-neutral test doubles. */ + mocks: { + send?: RelayTestMock; + connect?: RelayTestMock; + disconnect?: RelayTestMock; + handlers?: { + [key in RelayEvent]?: RelayTestMock; + }; + }; + /** Callbacks captured from event registrations. */ + capturedCallbacks: { + [E in RelayEvent]?: RelayEventCallbacks[E][]; + }; + /** Options passed to the relay under test. */ + options?: { + connectionTimeout?: number; + bufferFlushDelay?: number; + autoReconnect?: boolean; + maxReconnectAttempts?: number; + maxReconnectDelay?: number; + }; +} diff --git a/src/types/README.md b/src/types/README.md index 1edd5f18..619b26c8 100644 --- a/src/types/README.md +++ b/src/types/README.md @@ -35,13 +35,20 @@ Contains the fundamental types for Nostr events and communication: Contains types for the Nostr protocol messages and communication: -- **`NostrEventMessage`**: ["EVENT", subscription_id, event] message format +- **`NostrEventMessage`**: Client EVENT publication or Relay subscription EVENT tuple +- **`NostrClientToServerEventMessage`**: Client EVENT publication tuple +- **`NostrServerToClientEventMessage`**: Relay EVENT delivery tuple - **`NostrReqMessage`**: ["REQ", subscription_id, ...filters] message format - **`NostrCloseMessage`**: ["CLOSE", subscription_id] message format - **`NostrOkMessage`**: ["OK", event_id, success, message] message format - **`NostrEoseMessage`**: ["EOSE", subscription_id] message format +- **`NostrClosedMessage`**: ["CLOSED", subscription_id, message] message format - **`NostrNoticeMessage`**: ["NOTICE", message] message format -- **`NostrAuthMessage`**: ["AUTH", challenge] message format (NIP-42) +- **`NostrAuthMessage`**: Relay challenge or client authentication EVENT tuple (NIP-42) +- **`NostrRelayAuthMessage`**: Relay AUTH challenge tuple +- **`NostrClientAuthMessage`**: Client AUTH response EVENT tuple +- **`NostrClientMessage`**: Union of tuples sent from clients to relays +- **`NostrRelayMessage`**: Union of tuples sent from relays to clients - **`NostrMessage`**: Union type of all message types - **`RelayConnectionOptions`**: Options for configuring relay connections @@ -456,4 +463,4 @@ The types in this directory implement these NIPs: - **[NIP-11](https://github.com/nostr-protocol/nips/blob/master/11.md)**: Relay information document - **[NIP-16](https://github.com/nostr-protocol/nips/blob/master/16.md)**: Ephemeral events - **[NIP-42](https://github.com/nostr-protocol/nips/blob/master/42.md)**: Authentication of clients to relays -- **[NIP-50](https://github.com/nostr-protocol/nips/blob/master/50.md)**: Search capability \ No newline at end of file +- **[NIP-50](https://github.com/nostr-protocol/nips/blob/master/50.md)**: Search capability diff --git a/src/types/globals.d.ts b/src/types/globals.d.ts deleted file mode 100644 index 44539198..00000000 --- a/src/types/globals.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { NostrRelay } from "../utils/ephemeral-relay"; - -declare global { - // eslint-disable-next-line no-var - var __NOSTR_RELAY_INSTANCE__: NostrRelay | undefined; -} - -export {}; // This line ensures the file is treated as a module. diff --git a/src/types/nostr.ts b/src/types/nostr.ts index 116bade9..b3a06af0 100644 --- a/src/types/nostr.ts +++ b/src/types/nostr.ts @@ -623,62 +623,6 @@ export interface RelayDebugOptions { maxMessageHistory: number; } -/** - * Test context for relay testing - */ -export interface RelayTestContext { - /** The relay instance being tested */ - relay: RelayInterface; - /** Original methods/properties that were mocked during testing */ - originals: { - /** Original WebSocket instance */ - ws?: WebSocket | null; - /** Original on method for event handlers */ - on?: ( - event: E, - callback: RelayEventCallbacks[E], - ) => void; - /** Original off method for event handlers */ - off?: ( - event: E, - callback: RelayEventCallbacks[E], - ) => void; - }; - /** Mock functions used during testing */ - mocks: { - /** Mock WebSocket send function */ - send?: jest.Mock; - /** Mock connect function */ - connect?: jest.Mock; - /** Mock disconnect function */ - disconnect?: jest.Mock; - /** Mock event handlers by event type */ - handlers?: { - [key in RelayEvent]?: jest.Mock; - }; - }; - /** Captured callbacks from event registrations */ - capturedCallbacks: { - /** Callbacks for ok events */ - ok?: RelayEventCallbacks[RelayEvent.OK][]; - /** Callbacks for other event types */ - [key: string]: RelayEventCallbacks[keyof RelayEventCallbacks][] | undefined; - }; - /** Options passed to the relay */ - options?: { - /** Connection timeout in milliseconds */ - connectionTimeout?: number; - /** Delay between buffering events and processing them (ms) */ - bufferFlushDelay?: number; - /** Whether to automatically reconnect on disconnection */ - autoReconnect?: boolean; - /** Maximum number of reconnection attempts (0 for unlimited) */ - maxReconnectAttempts?: number; - /** Maximum delay between reconnection attempts (ms) */ - maxReconnectDelay?: number; - }; -} - /** * Enum for standard relay error types */ diff --git a/src/types/protocol.ts b/src/types/protocol.ts index 79b746d4..e3537f30 100644 --- a/src/types/protocol.ts +++ b/src/types/protocol.ts @@ -1,5 +1,4 @@ import { NostrEvent, NostrFilter } from "./nostr"; -// RelayEvent is now imported in relay.ts directly from nostr.ts /** * Message types for Nostr protocol communications as defined in NIP-01 @@ -32,20 +31,36 @@ export type NostrEoseMessage = ["EOSE", string]; /** NOTICE message: A human-readable message from a relay to a client */ export type NostrNoticeMessage = ["NOTICE", string]; +/** CLOSED message: A relay indicating that a subscription ended */ +export type NostrClosedMessage = ["CLOSED", string, string]; + /** AUTH message: A relay requesting client authentication (NIP-42) */ -export type NostrAuthMessage = - | ["AUTH", string] // relay ➜ client (challenge) - | ["AUTH", NostrEvent]; // client ➜ relay (response) +export type NostrRelayAuthMessage = ["AUTH", string]; -/** Union type of all possible message types */ -export type NostrMessage = - | NostrEventMessage +/** AUTH message: A client responding to a relay challenge (NIP-42) */ +export type NostrClientAuthMessage = ["AUTH", NostrEvent]; + +/** Union type for AUTH messages in either direction */ +export type NostrAuthMessage = NostrRelayAuthMessage | NostrClientAuthMessage; + +/** Messages sent from a Nostr client to a relay */ +export type NostrClientMessage = + | NostrClientToServerEventMessage | NostrReqMessage | NostrCloseMessage + | NostrClientAuthMessage; + +/** Messages sent from a Nostr relay to a client */ +export type NostrRelayMessage = + | NostrServerToClientEventMessage | NostrOkMessage | NostrEoseMessage + | NostrClosedMessage | NostrNoticeMessage - | NostrAuthMessage; + | NostrRelayAuthMessage; + +/** Union type of all possible message types */ +export type NostrMessage = NostrClientMessage | NostrRelayMessage; /** * Error type for Nostr protocol message parsing errors @@ -61,12 +76,12 @@ export class NostrMessageParseError extends Error { } } -// Export RelayEvent from nostr.ts instead of redefining it here - /** * Interface for relay connection options */ export interface RelayConnectionOptions { + /** Optional canonical diagnostic logger. */ + logger?: import("../utils/logger").DiagnosticLogger; /** Connection timeout in milliseconds */ connectionTimeout?: number; /** Delay between buffering events and processing them (ms) */ diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts index b7743291..0a2b85e9 100644 --- a/src/utils/crypto.ts +++ b/src/utils/crypto.ts @@ -1,6 +1,14 @@ import { schnorr } from "@noble/curves/secp256k1"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; import { sha256 as nobleSha256 } from "@noble/hashes/sha2"; +import type { DiagnosticLogger } from "./logger"; +import { + createDefaultDiagnosticLogger, + diagnosticFailureType, + reportDiagnostic, +} from "./diagnostics"; + +const defaultLogger = createDefaultDiagnosticLogger({ prefix: "crypto" }); /** * Sign an event with the given private key @@ -22,6 +30,7 @@ export function verifySignatureSync( eventId: string, signature: string, publicKey: string, + logger: DiagnosticLogger = defaultLogger, ): boolean { try { const eventIdBytes = hexToBytes(eventId); @@ -29,7 +38,9 @@ export function verifySignatureSync( const publicKeyBytes = hexToBytes(publicKey); return schnorr.verify(signatureBytes, eventIdBytes, publicKeyBytes); } catch (error) { - console.error("Failed to verify signature:", error); + reportDiagnostic(logger, "error", "Failed to verify signature", { + failureType: diagnosticFailureType(error), + }); return false; } } @@ -38,8 +49,9 @@ export async function verifySignature( eventId: string, signature: string, publicKey: string, + logger: DiagnosticLogger = defaultLogger, ): Promise { - return verifySignatureSync(eventId, signature, publicKey); + return verifySignatureSync(eventId, signature, publicKey, logger); } /** diff --git a/src/utils/diagnostics.ts b/src/utils/diagnostics.ts new file mode 100644 index 00000000..cfdf72e9 --- /dev/null +++ b/src/utils/diagnostics.ts @@ -0,0 +1,96 @@ +import { + Logger, + LogLevel, + type DiagnosticLogArgument, + type DiagnosticLogger, + type LoggerOptions, +} from "./logger"; + +type DiagnosticMethod = keyof DiagnosticLogger; +const SAFE_ERROR_NAME = /^(?:Error|[A-Z][A-Za-z0-9]{0,58}Error)$/; + +/** Convert an unknown value into the structured diagnostic contract. */ +export function asDiagnosticArgument(value: unknown): DiagnosticLogArgument { + if ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "object" + ) { + return value; + } + + return String(value); +} + +/** Emit an observational diagnostic without allowing the sink to alter behavior. */ +export function reportDiagnostic( + logger: DiagnosticLogger, + method: DiagnosticMethod, + message: string, + ...context: DiagnosticLogArgument[] +): void { + try { + logger[method].call(logger, message, ...context); + } catch { + // Diagnostics are observational and must not replace public results/errors. + } +} + +/** Wrap an injected logger so every diagnostic method is non-throwing. */ +export function protectDiagnosticLogger( + logger: DiagnosticLogger, +): DiagnosticLogger { + return { + error: (message, ...context) => + reportDiagnostic(logger, "error", message, ...context), + warn: (message, ...context) => + reportDiagnostic(logger, "warn", message, ...context), + info: (message, ...context) => + reportDiagnostic(logger, "info", message, ...context), + debug: (message, ...context) => + reportDiagnostic(logger, "debug", message, ...context), + trace: (message, ...context) => + reportDiagnostic(logger, "trace", message, ...context), + }; +} + +/** Create the WARN-visible console-backed default used by production modules. */ +export function createDefaultDiagnosticLogger( + options: LoggerOptions = {}, +): DiagnosticLogger { + return protectDiagnosticLogger( + new Logger({ level: LogLevel.WARN, ...options }), + ); +} + +/** Return stable failure metadata without forwarding untrusted error messages. */ +export function diagnosticFailureType(error: unknown): string { + if (error instanceof Error) { + try { + const { name } = error; + return typeof name === "string" && SAFE_ERROR_NAME.test(name) + ? name + : "Error"; + } catch { + return "Error"; + } + } + if (error === null) return "null"; + return typeof error; +} + +/** Remove credentials, paths, queries, and fragments from relay diagnostics. */ +export function safeRelayDiagnostic(url: string): string { + try { + const parsed = new URL(url); + if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { + return ""; + } + return `${parsed.protocol}//${parsed.host}`; + } catch { + return ""; + } +} diff --git a/src/utils/ephemeral-relay.ts b/src/utils/ephemeral-relay.ts index bf5a363b..7d93d358 100644 --- a/src/utils/ephemeral-relay.ts +++ b/src/utils/ephemeral-relay.ts @@ -1,102 +1,27 @@ import EventEmitter from "events"; -import { WebSocket, WebSocketServer } from "ws"; -import { NostrEvent, NostrFilter } from "../types/nostr"; -import { - NostrMessage, - NostrOkMessage, - NostrEoseMessage, -} from "../types/protocol"; -import { validateEvent } from "../nip01/event"; -import { isValidPublicKeyPoint } from "../nip44"; -import { - validateArrayAccess, - safeArrayAccess, - SecurityValidationError, -} from "./security-validator"; -import { - InMemoryWebSocketServer, - registerInMemoryServer, - unregisterInMemoryServer, -} from "./inMemoryWebSocket"; +import { NostrEvent } from "../types/nostr"; +import { protectDiagnosticLogger } from "./diagnostics"; import { maybeUnref } from "./timers"; import { notifyRelayDisconnectObservers } from "./websocket"; +import { DiagnosticLogger, Logger } from "./logger"; import { - DiagnosticLogArgument, - DiagnosticLogger, - Logger, -} from "./logger"; - -/** - * Validates if a string is a valid 32-byte hex string (case-insensitive). - * Unlike isValidPublicKeyPoint, this accepts both uppercase and lowercase hex. - */ -function isValid32ByteHex(hex: string): boolean { - return /^[0-9a-fA-F]{64}$/.test(hex); -} - -/** - * Validates if a string is a valid 64-byte hex string (case-insensitive). - * Unlike isValidPublicKeyPoint, this accepts both uppercase and lowercase hex. - */ -function isValid64ByteHex(hex: string): boolean { - return /^[0-9a-fA-F]{128}$/.test(hex); -} + createClientSession, + RelaySession, + RelaySessionHost, + RelaySubscription, +} from "./ephemeral-relay/client-session"; +import { + createRelayTransport, + RelayTransport, +} from "./ephemeral-relay/transport"; /* ================ [ Configuration ] ================ */ // Prefer 127.0.0.1 over localhost to avoid IPv6 resolution issues in CI. const HOST = "ws://127.0.0.1"; -function toDiagnosticArgument(value: unknown): DiagnosticLogArgument { - if ( - value === null || - value === undefined || - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" || - typeof value === "object" - ) { - return value; - } - - return String(value); -} - -function createNonThrowingDiagnosticLogger( - logger: DiagnosticLogger, -): DiagnosticLogger { - const write = ( - level: keyof DiagnosticLogger, - message: string, - args: DiagnosticLogArgument[], - ) => { - try { - logger[level](message, ...args); - } catch { - // Diagnostics are observational and must not alter Relay behavior. - } - }; - - return { - error: (message, ...args) => write("error", message, args), - warn: (message, ...args) => write("warn", message, args), - info: (message, ...args) => write("info", message, args), - debug: (message, ...args) => write("debug", message, args), - trace: (message, ...args) => write("trace", message, args), - }; -} - /* ================ [ Interfaces ] ================ */ -// Using NostrFilter from types/nostr.ts -type EventFilter = NostrFilter; - -// Using NostrEvent from types/nostr.ts -type SignedEvent = NostrEvent; - -// Extended type for relay events that adds a subscription id in the middle -type NostrRelayEventMessage = ["EVENT", string, NostrEvent]; - /** Optional lifecycle settings for the public ephemeral Relay. */ export interface NostrRelayOptions { /** Seconds between cache purges. */ @@ -105,29 +30,21 @@ export interface NostrRelayOptions { logger?: DiagnosticLogger; } -interface Subscription { - filters: EventFilter[]; - instance: ClientSession; - sub_id: string; -} - /* ================ [ Server Class ] ================ */ export class NostrRelay { private readonly _emitter: EventEmitter; private readonly _port: number; private readonly _purge: number | null; - private readonly _subs: Map; + private readonly _subs: Map; private readonly _logger: DiagnosticLogger; - private readonly _sessions: Set; + private readonly _sessions: Set; + private readonly _sessionHost: RelaySessionHost; - private _wss: WebSocketServer | null; - private _inMemoryServer: InMemoryWebSocketServer | null = null; - private _cache: SignedEvent[]; + private _transport: RelayTransport | null = null; + private _cache: NostrEvent[]; private _closePromise: Promise | null = null; private _purgeTimer: NodeJS.Timeout | null = null; - private _actualPort: number | null = null; - private _acceptingConnections = false; public conn: number; @@ -145,13 +62,21 @@ export class NostrRelay { this._port = port; this._purge = options.purgeInterval ?? null; this._subs = new Map(); - this._logger = createNonThrowingDiagnosticLogger( + this._logger = protectDiagnosticLogger( options.logger ?? new Logger({ silent: true }), ); this._sessions = new Set(); - this._wss = null; + this._sessionHost = { + cachedEvents: () => this._cache, + subscriptions: () => this._subs, + store: (event) => this.store(event), + broadcast: (message, sender) => + this._transport?.broadcast(message, sender), + clientDisconnected: () => { + this.conn = Math.max(0, this.conn - 1); + }, + }; this.conn = 0; - this._actualPort = null; } get cache() { @@ -163,15 +88,15 @@ export class NostrRelay { } get url() { - const port = this._actualPort || this._port; + const port = this._transport?.actualPort || this._port; return `${HOST}:${port}`; } get wss() { - if (this._wss === null) { + if (!this._transport) { throw new Error("websocket server not initialized"); } - return this._wss; + return this._transport.server; } async start() { @@ -179,194 +104,46 @@ export class NostrRelay { await this._closePromise; } - if (this._wss || this._inMemoryServer) { + if (this._transport) { return this; } - const handleConnection = (socket: WebSocket | EventEmitter) => { - if (!this._acceptingConnections) { - const closingSocket = socket as WebSocket; - try { - closingSocket.terminate(); - } catch { - try { - closingSocket.close(1001, "Relay shutting down"); - } catch { - // The Relay is already closing; there is no session state to retain. - } - } - return; - } - - const instance = new ClientSession( - this, - socket as WebSocket, - this._logger, - ); - this._sessions.add(instance); - void instance.closed.then(() => this._sessions.delete(instance)); - - socket.on("message", (msg: unknown) => - instance._handler( - typeof msg === "string" || msg instanceof String - ? msg.toString() - : Buffer.isBuffer(msg) - ? msg.toString() - : JSON.stringify(msg), - ), - ); - socket.on("error", (err: unknown) => - instance._onerr( - err instanceof Error - ? err - : new Error(String(err ?? "Unknown error")), - ), - ); - socket.on("close", (code: number) => instance._cleanup(code)); - - this.conn += 1; - }; - - const initialiseAfterStart = () => { - this._acceptingConnections = true; - if (this._purge !== null) { - if (this._purgeTimer) { - clearInterval(this._purgeTimer); - } - this._purgeTimer = setInterval(() => { - this._cache = []; - }, this._purge * 1000); - maybeUnref(this._purgeTimer); - } - this._logger.info("Relay started", { url: this.url }); - }; - - const shouldFallbackToInMemory = (error: unknown) => { - if (!error || typeof error !== "object") { - return false; - } - const code = - "code" in error && typeof (error as { code: unknown }).code === "string" - ? ((error as { code: string }).code as string) - : ""; - const message = - "message" in error && - typeof (error as { message: unknown }).message === "string" - ? ((error as { message: string }).message as string) - : ""; - // In restricted runtimes, binding to port 0 may report EADDRINUSE even - // though no specific port was requested; fall back to in-memory relay. - const isDynamicPortConflict = - this._port === 0 && - (code === "EADDRINUSE" || message.includes("EADDRINUSE")); - return ( - isDynamicPortConflict || - code === "EACCES" || - code === "EPERM" || - code === "EADDRNOTAVAIL" || - message.includes("EPERM") || - message.includes("EACCES") || - message.includes("EADDRNOTAVAIL") - ); - }; - - const resetFailedWebSocketServer = (wss: WebSocketServer) => { - wss.removeAllListeners(); - try { - wss.close(); - } catch { - // A listener that failed to bind may already be fully closed. - } - if (this._wss === wss) this._wss = null; - this._actualPort = null; - this._acceptingConnections = false; - }; - - const startInMemory = () => { - const { server, port } = registerInMemoryServer( - this._port === 0 ? undefined : this._port, - ); - - this._inMemoryServer = server; - this._wss = server as unknown as WebSocketServer; - this._actualPort = port; - - server.on( - "connection", - handleConnection as (socket: EventEmitter) => void, - ); - - initialiseAfterStart(); + const transport = createRelayTransport({ + port: this._port, + logger: this._logger, + onConnection: (socket) => { + const instance = createClientSession( + this._sessionHost, + socket, + this._logger, + ); + this._sessions.add(instance); + void instance.closed.then(() => this._sessions.delete(instance)); - return new Promise((res) => { - queueMicrotask(() => { - this._emitter.emit("connected"); - res(this); - }); - }); - }; + this.conn += 1; + }, + }); + this._transport = transport; - // Bun on Linux CI has shown flakiness with real TCP listeners (port 0 / ephemeral ports). - // Prefer the in-memory transport in Bun to keep the test suite deterministic. - if (this._port === 0 && typeof (globalThis as unknown as { Bun?: unknown }).Bun !== "undefined") { - return startInMemory(); + try { + await transport.start(); + } catch (error) { + if (this._transport === transport) this._transport = null; + throw error; } - return new Promise((resolve, reject) => { - try { - const wss = new WebSocketServer({ port: this._port, host: "127.0.0.1" }); - this._wss = wss; - wss.on("connection", handleConnection); - - const cleanup = () => { - wss.off("listening", onListening); - wss.off("error", onError); - }; - - const onListening = () => { - cleanup(); - const address = wss.address(); - if (address && typeof address === "object" && "port" in address) { - const port = - typeof address.port === "number" && address.port > 0 - ? address.port - : null; - this._actualPort = port; - } - // If we couldn't determine a usable port (e.g. Bun/compat oddities with port 0), - // fall back to in-memory transport rather than returning a ws://...:0 URL. - if (this._port === 0 && !this._actualPort) { - resetFailedWebSocketServer(wss); - startInMemory().then(resolve).catch(reject); - return; - } - - initialiseAfterStart(); - this._emitter.emit("connected"); - resolve(this); - }; - - const onError = (error: unknown) => { - cleanup(); - if (shouldFallbackToInMemory(error)) { - resetFailedWebSocketServer(wss); - startInMemory().then(resolve).catch(reject); - } else { - resetFailedWebSocketServer(wss); - reject(error); - } - }; - - wss.once("listening", onListening); - wss.once("error", onError); - } catch (error) { - if (shouldFallbackToInMemory(error)) { - startInMemory().then(resolve).catch(reject); - } else { - reject(error); - } + if (this._purge !== null) { + if (this._purgeTimer) { + clearInterval(this._purgeTimer); } - }); + this._purgeTimer = setInterval(() => { + this._cache = []; + }, this._purge * 1000); + maybeUnref(this._purgeTimer); + } + this._logger.info("Relay started", { url: this.url }); + this._emitter.emit("connected"); + return this; } onconnect(cb: () => void) { @@ -390,92 +167,33 @@ export class NostrRelay { } private async performClose(): Promise { - this._acceptingConnections = false; const closedUrl = this.url; - const inMemoryServer = this._inMemoryServer; - const wss = inMemoryServer ? null : this._wss; - const ownedTransport = inMemoryServer !== null || wss !== null; + const transport = this._transport; const sessions = [...this._sessions]; - const transportShutdown = wss - ? this.closeWebSocketTransport(wss) - : Promise.resolve(); this._emitter.removeAllListeners(); if (this._purgeTimer) clearInterval(this._purgeTimer); this._purgeTimer = null; - this._inMemoryServer = null; - this._wss = null; + this._transport = null; this._subs.clear(); this._cache = []; - const sessionShutdown = Promise.all( - sessions.map((session) => session.close()), - ); - - if (inMemoryServer) { - unregisterInMemoryServer(this._actualPort || this._port); - await sessionShutdown; - inMemoryServer.removeAllListeners(); - } else if (wss) { - let sessionTimeout: NodeJS.Timeout | null = null; - const timedOut = new Promise((resolve) => { - sessionTimeout = setTimeout(() => resolve(true), 1000); - maybeUnref(sessionTimeout); - }); - const sessionsClosed = sessionShutdown.then(() => false); - - if (await Promise.race([sessionsClosed, timedOut])) { - this._logger.warn("Relay client close timed out; forcing cleanup"); - sessions.forEach((session) => session.forceClose()); - await sessionShutdown; - } - if (sessionTimeout) clearTimeout(sessionTimeout); - - await transportShutdown; - wss.removeAllListeners(); - } - - if (ownedTransport) { + if (transport) { + await transport.close( + async () => { + await Promise.all(sessions.map((session) => session.close())); + }, + () => sessions.forEach((session) => session.forceClose()), + ); notifyRelayDisconnectObservers(closedUrl); } this._sessions.clear(); this.conn = 0; - this._actualPort = null; this._logger.info("Relay closed", { url: closedUrl }); } - private closeWebSocketTransport(wss: WebSocketServer): Promise { - return new Promise((resolve) => { - let timeout: NodeJS.Timeout | null = null; - const finish = () => { - if (timeout) clearTimeout(timeout); - timeout = null; - resolve(); - }; - - timeout = setTimeout(() => { - this._logger.warn( - "Relay transport close timed out; forcing cleanup", - ); - finish(); - }, 1000); - maybeUnref(timeout); - - try { - // Calling close immediately stops the transport accepting new sockets; - // its callback still waits for the tracked sessions to drain below. - wss.close(finish); - } catch (error) { - this._logger.warn("Relay transport close failed", { - error: toDiagnosticArgument(error), - }); - finish(); - } - }); - } - - store(event: SignedEvent) { + store(event: NostrEvent) { const isSimpleReplaceable = event.kind === 0 || event.kind === 3 || @@ -590,639 +308,3 @@ export class NostrRelay { } } } - -/* ================ [ Instance Class ] ================ */ - -class ClientSession { - private _sid: string; - private readonly _relay: NostrRelay; - private readonly _socket: WebSocket; - private readonly _subs: Set; - private readonly _logger: DiagnosticLogger; - private readonly _closed: Promise; - private _resolveClosed!: () => void; - private _cleaned = false; - - constructor( - relay: NostrRelay, - socket: WebSocket, - logger: DiagnosticLogger, - ) { - this._relay = relay; - this._logger = logger; - this._closed = new Promise((resolve) => { - this._resolveClosed = resolve; - }); - // Generate cryptographically secure session ID - if (typeof crypto !== "undefined" && crypto.getRandomValues) { - const array = new Uint8Array(3); - crypto.getRandomValues(array); - this._sid = Array.from(array, (byte) => - byte.toString(16).padStart(2, "0"), - ).join(""); - } else if ( - typeof process !== "undefined" && - process.versions && - process.versions.node - ) { - try { - // Try to use require for CommonJS environments - // eslint-disable-next-line @typescript-eslint/no-var-requires - const nodeCrypto = require("crypto"); - this._sid = nodeCrypto.randomBytes(3).toString("hex"); - } catch (requireError) { - // If require fails (ESM environment), generate a fallback ID - // that will remain immutable for the session lifetime - const tempArray = new Uint8Array(3); - // Use Math.random as permanent fallback - for (let i = 0; i < tempArray.length; i++) { - tempArray[i] = Math.floor(Math.random() * 256); - } - this._sid = Array.from(tempArray, (byte) => - byte.toString(16).padStart(2, "0"), - ).join(""); - - // Log warning but keep the generated ID immutable - this._logger.warn( - "Using Math.random for session ID generation; provide crypto for stronger identifiers", - ); - } - } else { - // As a last resort, use Math.random with a timestamp component - // to ensure uniqueness even without crypto - const timestamp = Date.now(); - const random = Math.floor(Math.random() * 0xffffff); - this._sid = ((timestamp & 0xffffff) ^ random) - .toString(16) - .padStart(6, "0"); - } - this._socket = socket; - this._subs = new Set(); - - this.log.client("client connected"); - } - - get sid() { - return this._sid; - } - - get relay() { - return this._relay; - } - - get socket() { - return this._socket; - } - - get closed(): Promise { - return this._closed; - } - - close(): Promise { - if (this._cleaned) return this._closed; - - try { - if ( - this.socket.readyState === WebSocket.OPEN || - this.socket.readyState === WebSocket.CONNECTING - ) { - this.socket.close(1000, "Relay shutting down"); - } else if (this.socket.readyState === WebSocket.CLOSED) { - this._cleanup(1000); - } - } catch (error) { - this._logger.warn("Relay client close failed", { - sessionId: this._sid, - error: toDiagnosticArgument(error), - }); - this._cleanup(1006); - } - - return this._closed; - } - - forceClose(): void { - try { - this.socket.terminate(); - } catch { - // Cleanup below is authoritative even when the transport cannot terminate. - } - this._cleanup(1006); - } - - _cleanup(code: number) { - if (this._cleaned) return; - this._cleaned = true; - - try { - // First remove all subscriptions associated with this client - for (const subId of this._subs) { - this.remSub(subId); - } - this._subs.clear(); - - // Close the socket if it's still open - if (this.socket.readyState === WebSocket.OPEN) { - this.socket.close(); - } - - this.relay.conn = Math.max(0, this.relay.conn - 1); - this.log.client( - `[ ${this._sid} ]`, - "client disconnected with code:", - code, - ); - } catch (e) { - this._logger.error("Relay client cleanup failed", { - sessionId: this._sid, - error: toDiagnosticArgument(e), - }); - } finally { - this._resolveClosed(); - } - } - - _handler(message: string) { - try { - // Try to parse as JSON - const parsed = JSON.parse(message); - - // Handle NIP-46 messages (which might not follow standard Nostr format) - if (parsed && Array.isArray(parsed) && parsed.length > 0) { - // Check if it's a standard Nostr message - if (["EVENT", "REQ", "CLOSE"].includes(parsed[0])) { - const verb = parsed[0]; - - switch (verb) { - case "EVENT": - if (parsed.length !== 2) { - this.log.debug("EVENT message missing params:", parsed); - return this.send([ - "NOTICE", - "invalid: EVENT message missing params", - ]); - } - return this._onevent(parsed[1] as SignedEvent); - - case "REQ": - if (parsed.length < 2) { - this.log.debug("REQ message missing params:", parsed); - return this.send([ - "NOTICE", - "invalid: REQ message missing params", - ]); - } - { - const sub_id = parsed[1] as string; - const filters = parsed.slice(2) as EventFilter[]; - return this._onreq(sub_id, filters); - } - - case "CLOSE": - if (parsed.length !== 2) { - this.log.debug("CLOSE message missing params:", parsed); - return this.send([ - "NOTICE", - "invalid: CLOSE message missing params", - ]); - } - return this._onclose(parsed[1] as string); - } - } else { - // This could be a direct NIP-46 message, broadcast it to other clients - try { - this.relay.wss.clients.forEach((client) => { - if ( - client !== this.socket && - client.readyState === WebSocket.OPEN - ) { - client.send(message); - } - }); - return; - } catch (e) { - this.log.error("Error broadcasting message:", e); - return; - } - } - } - - this.log.debug("unhandled message format:", message); - return this.send(["NOTICE", "Unable to handle message"]); - } catch (e) { - this.log.debug("failed to parse message:\n\n", message); - return this.send(["NOTICE", "Unable to parse message"]); - } - } - - _onclose(sub_id: string) { - this.log.info("closed subscription:", sub_id); - this.remSub(sub_id); - } - - _onerr(err: Error) { - this.log.info("socket encountered an error:\n\n", err); - } - - async _onevent(event: SignedEvent) { - try { - // Special handling for NIP-46 events (kind 24133) - if (event.kind === 24133) { - // Validate basic structure but with NIP-46 specific validation - if (!(await this.validateNIP46Event(event))) { - this.log.debug("NIP-46 event failed validation:", event); - this.send([ - "OK", - event.id, - false, - "NIP-46 event failed validation", - ] as NostrOkMessage); - return; - } - - this.relay.store(event); - - // Find subscriptions that match this event - for (const [uid, sub] of this.relay.subs.entries()) { - for (const filter of sub.filters) { - if (filter.kinds?.includes(24133)) { - // Check for #p tag filter - safe array access - const pTags = event.tags - .filter((tag) => { - try { - return ( - validateArrayAccess(tag, 0) && - safeArrayAccess(tag, 0) === "p" - ); - } catch { - return false; - } - }) - .map((tag) => { - try { - return safeArrayAccess(tag, 1); - } catch { - return null; - } - }) - .filter((val): val is string => typeof val === "string"); - const pFilters = filter["#p"] || []; - - // If there's a #p filter, make sure the event matches it - if ( - pFilters.length > 0 && - !pTags.some((tag) => pFilters.includes(tag)) - ) { - continue; - } - - // Send to matching subscription - safe array access - try { - const uidParts = uid.split("/"); - if (validateArrayAccess(uidParts, 1)) { - const subId = safeArrayAccess(uidParts, 1); - sub.instance.send([ - "EVENT", - subId, - event, - ] as NostrRelayEventMessage); - break; - } - } catch (error) { - if (error instanceof SecurityValidationError) { - this.log.debug( - `Bounds checking error in subscription routing: ${error.message}`, - ); - } - continue; - } - } - } - } - - // Send OK message - this.send(["OK", event.id, true, ""] as NostrOkMessage); - return; - } - - // Standard event processing - this.log.client("received event id:", event.id); - this.log.debug("event:", event); - - // Standard event processing - wrap validateEvent in try-catch - try { - if (!(await validateEvent(event))) { - this.log.debug("event failed validation (returned false):", event); - this.send([ - "OK", - event.id, - false, - "event failed validation: validateEvent returned false", - ] as NostrOkMessage); - return; - } - } catch (validationError) { - // If validateEvent itself throws (e.g. NostrValidationError from getEventHash) - let errorMessage = "event validation error"; - if (validationError instanceof Error) { - errorMessage = validationError.message; - } - this.log.debug( - `event failed validation (threw error): ${errorMessage}`, - event, - ); - this.send([ - "OK", - event.id, - false, - `invalid: ${errorMessage}`, - ] as NostrOkMessage); - return; - } - - this.send(["OK", event.id, true, ""] as NostrOkMessage); - this.relay.store(event); - - for (const { filters, instance, sub_id } of this.relay.subs.values()) { - for (const filter of filters) { - if (match_filter(event, filter)) { - instance.log.client(`event matched subscription: ${sub_id}`); - instance.send(["EVENT", sub_id, event] as NostrRelayEventMessage); - } - } - } - } catch (e) { - this.log.error("Error processing event:", e); - } - } - - _onreq(sub_id: string, filters: EventFilter[]): void { - if (filters.length === 0) { - this.log.client("request has no filters"); - return; - } - - this.log.client("received subscription request:", sub_id); - this.log.debug("filters:", filters); - - // Add subscription - this.addSub(sub_id, ...filters); - - // For each filter - let count = 0; - for (const filter of filters) { - // Set the limit count, if any - let limitCount = filter.limit; - - for (const event of this.relay.cache) { - // If limit is reached, stop sending events - if (limitCount !== undefined && limitCount <= 0) break; - - // Check if event matches filter - if (match_filter(event, filter)) { - this.send(["EVENT", sub_id, event] as NostrRelayEventMessage); - count++; - this.log.client(`event matched in cache: ${event.id}`); - this.log.client(`event matched subscription: ${sub_id}`); - - // Update limit counter - if (limitCount !== undefined) limitCount--; - } - } - } - - this.log.debug(`sent ${count} matching events from cache`); - - // Send EOSE - this.send(["EOSE", sub_id] as NostrEoseMessage); - } - - get log() { - const write = ( - level: "error" | "info" | "debug" | "trace", - messages: unknown[], - ) => { - const [message = "", ...args] = messages; - this._logger[level]( - `[Relay client ${this._sid}] ${String(message)}`, - ...args.map(toDiagnosticArgument), - ); - }; - - return { - client: (...msg: unknown[]) => write("trace", msg), - debug: (...msg: unknown[]) => write("debug", msg), - info: (...msg: unknown[]) => write("info", msg), - error: (...msg: unknown[]) => write("error", msg), - }; - } - - addSub(sub_id: string, ...filters: EventFilter[]) { - const uid = `${this.sid}/${sub_id}`; - this.relay.subs.set(uid, { filters, instance: this, sub_id }); - this._subs.add(sub_id); - } - - remSub(subId: string) { - try { - const uid = `${this.sid}/${subId}`; - this.relay.subs.delete(uid); - this._subs.delete(subId); - } catch (e) { - // Ignore errors - } - } - - send(message: NostrMessage | NostrRelayEventMessage) { - try { - if (this.socket.readyState === WebSocket.OPEN) { - this.socket.send(JSON.stringify(message)); - } - } catch (e) { - this.log.error("Failed to send message:", e); - } - } - - // Method to validate NIP-46 events - async validateNIP46Event(event: SignedEvent): Promise { - // Check required fields exist with proper types - if (!isValidPublicKeyPoint(event.pubkey)) { - this.log.debug("NIP-46 validation failed: invalid pubkey"); - return false; - } - - if (!event.created_at || typeof event.created_at !== "number") { - this.log.debug("NIP-46 validation failed: invalid created_at"); - return false; - } - - if (event.kind !== 24133) { - this.log.debug("NIP-46 validation failed: invalid kind"); - return false; - } - - if (!Array.isArray(event.tags)) { - this.log.debug("NIP-46 validation failed: invalid tags"); - return false; - } - - // For NIP-46, we need to have at least one p tag with a valid pubkey - const hasPTag = event.tags.some((tag: string[]) => { - try { - return ( - Array.isArray(tag) && - validateArrayAccess(tag, 0) && - validateArrayAccess(tag, 1) && - safeArrayAccess(tag, 0) === "p" && - typeof safeArrayAccess(tag, 1) === "string" && - isValidPublicKeyPoint(safeArrayAccess(tag, 1) as string) - ); - } catch (error) { - // If bounds checking fails, this tag is invalid - return false; - } - }); - - if (!hasPTag) { - // For debugging, log the tags structure - this.log.debug( - "NIP-46 validation failed: no valid p tag found", - JSON.stringify(event.tags), - ); - return false; - } - - if (typeof event.content !== "string") { - this.log.debug("NIP-46 validation failed: invalid content"); - return false; - } - - if ( - !event.sig || - typeof event.sig !== "string" || - !isValid64ByteHex(event.sig) - ) { - this.log.debug("NIP-46 validation failed: invalid signature"); - return false; - } - - // Verify signature for NIP-46 events using the canonical validateEvent - try { - if (!(await validateEvent(event))) { - this.log.debug( - "NIP-46 validation failed: invalid signature verification", - ); - return false; - } - } catch (error) { - this.log.debug( - "NIP-46 validation failed: error during signature verification", - error, - ); - return false; - } - - // Validate event.id: must be 64-char hex (case-insensitive) - if (!isValid32ByteHex(event.id)) { - this.log.debug("NIP-46 validation failed: invalid id format"); - return false; - } - - // For NIP-46, we've passed all the validation checks - return true; - } -} - -/* ================ [ Methods ] ================ */ - -function match_filter(event: SignedEvent, filter: EventFilter = {}): boolean { - const { authors, ids, kinds, since, until, search, ...rest } = filter; - - // Extract all tag filters from rest - const tag_filters: string[][] = Object.entries(rest) - .filter((e) => e[0].startsWith("#")) - .map((e) => [e[0].slice(1), ...(e[1] as string[])]); - - if (ids !== undefined && !ids.includes(event.id)) { - return false; - } else if (since !== undefined && event.created_at < since) { - return false; - } else if (until !== undefined && event.created_at > until) { - return false; - } else if (authors !== undefined && !authors.includes(event.pubkey)) { - return false; - } else if (kinds !== undefined && !kinds.includes(event.kind)) { - return false; - } else if (search !== undefined && search.length > 0) { - const query = search.toLowerCase(); - const contentMatch = event.content.toLowerCase().includes(query); - const tagMatch = event.tags.some((tag) => - tag.some((v) => v.toLowerCase().includes(query)), - ); - if (!contentMatch && !tagMatch) return false; - return tag_filters.length > 0 ? match_tags(tag_filters, event.tags) : true; - } else if (tag_filters.length > 0) { - return match_tags(tag_filters, event.tags); - } else { - return true; - } -} - -function match_tags(filters: string[][], tags: string[][]): boolean { - // For each filter, we need to find at least one match in event tags - for (const filter of filters) { - let filterMatched = false; - - // Safe access to filter elements - try { - if (!validateArrayAccess(filter, 0)) { - filterMatched = true; // Empty filter matches everything - continue; - } - - const key = safeArrayAccess(filter, 0); - const terms = filter.slice(1); - - // Skip empty filter terms - if (terms.length === 0) { - filterMatched = true; - continue; - } - - // For each tag that matches the filter key - for (const tag of tags) { - try { - if (!validateArrayAccess(tag, 0) || safeArrayAccess(tag, 0) !== key) { - continue; - } - - const params = tag.slice(1); - - // For each term in the filter - for (const term of terms) { - // If any term matches any parameter, this filter condition is satisfied - if (params.includes(term)) { - filterMatched = true; - break; - } - } - - // If we found a match for this filter, we can stop checking tags - if (filterMatched) break; - } catch (error) { - // Skip malformed tags - continue; - } - } - } catch (error) { - // Skip malformed filters - continue; - } - - // If no match was found for this filter condition, event doesn't match - if (!filterMatched) return false; - } - - // All filter conditions were satisfied - return true; -} diff --git a/src/utils/ephemeral-relay/client-session.ts b/src/utils/ephemeral-relay/client-session.ts new file mode 100644 index 00000000..b3e4562c --- /dev/null +++ b/src/utils/ephemeral-relay/client-session.ts @@ -0,0 +1,625 @@ +import { WebSocket } from "ws"; +import { validateEvent } from "../../nip01/event"; +import type { NostrEvent, NostrFilter } from "../../types/nostr"; +import type { + NostrEoseMessage, + NostrOkMessage, + NostrRelayMessage, +} from "../../types/protocol"; +import { asDiagnosticArgument } from "../diagnostics"; +import { isValidPublicKeyPoint } from "../key-validation"; +import type { DiagnosticLogger } from "../logger"; +import { + safeArrayAccess, + SecurityValidationError, + validateArrayAccess, + validateFilters, +} from "../security-validator"; +import { isHexOfLength } from "../wire-validation"; +import { matchesFilter } from "./filter-match"; + +function isValid32ByteHex(hex: string): boolean { + return isHexOfLength(hex, 64); +} + +function isValid64ByteHex(hex: string): boolean { + return isHexOfLength(hex, 128); +} + +/** Lifecycle and protocol operations exposed to the Relay composition root. */ +export interface RelaySession { + readonly closed: Promise; + close(): Promise; + forceClose(): void; + send(message: NostrRelayMessage): void; + sendMatchedEvent(subscriptionId: string, event: NostrEvent): void; +} + +/** Subscription state shared between active client sessions. */ +export interface RelaySubscription { + filters: NostrFilter[]; + instance: RelaySession; + subscriptionId: string; +} + +/** Narrow Relay capabilities required by one client session. */ +export interface RelaySessionHost { + cachedEvents(): readonly NostrEvent[]; + subscriptions(): Map; + store(event: NostrEvent): void; + broadcast(message: string, sender: WebSocket): void; + clientDisconnected(): void; +} + +/** Create a client session and attach its socket event handlers. */ +export function createClientSession( + host: RelaySessionHost, + socket: WebSocket, + logger: DiagnosticLogger, +): RelaySession { + const session = new ClientSession(host, socket, logger); + + socket.on("message", (message: unknown) => + session.handleMessage( + typeof message === "string" || message instanceof String + ? message.toString() + : Buffer.isBuffer(message) + ? message.toString() + : JSON.stringify(message), + ), + ); + socket.on("error", (error: unknown) => + session.handleError( + error instanceof Error + ? error + : new Error(String(error ?? "Unknown error")), + ), + ); + socket.on("close", (code: number) => session.cleanup(code)); + + return session; +} + +/** Owns one client's wire protocol, subscriptions, and socket lifecycle. */ +class ClientSession implements RelaySession { + private _sid: string; + private readonly _host: RelaySessionHost; + private readonly _socket: WebSocket; + private readonly _subs: Set; + private readonly _logger: DiagnosticLogger; + private readonly _closed: Promise; + private _resolveClosed!: () => void; + private _cleaned = false; + + constructor( + host: RelaySessionHost, + socket: WebSocket, + logger: DiagnosticLogger, + ) { + this._host = host; + this._logger = logger; + this._closed = new Promise((resolve) => { + this._resolveClosed = resolve; + }); + // Generate cryptographically secure session ID + if (typeof crypto !== "undefined" && crypto.getRandomValues) { + const array = new Uint8Array(3); + crypto.getRandomValues(array); + this._sid = Array.from(array, (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + } else if ( + typeof process !== "undefined" && + process.versions && + process.versions.node + ) { + try { + // Try to use require for CommonJS environments + // eslint-disable-next-line @typescript-eslint/no-var-requires + const nodeCrypto = require("crypto"); + this._sid = nodeCrypto.randomBytes(3).toString("hex"); + } catch (requireError) { + // If require fails (ESM environment), generate a fallback ID + // that will remain immutable for the session lifetime + const tempArray = new Uint8Array(3); + // Use Math.random as permanent fallback + for (let i = 0; i < tempArray.length; i++) { + tempArray[i] = Math.floor(Math.random() * 256); + } + this._sid = Array.from(tempArray, (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + + // Log warning but keep the generated ID immutable + this._logger.warn( + "Using Math.random for session ID generation; provide crypto for stronger identifiers", + ); + } + } else { + // As a last resort, use Math.random with a timestamp component + // to ensure uniqueness even without crypto + const timestamp = Date.now(); + const random = Math.floor(Math.random() * 0xffffff); + this._sid = ((timestamp & 0xffffff) ^ random) + .toString(16) + .padStart(6, "0"); + } + this._socket = socket; + this._subs = new Set(); + + this.log.client("client connected"); + } + + get sid() { + return this._sid; + } + + get host() { + return this._host; + } + + get socket() { + return this._socket; + } + + get closed(): Promise { + return this._closed; + } + + close(): Promise { + if (this._cleaned) return this._closed; + + try { + if ( + this.socket.readyState === WebSocket.OPEN || + this.socket.readyState === WebSocket.CONNECTING + ) { + this.socket.close(1000, "Relay shutting down"); + } else if (this.socket.readyState === WebSocket.CLOSED) { + this.cleanup(1000); + } + } catch (error) { + this._logger.warn("Relay client close failed", { + sessionId: this._sid, + error: asDiagnosticArgument(error), + }); + this.cleanup(1006); + } + + return this._closed; + } + + forceClose(): void { + try { + this.socket.terminate(); + } catch { + // Cleanup below is authoritative even when the transport cannot terminate. + } + this.cleanup(1006); + } + + cleanup(code: number): void { + if (this._cleaned) return; + this._cleaned = true; + + try { + // First remove all subscriptions associated with this client + for (const subId of this._subs) { + this.remSub(subId); + } + this._subs.clear(); + + // Close the socket if it's still open + if (this.socket.readyState === WebSocket.OPEN) { + this.socket.close(); + } + + this.host.clientDisconnected(); + this.log.client( + `[ ${this._sid} ]`, + "client disconnected with code:", + code, + ); + } catch (e) { + this._logger.error("Relay client cleanup failed", { + sessionId: this._sid, + error: asDiagnosticArgument(e), + }); + } finally { + this._resolveClosed(); + } + } + + handleMessage(message: string): void { + try { + // Try to parse as JSON + const parsed = JSON.parse(message); + + // Handle NIP-46 messages (which might not follow standard Nostr format) + if (parsed && Array.isArray(parsed) && parsed.length > 0) { + // Check if it's a standard Nostr message + if (["EVENT", "REQ", "CLOSE"].includes(parsed[0])) { + const verb = parsed[0]; + + switch (verb) { + case "EVENT": + if (parsed.length !== 2) { + this.log.debug("EVENT message missing params:", parsed); + return this.send([ + "NOTICE", + "invalid: EVENT message missing params", + ]); + } + void this.handleEvent(parsed[1] as NostrEvent); + return; + + case "REQ": + if (parsed.length < 2) { + this.log.debug("REQ message missing params:", parsed); + return this.send([ + "NOTICE", + "invalid: REQ message missing params", + ]); + } + { + const subscriptionId = parsed[1]; + if (typeof subscriptionId !== "string") { + return this.send([ + "NOTICE", + "invalid: REQ subscription id must be a string", + ]); + } + let filters: NostrFilter[]; + try { + filters = validateFilters(parsed.slice(2)); + } catch (error) { + if (error instanceof SecurityValidationError) { + return this.send(["NOTICE", "invalid: REQ filters"]); + } + throw error; + } + return this.handleRequest(subscriptionId, filters); + } + + case "CLOSE": + if (parsed.length !== 2) { + this.log.debug("CLOSE message missing params:", parsed); + return this.send([ + "NOTICE", + "invalid: CLOSE message missing params", + ]); + } + return this.handleClose(parsed[1] as string); + } + } else { + // This could be a direct NIP-46 message, broadcast it to other clients + try { + this.host.broadcast(message, this.socket); + return; + } catch (e) { + this.log.error("Error broadcasting message:", e); + return; + } + } + } + + this.log.debug("unhandled message format:", message); + return this.send(["NOTICE", "Unable to handle message"]); + } catch (e) { + this.log.debug("failed to parse message:\n\n", message); + return this.send(["NOTICE", "Unable to parse message"]); + } + } + + private handleClose(subscriptionId: string): void { + this.log.info("closed subscription:", subscriptionId); + this.remSub(subscriptionId); + } + + handleError(err: Error): void { + this.log.info("socket encountered an error:\n\n", err); + } + + private async handleEvent(event: NostrEvent): Promise { + try { + // Special handling for NIP-46 events (kind 24133) + if (event.kind === 24133) { + // Validate basic structure but with NIP-46 specific validation + if (!(await this.validateNIP46Event(event))) { + this.log.debug("NIP-46 event failed validation:", event); + this.send([ + "OK", + event.id, + false, + "NIP-46 event failed validation", + ] as NostrOkMessage); + return; + } + + this.host.store(event); + + // Find subscriptions that match this event + for (const sub of this.host.subscriptions().values()) { + for (const filter of sub.filters) { + if (filter.kinds?.includes(24133)) { + // Check for #p tag filter - safe array access + const pTags = event.tags + .filter((tag) => { + try { + return ( + validateArrayAccess(tag, 0) && + safeArrayAccess(tag, 0) === "p" + ); + } catch { + return false; + } + }) + .map((tag) => { + try { + return safeArrayAccess(tag, 1); + } catch { + return null; + } + }) + .filter((val): val is string => typeof val === "string"); + const pFilters = filter["#p"] || []; + + // If there's a #p filter, make sure the event matches it + if ( + pFilters.length > 0 && + !pTags.some((tag) => pFilters.includes(tag)) + ) { + continue; + } + + if (typeof sub.subscriptionId !== "string") { + continue; + } + sub.instance.sendMatchedEvent(sub.subscriptionId, event); + break; + } + } + } + + // Send OK message + this.send(["OK", event.id, true, ""] as NostrOkMessage); + return; + } + + // Standard event processing + this.log.client("received event id:", event.id); + this.log.debug("event:", event); + + // Standard event processing - wrap validateEvent in try-catch + try { + if (!(await validateEvent(event))) { + this.log.debug("event failed validation (returned false):", event); + this.send([ + "OK", + event.id, + false, + "event failed validation: validateEvent returned false", + ] as NostrOkMessage); + return; + } + } catch (validationError) { + // If validateEvent itself throws (e.g. NostrValidationError from getEventHash) + let errorMessage = "event validation error"; + if (validationError instanceof Error) { + errorMessage = validationError.message; + } + this.log.debug( + `event failed validation (threw error): ${errorMessage}`, + event, + ); + this.send([ + "OK", + event.id, + false, + `invalid: ${errorMessage}`, + ] as NostrOkMessage); + return; + } + + this.send(["OK", event.id, true, ""] as NostrOkMessage); + this.host.store(event); + + for (const { filters, instance, subscriptionId } of this.host + .subscriptions() + .values()) { + for (const filter of filters) { + if (matchesFilter(event, filter)) { + instance.sendMatchedEvent(subscriptionId, event); + } + } + } + } catch (e) { + this.log.error("Error processing event:", e); + } + } + + private handleRequest(subscriptionId: string, filters: NostrFilter[]): void { + if (filters.length === 0) { + this.log.client("request has no filters"); + return; + } + + this.log.client("received subscription request:", subscriptionId); + this.log.debug("filters:", filters); + + // Add subscription + this.addSub(subscriptionId, ...filters); + + // For each filter + let count = 0; + for (const filter of filters) { + // Set the limit count, if any + let limitCount = filter.limit; + + for (const event of this.host.cachedEvents()) { + // If limit is reached, stop sending events + if (limitCount !== undefined && limitCount <= 0) break; + + // Check if event matches filter + if (matchesFilter(event, filter)) { + this.send(["EVENT", subscriptionId, event]); + count++; + this.log.client(`event matched in cache: ${event.id}`); + this.log.client(`event matched subscription: ${subscriptionId}`); + + // Update limit counter + if (limitCount !== undefined) limitCount--; + } + } + } + + this.log.debug(`sent ${count} matching events from cache`); + + // Send EOSE + this.send(["EOSE", subscriptionId] as NostrEoseMessage); + } + + get log() { + const write = ( + level: "error" | "info" | "debug" | "trace", + messages: unknown[], + ) => { + const [message = "", ...args] = messages; + this._logger[level]( + `[Relay client ${this._sid}] ${String(message)}`, + ...args.map(asDiagnosticArgument), + ); + }; + + return { + client: (...msg: unknown[]) => write("trace", msg), + debug: (...msg: unknown[]) => write("debug", msg), + info: (...msg: unknown[]) => write("info", msg), + error: (...msg: unknown[]) => write("error", msg), + }; + } + + addSub(subscriptionId: string, ...filters: NostrFilter[]) { + const uid = `${this.sid}/${subscriptionId}`; + this.host.subscriptions().set(uid, { + filters, + instance: this, + subscriptionId, + }); + this._subs.add(subscriptionId); + } + + remSub(subId: string) { + try { + const uid = `${this.sid}/${subId}`; + this.host.subscriptions().delete(uid); + this._subs.delete(subId); + } catch (e) { + // Ignore errors + } + } + + send(message: NostrRelayMessage) { + try { + if (this.socket.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify(message)); + } + } catch (e) { + this.log.error("Failed to send message:", e); + } + } + + sendMatchedEvent(subscriptionId: string, event: NostrEvent): void { + this.log.client(`event matched subscription: ${subscriptionId}`); + this.send(["EVENT", subscriptionId, event]); + } + + // Method to validate NIP-46 events + async validateNIP46Event(event: NostrEvent): Promise { + // Check required fields exist with proper types + if (!isValidPublicKeyPoint(event.pubkey)) { + this.log.debug("NIP-46 validation failed: invalid pubkey"); + return false; + } + + if (!event.created_at || typeof event.created_at !== "number") { + this.log.debug("NIP-46 validation failed: invalid created_at"); + return false; + } + + if (event.kind !== 24133) { + this.log.debug("NIP-46 validation failed: invalid kind"); + return false; + } + + if (!Array.isArray(event.tags)) { + this.log.debug("NIP-46 validation failed: invalid tags"); + return false; + } + + // For NIP-46, we need to have at least one p tag with a valid pubkey + const hasPTag = event.tags.some((tag: string[]) => { + try { + return ( + Array.isArray(tag) && + validateArrayAccess(tag, 0) && + validateArrayAccess(tag, 1) && + safeArrayAccess(tag, 0) === "p" && + typeof safeArrayAccess(tag, 1) === "string" && + isValidPublicKeyPoint(safeArrayAccess(tag, 1) as string) + ); + } catch (error) { + // If bounds checking fails, this tag is invalid + return false; + } + }); + + if (!hasPTag) { + // For debugging, log the tags structure + this.log.debug( + "NIP-46 validation failed: no valid p tag found", + JSON.stringify(event.tags), + ); + return false; + } + + if (typeof event.content !== "string") { + this.log.debug("NIP-46 validation failed: invalid content"); + return false; + } + + if ( + !event.sig || + typeof event.sig !== "string" || + !isValid64ByteHex(event.sig) + ) { + this.log.debug("NIP-46 validation failed: invalid signature"); + return false; + } + + // Verify signature for NIP-46 events using the canonical validateEvent + try { + if (!(await validateEvent(event))) { + this.log.debug( + "NIP-46 validation failed: invalid signature verification", + ); + return false; + } + } catch (error) { + this.log.debug( + "NIP-46 validation failed: error during signature verification", + error, + ); + return false; + } + + // Validate event.id: must be 64-char hex (case-insensitive) + if (!isValid32ByteHex(event.id)) { + this.log.debug("NIP-46 validation failed: invalid id format"); + return false; + } + + // For NIP-46, we've passed all the validation checks + return true; + } +} diff --git a/src/utils/ephemeral-relay/filter-match.ts b/src/utils/ephemeral-relay/filter-match.ts new file mode 100644 index 00000000..819b4a33 --- /dev/null +++ b/src/utils/ephemeral-relay/filter-match.ts @@ -0,0 +1,83 @@ +import type { NostrEvent, NostrFilter } from "../../types/nostr"; +import { safeArrayAccess, validateArrayAccess } from "../security-validator"; + +/** Return whether a Nostr Event satisfies one Subscription Filter. */ +export function matchesFilter( + event: NostrEvent, + filter: NostrFilter = {}, +): boolean { + const { authors, ids, kinds, since, until, search, ...rest } = filter; + + const tagFilters: string[][] = Object.entries(rest) + .filter(([key]) => key.startsWith("#")) + .map(([key, values]) => [key.slice(1), ...(values as string[])]); + + if (ids !== undefined && !ids.includes(event.id)) { + return false; + } + if (since !== undefined && event.created_at < since) { + return false; + } + if (until !== undefined && event.created_at > until) { + return false; + } + if (authors !== undefined && !authors.includes(event.pubkey)) { + return false; + } + if (kinds !== undefined && !kinds.includes(event.kind)) { + return false; + } + if (search !== undefined && search.length > 0) { + const query = search.toLowerCase(); + const contentMatch = event.content.toLowerCase().includes(query); + const tagMatch = event.tags.some((tag) => + tag.some((value) => value.toLowerCase().includes(query)), + ); + if (!contentMatch && !tagMatch) return false; + return tagFilters.length > 0 ? matchesTags(tagFilters, event.tags) : true; + } + return tagFilters.length > 0 ? matchesTags(tagFilters, event.tags) : true; +} + +function matchesTags(filters: string[][], tags: string[][]): boolean { + for (const filter of filters) { + let filterMatched = false; + + try { + if (!validateArrayAccess(filter, 0)) { + filterMatched = true; + continue; + } + + const key = safeArrayAccess(filter, 0); + const terms = filter.slice(1); + + if (terms.length === 0) { + filterMatched = true; + continue; + } + + for (const tag of tags) { + try { + if (!validateArrayAccess(tag, 0) || safeArrayAccess(tag, 0) !== key) { + continue; + } + + const params = tag.slice(1); + if (terms.some((term) => params.includes(term))) { + filterMatched = true; + break; + } + } catch { + // Malformed tags do not satisfy a filter. + } + } + } catch { + // Malformed filters do not match. + } + + if (!filterMatched) return false; + } + + return true; +} diff --git a/src/utils/ephemeral-relay/transport.ts b/src/utils/ephemeral-relay/transport.ts new file mode 100644 index 00000000..457492a3 --- /dev/null +++ b/src/utils/ephemeral-relay/transport.ts @@ -0,0 +1,296 @@ +import { EventEmitter } from "events"; +import { WebSocket, WebSocketServer } from "ws"; +import { + InMemoryWebSocketServer, + registerInMemoryServer, + unregisterInMemoryServer, +} from "../inMemoryWebSocket"; +import { asDiagnosticArgument } from "../diagnostics"; +import type { DiagnosticLogger } from "../logger"; +import { maybeUnref } from "../timers"; + +export interface RelayTransport { + readonly actualPort: number | null; + readonly server: WebSocketServer; + start(): Promise; + broadcast(message: string, sender: WebSocket): void; + close( + closeSessions: () => Promise, + forceSessions: () => void, + ): Promise; +} + +interface RelayTransportOptions { + port: number; + logger: DiagnosticLogger; + onConnection(socket: WebSocket): void; +} + +/** Create one private owner for Relay connection and shutdown mechanics. */ +export function createRelayTransport( + options: RelayTransportOptions, +): RelayTransport { + return new ManagedRelayTransport(options); +} + +/** Owns native/in-memory selection, connections, and ordered shutdown. */ +class ManagedRelayTransport implements RelayTransport { + private readonly port: number; + private readonly logger: DiagnosticLogger; + private readonly onConnection: (socket: WebSocket) => void; + private webSocketServer: WebSocketServer | null = null; + private inMemoryServer: InMemoryWebSocketServer | null = null; + private acceptingConnections = false; + private boundPort: number | null = null; + + constructor(options: RelayTransportOptions) { + this.port = options.port; + this.logger = options.logger; + this.onConnection = options.onConnection; + } + + get actualPort(): number | null { + return this.boundPort; + } + + get server(): WebSocketServer { + if (!this.webSocketServer) { + throw new Error("websocket server not initialized"); + } + return this.webSocketServer; + } + + async start(): Promise { + // Bun on Linux CI has shown flakiness with real TCP listeners (port 0 / + // ephemeral ports). Prefer the in-memory transport for deterministic tests. + if ( + this.port === 0 && + typeof (globalThis as unknown as { Bun?: unknown }).Bun !== "undefined" + ) { + await this.startInMemory(); + return; + } + + await new Promise((resolve, reject) => { + try { + const server = new WebSocketServer({ + port: this.port, + host: "127.0.0.1", + }); + this.webSocketServer = server; + server.on("connection", (socket) => this.accept(socket)); + + const cleanup = () => { + server.off("listening", onListening); + server.off("error", onError); + }; + + const onListening = () => { + cleanup(); + server.on("error", (error) => this.handleRuntimeError(error)); + const address = server.address(); + if (address && typeof address === "object" && "port" in address) { + this.boundPort = + typeof address.port === "number" && address.port > 0 + ? address.port + : null; + } + + // Some compatibility runtimes can bind port 0 without reporting the + // selected port. Use the in-memory transport instead of exposing :0. + if (this.port === 0 && !this.boundPort) { + this.resetFailedWebSocketServer(server); + this.startInMemory().then(resolve).catch(reject); + return; + } + + this.acceptingConnections = true; + resolve(); + }; + + const onError = (error: unknown) => { + cleanup(); + if (this.shouldFallbackToInMemory(error)) { + this.resetFailedWebSocketServer(server); + this.startInMemory().then(resolve).catch(reject); + } else { + this.resetFailedWebSocketServer(server); + reject(error); + } + }; + + server.once("listening", onListening); + server.once("error", onError); + } catch (error) { + if (this.shouldFallbackToInMemory(error)) { + this.startInMemory().then(resolve).catch(reject); + } else { + reject(error); + } + } + }); + } + + broadcast(message: string, sender: WebSocket): void { + this.server.clients.forEach((client) => { + if (client !== sender && client.readyState === WebSocket.OPEN) { + client.send(message); + } + }); + } + + async close( + closeSessions: () => Promise, + forceSessions: () => void, + ): Promise { + this.acceptingConnections = false; + + const inMemoryServer = this.inMemoryServer; + const webSocketServer = inMemoryServer ? null : this.webSocketServer; + const transportShutdown = webSocketServer + ? this.closeWebSocketTransport(webSocketServer) + : Promise.resolve(); + let sessionTimeout: NodeJS.Timeout | null = null; + + try { + if (inMemoryServer) { + unregisterInMemoryServer(this.boundPort || this.port); + await closeSessions(); + } else if (webSocketServer) { + const sessionShutdown = closeSessions(); + const timedOut = new Promise((resolve) => { + sessionTimeout = setTimeout(() => resolve(true), 1000); + maybeUnref(sessionTimeout); + }); + const sessionsClosed = sessionShutdown.then(() => false); + + if (await Promise.race([sessionsClosed, timedOut])) { + this.logger.warn("Relay client close timed out; forcing cleanup"); + forceSessions(); + await sessionShutdown; + } + } + } finally { + if (sessionTimeout) clearTimeout(sessionTimeout); + + try { + await transportShutdown; + inMemoryServer?.removeAllListeners(); + webSocketServer?.removeAllListeners(); + } finally { + this.inMemoryServer = null; + this.webSocketServer = null; + this.boundPort = null; + } + } + } + + private accept(socket: WebSocket): void { + if (!this.acceptingConnections) { + try { + socket.terminate(); + } catch { + try { + socket.close(1001, "Relay shutting down"); + } catch { + // The Relay is already closing; there is no session state to retain. + } + } + return; + } + + this.onConnection(socket); + } + + private startInMemory(): Promise { + const { server, port } = registerInMemoryServer( + this.port === 0 ? undefined : this.port, + ); + + this.inMemoryServer = server; + this.webSocketServer = server as unknown as WebSocketServer; + this.boundPort = port; + server.on("connection", (socket: EventEmitter) => + this.accept(socket as WebSocket), + ); + server.on("error", (error) => this.handleRuntimeError(error)); + this.acceptingConnections = true; + + return new Promise((resolve) => queueMicrotask(resolve)); + } + + private shouldFallbackToInMemory(error: unknown): boolean { + if (!error || typeof error !== "object") { + return false; + } + const code = + "code" in error && typeof (error as { code: unknown }).code === "string" + ? (error as { code: string }).code + : ""; + const message = + "message" in error && + typeof (error as { message: unknown }).message === "string" + ? (error as { message: string }).message + : ""; + // In restricted runtimes, binding to port 0 may report EADDRINUSE even + // though no specific port was requested; fall back to in-memory relay. + const isDynamicPortConflict = + this.port === 0 && + (code === "EADDRINUSE" || message.includes("EADDRINUSE")); + return ( + isDynamicPortConflict || + code === "EACCES" || + code === "EPERM" || + code === "EADDRNOTAVAIL" || + message.includes("EPERM") || + message.includes("EACCES") || + message.includes("EADDRNOTAVAIL") + ); + } + + private handleRuntimeError(error: unknown): void { + this.logger.warn("Relay transport error", { + error: asDiagnosticArgument(error), + }); + } + + private resetFailedWebSocketServer(server: WebSocketServer): void { + server.removeAllListeners(); + try { + server.close(); + } catch { + // A listener that failed to bind may already be fully closed. + } + if (this.webSocketServer === server) this.webSocketServer = null; + this.boundPort = null; + this.acceptingConnections = false; + } + + private closeWebSocketTransport(server: WebSocketServer): Promise { + return new Promise((resolve) => { + let timeout: NodeJS.Timeout | null = null; + const finish = () => { + if (timeout) clearTimeout(timeout); + timeout = null; + resolve(); + }; + + timeout = setTimeout(() => { + this.logger.warn("Relay transport close timed out; forcing cleanup"); + finish(); + }, 1000); + maybeUnref(timeout); + + try { + // Calling close immediately stops the transport accepting new sockets; + // its callback still waits for tracked sessions to drain. + server.close(finish); + } catch (error) { + this.logger.warn("Relay transport close failed", { + error: asDiagnosticArgument(error), + }); + finish(); + } + }); + } +} diff --git a/src/utils/key-validation.ts b/src/utils/key-validation.ts new file mode 100644 index 00000000..2585fa1f --- /dev/null +++ b/src/utils/key-validation.ts @@ -0,0 +1,41 @@ +import { secp256k1 } from "@noble/curves/secp256k1"; +import { isHexOfLength } from "./wire-validation"; + +const FIELD_PRIME = BigInt( + "0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f", +); + +export function isValidPublicKeyFormat(publicKey: string): boolean { + if (!isHexOfLength(publicKey, 64)) return false; + if (/^0{64}$/.test(publicKey) || /^f{64}$/.test(publicKey)) return false; + + try { + return BigInt(`0x${publicKey}`) < FIELD_PRIME; + } catch { + return false; + } +} + +export function isValidPublicKeyPoint(publicKey: string): boolean { + if (!isValidPublicKeyFormat(publicKey)) return false; + + for (const prefix of ["02", "03"]) { + try { + secp256k1.ProjectivePoint.fromHex(prefix + publicKey); + return true; + } catch { + // Try the other possible y-coordinate. + } + } + return false; +} + +export function isValidPrivateKey(privateKey: string): boolean { + if (!isHexOfLength(privateKey, 64)) return false; + try { + secp256k1.getPublicKey(privateKey); + return true; + } catch { + return false; + } +} diff --git a/src/utils/security-limits.ts b/src/utils/security-limits.ts new file mode 100644 index 00000000..33cd695e --- /dev/null +++ b/src/utils/security-limits.ts @@ -0,0 +1,35 @@ +/** Canonical resource limits shared by protocol-neutral validation consumers. */ +export const SECURITY_LIMITS = { + MAX_CONTENT_SIZE: 100000, + MAX_TAG_SIZE: 1000, + MAX_TAG_COUNT: 100, + MAX_TAG_ELEMENT_SIZE: 512, + MAX_FILTER_COUNT: 20, + MAX_FILTER_IDS: 1000, + MAX_FILTER_AUTHORS: 1000, + MAX_FILTER_KINDS: 100, + MAX_FILTER_TAG_VALUES: 1000, + MAX_SEARCH_LENGTH: 500, + MAX_ARRAY_SIZE: 10000, + MAX_OBJECT_DEPTH: 10, + MAX_STRING_LENGTH: 100000, + MAX_URL_LENGTH: 2048, + MAX_PUBKEY_LENGTH: 64, + MAX_SIGNATURE_LENGTH: 128, + MAX_ID_LENGTH: 64, + MIN_KIND: 0, + MAX_KIND: 65535, + MIN_CREATED_AT: 946684800, + MAX_CREATED_AT: 4102444800, + MIN_LIMIT: 0, + MAX_LIMIT: 5000, + MIN_SINCE: 946684800, + MAX_SINCE: 4102444800, + MIN_UNTIL: 946684800, + MAX_UNTIL: 4102444800, + MAX_RELAY_EVENT_BUFFERS: 1000, + MAX_EVENTS_PER_BUFFER: 100, + MAX_REPLACEABLE_EVENT_PUBKEYS: 10000, + MAX_REPLACEABLE_EVENTS_PER_PUBKEY: 50, + MAX_ADDRESSABLE_EVENTS: 50000, +} as const; diff --git a/src/utils/security-validator.ts b/src/utils/security-validator.ts index 1d84fe5c..0aa6337b 100644 --- a/src/utils/security-validator.ts +++ b/src/utils/security-validator.ts @@ -6,53 +6,14 @@ */ import { Filter } from "../types/nostr"; +import { isHexOfLength } from "./wire-validation"; +import { SECURITY_LIMITS } from "./security-limits"; +import type { DiagnosticLogger } from "./logger"; +import { createDefaultDiagnosticLogger, reportDiagnostic } from "./diagnostics"; -// Security Constants -export const SECURITY_LIMITS = { - // Content size limits (prevent DoS via large payloads) - MAX_CONTENT_SIZE: 100000, // 100KB - MAX_TAG_SIZE: 1000, - MAX_TAG_COUNT: 100, - MAX_TAG_ELEMENT_SIZE: 512, - - // Filter limits (prevent DoS via complex filters) - MAX_FILTER_COUNT: 20, - MAX_FILTER_IDS: 1000, - MAX_FILTER_AUTHORS: 1000, - MAX_FILTER_KINDS: 100, - MAX_FILTER_TAG_VALUES: 1000, - MAX_SEARCH_LENGTH: 500, - - // Array access safety - MAX_ARRAY_SIZE: 10000, - MAX_OBJECT_DEPTH: 10, - - // String limits - MAX_STRING_LENGTH: 100000, - MAX_URL_LENGTH: 2048, - MAX_PUBKEY_LENGTH: 64, - MAX_SIGNATURE_LENGTH: 128, - MAX_ID_LENGTH: 64, - - // Numeric limits - MIN_KIND: 0, - MAX_KIND: 65535, - MIN_CREATED_AT: 946684800, // Jan 1, 2000 - MAX_CREATED_AT: 4102444800, // Jan 1, 2100 - MIN_LIMIT: 0, - MAX_LIMIT: 5000, - MIN_SINCE: 946684800, // Jan 1, 2000 - MAX_SINCE: 4102444800, // Jan 1, 2100 - MIN_UNTIL: 946684800, // Jan 1, 2000 - MAX_UNTIL: 4102444800, // Jan 1, 2100 - - // Memory limits for relay buffers (prevent memory exhaustion) - MAX_RELAY_EVENT_BUFFERS: 1000, // Maximum number of event buffers per relay - MAX_EVENTS_PER_BUFFER: 100, // Maximum events per buffer - MAX_REPLACEABLE_EVENT_PUBKEYS: 10000, // Maximum pubkeys to track replaceable events - MAX_REPLACEABLE_EVENTS_PER_PUBKEY: 50, // Maximum replaceable events per pubkey - MAX_ADDRESSABLE_EVENTS: 50000, // Maximum addressable events to store -} as const; +const defaultLogger = createDefaultDiagnosticLogger({ prefix: "security" }); + +export { SECURITY_LIMITS } from "./security-limits"; // Security error types export class SecurityValidationError extends Error { @@ -651,7 +612,7 @@ export function validatePrivateKey( ); } - if (!/^[0-9a-f]{64}$/i.test(keyStr)) { + if (!isHexOfLength(keyStr, 64)) { throw new SecurityValidationError( "Private key must be valid hex (64 characters)", "INVALID_PRIVATE_KEY_FORMAT", @@ -668,6 +629,7 @@ export function enforceMemoryLimits( maxSize: number, accessTracker?: Map, context: string = "memory", + logger: DiagnosticLogger = defaultLogger, ): void { if (map.size <= maxSize) { return; @@ -709,9 +671,12 @@ export function enforceMemoryLimits( } } - if (typeof console !== "undefined" && console.warn && removedCount > 0) { - console.warn( - `Security: Enforced memory limit for ${context}, removed ${removedCount} entries (${initialSize} -> ${map.size})`, - ); + if (removedCount > 0) { + reportDiagnostic(logger, "warn", "Enforced memory limit", { + finalSize: map.size, + initialSize, + removedCount, + scope: context, + }); } } diff --git a/src/utils/test-helpers.ts b/src/utils/test-helpers.ts deleted file mode 100644 index 7e0ea5b0..00000000 --- a/src/utils/test-helpers.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { NostrRelay } from "./ephemeral-relay"; - -let relay: NostrRelay | null = null; - -export async function startEphemeralRelay(port: number = 0): Promise { - relay = new NostrRelay(port); - await relay.start(); - return relay.url; -} - -export async function stopEphemeralRelay(): Promise { - if (relay) { - try { - // Close the relay and wait for it to finish - await relay.close(); - } catch (error) { - console.error("Error closing ephemeral relay:", error); - } finally { - // Ensure the relay reference is cleared even if there's an error - relay = null; - - // Add a longer delay to ensure all resources are released - // This helps prevent test failures due to port conflicts - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - } -} diff --git a/src/utils/wire-validation.ts b/src/utils/wire-validation.ts new file mode 100644 index 00000000..112ccdbf --- /dev/null +++ b/src/utils/wire-validation.ts @@ -0,0 +1,25 @@ +/** Validate a case-insensitive hexadecimal wire value at an exact width. */ +export function isHexOfLength(value: unknown, length: number): value is string { + return ( + typeof value === "string" && + value.length === length && + /^[0-9a-f]+$/i.test(value) + ); +} + +/** Validate a lowercase hexadecimal wire value at an exact width. */ +export function isLowercaseHexOfLength( + value: unknown, + length: number, +): value is string { + return ( + typeof value === "string" && + value.length === length && + /^[0-9a-f]+$/.test(value) + ); +} + +/** Measure a wire string by its UTF-8 representation rather than UTF-16 units. */ +export function utf8ByteLength(value: string): number { + return new TextEncoder().encode(value).length; +} diff --git a/tests/README.md b/tests/README.md index b5631a6e..dc6f4961 100644 --- a/tests/README.md +++ b/tests/README.md @@ -66,18 +66,29 @@ Tests are organized into directories by NIP number, with subdirectories for spec ## Running Tests -To run all tests: +To run the routine feedback lane: ```bash npm test +bun run test:bun ``` -To run all tests with Bun: +To run only the named slow security and performance lane: ```bash -bun run test:bun +npm run test:slow +bun run test:bun:slow ``` +To run the complete assurance set in either runtime: + +```bash +npm run test:all +bun run test:bun:all +``` + +Each complete command already runs its routine and slow lanes; do not run the standalone slow command first. + To run tests for a specific NIP: ```bash diff --git a/tests/integration.test.ts b/tests/integration.test.ts index e25f5369..5285b79c 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -1,11 +1,7 @@ import { Nostr } from "../src/nip01/nostr"; import { NostrEvent, RelayEvent } from "../src/types/nostr"; import { generateKeypair } from "../src/utils/crypto"; -import { - startEphemeralRelay, - stopEphemeralRelay, -} from "../src/utils/test-helpers"; -import { getNostrInternals } from "./types"; +import { startEphemeralRelay, stopEphemeralRelay } from "./utils/test-helpers"; describe("Nostr Client Integration", () => { let nostr: Nostr; @@ -54,16 +50,11 @@ describe("Nostr Client Integration", () => { it("should handle connection timeouts gracefully", async () => { // Create a new client with a non-routable address to force timeout - const timeoutClient = new Nostr(["ws://10.255.255.255:8080"]); + const timeoutClient = new Nostr(["ws://10.255.255.255:8080"], { + relayOptions: { connectionTimeout: 500 }, + }); timeoutClient.setPrivateKey(privateKey); - // Set a very short timeout to make test fast - // Access the relay directly to set the timeout - const relays = getNostrInternals(timeoutClient).relays; - for (const relay of relays.values()) { - relay.setConnectionTimeout(500); - } - // Track connection errors let errorCount = 0; timeoutClient.on(RelayEvent.Error, () => { diff --git a/tests/nip01/event/addressable-events.test.ts b/tests/nip01/event/addressable-events.test.ts index 34ad947d..3d99dc65 100644 --- a/tests/nip01/event/addressable-events.test.ts +++ b/tests/nip01/event/addressable-events.test.ts @@ -3,16 +3,14 @@ import { createAddressableEvent, createSignedEvent, } from "../../../src/nip01/event"; -import { Relay } from "../../../src/nip01/relay"; -import { asTestRelay } from "../../types"; +import { RelayEventStore } from "../../../src/nip01/relayEventStore"; import { NostrEvent } from "../../../src/types/nostr"; // Mock WebSocket jest.mock("websocket-polyfill", () => ({})); describe("Addressable Events (NIP-01 §7.1)", () => { - let relay: Relay; - let testRelay: ReturnType; + let store: RelayEventStore; let user1Keypair: { privateKey: string; publicKey: string }; let testEvents: { event1: NostrEvent; @@ -22,9 +20,7 @@ describe("Addressable Events (NIP-01 §7.1)", () => { }; beforeEach(async () => { - // Create a relay instance and get test access to private methods - relay = new Relay("wss://test.relay"); - testRelay = asTestRelay(relay); + store = new RelayEventStore(); // Set up test data user1Keypair = await generateKeypair(); @@ -71,17 +67,17 @@ describe("Addressable Events (NIP-01 §7.1)", () => { test("Different d-tag values create separate addressable events", async () => { // Process events with same pubkey and kind but different d-tag values - testRelay.processValidatedEvent(testEvents.event1, "sub1"); - testRelay.processValidatedEvent(testEvents.event2, "sub1"); + store.storeAddressable(testEvents.event1); + store.storeAddressable(testEvents.event2); // Get latest events for each d-tag value - const latest1 = relay.getLatestAddressableEvent( + const latest1 = store.getAddressable( 30000, user1Keypair.publicKey, "value1", ); - const latest2 = relay.getLatestAddressableEvent( + const latest2 = store.getAddressable( 30000, user1Keypair.publicKey, "value2", @@ -96,13 +92,13 @@ describe("Addressable Events (NIP-01 §7.1)", () => { test("Newer event with same d-tag replaces older one", async () => { // Process original event - testRelay.processValidatedEvent(testEvents.event1, "sub1"); + store.storeAddressable(testEvents.event1); // Process newer event with same d-tag - testRelay.processValidatedEvent(testEvents.event3, "sub1"); + store.storeAddressable(testEvents.event3); // Get latest event - const latest = relay.getLatestAddressableEvent( + const latest = store.getAddressable( 30000, user1Keypair.publicKey, "value1", @@ -115,17 +111,17 @@ describe("Addressable Events (NIP-01 §7.1)", () => { test("Different kinds with same d-tag are stored separately", async () => { // Process events with same pubkey and d-tag but different kinds - testRelay.processValidatedEvent(testEvents.event1, "sub1"); - testRelay.processValidatedEvent(testEvents.event4, "sub1"); + store.storeAddressable(testEvents.event1); + store.storeAddressable(testEvents.event4); // Get latest events for each kind - const latest1 = relay.getLatestAddressableEvent( + const latest1 = store.getAddressable( 30000, user1Keypair.publicKey, "value1", ); - const latest2 = relay.getLatestAddressableEvent( + const latest2 = store.getAddressable( 30001, user1Keypair.publicKey, "value1", @@ -170,10 +166,10 @@ describe("Addressable Events (NIP-01 §7.1)", () => { // At this point, eventA.id < eventB.id is guaranteed // Scenario 1: Process larger ID event first, then smaller ID event - testRelay.processAddressableEvent(eventB); // Process B (larger id) - testRelay.processAddressableEvent(eventA); // Process A (smaller id) + store.storeAddressable(eventB); // Process B (larger id) + store.storeAddressable(eventA); // Process A (smaller id) - let latestEvent = relay.getLatestAddressableEvent( + let latestEvent = store.getAddressable( 30000, user1Keypair.publicKey, dTagValue, @@ -189,10 +185,10 @@ describe("Addressable Events (NIP-01 §7.1)", () => { // For this specific test targeting `processAddressableEvent` directly, the previous event is overwritten. // Scenario 2: Process smaller ID event first, then larger ID event - testRelay.processAddressableEvent(eventA); // Process A (smaller id) - testRelay.processAddressableEvent(eventB); // Process B (larger id) + store.storeAddressable(eventA); // Process A (smaller id) + store.storeAddressable(eventB); // Process B (larger id) - latestEvent = relay.getLatestAddressableEvent( + latestEvent = store.getAddressable( 30000, user1Keypair.publicKey, dTagValue, diff --git a/tests/nip01/event/event-ordering-integration.test.ts b/tests/nip01/event/event-ordering-integration.test.ts index 887fa9e6..8e2887e0 100644 --- a/tests/nip01/event/event-ordering-integration.test.ts +++ b/tests/nip01/event/event-ordering-integration.test.ts @@ -5,11 +5,22 @@ import { Relay } from "../../../src/nip01/relay"; import { NostrEvent } from "../../../src/types/nostr"; +import { + replaceRelayInboundValidator, + waitForRelayValidation, +} from "../../../src/testing"; import { useWebSocketImplementation, resetWebSocketImplementation, } from "../../../src/utils/websocket"; -import { afterEach, beforeEach, describe, expect, jest, test } from "@jest/globals"; +import { + afterEach, + beforeEach, + describe, + expect, + jest, + test, +} from "@jest/globals"; /** * Mock WebSocket interface for testing @@ -36,14 +47,7 @@ describe("Relay Event Ordering Integration", () => { let relay: Relay; let onEventCallback: jest.Mock; let onEOSECallback: jest.Mock; - - // Type assertion function to access private members for testing - const asTestable = (r: Relay) => - r as unknown as { - sortEvents: (events: NostrEvent[]) => NostrEvent[]; - flushSubscriptionBuffer: (subscriptionId: string) => void; - eventBuffers: Map; - }; + let restoreValidator: () => void; beforeEach(() => { jest.useFakeTimers(); @@ -72,17 +76,17 @@ describe("Relay Event Ordering Integration", () => { // Create a relay with a 50ms buffer flush delay relay = new Relay("wss://test-relay.com", { bufferFlushDelay: 50 }); - // Expose private methods for testing - const testable = asTestable(relay); - testable.sortEvents = testable.sortEvents.bind(relay); - testable.flushSubscriptionBuffer = - testable.flushSubscriptionBuffer.bind(relay); + restoreValidator = replaceRelayInboundValidator( + relay, + async (event) => event as NostrEvent, + ); }); afterEach(() => { jest.clearAllMocks(); jest.useRealTimers(); resetWebSocketImplementation(); + restoreValidator?.(); if (relay) { relay.disconnect(); } @@ -146,16 +150,13 @@ describe("Relay Event Ordering Integration", () => { sig: "sig", }; - // Manually add events to the buffer - asTestable(relay).eventBuffers.set(subscriptionId, [ - event1, - event3, - event4, - event2, - ]); - - // Directly call flushSubscriptionBuffer - asTestable(relay).flushSubscriptionBuffer(subscriptionId); + for (const event of [event1, event3, event4, event2]) { + mockSocketInstance.onmessage?.({ + data: JSON.stringify(["EVENT", subscriptionId, event]), + } as MessageEvent); + } + await waitForRelayValidation(relay, subscriptionId); + jest.advanceTimersByTime(50); // Now events should be delivered in the correct order expect(onEventCallback).toHaveBeenCalledTimes(4); @@ -215,13 +216,17 @@ describe("Relay Event Ordering Integration", () => { sig: "sig", }; - // Manually add events to the buffer - asTestable(relay).eventBuffers.set(subscriptionId, [event1, event2]); + for (const event of [event1, event2]) { + mockSocketInstance.onmessage?.({ + data: JSON.stringify(["EVENT", subscriptionId, event]), + } as MessageEvent); + } // Simulate receiving EOSE mockSocketInstance.onmessage?.({ data: JSON.stringify(["EOSE", subscriptionId]), } as MessageEvent); + await waitForRelayValidation(relay, subscriptionId); // EOSE should trigger immediate buffer flush expect(onEventCallback).toHaveBeenCalledTimes(2); @@ -261,11 +266,9 @@ describe("Relay Event Ordering Integration", () => { sig: "sig", }; - // Manually add events to the buffer - asTestable(relay).eventBuffers.set(subscriptionId, [event]); - - // Directly call flushSubscriptionBuffer - asTestable(relay).flushSubscriptionBuffer(subscriptionId); + mockSocketInstance.onmessage?.({ + data: JSON.stringify(["EVENT", subscriptionId, event]), + } as MessageEvent); // No events should be delivered expect(onEventCallback).not.toHaveBeenCalled(); diff --git a/tests/nip01/event/event-ordering.test.ts b/tests/nip01/event/event-ordering.test.ts index 89ed18d5..26f332a7 100644 --- a/tests/nip01/event/event-ordering.test.ts +++ b/tests/nip01/event/event-ordering.test.ts @@ -4,25 +4,15 @@ * 1. By created_at timestamp (newest first) * 2. By event ID (lexically) when timestamps are the same * - * Note: The integration test for the relay's buffer mechanism is in - * event-ordering-integration.test.ts but requires additional work to properly - * mock the WebSocket connections. + * The integration test for Relay buffer delivery lives in + * event-ordering-integration.test.ts. */ import { NostrEvent } from "../../../src/types/nostr"; +import { RelayEventStore } from "../../../src/nip01/relayEventStore"; describe("Event ordering", () => { - // Simple implementation of the sorting function based on NIP-01 spec - function sortEvents(events: NostrEvent[]): NostrEvent[] { - return [...events].sort((a, b) => { - // Sort by created_at (descending - newer events first) - if (a.created_at !== b.created_at) { - return b.created_at - a.created_at; - } - // If created_at is the same, sort by id (ascending lexical order) - return a.id.localeCompare(b.id); - }); - } + const sortEvents = RelayEventStore.sortEvents; test("should order events by created_at (newest first)", () => { // Create test events with different timestamps diff --git a/tests/nip01/event/nostr-publish.test.ts b/tests/nip01/event/nostr-publish.test.ts index d223302c..2ff9c697 100644 --- a/tests/nip01/event/nostr-publish.test.ts +++ b/tests/nip01/event/nostr-publish.test.ts @@ -7,6 +7,7 @@ import { RelayStatus, NostrFilter, } from "../../../src/types/nostr"; +import { installNostrTestRelay } from "../../../src/testing"; // Simulated relay for testing class MockRelay { @@ -79,37 +80,20 @@ class MockRelay { } } -// Interface for Nostr private members we need to access in tests -interface NostrPrivateMembers { - privateKey: string; - publicKey: string; - relays: Map; // Properly typed for our test context -} - -// Extended Nostr class for testing that exposes private members -class TestNostr extends Nostr { - getPrivateMembers(): NostrPrivateMembers { - return this as unknown as NostrPrivateMembers; - } - - setPrivateKey(key: string): void { - this.getPrivateMembers().privateKey = key; - } - - setPublicKey(key: string): void { - this.getPrivateMembers().publicKey = key; - } - - setRelays(relays: Map): void { - this.getPrivateMembers().relays = relays; +function installMockRelays( + nostr: Nostr, + relays: ReadonlyMap, +): void { + for (const [url, relay] of relays) { + installNostrTestRelay(nostr, url, relay); } } describe("Nostr publish methods", () => { - let nostr: TestNostr; + let nostr: Nostr; beforeEach(() => { - nostr = new TestNostr(); + nostr = new Nostr(); }); describe("publishEvent", () => { @@ -130,12 +114,7 @@ describe("Nostr publish methods", () => { ); // Inject mock relays - nostr.setRelays(relays); - - // Set required keys - nostr.setPrivateKey("test-private-key"); - nostr.setPublicKey("test-public-key"); - + installMockRelays(nostr, relays); // Create test event const event: NostrEvent = { id: "test-event-id", @@ -175,12 +154,7 @@ describe("Nostr publish methods", () => { ); // Inject mock relays - nostr.setRelays(relays); - - // Set required keys - nostr.setPrivateKey("test-private-key"); - nostr.setPublicKey("test-public-key"); - + installMockRelays(nostr, relays); // Create test event const event: NostrEvent = { id: "test-event-id", @@ -205,12 +179,6 @@ describe("Nostr publish methods", () => { it("should handle having no relays configured", async () => { // Empty relays map - nostr.setRelays(new Map()); - - // Set required keys - nostr.setPrivateKey("test-private-key"); - nostr.setPublicKey("test-public-key"); - // Create test event const event: NostrEvent = { id: "test-event-id", @@ -256,12 +224,7 @@ describe("Nostr publish methods", () => { ); // Inject mock relays - nostr.setRelays(relays); - - // Set required keys - nostr.setPrivateKey("test-private-key"); - nostr.setPublicKey("test-public-key"); - + installMockRelays(nostr, relays); // Create test event const event: NostrEvent = { id: "test-event-id", diff --git a/tests/nip01/nostr.test.ts b/tests/nip01/nostr.test.ts index b9de4a15..2fb277c5 100644 --- a/tests/nip01/nostr.test.ts +++ b/tests/nip01/nostr.test.ts @@ -3,14 +3,18 @@ import { NostrEvent, Filter, RelayEvent, - Relay, RelayReceivedEvent, } from "../../src"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { + dispatchRelayMessage, + getRelaySocket, + installNostrTestRelay, + NostrRelay, +} from "../../src/testing"; import { generateKeypair } from "../../src/utils/crypto"; import { encrypt as encryptNIP04 } from "../../src/nip04"; import { createMetadataEvent } from "../../src/nip01/event"; -import { getNostrInternals, asTestRelay, testUtils } from "../types"; +import { testUtils } from "../types"; // Use ephemeral relay for all tests let ephemeralRelay: NostrRelay; @@ -72,9 +76,28 @@ describe("Nostr Client", () => { client.addRelay(additionalRelay); // This test verifies the relay was added but doesn't need to connect - const relays = getNostrInternals(client).relays; const normalizedRelay = testUtils.normalizeRelayUrl(additionalRelay); - expect(relays.has(normalizedRelay)).toBe(true); + expect(client.getRelay(normalizedRelay)).toBeDefined(); + }); + + test("should use one Relay identity across normalized public operations", () => { + const parsed = new URL(ephemeralRelay.url); + const equivalentUrl = `${parsed.protocol}//${parsed.hostname.toUpperCase()}:${parsed.port}/`; + + const existing = client.getRelay(ephemeralRelay.url); + expect(client.addRelay(equivalentUrl)).toBe(existing); + expect(client.getRelay(equivalentUrl)).toBe(existing); + + client.removeRelay(equivalentUrl); + expect(client.getRelay(ephemeralRelay.url)).toBeUndefined(); + }); + + test("should keep invalid public lookup and removal operations graceful", () => { + expect(client.getRelay("https://not-a-relay.example")).toBeUndefined(); + expect(() => + client.removeRelay("https://not-a-relay.example"), + ).not.toThrow(); + expect(client.getRelay(ephemeralRelay.url)).toBeDefined(); }); test("should connect to relays", async () => { @@ -93,8 +116,8 @@ describe("Nostr Client", () => { challenge = nextChallenge; }); - const relay = Array.from(getNostrInternals(client).relays.values())[0]; - asTestRelay(relay as Relay).handleMessage(["AUTH", "relay-challenge"]); + const relay = client.getRelay(ephemeralRelay.url)!; + dispatchRelayMessage(relay, ["AUTH", "relay-challenge"]); expect(challenge).toBe("relay-challenge"); }); @@ -120,9 +143,8 @@ describe("Nostr Client", () => { const relayUrl = ephemeralRelay.url; client.removeRelay(relayUrl); - const relays = getNostrInternals(client).relays; const normalizedRelayUrl = testUtils.normalizeRelayUrl(relayUrl); - expect(relays.has(normalizedRelayUrl)).toBe(false); + expect(client.getRelay(normalizedRelayUrl)).toBeUndefined(); }); }); @@ -140,7 +162,7 @@ describe("Nostr Client", () => { }); test("should publish metadata", async () => { - await client.generateKeys(); + const keys = await client.generateKeys(); await client.connectToRelays(); const metadata = { @@ -155,12 +177,8 @@ describe("Nostr Client", () => { // In test environments, the event might return null if the relay doesn't respond in time // But we can verify the event was created correctly before publishing if (!event) { - // Directly access the last created event to verify it was created properly - const privateKey = getNostrInternals(client).privateKey; - const _publicKey = getNostrInternals(client).publicKey; - // Create the event directly to verify structure - const metadataEvent = createMetadataEvent(metadata, privateKey); + const metadataEvent = createMetadataEvent(metadata, keys.privateKey); expect(metadataEvent.kind).toBe(0); expect(JSON.parse(metadataEvent.content)).toEqual(metadata); } else { @@ -187,10 +205,9 @@ describe("Nostr Client", () => { const note = await client.publishTextNote("relay-aware fetch"); expect(note).toBeDefined(); - const events = await client.fetchManyDetailed( - [{ ids: [note!.id] }], - { maxWait: 500 }, - ); + const events = await client.fetchManyDetailed([{ ids: [note!.id] }], { + maxWait: 500, + }); expect(events).toHaveLength(2); expect(events.map((event) => event.event.id)).toEqual([ @@ -257,9 +274,9 @@ describe("Nostr Client", () => { ); expect(subscriptionIdsByRelay.get(ephemeralRelay.url)).toBeTruthy(); expect(subscriptionIdsByRelay.get(secondRelay.url)).toBeTruthy(); - expect( - subscriptionIdsByRelay.get(ephemeralRelay.url), - ).not.toEqual(subscriptionIdsByRelay.get(secondRelay.url)); + expect(subscriptionIdsByRelay.get(ephemeralRelay.url)).not.toEqual( + subscriptionIdsByRelay.get(secondRelay.url), + ); } finally { await secondRelay.close(); } @@ -407,8 +424,7 @@ describe("Nostr Client", () => { autoClose: true, }); - const relayMap = getNostrInternals(client).relays as Map; - const relay = Array.from(relayMap.values())[0] as Relay; + const relay = client.getRelay(ephemeralRelay.url)!; expect(relay.getSubscriptionIds().has(subIds[0])).toBe(true); @@ -426,10 +442,10 @@ describe("Nostr Client", () => { const client = new Nostr([mockRelay.url]); await client.connectToRelays(); - // Get first relay using type assertion to access private property - const relayMap = getNostrInternals(client).relays as Map; - const relay = Array.from(relayMap.values())[0] as Relay; + const relay = client.getRelay(mockRelay.url)!; expect(relay).toBeDefined(); + const connectedSocket = getRelaySocket(relay); + expect(connectedSocket?.readyState).toBe(1); // Spy on relay's unsubscribe method const unsubscribeSpy = jest.spyOn(relay, "unsubscribe"); @@ -454,8 +470,9 @@ describe("Nostr Client", () => { // Verify all subscriptions were removed expect(relay.getSubscriptionIds().size).toBe(0); - // Verify relay is still connected (not disconnected) - expect(asTestRelay(relay).connected).toBe(true); + // Verify unsubscribeAll preserved the same open transport. + expect(getRelaySocket(relay)).toBe(connectedSocket); + expect(connectedSocket?.readyState).toBe(1); // Cleanup client.disconnectFromRelays(); @@ -553,10 +570,8 @@ describe("Nostr Client", () => { expect(events[0].content).toBe("multi-note"); // ensure subscriptions cleaned up - getNostrInternals(multi).relays.forEach((relay) => { - const testRelay = asTestRelay(relay as Relay); - expect(testRelay.getSubscriptionIds().size).toBe(0); - }); + expect(multi.getRelay(relayA.url)?.getSubscriptionIds().size).toBe(0); + expect(multi.getRelay(relayB.url)?.getSubscriptionIds().size).toBe(0); multi.disconnectFromRelays(); await relayA.close(); @@ -982,13 +997,10 @@ describe("Nostr client", () => { ]); // Inject our mock relays into the Nostr client - getNostrInternals(nostr).relays.set("wss://mock-relay1.com", relay1); - getNostrInternals(nostr).relays.set("wss://mock-relay2.com", relay2); + installNostrTestRelay(nostr, "wss://mock-relay1.com", relay1); + installNostrTestRelay(nostr, "wss://mock-relay2.com", relay2); // Set keys so we don't throw errors - getNostrInternals(nostr).privateKey = "test-private-key"; - getNostrInternals(nostr).publicKey = "test-public-key"; - // Create a test event const event: NostrEvent = { id: "test-event-id", @@ -1030,13 +1042,10 @@ describe("Nostr client", () => { ]); // Inject our mock relays into the Nostr client - getNostrInternals(nostr).relays.set("wss://mock-relay1.com", relay1); - getNostrInternals(nostr).relays.set("wss://mock-relay2.com", relay2); + installNostrTestRelay(nostr, "wss://mock-relay1.com", relay1); + installNostrTestRelay(nostr, "wss://mock-relay2.com", relay2); // Set keys so we don't throw errors - getNostrInternals(nostr).privateKey = "test-private-key"; - getNostrInternals(nostr).publicKey = "test-public-key"; - // Create a test event const event: NostrEvent = { id: "test-event-id", @@ -1073,7 +1082,7 @@ describe("Nostr client", () => { }); const relay = new MockRelay("wss://mock-relay1.com", [{ success: true }]); - getNostrInternals(nostr).relays.set("wss://mock-relay1.com", relay); + installNostrTestRelay(nostr, "wss://mock-relay1.com", relay); const event = (id: string): NostrEvent => ({ id, @@ -1085,12 +1094,16 @@ describe("Nostr client", () => { sig: "test-signature", }); - await expect(nostr.publishEvent(event("event-1"))).resolves.toMatchObject({ - success: true, - }); - await expect(nostr.publishEvent(event("event-2"))).resolves.toMatchObject({ - success: true, - }); + await expect(nostr.publishEvent(event("event-1"))).resolves.toMatchObject( + { + success: true, + }, + ); + await expect(nostr.publishEvent(event("event-2"))).resolves.toMatchObject( + { + success: true, + }, + ); await expect(nostr.publishEvent(event("event-3"))).rejects.toThrow( /Publish rate limit exceeded/, ); @@ -1114,14 +1127,11 @@ describe("Nostr client", () => { ]); // Inject our mock relays into the Nostr client - getNostrInternals(nostr).relays.set("wss://mock-relay1.com", relay1); - getNostrInternals(nostr).relays.set("wss://mock-relay2.com", relay2); - getNostrInternals(nostr).relays.set("wss://mock-relay3.com", relay3); + installNostrTestRelay(nostr, "wss://mock-relay1.com", relay1); + installNostrTestRelay(nostr, "wss://mock-relay2.com", relay2); + installNostrTestRelay(nostr, "wss://mock-relay3.com", relay3); // Set keys so we don't throw errors - getNostrInternals(nostr).privateKey = "test-private-key"; - getNostrInternals(nostr).publicKey = "test-public-key"; - // Create a test event const event: NostrEvent = { id: "test-event-id", @@ -1164,9 +1174,8 @@ describe("Nostr client", () => { const relay = new MockRelay("wss://mock-relay1.com"); const keys = await generateKeypair(); - getNostrInternals(nostr).relays.set("wss://mock-relay1.com", relay); - getNostrInternals(nostr).privateKey = keys.privateKey; - getNostrInternals(nostr).publicKey = keys.publicKey; + installNostrTestRelay(nostr, "wss://mock-relay1.com", relay); + nostr.setPrivateKey(keys.privateKey); const result = await nostr.authenticateRelay( "wss://mock-relay1.com", @@ -1190,9 +1199,8 @@ describe("Nostr client", () => { const relay = new MockRelay("wss://mock-relay1.com"); const keys = await generateKeypair(); - getNostrInternals(nostr).relays.set("wss://mock-relay1.com", relay); - getNostrInternals(nostr).privateKey = keys.privateKey; - getNostrInternals(nostr).publicKey = keys.publicKey; + installNostrTestRelay(nostr, "wss://mock-relay1.com", relay); + nostr.setPrivateKey(keys.privateKey); await nostr.authenticateRelay("WSS://MOCK-RELAY1.COM/", "challenge-789"); @@ -1208,8 +1216,7 @@ describe("Nostr client", () => { const nostr = new Nostr(); const keys = await generateKeypair(); - getNostrInternals(nostr).privateKey = keys.privateKey; - getNostrInternals(nostr).publicKey = keys.publicKey; + nostr.setPrivateKey(keys.privateKey); await expect( nostr.authenticateRelay("wss://mock-relay1.com", "challenge-789"), @@ -1220,7 +1227,7 @@ describe("Nostr client", () => { const nostr = new Nostr(); const relay = new MockRelay("wss://mock-relay1.com"); - getNostrInternals(nostr).relays.set("wss://mock-relay1.com", relay); + installNostrTestRelay(nostr, "wss://mock-relay1.com", relay); await expect( nostr.authenticateRelay("wss://mock-relay1.com", "challenge-789"), @@ -1237,9 +1244,8 @@ describe("Nostr client", () => { const relay = new MockRelay("wss://mock-relay1.com"); const keys = await generateKeypair(); - getNostrInternals(nostr).relays.set("wss://mock-relay1.com", relay); - getNostrInternals(nostr).privateKey = keys.privateKey; - getNostrInternals(nostr).publicKey = keys.publicKey; + installNostrTestRelay(nostr, "wss://mock-relay1.com", relay); + nostr.setPrivateKey(keys.privateKey); await expect( nostr.authenticateRelay("wss://mock-relay1.com", "challenge-1"), @@ -1257,24 +1263,20 @@ describe("Detailed subscription cleanup", () => { it("should unsubscribe earlier relays when a later detailed subscribe fails", () => { const nostr = new Nostr(); const firstRelay = { + disconnect: jest.fn(), subscribe: jest.fn().mockReturnValue("sub-1"), unsubscribe: jest.fn(), }; const secondRelay = { + disconnect: jest.fn(), subscribe: jest.fn(() => { throw new Error("subscribe failed"); }), unsubscribe: jest.fn(), }; - getNostrInternals(nostr).relays.set( - "wss://mock-relay1.com", - firstRelay as unknown as Relay, - ); - getNostrInternals(nostr).relays.set( - "wss://mock-relay2.com", - secondRelay as unknown as Relay, - ); + installNostrTestRelay(nostr, "wss://mock-relay1.com", firstRelay); + installNostrTestRelay(nostr, "wss://mock-relay2.com", secondRelay); expect(() => nostr.subscribeDetailed([{ kinds: [1], limit: 1 }], () => {}), @@ -1288,6 +1290,7 @@ describe("Addressable events functionality", () => { let nostr: Nostr; // Define with specific structure for the test let mockRelays: { + disconnect: jest.Mock; getLatestAddressableEvent: jest.Mock; getAddressableEventsByPubkey: jest.Mock; getAddressableEventsByKind: jest.Mock; @@ -1297,11 +1300,13 @@ describe("Addressable events functionality", () => { // Create mock relay objects instead of actual Relay instances mockRelays = [ { + disconnect: jest.fn(), getLatestAddressableEvent: jest.fn(), getAddressableEventsByPubkey: jest.fn(), getAddressableEventsByKind: jest.fn(), }, { + disconnect: jest.fn(), getLatestAddressableEvent: jest.fn(), getAddressableEventsByPubkey: jest.fn(), getAddressableEventsByKind: jest.fn(), @@ -1312,9 +1317,8 @@ describe("Addressable events functionality", () => { nostr = new Nostr(); // Add the mocked relays to the nostr instance - const relays = getNostrInternals(nostr).relays; - relays.set("wss://relay1.example.com", mockRelays[0] as unknown as Relay); - relays.set("wss://relay2.example.com", mockRelays[1] as unknown as Relay); + installNostrTestRelay(nostr, "wss://relay1.example.com", mockRelays[0]); + installNostrTestRelay(nostr, "wss://relay2.example.com", mockRelays[1]); }); test("getLatestAddressableEvent should return the latest event from all relays", () => { diff --git a/tests/nip01/relay/filters.test.ts b/tests/nip01/relay/filters.test.ts index 72eb3d13..792dcb44 100644 --- a/tests/nip01/relay/filters.test.ts +++ b/tests/nip01/relay/filters.test.ts @@ -8,9 +8,10 @@ import { Filter, NostrEvent, NostrKind, + RelayEvent, } from "../../../src/types/nostr"; import { Relay } from "../../../src/nip01/relay"; -import { NostrRelay } from "../../../src/utils/ephemeral-relay"; +import { getRelaySocket, NostrRelay } from "../../../src/testing"; import { createSignedEvent } from "../../../src/nip01/event"; import { generateKeypair } from "../../../src/utils/crypto"; import { testUtils } from "../../types"; @@ -62,6 +63,22 @@ describe("Enhanced NostrFilter Types", () => { // Test 1: Type validation for filters with new tag types describe("Filter Type Validation", () => { + test("rejects malformed REQ filters with a stable NOTICE", async () => { + const notice = new Promise((resolve) => { + const handler = (_relayUrl: string, message: string) => { + relay.off(RelayEvent.Notice, handler); + resolve(message); + }; + relay.on(RelayEvent.Notice, handler); + }); + + const socket = getRelaySocket(relay); + expect(socket).not.toBeNull(); + socket?.send(JSON.stringify(["REQ", "invalid-filter", { kinds: "1" }])); + + await expect(notice).resolves.toBe("invalid: REQ filters"); + }); + test("should accept filters with all defined tag types", () => { // This test verifies TypeScript accepts the expanded type definition // Create a filter with all the standard tag types diff --git a/tests/nip01/relay/relay-reconnect.test.ts b/tests/nip01/relay/relay-reconnect.test.ts index ec576a87..ddb9aeb5 100644 --- a/tests/nip01/relay/relay-reconnect.test.ts +++ b/tests/nip01/relay/relay-reconnect.test.ts @@ -2,9 +2,9 @@ * Tests for Relay Reconnection functionality */ -import { Relay, ReconnectionStrategy } from "../../../src"; +import { Relay } from "../../../src"; +import { scheduleRelayReconnect } from "../../../src/testing"; import { RelayConnectionOptions } from "../../../src/types/protocol"; -import { asTestRelay } from "../../types"; // Mock WebSocket jest.mock("websocket-polyfill", () => ({})); @@ -42,67 +42,77 @@ describe("Relay: Reconnection Configuration", () => { return relay; } - test("should initialize with default reconnection options", () => { - const relay = createRelay("wss://test.relay"); - const testRelay = asTestRelay(relay); - - // Verify the relay has the expected default values - expect(testRelay.autoReconnect).toBe(true); - expect(testRelay.maxReconnectAttempts).toBe(10); - expect(testRelay.maxReconnectDelay).toBe(30000); - expect(testRelay.reconnectAttempts).toBe(0); - }); + function captureReconnectTimers(): { + callbacks: Array<() => void>; + delays: number[]; + restore(): void; + } { + const callbacks: Array<() => void> = []; + const delays: number[] = []; + const timeoutSpy = jest + .spyOn(globalThis, "setTimeout") + .mockImplementation((callback: TimerHandler, delay?: number) => { + const timer = { unref: () => timer } as unknown as NodeJS.Timeout; + callbacks.push(callback as () => void); + delays.push(delay ?? 0); + return timer; + }); + return { + callbacks, + delays, + restore: () => timeoutSpy.mockRestore(), + }; + } - test("should allow custom reconnection options", () => { + test("applies constructor reconnection limits and capped delay", () => { const options: RelayConnectionOptions = { - autoReconnect: false, - maxReconnectAttempts: 5, - maxReconnectDelay: 15000, + autoReconnect: true, + maxReconnectAttempts: 2, + maxReconnectDelay: 1500, }; - + const timers = captureReconnectTimers(); const relay = createRelay("wss://test.relay", options); - const testRelay = asTestRelay(relay); - - // Verify the custom options were applied - expect(testRelay.autoReconnect).toBe(false); - expect(testRelay.maxReconnectAttempts).toBe(5); - expect(testRelay.maxReconnectDelay).toBe(15000); - }); - - test("should allow changing auto-reconnect setting", () => { - const relay = createRelay("wss://test.relay", { autoReconnect: false }); - const testRelay = asTestRelay(relay); + const connectSpy = jest.spyOn(relay, "connect").mockResolvedValue(true); - // Verify initial state - expect(testRelay.autoReconnect).toBe(false); - - // Change the setting - relay.setAutoReconnect(true); - - // Verify the setting was updated - expect(testRelay.autoReconnect).toBe(true); - }); - - test("should allow changing max reconnect attempts", () => { - const relay = createRelay("wss://test.relay"); - const testRelay = asTestRelay(relay); - - // Change the setting - relay.setMaxReconnectAttempts(20); - - // Verify the setting was updated - expect(testRelay.maxReconnectAttempts).toBe(20); + try { + scheduleRelayReconnect(relay); + timers.callbacks[0](); + scheduleRelayReconnect(relay); + timers.callbacks[1](); + scheduleRelayReconnect(relay); + + expect(connectSpy).toHaveBeenCalledTimes(2); + expect(timers.callbacks).toHaveLength(2); + expect(timers.delays[1]).toBeGreaterThanOrEqual(1500); + expect(timers.delays[1]).toBeLessThanOrEqual(1950); + } finally { + connectSpy.mockRestore(); + timers.restore(); + } }); - test("should allow changing max reconnect delay", () => { + test("applies reconnect limit and delay changes to scheduling behavior", () => { const relay = createRelay("wss://test.relay"); - const testRelay = asTestRelay(relay); - - // Change the setting - relay.setMaxReconnectDelay(60000); + const timers = captureReconnectTimers(); + const connectSpy = jest.spyOn(relay, "connect").mockResolvedValue(true); - // Verify the setting was updated - expect(testRelay.maxReconnectDelay).toBe(60000); + try { + relay.setMaxReconnectAttempts(2); + relay.setMaxReconnectDelay(1200); + scheduleRelayReconnect(relay); + timers.callbacks[0](); + scheduleRelayReconnect(relay); + timers.callbacks[1](); + scheduleRelayReconnect(relay); + + expect(connectSpy).toHaveBeenCalledTimes(2); + expect(timers.callbacks).toHaveLength(2); + expect(timers.delays[1]).toBeGreaterThanOrEqual(1200); + expect(timers.delays[1]).toBeLessThanOrEqual(1560); + } finally { + connectSpy.mockRestore(); + timers.restore(); + } }); test("should validate max reconnect attempts (non-negative)", () => { @@ -123,11 +133,33 @@ describe("Relay: Reconnection Configuration", () => { }).toThrow(/at least 1000ms/); }); - test("ignores stale queued callbacks without losing replacement timers", () => { + test("disabling auto-reconnect cancels an already queued attempt", () => { + const relay = createRelay("wss://test.relay"); + const queuedReconnects: Array<() => void> = []; + const timeoutSpy = jest + .spyOn(globalThis, "setTimeout") + .mockImplementation((callback: TimerHandler) => { + const timer = { unref: () => timer } as unknown as NodeJS.Timeout; + queuedReconnects.push(callback as () => void); + return timer; + }); + const connectSpy = jest.spyOn(relay, "connect").mockResolvedValue(true); + + try { + scheduleRelayReconnect(relay); + relay.setAutoReconnect(false); + queuedReconnects[0](); + + expect(connectSpy).not.toHaveBeenCalled(); + } finally { + connectSpy.mockRestore(); + timeoutSpy.mockRestore(); + } + }); + + test("ignores stale queued callbacks without losing the replacement attempt", () => { const relay = createRelay("wss://test.relay"); - const testRelay = asTestRelay(relay); const queuedReconnects: Array<() => void> = []; - const timers: NodeJS.Timeout[] = []; const timeoutSpy = jest .spyOn(globalThis, "setTimeout") .mockImplementation((callback: TimerHandler) => { @@ -135,62 +167,28 @@ describe("Relay: Reconnection Configuration", () => { unref: () => timer, } as unknown as NodeJS.Timeout; queuedReconnects.push(callback as () => void); - timers.push(timer); return timer; }); - const clearTimeoutSpy = jest - .spyOn(globalThis, "clearTimeout") - .mockImplementation(() => {}); + const connectSpy = jest.spyOn(relay, "connect").mockResolvedValue(true); try { - testRelay.scheduleReconnect(); + scheduleRelayReconnect(relay); relay.disconnect(); - testRelay.scheduleReconnect(); + scheduleRelayReconnect(relay); queuedReconnects[0](); - expect(testRelay.reconnectTimer).toBe(timers[1]); - expect(testRelay.reconnectAttempts).toBe(0); + expect(connectSpy).not.toHaveBeenCalled(); - relay.disconnect(); queuedReconnects[1](); + expect(connectSpy).toHaveBeenCalledTimes(1); - expect(testRelay.reconnectAttempts).toBe(0); - expect(testRelay.reconnectTimer).toBeNull(); - expect(testRelay.connectionPromise).toBeNull(); + scheduleRelayReconnect(relay); + relay.disconnect(); + queuedReconnects[2](); + expect(connectSpy).toHaveBeenCalledTimes(1); } finally { - clearTimeoutSpy.mockRestore(); + connectSpy.mockRestore(); timeoutSpy.mockRestore(); } }); - - // Additional test for ReconnectionStrategy interface - test("should be configurable with a full ReconnectionStrategy", () => { - // This test serves as a type-check for future implementation - // that uses the ReconnectionStrategy interface directly - - // Define a strategy that could be used in the future - const strategy: ReconnectionStrategy = { - enabled: true, - maxAttempts: 15, - initialDelay: 1000, - maxDelay: 45000, - backoffFactor: 1.5, - useJitter: true, - jitterFactor: 0.2, - }; - - // Current implementation uses individual properties, but - // we can test that we can extract these properties correctly - const relay = createRelay("wss://test.relay", { - autoReconnect: strategy.enabled, - maxReconnectAttempts: strategy.maxAttempts, - maxReconnectDelay: strategy.maxDelay, - }); - const testRelay = asTestRelay(relay); - - // Verify basic properties were applied - expect(testRelay.autoReconnect).toBe(strategy.enabled); - expect(testRelay.maxReconnectAttempts).toBe(strategy.maxAttempts); - expect(testRelay.maxReconnectDelay).toBe(strategy.maxDelay); - }); }); diff --git a/tests/nip01/relay/relay.test.ts b/tests/nip01/relay/relay.test.ts index 21d3ece3..f7f259b9 100644 --- a/tests/nip01/relay/relay.test.ts +++ b/tests/nip01/relay/relay.test.ts @@ -11,10 +11,15 @@ import { getPublicKey, signEvent, } from "../../../src"; -import { NostrRelay } from "../../../src/utils/ephemeral-relay"; +import { + dispatchRelayMessage, + installRelaySocket, + NostrRelay, + replaceRelayInboundValidator, + waitForRelayValidation, +} from "../../../src/testing"; import { testUtils } from "../../types"; import { - asTestRelay, RelayConnectCallback, RelayDisconnectCallback, RelayErrorCallback, @@ -72,24 +77,8 @@ async function handleInboundEvent( subscriptionId: string, event: unknown, ): Promise { - const relayInternals = asTestRelay(relay); - relayInternals.handleMessage(["EVENT", subscriptionId, event]); - - const deadline = Date.now() + 1000; - while (Date.now() < deadline) { - const pendingValidations = - relayInternals.pendingValidationCounts.get(subscriptionId) ?? 0; - - if (pendingValidations === 0) { - return; - } - - await testUtils.sleep(1); - } - - throw new Error( - `Timed out waiting for inbound EVENT validation for ${subscriptionId}`, - ); + dispatchRelayMessage(relay, ["EVENT", subscriptionId, event]); + await waitForRelayValidation(relay, subscriptionId); } function expectRelayError(errors: unknown[], message: string): void { @@ -119,6 +108,21 @@ describe("Relay", () => { }); describe("Connection Management", () => { + test("disconnect discards buffered delivery without an active socket", async () => { + const relay = new Relay("wss://example.com"); + const onEvent = jest.fn(); + const subscriptionId = relay.subscribe([{}], onEvent); + const event = await createSignedRelayEvent(); + + dispatchRelayMessage(relay, ["EVENT", subscriptionId, event]); + + relay.disconnect(); + await testUtils.sleep(20); + + expect(relay.getSubscriptionIds().size).toBe(0); + expect(onEvent).not.toHaveBeenCalled(); + }); + test("should connect to ephemeral relay", async () => { const relay = new Relay(ephemeralRelay.url); @@ -224,9 +228,6 @@ describe("Relay", () => { await relay.connect(); relay.disconnect(); - // Verify the connectionPromise is cleared - expect(asTestRelay(relay).connectionPromise).toBeNull(); - // Second connect attempt should work const result = await relay.connect(); expect(result).toBe(true); @@ -258,7 +259,7 @@ describe("Relay", () => { const authHandler = jest.fn(); relay.on(RelayEvent.Auth, authHandler); - asTestRelay(relay).handleMessage(["AUTH", "challenge-123"]); + dispatchRelayMessage(relay, ["AUTH", "challenge-123"]); expect(authHandler).toHaveBeenCalledWith("challenge-123"); }); @@ -270,7 +271,7 @@ describe("Relay", () => { relay.on(RelayEvent.Auth, authHandler); relay.on(RelayEvent.Error, errorHandler); - asTestRelay(relay).handleMessage(["AUTH", 123]); + dispatchRelayMessage(relay, ["AUTH", 123]); expect(authHandler).not.toHaveBeenCalled(); expect(errorHandler).toHaveBeenCalledTimes(1); @@ -398,10 +399,7 @@ describe("Relay", () => { test("should send AUTH events to the relay and wait for OK by default", async () => { const authRelay = new Relay(ephemeralRelay.url); - const relayInternals = asTestRelay(authRelay); - - relayInternals.connected = true; - relayInternals.ws = { + installRelaySocket(authRelay, { readyState: 1, onopen: null, onclose: null, @@ -409,10 +407,15 @@ describe("Relay", () => { onmessage: null, send: jest.fn((payload: string) => { expect(payload).toBe(JSON.stringify(["AUTH", authEvent])); - relayInternals.handleMessage(["OK", authEvent.id, true, "accepted"]); + dispatchRelayMessage(authRelay, [ + "OK", + authEvent.id, + true, + "accepted", + ]); }), close: jest.fn(), - } as unknown as WebSocket; + }); const authEvent: NostrEvent = { id: "auth-event-id", @@ -488,10 +491,7 @@ describe("Relay", () => { test("should support waitForAck false for AUTH events", async () => { const authRelay = new Relay(ephemeralRelay.url); const send = jest.fn(); - const relayInternals = asTestRelay(authRelay); - - relayInternals.connected = true; - relayInternals.ws = { + installRelaySocket(authRelay, { readyState: 1, onopen: null, onclose: null, @@ -499,7 +499,7 @@ describe("Relay", () => { onmessage: null, send, close: jest.fn(), - } as unknown as WebSocket; + }); const authEvent: NostrEvent = { id: "auth-event-id", @@ -538,14 +538,13 @@ describe("Relay", () => { }; // Avoid relay-side validation errors interfering with timeout behavior - const testRelay = asTestRelay(relay); - const originalWs = testRelay.ws; const mockSend = jest.fn(); - testRelay.ws = { + const restoreSocket = installRelaySocket(relay, { // Use literal readyState values to avoid global WebSocket mutations readyState: 1, // OPEN send: mockSend, - } as unknown as WebSocket; + close: jest.fn(), + }); try { // Use a short real timeout for cross-runner compatibility @@ -565,7 +564,7 @@ describe("Relay", () => { } } finally { // Restore the original socket - testRelay.ws = originalWs; + restoreSocket(); } }); @@ -608,20 +607,24 @@ describe("Relay", () => { }; // Mock the WebSocket readyState to simulate a non-OPEN state - const testRelay = asTestRelay(relay); - const origWs = testRelay.ws; - testRelay.ws = { readyState: 2 } as WebSocket; // CLOSING - - // Try to publish with a socket that's not in OPEN state - const result: PublishResponse = await relay.publish(event); + const restoreSocket = installRelaySocket( + relay, + { readyState: 2, send: jest.fn(), close: jest.fn() }, + true, + ); - // Should fail with not_connected reason - expect(result.success).toBe(false); - expect(result.reason).toBe("not_connected"); - expect(result.relay).toBe(ephemeralRelay.url); + try { + // Try to publish with a socket that's not in OPEN state + const result: PublishResponse = await relay.publish(event); - // Restore the original WebSocket - testRelay.ws = origWs; + // Should fail with not_connected reason + expect(result.success).toBe(false); + expect(result.reason).toBe("not_connected"); + expect(result.relay).toBe(ephemeralRelay.url); + } finally { + // Restore the original WebSocket + restoreSocket(); + } }); test("should support waitForAck option", async () => { @@ -638,29 +641,28 @@ describe("Relay", () => { // Use an isolated relay instance to avoid shared-connection races const isolatedRelay = new Relay("ws://example.com"); - const testRelay = asTestRelay(isolatedRelay); - const origWs = testRelay.ws; - const origConnected = testRelay.connected; const mockSend = jest.fn(); - testRelay.ws = { + const restoreSocket = installRelaySocket(isolatedRelay, { readyState: 1, // OPEN send: mockSend, - } as unknown as WebSocket; - testRelay.connected = true; + close: jest.fn(), + }); try { // Publish with waitForAck: false const options: PublishOptions = { waitForAck: false }; - const result: PublishResponse = await isolatedRelay.publish(event, options); + const result: PublishResponse = await isolatedRelay.publish( + event, + options, + ); // Should immediately return success without waiting for OK expect(result.success).toBe(true); - expect(result.relay).toBe(asTestRelay(isolatedRelay).url); + expect(result.relay).toBe("ws://example.com"); expect(mockSend).toHaveBeenCalled(); } finally { // Restore original relay connection state - testRelay.ws = origWs; - testRelay.connected = origConnected; + restoreSocket(); isolatedRelay.disconnect(); } }); @@ -689,27 +691,27 @@ describe("Relay", () => { nonRoutableRelay.disconnect(); // For timeout error - const timeoutRelay = asTestRelay(relay); - const originalWs = timeoutRelay.ws; - timeoutRelay.ws = { + const restoreTimeoutSocket = installRelaySocket(relay, { readyState: 1, // OPEN send: jest.fn(), - } as unknown as WebSocket; + close: jest.fn(), + }); const timeoutPromise = relay.publish(event, { timeout: 20 }); const timeoutResult = await timeoutPromise; expect(timeoutResult.success).toBe(false); expect(["timeout", "not_connected"]).toContain(timeoutResult.reason); - timeoutRelay.ws = originalWs; + restoreTimeoutSocket(); // For disconnected error - const testRelay = asTestRelay(relay); - const mockWs = { readyState: 3 }; // CLOSED - const origWs = testRelay.ws; - testRelay.ws = mockWs as unknown as WebSocket; + const restoreClosedSocket = installRelaySocket( + relay, + { readyState: 3, send: jest.fn(), close: jest.fn() }, + true, + ); const disconnectedResult = await relay.publish(event); expect(disconnectedResult.success).toBe(false); expect(disconnectedResult.reason).toBe("not_connected"); - testRelay.ws = origWs; + restoreClosedSocket(); }); }); @@ -941,9 +943,7 @@ describe("Relay", () => { rawMessage: testRawMessage, }; - // Access the private handleMessage method using type assertion - const internalRelay = asTestRelay(relay); - internalRelay.handleMessage([ + dispatchRelayMessage(relay, [ "OK", testEventId, testSuccess, @@ -985,8 +985,7 @@ describe("Relay", () => { message: "Event was stored", rawMessage: testRawMessage, }; - const internalRelay = asTestRelay(relay); - internalRelay.handleMessage([ + dispatchRelayMessage(relay, [ "OK", testEventId, testSuccess, @@ -1018,20 +1017,14 @@ describe("Relay", () => { }); // Create a subscription so we can verify it gets removed - const subId = "test-subscription-id"; - const internalRelay = asTestRelay(relay); - internalRelay.subscriptions.set(subId, { - id: subId, - filters: [{ kinds: [1] }], - onEvent: () => {}, - }); + const subId = relay.subscribe([{ kinds: [1] }], () => {}); // Verify the subscription exists before - expect(internalRelay.subscriptions.has(subId)).toBe(true); + expect(relay.getSubscriptionIds().has(subId)).toBe(true); // Manually trigger the CLOSED message handling const testMessage = "Subscription closed due to inactivity"; - internalRelay.handleMessage(["CLOSED", subId, testMessage]); + dispatchRelayMessage(relay, ["CLOSED", subId, testMessage]); // Verify the handler was called with the right parameters expect(closedMessageReceived).toBe(true); @@ -1039,7 +1032,7 @@ describe("Relay", () => { expect(closedMessage).toBe(testMessage); // Verify the subscription was removed - expect(internalRelay.subscriptions.has(subId)).toBe(false); + expect(relay.getSubscriptionIds().has(subId)).toBe(false); }); }); @@ -1061,6 +1054,35 @@ describe("Relay", () => { expect(onEvent).toHaveBeenCalledWith(event); }); + test("should discard validation results after the subscription is removed", async () => { + const onEvent = jest.fn(); + const subscriptionId = relay.subscribe([{ kinds: [30001] }], onEvent); + const event = await createSignedRelayEvent({ + kind: 30001, + tags: [["d", "late"]], + }); + let resolveValidation!: (validated: NostrEvent) => void; + const validation = new Promise((resolve) => { + resolveValidation = resolve; + }); + const restoreValidator = replaceRelayInboundValidator( + relay, + jest.fn(() => validation), + ); + + dispatchRelayMessage(relay, ["EVENT", subscriptionId, event]); + relay.unsubscribe(subscriptionId); + resolveValidation(event); + await validation; + await Promise.resolve(); + + expect(onEvent).not.toHaveBeenCalled(); + expect( + relay.getLatestAddressableEvent(30001, event.pubkey, "late"), + ).toBeUndefined(); + restoreValidator(); + }); + test("should reject malformed inbound EVENT messages", async () => { const onEvent = jest.fn(); const errors: unknown[] = []; @@ -1278,350 +1300,32 @@ describe("Relay", () => { expect(onEvent).toHaveBeenCalledTimes(1); expect(onEvent).toHaveBeenCalledWith(encryptedEvent); }); - }); - - describe("Addressable events (kinds 30000-39999)", () => { - let relay: Relay; - let testEvent: NostrEvent; - - beforeEach(async () => { - relay = new Relay(ephemeralRelay.url); - await relay.connect(); - // Create a valid addressable event - testEvent = { - id: "b0635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f3cd", - pubkey: - "884704d5780d85163c138d2695ca263eb7552b0f2c5ebaf86c8d9c4e62a337e3", - created_at: 1671312795, - kind: 30001, - tags: [ - ["d", "test-identifier"], - ["t", "test"], - ], - content: "test content for addressable event", - sig: "553e66e4316cd6f8e2c363b7677d41da0f6b37775daa7728acdda6e477ed2fbf03be27c33f1eddaa2ee2711e9ca7c0ad7e97dd4d3eb4c1cf9ceaf98d9000af6a", - }; - }); - - afterEach(() => { - relay.disconnect(); - }); - - test("should process and store addressable events", () => { - // Access the private method directly for testing - const testRelay = asTestRelay(relay); - testRelay.processAddressableEvent(testEvent); - - // Get the stored event using the public API - const storedEvent = relay.getLatestAddressableEvent( - 30001, - testEvent.pubkey, - "test-identifier", - ); - - // Verify it was stored correctly - expect(storedEvent).toBeDefined(); - expect(storedEvent?.id).toBe(testEvent.id); - expect(storedEvent?.kind).toBe(30001); - }); - - test("should replace older events with the same pubkey, kind, and d-tag", () => { - // Create an older event with the same pubkey, kind, and d-tag - const olderEvent = { - ...testEvent, - id: "c0635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f3ee", - created_at: 1671312700, // Older timestamp - content: "older content", - }; - - // Create a newer event with the same pubkey, kind, and d-tag - const newerEvent = { - ...testEvent, - id: "d0635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f3ff", - created_at: 1671312900, // Newer timestamp - content: "newer content", - }; - - // Process events out of order (newer first, then older) - const testRelay = asTestRelay(relay); - testRelay.processAddressableEvent(newerEvent); - testRelay.processAddressableEvent(olderEvent); - - // Get the stored event - const storedEvent = relay.getLatestAddressableEvent( - 30001, - testEvent.pubkey, - "test-identifier", - ); - - // Verify the newer event was kept - expect(storedEvent).toBeDefined(); - expect(storedEvent?.id).toBe(newerEvent.id); - expect(storedEvent?.content).toBe("newer content"); - }); - - test("should handle events with different d-tags separately", () => { - // Create an event with a different d-tag - const differentDTagEvent = { - ...testEvent, - id: "e0635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f400", - tags: [ - ["d", "different-identifier"], - ["t", "test"], - ], - content: "different d-tag content", - }; - - // Process both events - const testRelay = asTestRelay(relay); - testRelay.processAddressableEvent(testEvent); - testRelay.processAddressableEvent(differentDTagEvent); - - // Get the stored events - const event1 = relay.getLatestAddressableEvent( - 30001, - testEvent.pubkey, - "test-identifier", - ); - const event2 = relay.getLatestAddressableEvent( - 30001, - testEvent.pubkey, - "different-identifier", - ); - - // Verify both events were stored - expect(event1).toBeDefined(); - expect(event1?.id).toBe(testEvent.id); - expect(event1?.content).toBe("test content for addressable event"); - - expect(event2).toBeDefined(); - expect(event2?.id).toBe(differentDTagEvent.id); - expect(event2?.content).toBe("different d-tag content"); - }); - - test("should retrieve addressable events by pubkey", () => { - // Create another event with the same pubkey but different kind and d-tag - const anotherEvent = { - ...testEvent, - id: "f0635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f401", - kind: 30002, - tags: [ - ["d", "another-identifier"], - ["t", "test"], - ], - content: "another content", - }; - - // Process both events - const testRelay = asTestRelay(relay); - testRelay.processAddressableEvent(testEvent); - testRelay.processAddressableEvent(anotherEvent); - - // Get events by pubkey - const events = relay.getAddressableEventsByPubkey(testEvent.pubkey); - - // Verify both events were retrieved - expect(events.length).toBe(2); - expect(events.some((e) => e.id === testEvent.id)).toBe(true); - expect(events.some((e) => e.id === anotherEvent.id)).toBe(true); - }); - - test("should retrieve addressable events by kind", () => { - // Create another event with the same kind but different pubkey - const differentPubkeyEvent = { - ...testEvent, - id: "g0635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f402", - pubkey: - "994704d5780d85163c138d2695ca263eb7552b0f2c5ebaf86c8d9c4e62a337f4", - tags: [ - ["d", "another-pubkey-identifier"], - ["t", "test"], - ], - content: "different pubkey content", - }; - - // Process both events - const testRelay = asTestRelay(relay); - testRelay.processAddressableEvent(testEvent); - testRelay.processAddressableEvent(differentPubkeyEvent); - - // Get events by kind - const events = relay.getAddressableEventsByKind(30001); - - // Verify both events were retrieved - expect(events.length).toBe(2); - expect(events.some((e) => e.id === testEvent.id)).toBe(true); - expect(events.some((e) => e.id === differentPubkeyEvent.id)).toBe(true); - }); - - test("should properly handle addressable events through handleMessage", async () => { - // Create a subscription to receive events - const onEvent = jest.fn(); - const subscriptionId = relay.subscribe([{}], onEvent); - const testRelay = asTestRelay(relay); - - // Create a valid addressable event - const addressableEvent = await createSignedRelayEvent({ + test("stores signed addressable events received through the protocol path", async () => { + const event = await createSignedRelayEvent({ kind: 30001, - tags: [["d", "test-identifier"]], - content: "Addressable event through handleMessage", + tags: [["d", "profile"]], + content: "addressable", }); + const subscriptionId = relay.subscribe([{ kinds: [30001] }], () => {}); - // Process the event through handleMessage - testRelay.handleMessage(["EVENT", subscriptionId, addressableEvent]); - - await testUtils.sleep(20); + await handleInboundEvent(relay, subscriptionId, event); - const storedEvent = relay.getLatestAddressableEvent( - 30001, - addressableEvent.pubkey, - "test-identifier", - ); - expect(storedEvent).toBeDefined(); - expect(storedEvent?.id).toBe(addressableEvent.id); - expect(onEvent).toHaveBeenCalledWith(addressableEvent); + expect( + relay.getLatestAddressableEvent(30001, event.pubkey, "profile"), + ).toEqual(event); }); - }); - - describe("Replaceable events (kinds 0, 3, 10000-19999)", () => { - let relay: Relay; - let testEvent: NostrEvent; - - beforeEach(async () => { - relay = new Relay(ephemeralRelay.url); - await relay.connect(); - // Create a valid replaceable event (kind 0) - testEvent = { - id: "a0635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f3cd", - pubkey: - "884704d5780d85163c138d2695ca263eb7552b0f2c5ebaf86c8d9c4e62a337e3", - created_at: 1671312795, + test("stores signed replaceable events received through the protocol path", async () => { + const event = await createSignedRelayEvent({ kind: 0, - tags: [ - ["name", "Test User"], - ["about", "Test description"], - ], - content: JSON.stringify({ - name: "Test User", - about: "Test description", - }), - sig: "553e66e4316cd6f8e2c363b7677d41da0f6b37775daa7728acdda6e477ed2fbf03be27c33f1eddaa2ee2711e9ca7c0ad7e97dd4d3eb4c1cf9ceaf98d9000af6a", - }; - }); - - afterEach(() => { - relay.disconnect(); - }); - - test("should process and store replaceable events", () => { - // Process the event - const testRelay = asTestRelay(relay); - testRelay.processReplaceableEvent(testEvent); - - // Create a newer version - const newerEvent = { - ...testEvent, - created_at: testEvent.created_at + 1000, - id: "b1635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f3cf", - content: JSON.stringify({ - name: "Test User Updated", - about: "Updated description", - }), - }; - - // Process the newer event - testRelay.processReplaceableEvent(newerEvent); - - // Get the latest event - const retrievedEvent = relay.getLatestReplaceableEvent( - testEvent.pubkey, - testEvent.kind, - ); - - // It should be the newer event - expect(retrievedEvent).toBeDefined(); - expect(retrievedEvent?.id).toBe(newerEvent.id); - expect(retrievedEvent?.content).toBe(newerEvent.content); - }); - - test("should keep older event if newer one has older timestamp", () => { - // Process the event - const testRelay = asTestRelay(relay); - testRelay.processReplaceableEvent(testEvent); - - // Create an event with newer id but older timestamp - const olderEvent = { - ...testEvent, - created_at: testEvent.created_at - 1000, // Older timestamp - id: "c1635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f3c0", // Newer id - content: JSON.stringify({ - name: "Test User Older", - about: "This should not replace the original", - }), - }; - - // Process the older event - testRelay.processReplaceableEvent(olderEvent); - - // Get the latest event - const retrievedEvent = relay.getLatestReplaceableEvent( - testEvent.pubkey, - testEvent.kind, - ); - - // It should still be the original event - expect(retrievedEvent).toBeDefined(); - expect(retrievedEvent?.id).toBe(testEvent.id); - expect(retrievedEvent?.content).toBe(testEvent.content); - }); - - test("should retrieve replaceable events by pubkey and kind", () => { - // Process events of different kinds - const testRelay = asTestRelay(relay); - testRelay.processReplaceableEvent(testEvent); // kind 0 - - // Create a kind 3 event - const contactEvent = { - ...testEvent, - kind: 3, - content: "", - tags: [["p", "some-pubkey", "some-relay"]], - id: "d1635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f3d1", - }; - - // Create a kind 10002 event - const relayEvent = { - ...testEvent, - kind: 10002, - content: "", - tags: [["r", "wss://relay.example.com", "read"]], - id: "e1635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f3e2", - }; - - // Process these events - testRelay.processReplaceableEvent(contactEvent); - testRelay.processReplaceableEvent(relayEvent); + content: "metadata", + }); + const subscriptionId = relay.subscribe([{ kinds: [0] }], () => {}); - // Retrieve each kind - const retrievedKind0 = relay.getLatestReplaceableEvent( - testEvent.pubkey, - 0, - ); - const retrievedKind3 = relay.getLatestReplaceableEvent( - testEvent.pubkey, - 3, - ); - const retrievedKind10002 = relay.getLatestReplaceableEvent( - testEvent.pubkey, - 10002, - ); + await handleInboundEvent(relay, subscriptionId, event); - // Verify correct events were retrieved - expect(retrievedKind0?.id).toBe(testEvent.id); - expect(retrievedKind3?.id).toBe(contactEvent.id); - expect(retrievedKind10002?.id).toBe(relayEvent.id); + expect(relay.getLatestReplaceableEvent(event.pubkey, 0)).toEqual(event); }); }); }); diff --git a/tests/nip01/relay/relayEventStore.test.ts b/tests/nip01/relay/relayEventStore.test.ts new file mode 100644 index 00000000..1fe3d173 --- /dev/null +++ b/tests/nip01/relay/relayEventStore.test.ts @@ -0,0 +1,226 @@ +import { RelayEventStore } from "../../../src/nip01/relayEventStore"; +import { NostrEvent } from "../../../src/types/nostr"; + +function event( + id: string, + createdAt: number, + overrides: Partial = {}, +): NostrEvent { + return { + id, + pubkey: "pubkey", + created_at: createdAt, + kind: 1, + tags: [], + content: id, + sig: "sig", + ...overrides, + }; +} + +describe("RelayEventStore", () => { + test.each([ + ["maxEventBuffers", 0], + ["maxEventsPerBuffer", -1], + ["maxReplaceableEventPubkeys", Number.NaN], + ["maxReplaceableEventsPerPubkey", Number.POSITIVE_INFINITY], + ["maxAddressableEvents", 1.5], + ["maxAddressableEvents", Number.MAX_SAFE_INTEGER + 1], + ] as const)("rejects invalid %s capacity %s", (option, value) => { + expect(() => new RelayEventStore({ [option]: value })).toThrow( + `${option} must be a positive safe integer`, + ); + }); + + test("drains buffered events in NIP-01 order", () => { + const store = new RelayEventStore(); + store.addToBuffer("sub", event("d", 2)); + store.addToBuffer("sub", event("b", 3)); + store.addToBuffer("sub", event("a", 3)); + store.addToBuffer("sub", event("c", 1)); + + expect(store.drainBuffer("sub").map(({ id }) => id)).toEqual([ + "a", + "b", + "d", + "c", + ]); + expect(store.drainBuffer("sub")).toEqual([]); + }); + + test("enforces per-buffer capacity by retaining the newest arrivals", () => { + const store = new RelayEventStore({ maxEventsPerBuffer: 2 }); + store.addToBuffer("sub", event("first", 3)); + store.addToBuffer("sub", event("second", 2)); + store.addToBuffer("sub", event("third", 1)); + + expect(store.drainBuffer("sub").map(({ id }) => id)).toEqual([ + "second", + "third", + ]); + }); + + test("evicts the least-recently-used subscription buffer deterministically", () => { + let now = 0; + const evictions: string[] = []; + const store = new RelayEventStore({ + maxEventBuffers: 2, + now: () => now++, + onEviction: (message) => evictions.push(message), + }); + store.addToBuffer("old", event("old", 1)); + store.addToBuffer("kept", event("kept", 1)); + store.addToBuffer("new", event("new", 1)); + + expect([...store.bufferIds()]).toEqual(["kept", "new"]); + expect(evictions).toEqual(["Evicted event buffer for subscription: old"]); + }); + + test("uses the lower event id when replaceable timestamps tie", () => { + const store = new RelayEventStore(); + store.storeReplaceable(event("z", 10, { kind: 0 })); + store.storeReplaceable(event("a", 10, { kind: 0 })); + store.storeReplaceable(event("m", 10, { kind: 0 })); + + expect(store.getReplaceable("pubkey", 0)?.id).toBe("a"); + }); + + test("keeps the newer replaceable event when an older candidate arrives", () => { + const store = new RelayEventStore(); + store.storeReplaceable(event("newer", 20, { kind: 0 })); + store.storeReplaceable(event("older", 10, { kind: 0 })); + + expect(store.getReplaceable("pubkey", 0)?.id).toBe("newer"); + }); + + test("bounds replaceable pubkeys and ignores misses for LRU accounting", () => { + let now = 0; + const store = new RelayEventStore({ + maxReplaceableEventPubkeys: 1, + now: () => now++, + }); + store.storeReplaceable(event("first", 1, { kind: 0, pubkey: "first" })); + expect(store.getReplaceable("missing", 0)).toBeUndefined(); + store.storeReplaceable(event("second", 2, { kind: 0, pubkey: "second" })); + + expect(store.getReplaceable("first", 0)).toBeUndefined(); + expect(store.getReplaceable("second", 0)?.id).toBe("second"); + }); + + test("bounds replaceable kinds per pubkey by oldest event time", () => { + const store = new RelayEventStore({ + maxReplaceableEventsPerPubkey: 2, + }); + store.storeReplaceable(event("old", 1, { kind: 0 })); + store.storeReplaceable(event("kept", 2, { kind: 3 })); + store.storeReplaceable(event("new", 3, { kind: 10000 })); + + expect(store.getReplaceable("pubkey", 0)).toBeUndefined(); + expect(store.getReplaceable("pubkey", 3)?.id).toBe("kept"); + expect(store.getReplaceable("pubkey", 10000)?.id).toBe("new"); + }); + + test("keys addressable events by kind, pubkey, and d tag", () => { + const store = new RelayEventStore(); + store.storeAddressable( + event("old", 1, { kind: 30001, tags: [["d", "profile"]] }), + ); + store.storeAddressable( + event("new", 2, { kind: 30001, tags: [["d", "profile"]] }), + ); + store.storeAddressable( + event("other", 1, { kind: 30001, tags: [["d", "other"]] }), + ); + + expect(store.getAddressable(30001, "pubkey", "profile")?.id).toBe("new"); + expect(store.getAddressableByPubkey("pubkey")).toHaveLength(2); + expect(store.getAddressableByKind(30001)).toHaveLength(2); + }); + + test("keeps the newer addressable event when an older candidate arrives", () => { + const store = new RelayEventStore(); + const coordinate = { kind: 30001, tags: [["d", "profile"]] }; + store.storeAddressable(event("newer", 20, coordinate)); + store.storeAddressable(event("older", 10, coordinate)); + + expect(store.getAddressable(30001, "pubkey", "profile")?.id).toBe("newer"); + }); + + test("bounds addressable storage and ignores misses for LRU accounting", () => { + let now = 0; + const store = new RelayEventStore({ + maxAddressableEvents: 1, + now: () => now++, + }); + store.storeAddressable( + event("first", 1, { kind: 30000, tags: [["d", "first"]] }), + ); + expect(store.getAddressable(30000, "pubkey", "missing")).toBeUndefined(); + store.storeAddressable( + event("second", 2, { kind: 30000, tags: [["d", "second"]] }), + ); + + expect(store.getAddressableByKind(30000).map(({ id }) => id)).toEqual([ + "second", + ]); + }); + + test("bulk addressable reads refresh only matching LRU entries", () => { + let now = 0; + const store = new RelayEventStore({ + maxAddressableEvents: 3, + now: () => now++, + }); + store.storeAddressable( + event("match-pubkey", 1, { + kind: 30000, + pubkey: "match", + tags: [["d", "one"]], + }), + ); + store.storeAddressable( + event("match-kind", 1, { + kind: 30001, + pubkey: "other", + tags: [["d", "two"]], + }), + ); + store.storeAddressable( + event("untouched", 1, { + kind: 30002, + pubkey: "other", + tags: [["d", "three"]], + }), + ); + + expect(store.getAddressableByPubkey("match")).toHaveLength(1); + expect(store.getAddressableByKind(30001)).toHaveLength(1); + store.storeAddressable( + event("new", 2, { + kind: 30003, + pubkey: "new", + tags: [["d", "four"]], + }), + ); + + expect(store.getAddressableByKind(30002)).toEqual([]); + expect(store.getAddressableByKind(30000)).toHaveLength(1); + expect(store.getAddressableByKind(30001)).toHaveLength(1); + expect(store.getAddressableByKind(30003)).toHaveLength(1); + }); + + test("clears buffering and retained event state together", () => { + const store = new RelayEventStore(); + store.addToBuffer("sub", event("buffered", 1)); + store.storeReplaceable(event("replaceable", 1, { kind: 0 })); + store.storeAddressable( + event("addressable", 1, { kind: 30000, tags: [["d", "d"]] }), + ); + + store.clear(); + + expect([...store.bufferIds()]).toEqual([]); + expect(store.getReplaceable("pubkey", 0)).toBeUndefined(); + expect(store.getAddressable(30000, "pubkey", "d")).toBeUndefined(); + }); +}); diff --git a/tests/nip01/relay/relayPool.test.ts b/tests/nip01/relay/relayPool.test.ts index fd70cdde..26c2b401 100644 --- a/tests/nip01/relay/relayPool.test.ts +++ b/tests/nip01/relay/relayPool.test.ts @@ -1,8 +1,19 @@ import { RelayPool, NostrEvent } from "../../../src"; import { testUtils } from "../../types"; -import { NostrRelay } from "../../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../../src/testing"; import { generateKeypair } from "../../../src/utils/crypto"; import { createTextNote, createSignedEvent } from "../../../src/nip01/event"; +import type { DiagnosticLogger } from "../../../src/utils/logger"; + +function createLogger(): jest.Mocked { + return { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + trace: jest.fn(), + }; +} const PORT1 = 0; const PORT2 = 0; @@ -324,32 +335,16 @@ describe("RelayPool", () => { }); test("should handle event processing errors gracefully", async () => { - const pool = new RelayPool([relay1.url, relay2.url]); + const logger = createLogger(); + const pool = new RelayPool([relay1.url, relay2.url], { logger }); const keys = await generateKeypair(); const received: NostrEvent[] = []; - const errors: Error[] = []; // Use a timestamp to filter only our test events const testTimestamp = Math.floor(Date.now() / 1000); - // Mock console.warn to capture error logs - const originalWarn = console.warn; - try { - console.warn = (...args: unknown[]) => { - // Call original to preserve output - originalWarn(...args); - - if ( - args[0] && - typeof args[0] === "string" && - args[0].includes("Error processing event from") - ) { - errors.push(new Error(args[0])); - } - }; - const sub = await pool.subscribe( [relay1.url, relay2.url], [{ kinds: [1], since: testTimestamp, authors: [keys.publicKey] }], @@ -433,10 +428,15 @@ describe("RelayPool", () => { expect(received.some((e) => e.content === "error event")).toBe(true); // Should have logged the error - expect(errors.length).toBeGreaterThan(0); + expect(logger.warn).toHaveBeenCalledWith( + "RelayPool event callback failed", + expect.objectContaining({ + eventId: errorEvent.id, + eventKind: errorEvent.kind, + failureType: "Error", + }), + ); } finally { - // Restore console.warn - console.warn = originalWarn; await pool.close(); } }); diff --git a/tests/nip01/relay/relayRegistry.test.ts b/tests/nip01/relay/relayRegistry.test.ts new file mode 100644 index 00000000..8289b5b6 --- /dev/null +++ b/tests/nip01/relay/relayRegistry.test.ts @@ -0,0 +1,30 @@ +import { Relay } from "../../../src/nip01/relay"; +import { RelayRegistry } from "../../../src/nip01/relayRegistry"; + +describe("RelayRegistry", () => { + test("canonicalizes Map-compatible access and owns replacement lifecycle", () => { + const registry = new RelayRegistry(); + const first = new Relay("wss://relay.example/path"); + const second = new Relay("wss://relay.example/path"); + const firstDisconnect = jest.spyOn(first, "disconnect"); + const secondDisconnect = jest.spyOn(second, "disconnect"); + + registry.set("wss://RELAY.EXAMPLE/path", first); + expect(registry.get("relay.example/path")).toBe(first); + expect(registry.has("wss://relay.example/path")).toBe(true); + + registry.set("wss://relay.example/path", second); + expect(firstDisconnect).toHaveBeenCalledTimes(1); + expect(registry.delete("RELAY.EXAMPLE/path")).toBe(true); + expect(secondDisconnect).toHaveBeenCalledTimes(1); + expect(registry.size).toBe(0); + }); + + test("keeps invalid lookup and deletion graceful", () => { + const registry = new RelayRegistry(); + + expect(registry.get("https://invalid.example")).toBeUndefined(); + expect(registry.has("https://invalid.example")).toBe(false); + expect(registry.delete("https://invalid.example")).toBe(false); + }); +}); diff --git a/tests/nip02/nip02.test.ts b/tests/nip02/nip02.test.ts index 87f6482d..46f2cb5d 100644 --- a/tests/nip02/nip02.test.ts +++ b/tests/nip02/nip02.test.ts @@ -1,5 +1,5 @@ import { NostrEvent, NostrKind, ContactsEvent } from "../../src/types/nostr"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; import { createSignedEvent, UnsignedEvent, diff --git a/tests/nip19/security.test.ts b/tests/nip19/security.test.ts index 5efd79b0..b2650fd2 100644 --- a/tests/nip19/security.test.ts +++ b/tests/nip19/security.test.ts @@ -22,6 +22,17 @@ import { } from "../../src/nip19/index"; import { isValidRelayUrl, filterProfile } from "../../src/nip19/secure"; import { bech32 } from "@scure/base"; +import type { DiagnosticLogger } from "../../src/utils/logger"; + +function createLogger(): jest.Mocked { + return { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + trace: jest.fn(), + }; +} describe("NIP-19: Comprehensive Security Tests", () => { // Test data @@ -391,34 +402,23 @@ describe("NIP-19: Comprehensive Security Tests", () => { "wss://user:password@relay.example.com", // URL with credentials ]; - // Spy on console.warn to verify it's called for invalid URLs - const originalWarn = console.warn; - const mockWarn = jest.fn(); - console.warn = mockWarn; - - try { - maliciousUrls.forEach((url) => { - const encoded = createProfileWithCustomRelays([url]); + const logger = createLogger(); + maliciousUrls.forEach((url) => { + const encoded = createProfileWithCustomRelays([url]); - // The decoder doesn't throw on invalid URLs, but it should warn about them - const decoded = decodeProfile(encoded as Bech32String); + // The decoder doesn't throw on invalid URLs, but it should warn about them. + const decoded = decodeProfile(encoded as Bech32String, logger); - // The library code doesn't filter URLs, just warns about them - expect(decoded.relays).toContain(url); - - // Verify a warning was issued - expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining( - `Warning: Invalid relay URL format found while decoding: ${url}`, - ), - ); + // The library code doesn't filter URLs, just warns about them. + expect(decoded.relays).toContain(url); + expect(logger.warn).toHaveBeenCalledWith( + "Invalid relay URL found while decoding nprofile", + { reason: "invalid-relay-url" }, + ); + expect(JSON.stringify(logger.warn.mock.calls)).not.toContain(url); - mockWarn.mockClear(); - }); - } finally { - // Restore original console.warn - console.warn = originalWarn; - } + logger.warn.mockClear(); + }); }); test("encodeProfile checks relay count", () => { diff --git a/tests/nip44/nip44-format-validation.test.ts b/tests/nip44/nip44-format-validation.test.ts index ddb239a5..081eec9d 100644 --- a/tests/nip44/nip44-format-validation.test.ts +++ b/tests/nip44/nip44-format-validation.test.ts @@ -3,8 +3,8 @@ import { isValidPublicKeyPoint, isValidPrivateKey, decodePayload, - NONCE_SIZE_V0, - MAC_SIZE_V0, + NONCE_SIZE_V2, + MAC_SIZE_V2, } from "../../src/nip44"; describe("NIP-44 Format Validation", () => { @@ -299,11 +299,13 @@ describe("NIP-44 decodePayload compliance tests", () => { // Generate a valid base64 string that's exactly 132 characters const minLengthPayload = "A".repeat(132); - // This should pass length validation and successfully decode (version 0 is supported for decryption) - const result = decodePayload(minLengthPayload); - expect(result.version).toBe(0); - expect(result.nonce).toHaveLength(NONCE_SIZE_V0); - expect(result.mac).toHaveLength(MAC_SIZE_V0); + // Use the only defined version so this test isolates the length boundary. + const bytes = Buffer.from(minLengthPayload, "base64"); + bytes[0] = 2; + const result = decodePayload(bytes.toString("base64")); + expect(result.version).toBe(2); + expect(result.nonce).toHaveLength(NONCE_SIZE_V2); + expect(result.mac).toHaveLength(MAC_SIZE_V2); expect(result.ciphertext.length).toBeGreaterThan(0); }); @@ -345,11 +347,12 @@ describe("NIP-44 decodePayload compliance tests", () => { // Create a valid base64 that decodes to exactly 99 bytes const minDecodedPayload = Buffer.alloc(99).toString("base64"); - // This should pass length validation and successfully decode (version 0 is supported for decryption) - const result = decodePayload(minDecodedPayload); - expect(result.version).toBe(0); - expect(result.nonce).toHaveLength(NONCE_SIZE_V0); - expect(result.mac).toHaveLength(MAC_SIZE_V0); + const bytes = Buffer.from(minDecodedPayload, "base64"); + bytes[0] = 2; + const result = decodePayload(bytes.toString("base64")); + expect(result.version).toBe(2); + expect(result.nonce).toHaveLength(NONCE_SIZE_V2); + expect(result.mac).toHaveLength(MAC_SIZE_V2); expect(result.ciphertext.length).toBeGreaterThan(0); }); @@ -357,11 +360,12 @@ describe("NIP-44 decodePayload compliance tests", () => { // Create a valid base64 that decodes to exactly 65,603 bytes const maxDecodedPayload = Buffer.alloc(65603).toString("base64"); - // This should pass length validation and successfully decode (version 0 is supported for decryption) - const result = decodePayload(maxDecodedPayload); - expect(result.version).toBe(0); - expect(result.nonce).toHaveLength(NONCE_SIZE_V0); - expect(result.mac).toHaveLength(MAC_SIZE_V0); + const bytes = Buffer.from(maxDecodedPayload, "base64"); + bytes[0] = 2; + const result = decodePayload(bytes.toString("base64")); + expect(result.version).toBe(2); + expect(result.nonce).toHaveLength(NONCE_SIZE_V2); + expect(result.mac).toHaveLength(MAC_SIZE_V2); expect(result.ciphertext.length).toBeGreaterThan(0); }); }); diff --git a/tests/nip44/nip44-official-vectors.test.ts b/tests/nip44/nip44-official-vectors.test.ts index 22e42826..7b271969 100644 --- a/tests/nip44/nip44-official-vectors.test.ts +++ b/tests/nip44/nip44-official-vectors.test.ts @@ -105,14 +105,14 @@ describe("NIP-44 implementation against official test vectors", () => { expect(() => { encrypt(plaintext, sec1, pub2, undefined, { version: 0 }); }).toThrowError( - "NIP-44: Encryption with version 0 is not permitted by the NIP-44 specification. Only decryption is supported for v0.", + "NIP-44: Encryption with version 0 is not permitted because the version is reserved.", ); // Test V1 encryption (should fail) expect(() => { encrypt(plaintext, sec1, pub2, undefined, { version: 1 }); }).toThrowError( - "NIP-44: Encryption with version 1 is not permitted by the NIP-44 specification. Only decryption is supported for v1.", + "NIP-44: Encryption with version 1 is not permitted because the version is deprecated and undefined.", ); // Test V2 encryption (should succeed) @@ -160,18 +160,8 @@ describe("NIP-44 implementation against official test vectors", () => { // Calculate public key const pub1 = getPublicKeyHex(sec1); - try { - // Try to decrypt the test vector payload - const decrypted = decrypt(payload, sec2, pub1); - expect(decrypted).toBe(plaintext); - } catch (error) { - // Log the error but don't fail the test, as there may be subtle - // differences in how MACs were generated in reference implementation - console.warn( - `⚠️ Failed to decrypt payload for plaintext: "${plaintext}"`, - error, - ); - } + const decrypted = decrypt(payload, sec2, pub1); + expect(decrypted).toBe(plaintext); } }); }); @@ -231,10 +221,12 @@ describe("NIP-44 implementation against official test vectors", () => { describe("decodePayload version handling", () => { const testVectorBase = testVectors.v2.valid.encrypt_decrypt[0]; const sec1 = testVectorBase.sec1; + const sec2 = testVectorBase.sec2; + const pub1 = getPublicKeyHex(sec1); const pub2 = getPublicKeyHex(testVectorBase.sec2); // Derive pub2 correctly const plaintext = testVectorBase.plaintext; - test("should correctly decode payloads with version 0, 1, 2", () => { + test("should decode v2 and reject reserved or undefined versions", () => { // Test V2 payload decoding (original logic) const encryptedV2ForDecode = encrypt(plaintext, sec1, pub2, undefined, { version: 2, @@ -245,16 +237,17 @@ describe("NIP-44 implementation against official test vectors", () => { expect(decodedV2.mac.length).toBe(32); // MAC_SIZE_V2 expect(decodedV2.ciphertext.length).toBeGreaterThan(0); - // Test V0 payload decoding by tampering a V2 payload's version byte + // NIP-44 reserves v0 and leaves v1 deprecated and undefined. let v2Buffer = Buffer.from(encryptedV2ForDecode, "base64"); if (v2Buffer.length > 0) { v2Buffer[0] = 0; // Set version to 0 const tamperedV0Payload = v2Buffer.toString("base64"); - const decodedV0 = decodePayload(tamperedV0Payload); - expect(decodedV0.version).toBe(0); - expect(decodedV0.nonce.length).toBe(32); // NONCE_SIZE_V0 (assuming 32) - expect(decodedV0.mac.length).toBe(32); // MAC_SIZE_V0 (assuming 32) - expect(decodedV0.ciphertext.length).toBeGreaterThan(0); + expect(() => decodePayload(tamperedV0Payload)).toThrow( + "NIP-44: Unsupported version: 0. This implementation supports version 2.", + ); + expect(() => decrypt(tamperedV0Payload, sec2, pub1)).toThrow( + "NIP-44: Unsupported version: 0. This implementation supports version 2.", + ); } else { throw new Error( "Failed to create a V2 payload for V0 tampering in decode test", @@ -276,11 +269,12 @@ describe("NIP-44 implementation against official test vectors", () => { if (v2Buffer.length > 0) { v2Buffer[0] = 1; // Set version to 1 const tamperedV1Payload = v2Buffer.toString("base64"); - const decodedV1 = decodePayload(tamperedV1Payload); - expect(decodedV1.version).toBe(1); - expect(decodedV1.nonce.length).toBe(32); // NONCE_SIZE_V1 (assuming 32) - expect(decodedV1.mac.length).toBe(32); // MAC_SIZE_V1 (assuming 32) - expect(decodedV1.ciphertext.length).toBeGreaterThan(0); + expect(() => decodePayload(tamperedV1Payload)).toThrow( + "NIP-44: Unsupported version: 1. This implementation supports version 2.", + ); + expect(() => decrypt(tamperedV1Payload, sec2, pub1)).toThrow( + "NIP-44: Unsupported version: 1. This implementation supports version 2.", + ); } else { throw new Error( "Failed to create a V2 payload for V1 tampering in decode test", diff --git a/tests/nip44/nip44-padding-hmac.test.ts b/tests/nip44/nip44-padding-hmac.test.ts index dfb544e9..88123023 100644 --- a/tests/nip44/nip44-padding-hmac.test.ts +++ b/tests/nip44/nip44-padding-hmac.test.ts @@ -182,6 +182,21 @@ describe("NIP-44 Padding Implementation", () => { }); describe("NIP-44 HMAC Implementation", () => { + test("should reject message-key and AAD nonces that are not exactly 32 bytes", () => { + const conversationKey = new Uint8Array(32); + const hmacKey = new Uint8Array(32); + const message = new Uint8Array([1]); + + for (const invalidLength of [31, 33]) { + expect(() => + getMessageKeys(conversationKey, new Uint8Array(invalidLength)), + ).toThrow(`Nonce must be 32 bytes for key derivation, got: ${invalidLength}`); + expect(() => + hmacWithAAD(hmacKey, message, new Uint8Array(invalidLength)), + ).toThrow(`AAD (nonce) must be 32 bytes, got: ${invalidLength}`); + } + }); + test("should derive correct message keys from conversation key and nonce", () => { // Test vector from NIP-44 official vectors for message key derivation const vectors = testVectors.v2.valid.get_message_keys.keys; diff --git a/tests/nip44/nip44-performance-security.test.ts b/tests/nip44/nip44-performance-security.test.ts index e5e696ad..e8fe66d2 100644 --- a/tests/nip44/nip44-performance-security.test.ts +++ b/tests/nip44/nip44-performance-security.test.ts @@ -23,7 +23,7 @@ function measureTime(fn: () => T): { result: T; timeMs: number } { return { result, timeMs: end - start }; } -describe("NIP-44 Performance Tests", () => { +describe("[slow] NIP-44 Performance Tests", () => { const testPrivateKey = "0000000000000000000000000000000000000000000000000000000000000001"; const testPublicKey = getPublicKeyHex(testPrivateKey); @@ -193,7 +193,7 @@ describe("NIP-44 Performance Tests", () => { }); }); -describe("NIP-44 Security Tests", () => { +describe("[slow] NIP-44 Security Tests", () => { describe("Constant-Time Comparison Security", () => { test("should have consistent timing for equal arrays", () => { const array1 = new Uint8Array(32).fill(0xaa); diff --git a/tests/nip46/bunker-functionality.test.ts b/tests/nip46/bunker-functionality.test.ts index da137114..5a5cfe5c 100644 --- a/tests/nip46/bunker-functionality.test.ts +++ b/tests/nip46/bunker-functionality.test.ts @@ -1,13 +1,17 @@ import { SimpleNIP46Client, SimpleNIP46Bunker, + Nostr, NostrRemoteSignerBunker, generateKeypair, verifySignature, } from "../../src"; import { LogLevel } from "../../src/nip46"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NIP46Method } from "../../src/nip46/types"; +import { NIP46Wire } from "../../src/nip46/internal/wire"; +import { NostrRelay } from "../../src/testing"; import { NostrEvent } from "../../src/types/nostr"; +import { testUtils } from "../types"; describe("NIP-46 Bunker Functionality", () => { let relay: NostrRelay; @@ -372,6 +376,102 @@ describe("NIP-46 Bunker Functionality", () => { }); }); + describe("Replay protection", () => { + test("drops a duplicate request ID after the production bunker handler sees it", async () => { + const requester = await generateKeypair(); + const warnings: string[] = []; + const bunker = new NostrRemoteSignerBunker({ + userPubkey: userKeypair.publicKey, + signerPubkey: signerKeypair.publicKey, + relays: [relayUrl], + defaultPermissions: [NIP46Method.PING], + logger: { + error: () => {}, + warn: (message) => warnings.push(message), + info: () => {}, + debug: () => {}, + trace: () => {}, + }, + }); + bunker.setUserPrivateKey(userKeypair.privateKey); + bunker.setSignerPrivateKey(signerKeypair.privateKey); + + const sender = new Nostr([relayUrl]); + try { + await bunker.start(); + await sender.connectToRelays(); + const request = await NIP46Wire.createRequestEvent( + { + id: "replay-probe", + method: NIP46Method.PING, + params: [], + }, + requester, + signerKeypair.publicKey, + ); + const matchingResponses = () => + relay.cache.filter( + (event) => + event.kind === request.kind && + event.pubkey === signerKeypair.publicKey && + event.tags.some( + (tag) => tag[0] === "p" && tag[1] === requester.publicKey, + ), + ); + + await sender.publishEvent(request); + await testUtils.waitFor(() => matchingResponses().length === 1); + + await sender.publishEvent(request); + await testUtils.waitFor(() => + warnings.some((message) => message === "Replay attack detected"), + ); + + expect(matchingResponses()).toHaveLength(1); + + await bunker.stop(); + await bunker.start(); + await sender.publishEvent(request); + await testUtils.waitFor(() => matchingResponses().length === 2); + } finally { + sender.disconnectFromRelays(); + await bunker.stop().catch(() => {}); + } + }); + + test("stop clears the bunker-owned cleanup interval", async () => { + const setIntervalSpy = jest.spyOn(globalThis, "setInterval"); + const clearIntervalSpy = jest.spyOn(globalThis, "clearInterval"); + const bunker = new NostrRemoteSignerBunker({ + userPubkey: userKeypair.publicKey, + signerPubkey: signerKeypair.publicKey, + relays: [relayUrl], + defaultPermissions: [NIP46Method.PING], + rateLimitConfig: { cleanupIntervalMs: 300000 }, + }); + bunker.setUserPrivateKey(userKeypair.privateKey); + bunker.setSignerPrivateKey(signerKeypair.privateKey); + + try { + await bunker.start(); + const cleanupCallIndex = setIntervalSpy.mock.calls.findIndex( + ([, delay]) => delay === 60000, + ); + expect(cleanupCallIndex).toBeGreaterThanOrEqual(0); + const cleanupTimer = + setIntervalSpy.mock.results[cleanupCallIndex].value; + + await bunker.stop(); + + expect(clearIntervalSpy).toHaveBeenCalledWith(cleanupTimer); + } finally { + await bunker.stop().catch(() => {}); + setIntervalSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + } + }); + }); + describe("Bunker Initialization Security", () => { test("should validate bunker options on creation", () => { // Test empty userPubkey - should throw specific error diff --git a/tests/nip46/connection-failures.test.ts b/tests/nip46/connection-failures.test.ts index b0c98e13..20529769 100644 --- a/tests/nip46/connection-failures.test.ts +++ b/tests/nip46/connection-failures.test.ts @@ -4,7 +4,7 @@ import { generateKeypair, } from "../../src"; import { LogLevel } from "../../src/nip46"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; describe("NIP-46 Connection Failures", () => { let relay: NostrRelay; diff --git a/tests/nip46/core-functionality.test.ts b/tests/nip46/core-functionality.test.ts index 6196ca6d..e46e336f 100644 --- a/tests/nip46/core-functionality.test.ts +++ b/tests/nip46/core-functionality.test.ts @@ -7,25 +7,12 @@ import { verifySignature, } from "../../src"; import { LogLevel } from "../../src/nip46"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; -import { NIP46ConnectionError } from "../../src/nip46/types"; +import { invokeNip46BunkerConnect, NostrRelay } from "../../src/testing"; +import { NIP46ConnectionError, NIP46Method } from "../../src/nip46/types"; import { validateSecureInitialization } from "../../src/nip46/utils/security"; jest.setTimeout(60000); // 60 second timeout for NIP-46 operations to handle full test suite load -// Type for accessing internal client properties in tests -interface ClientWithInternals { - clientKeys: { publicKey: string }; -} - -// Interface for accessing internal bunker methods in tests -interface BunkerWithInternals { - handleConnect( - request: { id: string; method: string; params: string[] }, - clientPubkey: string, - ): Promise<{ id: string; result: string; error: string; auth_url?: string }>; -} - describe("NIP-46 Core Functionality (Optimized)", () => { let relay: NostrRelay; let relayUrl: string; @@ -162,6 +149,13 @@ describe("NIP-46 Core Functionality (Optimized)", () => { }); test("NIP-44 and NIP-04 encryption support", async () => { + bunker.setDefaultPermissions([ + "nip44_encrypt", + "nip44_decrypt", + "nip04_encrypt", + "nip04_decrypt", + ]); + const connectionString = bunker.getConnectionString(); await client.connect(connectionString); @@ -182,12 +176,6 @@ describe("NIP-46 Core Functionality (Optimized)", () => { ); expect(nip44Decrypted).toBe(message); - // Test NIP-04 (legacy) - need to grant permissions - const clientPubkey = (client as unknown as ClientWithInternals).clientKeys - .publicKey; - bunker.addClientPermission(clientPubkey, "nip04_encrypt"); - bunker.addClientPermission(clientPubkey, "nip04_decrypt"); - const nip04Encrypted = await client.nip04Encrypt( recipientKeys.publicKey, message, @@ -333,18 +321,7 @@ describe("NIP-46 Core Functionality (Optimized)", () => { // Test disconnect cleanup await fullClient.disconnect(); - const clientWithInternals = fullClient as unknown as { - connected: boolean; - pendingRequests: Map< - string, - { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - } - >; - }; - expect(clientWithInternals.connected).toBe(false); - expect(clientWithInternals.pendingRequests.size).toBe(0); + await expect(fullClient.ping()).rejects.toThrow(NIP46ConnectionError); } finally { await fullClient.disconnect().catch(() => {}); } @@ -408,24 +385,9 @@ describe("NIP-46 Core Functionality (Optimized)", () => { ]; await Promise.all(requests); - const clientWithInternals = testClient as unknown as { - pendingRequests: Map< - string, - { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - } - >; - connected: boolean; - }; - // Disconnect immediately await testClient.disconnect(); - // Verify cleanup - expect(clientWithInternals.pendingRequests.size).toBe(0); - expect(clientWithInternals.connected).toBe(false); - // New requests should be rejected await expect(testClient.ping()).rejects.toThrow(NIP46ConnectionError); @@ -496,10 +458,9 @@ describe("NIP-46 Core Functionality (Optimized)", () => { expect(typeof resolved).toBe("boolean"); // Test auth challenge format - const testBunker = authBunker as unknown as BunkerWithInternals; const connectRequest = { id: "test-connect-id", - method: "connect", + method: NIP46Method.CONNECT, params: [ testSignerKeypair.publicKey, "", @@ -507,7 +468,8 @@ describe("NIP-46 Core Functionality (Optimized)", () => { ], }; - const response = await testBunker.handleConnect( + const response = await invokeNip46BunkerConnect( + authBunker, connectRequest, testUserKeypair.publicKey, ); diff --git a/tests/nip46/diagnostic-redaction.test.ts b/tests/nip46/diagnostic-redaction.test.ts new file mode 100644 index 00000000..2e90caf5 --- /dev/null +++ b/tests/nip46/diagnostic-redaction.test.ts @@ -0,0 +1,453 @@ +import { + type DiagnosticLogArgument, + type DiagnosticLogger, + NostrRemoteSignerBunker, + NostrRemoteSignerClient, + LogLevel, + SimpleNIP46Bunker, + SimpleNIP46Client, + generateKeypair, +} from "../../src"; +import { NostrRelay } from "../../src/testing"; + +interface CapturedDiagnostic { + level: keyof DiagnosticLogger; + message: string; + args: DiagnosticLogArgument[]; +} + +function createCapturingLogger(): { + logger: DiagnosticLogger; + diagnostics: CapturedDiagnostic[]; +} { + const diagnostics: CapturedDiagnostic[] = []; + const capture = + (level: keyof DiagnosticLogger) => + (message: string, ...args: DiagnosticLogArgument[]): void => { + diagnostics.push({ level, message, args }); + }; + + return { + logger: { + error: capture("error"), + warn: capture("warn"), + info: capture("info"), + debug: capture("debug"), + trace: capture("trace"), + }, + diagnostics, + }; +} + +function renderDiagnostics(diagnostics: CapturedDiagnostic[]): string { + return JSON.stringify(diagnostics); +} + +function expectNoSensitiveDiagnostics( + diagnostics: CapturedDiagnostic[], + sensitiveValues: string[], +): void { + const rendered = renderDiagnostics(diagnostics); + + for (const sensitiveValue of sensitiveValues) { + expect(rendered).not.toContain(sensitiveValue); + } + + expect(diagnostics.length).toBeGreaterThan(0); + + for (const level of ["info", "debug", "trace"] as const) { + const atLevel = renderDiagnostics( + diagnostics.filter((diagnostic) => diagnostic.level === level), + ); + for (const sensitiveValue of sensitiveValues) { + expect(atLevel).not.toContain(sensitiveValue); + } + } +} + +describe("NIP-46 diagnostic redaction", () => { + let relay: NostrRelay; + let relayUrl: string; + + beforeAll(async () => { + relay = new NostrRelay(0); + await relay.start(); + relayUrl = relay.url; + }); + + afterAll(async () => { + await relay.close(); + }); + + test("advanced client and simple bunker never expose protocol secrets", async () => { + const userKeys = await generateKeypair(); + const signerKeys = await generateKeypair(); + const connectionSecret = "connection-secret-advanced-client"; + const eventPlaintext = "private-event-advanced-client"; + const encryptionPlaintext = "private-encryption-advanced-client"; + const recipientKeys = await generateKeypair(); + const clientDiagnostics = createCapturingLogger(); + const bunkerDiagnostics = createCapturingLogger(); + const bunker = new SimpleNIP46Bunker( + [relayUrl], + userKeys.publicKey, + signerKeys.publicKey, + { + secret: connectionSecret, + defaultPermissions: [ + "get_public_key", + "ping", + "sign_event", + "nip44_encrypt", + ], + logger: bunkerDiagnostics.logger, + }, + ); + const client = new NostrRemoteSignerClient({ + relays: [relayUrl], + timeout: 3000, + logger: clientDiagnostics.logger, + }); + + bunker.setUserPrivateKey(userKeys.privateKey); + bunker.setSignerPrivateKey(signerKeys.privateKey); + + try { + await bunker.start(); + await client.connect(bunker.getConnectionString()); + await client.signEvent({ + kind: 1, + content: eventPlaintext, + created_at: 1_700_000_000, + tags: [], + }); + await client.nip44Encrypt(recipientKeys.publicKey, encryptionPlaintext); + + expectNoSensitiveDiagnostics(clientDiagnostics.diagnostics, [ + connectionSecret, + eventPlaintext, + encryptionPlaintext, + userKeys.privateKey, + signerKeys.privateKey, + ]); + expectNoSensitiveDiagnostics(bunkerDiagnostics.diagnostics, [ + connectionSecret, + eventPlaintext, + encryptionPlaintext, + userKeys.privateKey, + signerKeys.privateKey, + ]); + expect(renderDiagnostics(clientDiagnostics.diagnostics)).toContain( + "sign_event", + ); + expect(renderDiagnostics(bunkerDiagnostics.diagnostics)).toContain( + "sign_event", + ); + } finally { + await client.disconnect().catch(() => undefined); + await bunker.stop().catch(() => undefined); + } + }); + + test("simple client and advanced bunker never expose protocol secrets", async () => { + const userKeys = await generateKeypair(); + const signerKeys = await generateKeypair(); + const connectionSecret = "connection-secret-simple-client"; + const eventPlaintext = "private-event-simple-client"; + const encryptionPlaintext = "private-encryption-simple-client"; + const recipientKeys = await generateKeypair(); + const clientDiagnostics = createCapturingLogger(); + const bunkerDiagnostics = createCapturingLogger(); + const bunker = new NostrRemoteSignerBunker({ + userPubkey: userKeys.publicKey, + signerPubkey: signerKeys.publicKey, + relays: [relayUrl], + secret: connectionSecret, + defaultPermissions: [ + "get_public_key", + "ping", + "sign_event", + "nip44_encrypt", + ], + logger: bunkerDiagnostics.logger, + }); + const client = new SimpleNIP46Client([relayUrl], { + timeout: 3000, + logger: clientDiagnostics.logger, + }); + + bunker.setUserPrivateKey(userKeys.privateKey); + bunker.setSignerPrivateKey(signerKeys.privateKey); + + try { + await bunker.start(); + await client.connect(bunker.getConnectionString()); + await client.signEvent({ + kind: 1, + content: eventPlaintext, + created_at: 1_700_000_001, + tags: [], + }); + await client.nip44Encrypt(recipientKeys.publicKey, encryptionPlaintext); + + expectNoSensitiveDiagnostics(clientDiagnostics.diagnostics, [ + connectionSecret, + eventPlaintext, + encryptionPlaintext, + userKeys.privateKey, + signerKeys.privateKey, + ]); + expectNoSensitiveDiagnostics(bunkerDiagnostics.diagnostics, [ + connectionSecret, + eventPlaintext, + encryptionPlaintext, + userKeys.privateKey, + signerKeys.privateKey, + ]); + expect(renderDiagnostics(clientDiagnostics.diagnostics)).toContain( + "sign_event", + ); + expect(renderDiagnostics(bunkerDiagnostics.diagnostics)).toContain( + "sign_event", + ); + } finally { + await client.disconnect().catch(() => undefined); + await bunker.stop().catch(() => undefined); + } + }); + + test("legacy simple facades redact secret connect responses", async () => { + const userKeys = await generateKeypair(); + const signerKeys = await generateKeypair(); + const connectionSecret = "legacy-connect-response-secret"; + const multilinePlaintext = "multiline-secret-first\nmultiline-secret-last"; + const clientDiagnostics = createCapturingLogger(); + const bunkerDiagnostics = createCapturingLogger(); + const bunker = new SimpleNIP46Bunker( + [relayUrl], + userKeys.publicKey, + signerKeys.publicKey, + { + secret: connectionSecret, + defaultPermissions: ["get_public_key", "sign_event"], + logger: bunkerDiagnostics.logger, + }, + ); + const client = new SimpleNIP46Client([relayUrl], { + timeout: 3000, + logger: clientDiagnostics.logger, + }); + + bunker.setUserPrivateKey(userKeys.privateKey); + bunker.setSignerPrivateKey(signerKeys.privateKey); + + try { + await bunker.start(); + await client.connect(bunker.getConnectionString()); + await client.signEvent({ + kind: 1, + content: multilinePlaintext, + created_at: 1_700_000_002, + tags: [], + }); + + expectNoSensitiveDiagnostics(clientDiagnostics.diagnostics, [ + connectionSecret, + multilinePlaintext, + "multiline-secret-last", + userKeys.privateKey, + signerKeys.privateKey, + ]); + expectNoSensitiveDiagnostics(bunkerDiagnostics.diagnostics, [ + connectionSecret, + multilinePlaintext, + "multiline-secret-last", + userKeys.privateKey, + signerKeys.privateKey, + ]); + expect(renderDiagnostics(clientDiagnostics.diagnostics)).toContain( + "Connect response requires secret", + ); + } finally { + await client.disconnect().catch(() => undefined); + await bunker.stop().catch(() => undefined); + } + }); + + test("invalid secrets retain safe failure metadata", async () => { + const userKeys = await generateKeypair(); + const signerKeys = await generateKeypair(); + const expectedSecret = "expected-connect-secret"; + const rejectedSecret = "rejected-connect-secret"; + const clientDiagnostics = createCapturingLogger(); + const bunkerDiagnostics = createCapturingLogger(); + const bunker = new SimpleNIP46Bunker( + [relayUrl], + userKeys.publicKey, + signerKeys.publicKey, + { + secret: expectedSecret, + logger: bunkerDiagnostics.logger, + }, + ); + const client = new NostrRemoteSignerClient({ + relays: [relayUrl], + timeout: 3000, + logger: clientDiagnostics.logger, + }); + + bunker.setUserPrivateKey(userKeys.privateKey); + bunker.setSignerPrivateKey(signerKeys.privateKey); + + try { + await bunker.start(); + const rejectedConnectionString = bunker + .getConnectionString() + .replace(expectedSecret, rejectedSecret); + + await expect(client.connect(rejectedConnectionString)).rejects.toThrow( + /invalid secret/i, + ); + + expectNoSensitiveDiagnostics(clientDiagnostics.diagnostics, [ + expectedSecret, + rejectedSecret, + userKeys.privateKey, + signerKeys.privateKey, + ]); + expectNoSensitiveDiagnostics(bunkerDiagnostics.diagnostics, [ + expectedSecret, + rejectedSecret, + userKeys.privateKey, + signerKeys.privateKey, + ]); + const failureDiagnostics = renderDiagnostics( + bunkerDiagnostics.diagnostics, + ); + expect(failureDiagnostics).toMatch(/invalid secret/i); + expect(failureDiagnostics).toContain("connect"); + } finally { + await client.disconnect().catch(() => undefined); + await bunker.stop().catch(() => undefined); + } + }); + + test("throwing diagnostics cannot alter public behavior", async () => { + const userKeys = await generateKeypair(); + const signerKeys = await generateKeypair(); + const setLevel = jest.fn(() => { + throw new Error("set level failed"); + }); + const throwDiagnostic = (): never => { + throw new Error("diagnostic failed"); + }; + const logger = { + error: throwDiagnostic, + warn: throwDiagnostic, + info: throwDiagnostic, + debug: throwDiagnostic, + trace: throwDiagnostic, + setLevel, + }; + + expect( + () => + new NostrRemoteSignerBunker({ + userPubkey: userKeys.publicKey, + logger, + }), + ).not.toThrow(); + + const bunker = new SimpleNIP46Bunker( + [relayUrl], + userKeys.publicKey, + signerKeys.publicKey, + { + defaultPermissions: ["ping"], + logger, + }, + ); + const client = new SimpleNIP46Client([relayUrl], { logger }); + bunker.setUserPrivateKey(userKeys.privateKey); + bunker.setSignerPrivateKey(signerKeys.privateKey); + + expect(() => client.setLogLevel(LogLevel.TRACE)).not.toThrow(); + expect(setLevel).toHaveBeenCalledWith(LogLevel.TRACE); + + try { + await bunker.start(); + await expect(client.connect(bunker.getConnectionString())).resolves.toBe( + userKeys.publicKey, + ); + await expect(client.ping()).resolves.toBe(true); + } finally { + await client.disconnect().catch(() => undefined); + await bunker.stop().catch(() => undefined); + } + }); + + test("untrusted Error messages retain only safe failure type", async () => { + const userKeys = await generateKeypair(); + const reflectedSecret = "reflected-error-secret"; + const multilinePayload = + "Decrypted content: multiline-reflected-first\nmultiline-reflected-last"; + const diagnostics = createCapturingLogger(); + const malformedPermissions = [ + new Error(reflectedSecret), + multilinePayload, + ] as unknown as string[]; + + expect( + () => + new NostrRemoteSignerBunker({ + userPubkey: userKeys.publicKey, + defaultPermissions: malformedPermissions, + logger: diagnostics.logger, + }), + ).not.toThrow(); + + const rendered = renderDiagnostics(diagnostics.diagnostics); + expect(rendered).not.toContain(reflectedSecret); + expect(rendered).not.toContain("multiline-reflected-last"); + expect(rendered).toContain("[REDACTED]"); + expect(rendered).toContain("Error"); + }); + + test("simple bunker does not stringify untrusted permission values", async () => { + const userKeys = await generateKeypair(); + const signerKeys = await generateKeypair(); + const reflectedSecret = "simple-permission-reflection-secret"; + const diagnostics = createCapturingLogger(); + const malformedPermissions = [ + new Error(reflectedSecret), + ] as unknown as string[]; + const bunker = new SimpleNIP46Bunker( + [relayUrl], + userKeys.publicKey, + signerKeys.publicKey, + { + defaultPermissions: malformedPermissions, + logLevel: LogLevel.DEBUG, + logger: diagnostics.logger, + }, + ); + const client = new NostrRemoteSignerClient({ + relays: [relayUrl], + timeout: 3000, + }); + bunker.setUserPrivateKey(userKeys.privateKey); + bunker.setSignerPrivateKey(signerKeys.privateKey); + + try { + await bunker.start(); + await client.connect(bunker.getConnectionString()); + + const rendered = renderDiagnostics(diagnostics.diagnostics); + expect(rendered).not.toContain(reflectedSecret); + expect(rendered).toContain("Client permissions"); + } finally { + await client.disconnect().catch(() => undefined); + await bunker.stop().catch(() => undefined); + } + }); +}); diff --git a/tests/nip46/input-validation.test.ts b/tests/nip46/input-validation.test.ts index 0364726b..31315132 100644 --- a/tests/nip46/input-validation.test.ts +++ b/tests/nip46/input-validation.test.ts @@ -3,7 +3,7 @@ import { SimpleNIP46Bunker, generateKeypair, } from "../../src"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; import { NIP46SecurityError } from "../../src/nip46/types"; import { parseConnectionString } from "../../src/nip46/utils/connection"; import { isValidAuthUrl } from "../../src/nip46/utils/auth"; @@ -34,6 +34,13 @@ describe("NIP-46 Input Validation Security", () => { let client: SimpleNIP46Client; let bunker: SimpleNIP46Bunker; let userKeypair: { publicKey: string; privateKey: string }; + let clientConnected = false; + + async function connectClient(connectionString: string): Promise { + const userPubkey = await client.connect(connectionString); + clientConnected = true; + return userPubkey; + } beforeAll(async () => { relay = new NostrRelay(0); @@ -63,6 +70,7 @@ describe("NIP-46 Input Validation Security", () => { "sign_event", "nip44_encrypt", "nip44_decrypt", + "ping", ]); await bunker.start(); @@ -70,11 +78,12 @@ describe("NIP-46 Input Validation Security", () => { // and the subscription is active, so no additional waiting is needed client = new SimpleNIP46Client([relay.url], { timeout: 10000 }); // Increased timeout for full test suite + clientConnected = false; }); afterEach(async () => { try { - if (client) { + if (client && clientConnected) { await client.disconnect(); } } catch (e) { @@ -105,45 +114,40 @@ describe("NIP-46 Input Validation Security", () => { }); describe("Connection String Validation", () => { - test("rejects malformed connection strings", async () => { - await expect(client.connect("invalid-connection")).rejects.toThrow(); - await expect(client.connect("http://not-a-bunker")).rejects.toThrow(); - await expect(client.connect("")).rejects.toThrow(); - await expect(client.connect("bunker://")).rejects.toThrow(); + test("rejects malformed connection strings", () => { + expect(() => parseConnectionString("invalid-connection")).toThrow(); + expect(() => parseConnectionString("http://not-a-bunker")).toThrow(); + expect(() => parseConnectionString("")).toThrow(); + expect(() => parseConnectionString("bunker://")).toThrow(); }); - test("rejects connection strings with invalid pubkeys", async () => { - await expect( - client.connect("bunker://invalidpubkey?relay=ws://localhost:3334"), - ).rejects.toThrow(); + test("rejects connection strings with invalid pubkeys", () => { + expect(() => + parseConnectionString( + "bunker://invalidpubkey?relay=ws://localhost:3334", + ), + ).toThrow(); - await expect( - client.connect( + expect(() => + parseConnectionString( "bunker://gg" + "a".repeat(62) + "?relay=ws://localhost:3334", ), - ).rejects.toThrow(); + ).toThrow(); }); - test("validates relay URLs in connection strings", async () => { + test("validates relay URLs in connection strings", () => { const validPubkey = "a".repeat(64); - // Use timeout helper that properly cleans up timers - await expect( - raceWithTimeout( - client.connect(`bunker://${validPubkey}?relay=http://insecure.com`), - 2000, - "timeout", + expect(() => + parseConnectionString( + `bunker://${validPubkey}?relay=http://insecure.com`, ), - ).rejects.toThrow(); + ).toThrow(); - await expect( - raceWithTimeout( - client.connect(`bunker://${validPubkey}?relay=invalid-url`), - 2000, - "timeout", - ), - ).rejects.toThrow(); - }, 6000); // Reduced timeout + expect(() => + parseConnectionString(`bunker://${validPubkey}?relay=invalid-url`), + ).toThrow(); + }); test("validates connection string length", () => { const longString = "bunker://" + "a".repeat(8200); @@ -311,15 +315,14 @@ describe("NIP-46 Input Validation Security", () => { expect(connectionString).toMatch(/^bunker:\/\/[a-f0-9]{64}\?/); // Should be able to parse and connect - await client.connect(connectionString); + await connectClient(connectionString); const userPubkey = await client.getPublicKey(); expect(typeof userPubkey).toBe("string"); expect(userPubkey.length).toBe(64); - await client.disconnect(); }); - test("Invalid connection string formats", async () => { + test("Invalid connection string formats", () => { // Test various invalid formats const invalidStrings = [ "bunker://", @@ -331,7 +334,7 @@ describe("NIP-46 Input Validation Security", () => { ]; for (const invalidString of invalidStrings) { - await expect(client.connect(invalidString)).rejects.toThrow(); + expect(() => parseConnectionString(invalidString)).toThrow(); } }); @@ -359,7 +362,7 @@ describe("NIP-46 Input Validation Security", () => { }); describe("Key Validation", () => { - test("accepts hex keys with mixed case in connection strings", async () => { + test("accepts hex keys with mixed case in connection strings", () => { // Test various case combinations for public keys in connection strings const testKeys = [ "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", // lowercase @@ -368,43 +371,12 @@ describe("NIP-46 Input Validation Security", () => { ]; for (const pubkey of testKeys) { - // Test connection string parsing - this is where validation actually happens const connectionString = `bunker://${pubkey}?relay=${relay.url}`; - - // These should pass connection string parsing but fail during actual connection - // since the pubkeys don't match the bunker's actual key - try { - await client.connect(connectionString); - // If we reach here, the connection succeeded when it should have failed - // This is a security issue - mismatched signer keys should be rejected - throw new Error( - `Security violation: Connection with mismatched signer key ${pubkey} should have been rejected but succeeded`, - ); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - // If it's our security violation error, re-throw to fail the test - if (errorMessage.startsWith("Security violation:")) { - throw error; - } - - // The key point is that it should NOT fail with the connection string parsing error - expect(errorMessage).not.toBe( - "Invalid signer public key in connection string", - ); - } finally { - // Always disconnect to prevent resource leaks and connection conflicts - try { - await client.disconnect(); - } catch (e) { - // Ignore cleanup errors - } - } + expect(parseConnectionString(connectionString).pubkey).toBe(pubkey); } }); - test("rejects invalid hex keys in connection strings", async () => { + test("rejects invalid hex keys in connection strings", () => { const invalidKeys = [ "G234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", // invalid character 'G' "1234567890abcdef123456789", // too short (25 chars) @@ -416,8 +388,7 @@ describe("NIP-46 Input Validation Security", () => { for (const pubkey of invalidKeys) { const connectionString = `bunker://${pubkey}?relay=${relay.url}`; - // These should throw during connection string parsing with the specific error message - await expect(client.connect(connectionString)).rejects.toThrow( + expect(() => parseConnectionString(connectionString)).toThrow( "Invalid signer public key in connection string", ); } @@ -427,7 +398,7 @@ describe("NIP-46 Input Validation Security", () => { describe("Event Content Validation", () => { test("handles events with various timestamps", async () => { const connectionString = bunker.getConnectionString(); - await client.connect(connectionString); + await connectClient(connectionString); // Current timestamp should always work const currentEvent = await client.signEvent({ @@ -452,7 +423,7 @@ describe("NIP-46 Input Validation Security", () => { test("handles events with various kind values", async () => { const connectionString = bunker.getConnectionString(); - await client.connect(connectionString); + await connectClient(connectionString); // Reduced test cases for speed const validKinds = [1, 1000]; // Reduced from [0, 1, 3, 1000, 10000] @@ -481,7 +452,7 @@ describe("NIP-46 Input Validation Security", () => { test("handles various content sizes", async () => { const connectionString = bunker.getConnectionString(); - await client.connect(connectionString); + await connectClient(connectionString); // Reduced content size for speed const normalContent = "Hello world! ".repeat(50); // Reduced from 100 @@ -517,7 +488,7 @@ describe("NIP-46 Input Validation Security", () => { test("validates event tag structure", async () => { const connectionString = bunker.getConnectionString(); - await client.connect(connectionString); + await connectClient(connectionString); // Valid tags should work const validEvent = await client.signEvent({ @@ -546,7 +517,7 @@ describe("NIP-46 Input Validation Security", () => { describe("Parameter Validation", () => { test("validates pubkey parameters", async () => { const connectionString = bunker.getConnectionString(); - await client.connect(connectionString); + await connectClient(connectionString); // Invalid pubkey format await expect( @@ -566,7 +537,7 @@ describe("NIP-46 Input Validation Security", () => { test("validates message size limits", async () => { const connectionString = bunker.getConnectionString(); - await client.connect(connectionString); + await connectClient(connectionString); const validPubkey = (await generateKeypair()).publicKey; @@ -590,7 +561,7 @@ describe("NIP-46 Input Validation Security", () => { test("sanitizes dangerous input", async () => { const connectionString = bunker.getConnectionString(); - await client.connect(connectionString); + await connectClient(connectionString); const validPubkey = (await generateKeypair()).publicKey; @@ -616,7 +587,7 @@ describe("NIP-46 Input Validation Security", () => { describe("Rate Limiting", () => { test("handles rapid requests gracefully", async () => { const connectionString = bunker.getConnectionString(); - await client.connect(connectionString); + await connectClient(connectionString); // Send multiple rapid requests const requests = Array(5) @@ -632,7 +603,7 @@ describe("NIP-46 Input Validation Security", () => { test("prevents DoS with large numbers of simultaneous requests", async () => { const connectionString = bunker.getConnectionString(); - await client.connect(connectionString); + await connectClient(connectionString); // Reduced request count for speed const requests = Array(10) @@ -674,7 +645,7 @@ describe("NIP-46 Input Validation Security", () => { expect.assertions(3); // Ensure all assertions are executed const connectionString = bunker.getConnectionString(); - await client.connect(connectionString); + await connectClient(connectionString); try { // Force an encryption error diff --git a/tests/nip46/performance-security.test.ts b/tests/nip46/performance-security.test.ts index 9f5f6036..3c37205a 100644 --- a/tests/nip46/performance-security.test.ts +++ b/tests/nip46/performance-security.test.ts @@ -4,13 +4,12 @@ import { NostrRemoteSignerBunker, generateKeypair, } from "../../src"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; -import { generateRequestId } from "../../src/nip46/utils/request-response"; +import { NostrRelay, replaceNip46RateLimiterDestroy } from "../../src/testing"; import { NIP46RateLimiter } from "../../src/nip46/utils/rate-limiter"; jest.setTimeout(60000); // 60 second timeout for performance tests to handle full test suite load -describe("NIP-46 Performance & DoS Protection", () => { +describe("[slow] NIP-46 Performance & DoS Protection", () => { let relay: NostrRelay; let client: SimpleNIP46Client; let bunker: SimpleNIP46Bunker; @@ -509,95 +508,6 @@ describe("NIP-46 Performance & DoS Protection", () => { }, 12000); // Increased timeout to be more realistic for concurrent operations }); - describe("Replay Attack Protection", () => { - let protectedBunker: NostrRemoteSignerBunker; - let signerKeypair: { publicKey: string; privateKey: string }; - - beforeEach(async () => { - signerKeypair = await generateKeypair(); - protectedBunker = new NostrRemoteSignerBunker({ - userPubkey: userKeypair.publicKey, - signerPubkey: signerKeypair.publicKey, - relays: [relay.url], - defaultPermissions: ["get_public_key", "ping"], - debug: true, - }); - protectedBunker.setUserPrivateKey(userKeypair.privateKey); - protectedBunker.setSignerPrivateKey(signerKeypair.privateKey); - await protectedBunker.start(); - }); - - afterEach(async () => { - if (protectedBunker) { - await protectedBunker.stop(); - } - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - test("Replay attack window is reduced to 2 minutes", async () => { - // Access private method for testing - const bunkerWithInternals = protectedBunker as unknown as { - usedRequestIds: Map; - isReplayAttack: (id: string) => boolean; - cleanupOldRequestIds: () => void; - }; - - // Simulate a request ID that's 1 minute old (should be valid) - const requestId1 = generateRequestId(); - const now = Date.now(); - bunkerWithInternals.usedRequestIds.set(requestId1, now - 60000); // 1 minute ago - - // Should be considered a replay attack because already used - expect(bunkerWithInternals.isReplayAttack(requestId1)).toBe(true); // Already used - - // Simulate a request ID that's 3 minutes old - const requestId2 = generateRequestId(); - bunkerWithInternals.usedRequestIds.set(requestId2, now - 180000); // 3 minutes ago - - // Clean up old IDs - bunkerWithInternals.cleanupOldRequestIds(); - - // The 3-minute-old ID should be cleaned up - expect(bunkerWithInternals.usedRequestIds.has(requestId2)).toBe(false); - }); - - test("Cleanup runs more frequently", async () => { - // This test verifies that cleanup interval is set to 1 minute - const bunkerWithInternals = protectedBunker as unknown as { - cleanupInterval: NodeJS.Timeout | null; - }; - - // Check that cleanup interval exists - expect(bunkerWithInternals.cleanupInterval).toBeDefined(); - expect(bunkerWithInternals.cleanupInterval).not.toBeNull(); - }); - - test("Old request IDs are properly cleaned up", async () => { - const bunkerWithInternals = protectedBunker as unknown as { - usedRequestIds: Map; - cleanupOldRequestIds: () => void; - }; - - // Add some old request IDs - const oldId1 = generateRequestId(); - const oldId2 = generateRequestId(); - const recentId = generateRequestId(); - - const now = Date.now(); - bunkerWithInternals.usedRequestIds.set(oldId1, now - 300000); // 5 minutes ago - bunkerWithInternals.usedRequestIds.set(oldId2, now - 180000); // 3 minutes ago - bunkerWithInternals.usedRequestIds.set(recentId, now - 30000); // 30 seconds ago - - // Run cleanup - bunkerWithInternals.cleanupOldRequestIds(); - - // Old IDs should be removed, recent one should remain - expect(bunkerWithInternals.usedRequestIds.has(oldId1)).toBe(false); - expect(bunkerWithInternals.usedRequestIds.has(oldId2)).toBe(false); - expect(bunkerWithInternals.usedRequestIds.has(recentId)).toBe(true); - }); - }); - describe("Advanced Memory Management", () => { let managedBunker: NostrRemoteSignerBunker; let signerKeypair: { publicKey: string; privateKey: string }; @@ -623,67 +533,39 @@ describe("NIP-46 Performance & DoS Protection", () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); - test("Cleanup interval is properly cleared on stop", async () => { - const bunkerWithInternals = managedBunker as unknown as { - cleanupInterval: NodeJS.Timeout | null; - }; - - // Verify cleanup interval exists - expect(bunkerWithInternals.cleanupInterval).toBeDefined(); + test("bunker operations remain healthy after a stop/start cycle", async () => { + const firstClient = new SimpleNIP46Client([relay.url], { timeout: 5000 }); + try { + await firstClient.connect(managedBunker.getConnectionString()); + await expect(firstClient.ping()).resolves.toBe(true); + } finally { + await firstClient.disconnect(); + } - // Stop the bunker await managedBunker.stop(); + await managedBunker.start(); - // Cleanup interval should be cleared - expect(bunkerWithInternals.cleanupInterval).toBeNull(); - }); - - test("All resources are properly cleaned up on stop", async () => { - const bunkerWithInternals = managedBunker as unknown as { - connectedClients: Map< - string, - { permissions: Set; lastSeen: number } - >; - usedRequestIds: Map; - pendingAuthChallenges: Map< - string, - { id: string; clientPubkey: string; timestamp: number } - >; - }; - - // Add some test data - bunkerWithInternals.connectedClients.set("test-client", { - permissions: new Set(), - lastSeen: Date.now(), - }); - bunkerWithInternals.usedRequestIds.set("test-request", Date.now()); - bunkerWithInternals.pendingAuthChallenges.set("test-challenge", { - id: "test", - clientPubkey: "test", - timestamp: Date.now(), + const restartedClient = new SimpleNIP46Client([relay.url], { + timeout: 5000, }); - - // Stop the bunker - await managedBunker.stop(); - - // All data structures should be cleared - expect(bunkerWithInternals.connectedClients.size).toBe(0); - expect(bunkerWithInternals.usedRequestIds.size).toBe(0); - expect(bunkerWithInternals.pendingAuthChallenges.size).toBe(0); + try { + await restartedClient.connect(managedBunker.getConnectionString()); + await expect(restartedClient.ping()).resolves.toBe(true); + } finally { + await restartedClient.disconnect(); + } }); test("Stop method handles errors gracefully", async () => { - const bunkerWithInternals = managedBunker as unknown as { - rateLimiter: { destroy: () => void }; - }; - - // Mock an error in the rate limiter - bunkerWithInternals.rateLimiter.destroy = jest.fn(() => { - throw new Error("Mock rate limiter error"); - }); + const restoreDestroy = replaceNip46RateLimiterDestroy( + managedBunker, + jest.fn(() => { + throw new Error("Mock rate limiter error"); + }), + ); - // Stop should not throw despite the error await expect(managedBunker.stop()).resolves.toBeUndefined(); + restoreDestroy(); }); test("rate limiter should clean up old data", (done) => { diff --git a/tests/nip46/permissions.test.ts b/tests/nip46/permissions.test.ts index b3cbe246..34f954cc 100644 --- a/tests/nip46/permissions.test.ts +++ b/tests/nip46/permissions.test.ts @@ -6,7 +6,7 @@ import { verifySignature, } from "../../src"; import { LogLevel } from "../../src/nip46"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; describe("NIP-46 Permission Handling", () => { let relay: NostrRelay; diff --git a/tests/nip46/protocol-core.test.ts b/tests/nip46/protocol-core.test.ts new file mode 100644 index 00000000..62734a52 --- /dev/null +++ b/tests/nip46/protocol-core.test.ts @@ -0,0 +1,252 @@ +import { getPublicKey } from "../../src/utils/crypto"; +import { NIP46RequestCorrelator } from "../../src/nip46/internal/request-correlator"; +import { NIP46ClientEngine } from "../../src/nip46/internal/client-engine"; +import { NIP46Wire } from "../../src/nip46/internal/wire"; +import { NIP46DiagnosticLogger } from "../../src/nip46/utils/diagnostics"; +import { installNip46ClientEngineLifecycleHooks } from "../../src/testing"; +import { + NIP46ConnectionError, + NIP46Method, + NIP46Request, + NIP46Response, + NIP46TimeoutError, +} from "../../src/nip46/types"; + +async function captureRejection(promise: Promise): Promise { + try { + await promise; + return new Error("Expected promise to reject"); + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } +} + +function createTestClientEngine(): NIP46ClientEngine { + const logger = new NIP46DiagnosticLogger({ + error: () => undefined, + warn: () => undefined, + info: () => undefined, + debug: () => undefined, + trace: () => undefined, + }); + return new NIP46ClientEngine({ + relays: [], + timeout: 1000, + logger, + relayStrategy: "add", + parseBeforeInitialConnect: true, + regenerateKeysOnConnect: false, + filterResponsesBySigner: true, + rejectProtocolErrors: false, + requireConnectedForRequests: true, + inspectPublishResult: false, + connectDelayMs: 0, + disconnectDelayMs: 0, + buildConnectParams: () => [], + timeoutError: () => new NIP46TimeoutError("timeout"), + disconnectError: () => new NIP46ConnectionError("disconnected"), + wrapPublishError: () => new NIP46ConnectionError("publish failed"), + }); +} + +describe("NIP-46 protocol core", () => { + const sender = { + privateKey: "11".repeat(32), + publicKey: getPublicKey("11".repeat(32)), + }; + const recipient = { + privateKey: "22".repeat(32), + publicKey: getPublicKey("22".repeat(32)), + }; + + test("rejects duplicate pending request IDs without replacing the owner", async () => { + const correlator = new NIP46RequestCorrelator(); + const first = correlator.register("duplicate", 1000, () => new Error()); + const firstOutcome = captureRejection(first); + + const duplicateError = await captureRejection( + correlator.register("duplicate", 1000, () => new Error()), + ); + expect(duplicateError.message).toContain("already pending"); + expect(correlator.pending.size).toBe(1); + + correlator.reject("duplicate", new Error("cleanup")); + expect((await firstOutcome).message).toBe("cleanup"); + expect(correlator.pending.size).toBe(0); + }); + + test("removes a pending request when its timeout settles", async () => { + const correlator = new NIP46RequestCorrelator(); + const request = correlator.register( + "timeout", + 5, + () => new Error("expected timeout"), + ); + + expect((await captureRejection(request)).message).toBe("expected timeout"); + expect(correlator.pending.size).toBe(0); + }); + + test("settles and removes a matching pending request", async () => { + const correlator = new NIP46RequestCorrelator(); + const response: NIP46Response = { + id: "settle-me", + result: "ok", + }; + const request = correlator.register("settle-me", 1000, () => new Error()); + + expect(correlator.settle(response)).toBe(true); + await expect(request).resolves.toEqual(response); + expect(correlator.pending.size).toBe(0); + }); + + test("cancels and removes every pending request", async () => { + const correlator = new NIP46RequestCorrelator(); + const first = correlator.register("first", 1000, () => new Error()); + const second = correlator.register("second", 1000, () => new Error()); + const outcomes = [captureRejection(first), captureRejection(second)]; + + correlator.cancelAll(new Error("expected cancellation")); + for (const error of await Promise.all(outcomes)) { + expect(error.message).toBe("expected cancellation"); + } + expect(correlator.pending.size).toBe(0); + }); + + test("accepts well-shaped extension methods and rejects malformed envelopes", async () => { + const extensionRequest: NIP46Request = { + id: "extension-request", + method: "future_method" as NIP46Method, + params: [], + }; + const requestEvent = await NIP46Wire.createRequestEvent( + extensionRequest, + sender, + recipient.publicKey, + ); + expect( + NIP46Wire.decryptRequest(requestEvent, recipient.privateKey), + ).toEqual(extensionRequest); + + const malformedRequestEvent = await NIP46Wire.createRequestEvent( + null as unknown as NIP46Request, + sender, + recipient.publicKey, + ); + expect(() => + NIP46Wire.decryptRequest(malformedRequestEvent, recipient.privateKey), + ).toThrow("Invalid NIP-46 request"); + + const malformedResponseEvent = await NIP46Wire.createResponseEvent( + "not-an-envelope" as unknown as NIP46Response, + sender, + recipient.publicKey, + ); + expect(() => + NIP46Wire.decryptResponse(malformedResponseEvent, recipient.privateKey), + ).toThrow("Invalid NIP-46 response"); + }); + + test("serializes client connect and disconnect transitions", async () => { + const events: string[] = []; + const engine = createTestClientEngine(); + Object.assign(engine.clientKeys, sender); + + let releaseConnection!: () => void; + const connectionGate = new Promise((resolve) => { + releaseConnection = resolve; + }); + let connectionEntered!: () => void; + const entered = new Promise((resolve) => { + connectionEntered = resolve; + }); + const restoreLifecycle = installNip46ClientEngineLifecycleHooks(engine, { + prepareConnection: jest.fn(async () => { + events.push("connect-start"); + connectionEntered(); + await connectionGate; + }), + setupSubscription: jest.fn(async () => undefined), + cleanup: jest.fn(async () => { + events.push("cleanup"); + }), + }); + jest.spyOn(engine, "request").mockImplementation(async (method) => { + events.push(method); + return { id: method, result: "ack" }; + }); + + const connecting = engine.connect(`bunker://${recipient.publicKey}`); + await entered; + const disconnecting = engine.disconnect(); + await Promise.resolve(); + const eventsBeforeRelease = [...events]; + releaseConnection(); + await Promise.all([connecting, disconnecting]); + + expect(eventsBeforeRelease).toEqual(["connect-start"]); + expect(events).toEqual([ + "connect-start", + NIP46Method.CONNECT, + NIP46Method.DISCONNECT, + "cleanup", + ]); + restoreLifecycle(); + }); + + test("finishes failed-connect cleanup before a queued disconnect", async () => { + const events: string[] = []; + const engine = createTestClientEngine(); + Object.assign(engine.clientKeys, sender); + + let releaseCleanup!: () => void; + const cleanupGate = new Promise((resolve) => { + releaseCleanup = resolve; + }); + let firstCleanupEntered!: () => void; + const cleanupEntered = new Promise((resolve) => { + firstCleanupEntered = resolve; + }); + let cleanupCalls = 0; + const restoreLifecycle = installNip46ClientEngineLifecycleHooks(engine, { + prepareConnection: jest.fn(async () => { + events.push("connect-failed"); + throw new Error("expected connect failure"); + }), + cleanup: jest.fn(async () => { + cleanupCalls += 1; + events.push(`cleanup-${cleanupCalls}-start`); + if (cleanupCalls === 1) { + firstCleanupEntered(); + await cleanupGate; + } + events.push(`cleanup-${cleanupCalls}-end`); + }), + }); + + const connecting = captureRejection( + engine.connect(`bunker://${recipient.publicKey}`), + ); + await cleanupEntered; + let disconnectSettled = false; + const disconnecting = engine.disconnect().then(() => { + disconnectSettled = true; + }); + await Promise.resolve(); + + expect(disconnectSettled).toBe(false); + expect(events).toEqual(["connect-failed", "cleanup-1-start"]); + + releaseCleanup(); + expect((await connecting).message).toBe("expected connect failure"); + await disconnecting; + expect(events).toEqual([ + "connect-failed", + "cleanup-1-start", + "cleanup-1-end", + "cleanup-2-start", + "cleanup-2-end", + ]); + restoreLifecycle(); + }); +}); diff --git a/tests/nip46/public-facade-seams.test.ts b/tests/nip46/public-facade-seams.test.ts new file mode 100644 index 00000000..ef1aafa3 --- /dev/null +++ b/tests/nip46/public-facade-seams.test.ts @@ -0,0 +1,281 @@ +import { + generateKeypair, + NostrRemoteSignerBunker, + NostrRemoteSignerClient, + SimpleNIP46Bunker, + SimpleNIP46Client, +} from "../../src"; +import { LogLevel } from "../../src/nip46"; +import { + NIP46ConnectionError, + NIP46DecryptionError, + NIP46EncryptionError, + NIP46Error, + NIP46SigningError, +} from "../../src/nip46/types"; +import { NostrRelay } from "../../src/testing"; + +jest.setTimeout(30000); + +describe("NIP-46 public facade seams", () => { + let relay: NostrRelay; + let relayUrl: string; + + beforeEach(async () => { + relay = new NostrRelay(0); + await relay.start(); + relayUrl = relay.url; + }); + + afterEach(async () => { + await relay.close(); + }); + + test("advanced client preserves success and reconnect behavior with the simple bunker", async () => { + const userKeys = await generateKeypair(); + const signerKeys = await generateKeypair(); + const bunker = new SimpleNIP46Bunker( + [relayUrl], + userKeys.publicKey, + signerKeys.publicKey, + { logLevel: LogLevel.ERROR }, + ); + bunker.setUserPrivateKey(userKeys.privateKey); + bunker.setSignerPrivateKey(signerKeys.privateKey); + bunker.setDefaultPermissions(["get_public_key", "ping"]); + + const client = new NostrRemoteSignerClient({ + relays: [relayUrl], + timeout: 1000, + }); + + try { + await bunker.start(); + const connectionString = bunker.getConnectionString(); + + await expect(client.connect(connectionString)).resolves.toBe("ack"); + await expect(client.getUserPublicKey()).resolves.toBe(userKeys.publicKey); + await expect(client.ping()).resolves.toBe("pong"); + + await client.disconnect(); + await expect(client.ping()).rejects.toBeInstanceOf(NIP46ConnectionError); + + await expect(client.connect(connectionString)).resolves.toBe("ack"); + await expect(client.ping()).resolves.toBe("pong"); + } finally { + await client.disconnect(); + await bunker.stop(); + } + }); + + test("advanced client cleans up a rejected connect before retrying", async () => { + const userKeys = await generateKeypair(); + const signerKeys = await generateKeypair(); + const bunker = new SimpleNIP46Bunker( + [relayUrl], + userKeys.publicKey, + signerKeys.publicKey, + { + secret: "expected-secret", + defaultPermissions: ["get_public_key", "ping"], + logLevel: LogLevel.ERROR, + }, + ); + bunker.setUserPrivateKey(userKeys.privateKey); + bunker.setSignerPrivateKey(signerKeys.privateKey); + const client = new NostrRemoteSignerClient({ + relays: [relayUrl], + timeout: 500, + }); + + try { + await bunker.start(); + const connectionString = bunker.getConnectionString(); + await expect( + client.connect( + connectionString.replace("expected-secret", "rejected-secret"), + ), + ).rejects.toThrow(/invalid secret/i); + await expect(client.ping()).rejects.toBeInstanceOf(NIP46ConnectionError); + + await expect(client.connect(connectionString)).resolves.toBe( + "expected-secret", + ); + await expect(client.ping()).resolves.toBe("pong"); + } finally { + await client.disconnect(); + await bunker.stop(); + } + }); + + test("simple client preserves success and protocol failure behavior with the advanced bunker", async () => { + const userKeys = await generateKeypair(); + const signerKeys = await generateKeypair(); + const bunker = new NostrRemoteSignerBunker({ + relays: [relayUrl], + userPubkey: userKeys.publicKey, + signerPubkey: signerKeys.publicKey, + defaultPermissions: ["get_public_key", "ping"], + }); + bunker.setPrivateKeys(userKeys.privateKey, signerKeys.privateKey); + + const client = new SimpleNIP46Client([relayUrl], { + timeout: 1000, + logLevel: LogLevel.ERROR, + }); + + try { + await bunker.start(); + await expect(client.connect(bunker.getConnectionString())).resolves.toBe( + userKeys.publicKey, + ); + await expect(client.ping()).resolves.toBe(true); + + await expect( + client.signEvent({ + kind: 1, + content: "not permitted", + created_at: Math.floor(Date.now() / 1000), + tags: [], + }), + ).rejects.toBeInstanceOf(NIP46SigningError); + await expect( + client.nip44Encrypt(signerKeys.publicKey, "not permitted"), + ).rejects.toBeInstanceOf(NIP46EncryptionError); + await expect( + client.nip44Decrypt(signerKeys.publicKey, "not permitted"), + ).rejects.toBeInstanceOf(NIP46DecryptionError); + + await expect(client.getRelays()).rejects.toBeInstanceOf(NIP46Error); + + await client.disconnect(); + await expect(client.ping()).resolves.toBe(false); + await expect(client.connect(bunker.getConnectionString())).resolves.toBe( + userKeys.publicKey, + ); + await expect(client.ping()).resolves.toBe(true); + } finally { + await client.disconnect(); + await bunker.stop(); + } + }); + + test("both client facades retain their public timeout and shutdown outcomes", async () => { + const userKeys = await generateKeypair(); + const signerKeys = await generateKeypair(); + const dormantBunker = new SimpleNIP46Bunker( + [relayUrl], + userKeys.publicKey, + signerKeys.publicKey, + { logLevel: LogLevel.ERROR }, + ); + dormantBunker.setUserPrivateKey(userKeys.privateKey); + dormantBunker.setSignerPrivateKey(signerKeys.privateKey); + const connectionString = dormantBunker.getConnectionString(); + + const advancedClient = new NostrRemoteSignerClient({ + relays: [relayUrl], + timeout: 75, + }); + const simpleClient = new SimpleNIP46Client([relayUrl], { + timeout: 75, + logLevel: LogLevel.ERROR, + }); + + try { + await expect(advancedClient.connect(connectionString)).rejects.toThrow( + /timed out/i, + ); + await expect(simpleClient.connect(connectionString)).rejects.toThrow( + /timed out/i, + ); + + await expect(advancedClient.disconnect()).resolves.toBeUndefined(); + await expect(simpleClient.disconnect()).resolves.toBeUndefined(); + await expect(advancedClient.ping()).rejects.toBeInstanceOf( + NIP46ConnectionError, + ); + await expect(simpleClient.ping()).resolves.toBe(false); + } finally { + await advancedClient.disconnect(); + await simpleClient.disconnect(); + await dormantBunker.stop(); + } + }); + + test("both client facades settle requests after bunker shutdown", async () => { + const userKeys = await generateKeypair(); + const signerKeys = await generateKeypair(); + const bunker = new SimpleNIP46Bunker( + [relayUrl], + userKeys.publicKey, + signerKeys.publicKey, + { + defaultPermissions: ["get_public_key", "ping"], + logLevel: LogLevel.ERROR, + }, + ); + bunker.setUserPrivateKey(userKeys.privateKey); + bunker.setSignerPrivateKey(signerKeys.privateKey); + + const advancedClient = new NostrRemoteSignerClient({ + relays: [relayUrl], + timeout: 100, + }); + const simpleClient = new SimpleNIP46Client([relayUrl], { + timeout: 100, + logLevel: LogLevel.ERROR, + }); + + try { + await bunker.start(); + const connectionString = bunker.getConnectionString(); + await advancedClient.connect(connectionString); + await simpleClient.connect(connectionString); + + await bunker.stop(); + await expect(advancedClient.ping()).rejects.toThrow(/timed out/i); + await expect(simpleClient.ping()).resolves.toBe(false); + await expect(advancedClient.disconnect()).resolves.toBeUndefined(); + await expect(simpleClient.disconnect()).resolves.toBeUndefined(); + } finally { + await advancedClient.disconnect(); + await simpleClient.disconnect(); + await bunker.stop(); + } + }); + + test("concurrent bunker lifecycle calls share one public transition", async () => { + const userKeys = await generateKeypair(); + const signerKeys = await generateKeypair(); + const bunker = new SimpleNIP46Bunker( + [relayUrl], + userKeys.publicKey, + signerKeys.publicKey, + { + defaultPermissions: ["get_public_key", "ping"], + logLevel: LogLevel.ERROR, + }, + ); + bunker.setUserPrivateKey(userKeys.privateKey); + bunker.setSignerPrivateKey(signerKeys.privateKey); + const client = new SimpleNIP46Client([relayUrl], { + timeout: 200, + logLevel: LogLevel.ERROR, + }); + + try { + await Promise.all([bunker.start(), bunker.start(), bunker.start()]); + await expect(client.connect(bunker.getConnectionString())).resolves.toBe( + userKeys.publicKey, + ); + await expect(client.ping()).resolves.toBe(true); + + await Promise.all([bunker.stop(), bunker.stop(), bunker.stop()]); + await expect(client.ping()).resolves.toBe(false); + } finally { + await client.disconnect(); + await bunker.stop(); + } + }); +}); diff --git a/tests/nip46/replay-guard.test.ts b/tests/nip46/replay-guard.test.ts new file mode 100644 index 00000000..bb818bca --- /dev/null +++ b/tests/nip46/replay-guard.test.ts @@ -0,0 +1,37 @@ +import { NIP46ReplayGuard } from "../../src/nip46/internal/replay-guard"; + +describe("NIP-46 replay guard", () => { + test("rejects duplicate IDs inside the two-minute window", () => { + let now = 1_000; + const guard = new NIP46ReplayGuard({ now: () => now }); + + expect(guard.isReplay("request")).toBe(false); + now += 60_000; + expect(guard.isReplay("request")).toBe(true); + expect(guard.size).toBe(1); + }); + + test("cleanup releases IDs older than the replay window", () => { + let now = 1_000; + const guard = new NIP46ReplayGuard({ now: () => now }); + guard.isReplay("old"); + now += 30_000; + guard.isReplay("recent"); + now += 91_000; + + expect(guard.cleanup()).toBe(1); + expect(guard.isReplay("old")).toBe(false); + expect(guard.isReplay("recent")).toBe(true); + }); + + test("clear resets every retained ID", () => { + const guard = new NIP46ReplayGuard(); + guard.isReplay("first"); + guard.isReplay("second"); + + guard.clear(); + + expect(guard.size).toBe(0); + expect(guard.isReplay("first")).toBe(false); + }); +}); diff --git a/tests/nip46/test-utils.ts b/tests/nip46/test-utils.ts index 2acbb362..23d26174 100644 --- a/tests/nip46/test-utils.ts +++ b/tests/nip46/test-utils.ts @@ -3,7 +3,7 @@ import { SimpleNIP46Bunker, LogLevel, } from "../../src/nip46"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; import { generateKeypair } from "../../src/utils/crypto"; export interface TestSetup { diff --git a/tests/nip47/client-encryption-tracking-simple.test.ts b/tests/nip47/client-encryption-tracking-simple.test.ts index d0778ab9..2df3eddd 100644 --- a/tests/nip47/client-encryption-tracking-simple.test.ts +++ b/tests/nip47/client-encryption-tracking-simple.test.ts @@ -1,8 +1,8 @@ /** - * Simplified test for NIP-47 client encryption tracking + * NIP-47 client encryption negotiation and response behavior * - * This test verifies that the client correctly tracks and uses the same - * encryption scheme for decrypting responses as was used for the request. + * These tests verify the negotiated request scheme through the wire event and + * prove the matching response path completes end to end. */ import { generateKeypair } from "../../src/utils/crypto"; @@ -16,26 +16,10 @@ import { NIP47EncryptionScheme, NIP47ConnectionOptions, NIP47Logger, - NIP47Request, } from "../../src/nip47/types"; -import { NostrEvent } from "../../src/types/nostr"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { dispatchNip47ClientResponse, NostrRelay } from "../../src/testing"; -// Type-safe interface for accessing private client methods in tests -interface ClientWithPrivateMethods { - sendRequest: (request: NIP47Request, expiration?: number) => Promise; - handleResponse: (event: NostrEvent) => Promise; - chooseEncryptionScheme: () => NIP47EncryptionScheme; - pendingRequests: Map< - string, - { - encryptionScheme: NIP47EncryptionScheme; - resolve: (response: unknown) => void; - } - >; -} - -describe("NIP-47: Client encryption tracking (simplified)", () => { +describe("NIP-47: Client encryption negotiation", () => { let relay: NostrRelay; beforeAll(async () => { @@ -49,7 +33,7 @@ describe("NIP-47: Client encryption tracking (simplified)", () => { } }); - test("should track and use the correct encryption scheme for responses", async () => { + test("uses NIP-44 for the request and completes the response roundtrip", async () => { const serviceKeys = await generateKeypair(); const clientKeys = await generateKeypair(); @@ -98,44 +82,6 @@ describe("NIP-47: Client encryption tracking (simplified)", () => { const client = new NostrWalletConnectClient(connectionOptions); - // Track what encryption was used - let requestEncryption: NIP47EncryptionScheme | undefined; - let responseDecryption: NIP47EncryptionScheme | undefined; - - // Create a type-safe wrapper for accessing private methods - const clientWithPrivates = client as unknown as ClientWithPrivateMethods; - - // Spy on the client's sendRequest to see what encryption it uses - const originalSendRequest = clientWithPrivates.sendRequest.bind(client); - clientWithPrivates.sendRequest = jest.fn( - async (request: NIP47Request, expiration?: number) => { - // Check the encryption scheme being used - const chooseEncryption = - clientWithPrivates.chooseEncryptionScheme.bind(client); - requestEncryption = chooseEncryption(); - return originalSendRequest(request, expiration); - }, - ); - - // Spy on handleResponse to see what decryption is used - const originalHandleResponse = - clientWithPrivates.handleResponse.bind(client); - clientWithPrivates.handleResponse = jest.fn(async (event: NostrEvent) => { - // The handleResponse now uses tracked encryption - const pendingRequests = clientWithPrivates.pendingRequests; - - // Get request ID from e-tag - const eTag = event.tags.find((tag: string[]) => tag[0] === "e"); - if (eTag && eTag[1]) { - const pending = pendingRequests.get(eTag[1]); - if (pending) { - responseDecryption = pending.encryptionScheme; - } - } - - return originalHandleResponse(event); - }); - await client.init(); // Wait for capabilities discovery @@ -145,16 +91,20 @@ describe("NIP-47: Client encryption tracking (simplified)", () => { const balance = await client.getBalance(); expect(balance).toBe(50000000); - // Verify that request and response used the same encryption - expect(requestEncryption).toBe(NIP47EncryptionScheme.NIP44_V2); - expect(responseDecryption).toBe(NIP47EncryptionScheme.NIP44_V2); - expect(requestEncryption).toBe(responseDecryption); + const request = relay.cache + .filter((event) => event.kind === 23194) + .reverse() + .find((event) => event.pubkey === client.getPublicKey()); + expect(request?.tags).toContainEqual([ + "encryption", + NIP47EncryptionScheme.NIP44_V2, + ]); await client.disconnect(); await service.disconnect(); }, 10000); - test("should fallback to NIP-04 when NIP-44 is not supported", async () => { + test("falls back to NIP-04 and completes the response roundtrip", async () => { const serviceKeys = await generateKeypair(); const clientKeys = await generateKeypair(); @@ -193,30 +143,17 @@ describe("NIP-47: Client encryption tracking (simplified)", () => { preferredEncryption: NIP47EncryptionScheme.NIP44_V2, }); - // Track encryption - let requestEncryption: NIP47EncryptionScheme | undefined; - - // Use the same type-safe interface - const clientWithPrivates2 = client as unknown as ClientWithPrivateMethods; - - const originalSendRequest = clientWithPrivates2.sendRequest.bind(client); - clientWithPrivates2.sendRequest = jest.fn( - async (request: NIP47Request, expiration?: number) => { - const chooseEncryption = - clientWithPrivates2.chooseEncryptionScheme.bind(client); - requestEncryption = chooseEncryption(); - return originalSendRequest(request, expiration); - }, - ); - await client.init(); await new Promise((resolve) => setTimeout(resolve, 200)); const info = await client.getInfo(); expect(info).toBeDefined(); - // Should have fallen back to NIP-04 - expect(requestEncryption).toBe(NIP47EncryptionScheme.NIP04); + const request = relay.cache + .filter((event) => event.kind === 23194) + .reverse() + .find((event) => event.pubkey === client.getPublicKey()); + expect(request?.tags.some((tag) => tag[0] === "encryption")).toBe(false); await client.disconnect(); await service.disconnect(); @@ -239,21 +176,20 @@ describe("NIP-47: Client encryption tracking (simplified)", () => { relays: [relay.url], logger, }); - const clientWithPrivates = client as unknown as ClientWithPrivateMethods; - clientWithPrivates.pendingRequests.set("request-id", { - encryptionScheme: NIP47EncryptionScheme.NIP04, - resolve: jest.fn(), - }); - - await clientWithPrivates.handleResponse({ - id: "response-id", - pubkey: serviceKeys.publicKey, - created_at: Math.floor(Date.now() / 1000), - kind: 23195, - tags: [["e", "request-id"]], - content: "invalid-encrypted-content", - sig: "", - }); + await dispatchNip47ClientResponse( + client, + "request-id", + NIP47EncryptionScheme.NIP04, + { + id: "response-id", + pubkey: serviceKeys.publicKey, + created_at: Math.floor(Date.now() / 1000), + kind: 23195, + tags: [["e", "request-id"]], + content: "invalid-encrypted-content", + sig: "", + }, + ); expect(errors).toEqual([ expect.stringContaining("Error handling nip04 response event"), diff --git a/tests/nip47/nip44-encryption.test.ts b/tests/nip47/nip44-encryption.test.ts index 7ac519ef..dd1b8f37 100644 --- a/tests/nip47/nip44-encryption.test.ts +++ b/tests/nip47/nip44-encryption.test.ts @@ -7,7 +7,7 @@ import { beforeEach, afterEach, } from "@jest/globals"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; import { generateKeypair } from "../../src/utils/crypto"; import { NostrWalletConnectClient, diff --git a/tests/nip47/nip47.test.ts b/tests/nip47/nip47.test.ts index 59781026..49b5766d 100644 --- a/tests/nip47/nip47.test.ts +++ b/tests/nip47/nip47.test.ts @@ -1,13 +1,11 @@ +import { describe, it, expect, beforeAll, afterAll, jest } from "@jest/globals"; import { - describe, - it, - expect, - beforeAll, - afterAll, - jest, - beforeEach, -} from "@jest/globals"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; + dispatchNip47ServiceRequest, + NostrRelay, + replaceNip47CapabilityDiscoveryWait, + replaceNip47RequestSender, +} from "../../src/testing"; +import type { NIP47RequestSender } from "../../src/testing"; import { generateKeypair } from "../../src/utils/crypto"; import { NostrWalletConnectClient, @@ -21,19 +19,25 @@ import { generateNWCURL, parseNWCURL, NIP47ErrorCode, + NIP47EventKind, GetInfoResponseResult, PaymentResponseResult, MakeInvoiceResponseResult, SignMessageResponseResult, NIP47Notification, NIP47Logger, + NIP47Response, } from "../../src/nip47"; import { NIP47ClientError } from "../../src/nip47/client"; import { createEvent, createSignedEvent } from "../../src/nip01/event"; -import { encrypt as encryptNIP04 } from "../../src/nip04"; +import { + decrypt as decryptNIP04, + encrypt as encryptNIP04, +} from "../../src/nip04"; import { getUnixTime } from "../../src/utils/time"; import type { NostrEvent } from "../../src/types/nostr"; import { testUtils } from "../types"; +import { validateNIP47Response } from "../../src/nip47/protocol"; // Mock Implementation class MockWalletImplementation implements WalletImplementation { @@ -89,7 +93,10 @@ class MockWalletImplementation implements WalletImplementation { }; } - async lookupInvoice(): Promise { + async lookupInvoice(_params: { + payment_hash?: string; + invoice?: string; + }): Promise { return { type: TransactionType.INCOMING, invoice: "lnbc10n1ptest", @@ -145,52 +152,6 @@ class MockWalletImplementation implements WalletImplementation { } } -// Type for the mock wallet access -type ServiceWithMockAccess = { - walletImpl: { - lookupInvoice: (params: { - payment_hash?: string; - invoice?: string; - }) => Promise; - }; -}; - -// Interface for accessing private members in tests -interface ServiceWithPrivates { - requestEncryption: Map< - string, - import("../../src/nip47/types").NIP47EncryptionScheme - >; - handleEvent: ( - event: import("../../src/types/nostr").NostrEvent, - ) => Promise; -} - -interface ClientInitializationState { - initialized: boolean; - waitForCapabilityDiscovery: () => Promise; - subIds: string[]; - client: { - connectToRelays: () => Promise; - subscribe: ( - filters: unknown[], - callback: (event: NostrEvent, relay: string) => void, - ) => string[]; - unsubscribe: (ids: string[]) => void; - disconnectFromRelays: () => void; - }; - handleNotification: (event: NostrEvent) => Promise; - sendRequest: ( - request: { method: NIP47Method }, - expiration?: number, - allowDuringInitialization?: boolean, - ) => Promise<{ - result_type: NIP47Method; - result: unknown; - error: null; - }>; -} - describe("NIP-47 client initialization fallback", () => { const createFallbackClient = () => { const fallbackClient = new NostrWalletConnectClient({ @@ -198,25 +159,44 @@ describe("NIP-47 client initialization fallback", () => { secret: "01".repeat(32), relays: ["wss://relay.example.com"], }); - const internals = fallbackClient as unknown as ClientInitializationState; - internals.waitForCapabilityDiscovery = async () => {}; const subscriptions: string[][] = []; const unsubscriptions: string[][] = []; const callbacks: Array<(event: NostrEvent, relay: string) => void> = []; - internals.client = { - connectToRelays: async () => {}, - subscribe: (_filters, callback) => { + let capabilityWait = async (): Promise => {}; + let requestSender: NIP47RequestSender = async () => { + throw new Error("request sender not configured"); + }; + replaceNip47CapabilityDiscoveryWait(fallbackClient, () => capabilityWait()); + replaceNip47RequestSender(fallbackClient, (...args) => + requestSender(...args), + ); + + const transport = fallbackClient.getNostrClient(); + const connectToRelays = jest + .spyOn(transport, "connectToRelays") + .mockResolvedValue(); + jest + .spyOn(transport, "subscribe") + .mockImplementation((_filters, callback) => { const ids = [`sub-${subscriptions.length + 1}`]; subscriptions.push(ids); callbacks.push(callback); return ids; - }, - unsubscribe: (ids) => unsubscriptions.push([...ids]), - disconnectFromRelays: () => {}, - }; + }); + jest.spyOn(transport, "unsubscribe").mockImplementation((ids) => { + unsubscriptions.push([...ids]); + }); + jest.spyOn(transport, "disconnectFromRelays").mockImplementation(() => {}); + return { fallbackClient, - internals, + connectToRelays, + useCapabilityWait: (wait: () => Promise) => { + capabilityWait = wait; + }, + useRequestSender: (send: NIP47RequestSender) => { + requestSender = send; + }, subscriptions, unsubscriptions, callbacks, @@ -224,11 +204,12 @@ describe("NIP-47 client initialization fallback", () => { }; it("allows explicit getInfo capability discovery during initialization", async () => { - const { fallbackClient, internals, subscriptions } = createFallbackClient(); - internals.sendRequest = async (request, _expiration, allowed) => { + const { fallbackClient, useRequestSender, subscriptions } = + createFallbackClient(); + useRequestSender(async (request, _expiration, allowed) => { expect(request.method).toBe(NIP47Method.GET_INFO); expect(allowed).toBe(true); - expect(internals.initialized).toBe(false); + expect(fallbackClient.supportsMethod(NIP47Method.GET_INFO)).toBe(false); return { result_type: NIP47Method.GET_INFO, result: { @@ -237,20 +218,19 @@ describe("NIP-47 client initialization fallback", () => { } as GetInfoResponseResult, error: null, }; - }; + }); await fallbackClient.init(); expect(fallbackClient.supportsMethod(NIP47Method.GET_INFO)).toBe(true); - expect(internals.initialized).toBe(true); expect(subscriptions).toHaveLength(1); }); it("cleans up and atomically retries after malformed capability discovery", async () => { - const { fallbackClient, internals, subscriptions, unsubscriptions } = + const { fallbackClient, useRequestSender, subscriptions, unsubscriptions } = createFallbackClient(); let attempt = 0; - internals.sendRequest = async () => { + useRequestSender(async () => { attempt += 1; return { result_type: NIP47Method.GET_INFO, @@ -266,36 +246,33 @@ describe("NIP-47 client initialization fallback", () => { } as GetInfoResponseResult), error: null, }; - }; + }); await expect(fallbackClient.init()).rejects.toThrow( "Failed to initialize wallet connection", ); - expect(internals.initialized).toBe(false); expect(fallbackClient.supportsMethod(NIP47Method.GET_INFO)).toBe(false); - expect(internals.subIds).toEqual([]); - expect(unsubscriptions).toEqual([["sub-1"]]); + expect(unsubscriptions.length).toBeGreaterThanOrEqual(1); + expect(unsubscriptions.every((ids) => ids[0] === "sub-1")).toBe(true); await fallbackClient.init(); - expect(internals.initialized).toBe(true); expect(fallbackClient.supportsMethod(NIP47Method.GET_INFO)).toBe(true); expect(subscriptions).toEqual([["sub-1"], ["sub-2"]]); - expect(internals.subIds).toEqual(["sub-2"]); }); it("coalesces concurrent and repeated initialization into one subscription", async () => { - const { fallbackClient, internals, subscriptions, callbacks } = + const { fallbackClient, useRequestSender, subscriptions, callbacks } = createFallbackClient(); - internals.sendRequest = async () => ({ + useRequestSender(async () => ({ result_type: NIP47Method.GET_INFO, result: { methods: [NIP47Method.GET_INFO], notifications: [NIP47NotificationType.PAYMENT_RECEIVED], } as GetInfoResponseResult, error: null, - }); + })); const first = fallbackClient.init(); const concurrent = fallbackClient.init(); @@ -306,54 +283,55 @@ describe("NIP-47 client initialization fallback", () => { expect(subscriptions).toHaveLength(1); expect(callbacks).toHaveLength(1); - let notificationDeliveries = 0; - internals.handleNotification = async () => { - notificationDeliveries += 1; - }; callbacks[0]( { - id: "notification-id", + id: "info-id", pubkey: "02".repeat(32), created_at: Math.floor(Date.now() / 1000), - kind: 23196, + kind: NIP47EventKind.INFO, tags: [], - content: "", + content: NIP47Method.PAY_INVOICE, sig: "", }, "wss://relay.example.com", ); await Promise.resolve(); - expect(notificationDeliveries).toBe(1); + expect(fallbackClient.supportsMethod(NIP47Method.PAY_INVOICE)).toBe(true); }); it.each([0, false, ""])( "rejects a falsy malformed capability result (%p)", async (result) => { - const { fallbackClient, internals, unsubscriptions } = + const { fallbackClient, useRequestSender, unsubscriptions } = createFallbackClient(); - internals.sendRequest = async () => ({ + useRequestSender(async () => ({ result_type: NIP47Method.GET_INFO, - result, + result: result as unknown as GetInfoResponseResult, error: null, - }); + })); await expect(fallbackClient.init()).rejects.toThrow( "Invalid get_info result", ); - expect(internals.initialized).toBe(false); + expect(fallbackClient.supportsMethod(NIP47Method.GET_INFO)).toBe(false); expect(unsubscriptions).toEqual([["sub-1"]]); }, ); it("invalidates an in-flight initialization when disconnected", async () => { - const { fallbackClient, internals, subscriptions } = - createFallbackClient(); + const { + fallbackClient, + useCapabilityWait, + useRequestSender, + subscriptions, + unsubscriptions, + } = createFallbackClient(); let releaseCapabilityWait!: () => void; const capabilityWaitGate = new Promise((resolve) => { releaseCapabilityWait = resolve; }); - internals.waitForCapabilityDiscovery = () => capabilityWaitGate; + useCapabilityWait(() => capabilityWaitGate); const initialization = fallbackClient.init(); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -368,42 +346,44 @@ describe("NIP-47 client initialization fallback", () => { "Client initialization cancelled by disconnect", ); - expect(internals.initialized).toBe(false); - expect(internals.subIds).toEqual([]); + expect(fallbackClient.supportsMethod(NIP47Method.GET_INFO)).toBe(false); + expect(unsubscriptions.length).toBeGreaterThanOrEqual(1); + expect(unsubscriptions.every((ids) => ids[0] === "sub-1")).toBe(true); - internals.waitForCapabilityDiscovery = async () => {}; - internals.sendRequest = async () => ({ + useCapabilityWait(async () => {}); + useRequestSender(async () => ({ result_type: NIP47Method.GET_INFO, result: { methods: [NIP47Method.GET_INFO], } as GetInfoResponseResult, error: null, - }); + })); await fallbackClient.init(); - expect(internals.initialized).toBe(true); + expect(fallbackClient.supportsMethod(NIP47Method.GET_INFO)).toBe(true); expect(subscriptions).toEqual([["sub-1"], ["sub-2"]]); }); it("prevents a stale attempt from resetting a newer successful initialization", async () => { - const { fallbackClient, internals, subscriptions } = createFallbackClient(); + const { fallbackClient, connectToRelays, useRequestSender, subscriptions } = + createFallbackClient(); let releaseFirstConnection!: () => void; const firstConnectionGate = new Promise((resolve) => { releaseFirstConnection = resolve; }); let connectionAttempt = 0; - internals.client.connectToRelays = async () => { + connectToRelays.mockImplementation(async () => { connectionAttempt += 1; if (connectionAttempt === 1) { await firstConnectionGate; } - }; - internals.sendRequest = async () => ({ + }); + useRequestSender(async () => ({ result_type: NIP47Method.GET_INFO, result: { methods: [NIP47Method.GET_INFO], } as GetInfoResponseResult, error: null, - }); + })); const staleAttempt = fallbackClient.init(); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -411,7 +391,6 @@ describe("NIP-47 client initialization fallback", () => { const currentAttempt = fallbackClient.init(); await currentAttempt; - expect(internals.initialized).toBe(true); expect(fallbackClient.supportsMethod(NIP47Method.GET_INFO)).toBe(true); expect(subscriptions).toEqual([["sub-1"]]); @@ -425,9 +404,8 @@ describe("NIP-47 client initialization fallback", () => { "Client initialization cancelled by disconnect", ); - expect(internals.initialized).toBe(true); expect(fallbackClient.supportsMethod(NIP47Method.GET_INFO)).toBe(true); - expect(internals.subIds).toEqual(["sub-1"]); + expect(subscriptions).toEqual([["sub-1"]]); }); }); @@ -438,6 +416,7 @@ describe("NIP-47: Nostr Wallet Connect", () => { let connectionOptions: NIP47ConnectionOptions; let service: NostrWalletService; let client: NostrWalletConnectClient; + let wallet: MockWalletImplementation; beforeAll(async () => { // Start ephemeral relay @@ -456,6 +435,7 @@ describe("NIP-47: Nostr Wallet Connect", () => { }; // Create and initialize service + wallet = new MockWalletImplementation(); service = new NostrWalletService( { relays: [relay.url], @@ -464,7 +444,7 @@ describe("NIP-47: Nostr Wallet Connect", () => { methods: Object.values(NIP47Method), notificationTypes: Object.values(NIP47NotificationType), }, - new MockWalletImplementation(), + wallet, ); await service.init(); @@ -668,7 +648,9 @@ describe("NIP-47: Nostr Wallet Connect", () => { expect(diagnostics).not.toContain(connectionOptions.secret); expect(diagnostics).not.toContain(diagnosticClient.getPublicKey()); expect(diagnostics).not.toMatch(/\b[0-9a-f]{64}\b/i); - expect(diagnostics).not.toMatch(/event id|request id|subscription ids?/i); + expect(diagnostics).not.toMatch( + /event id|request id|subscription ids?/i, + ); } finally { await diagnosticClient.disconnect(); } @@ -836,21 +818,16 @@ describe("NIP-47: Nostr Wallet Connect", () => { it("should handle not found errors", async () => { jest.setTimeout(10000); - // Mock the wallet implementation to throw a NOT_FOUND error - const serviceMock = service as unknown as ServiceWithMockAccess; - const originalLookup = serviceMock.walletImpl.lookupInvoice; - // Create a more specific mocking that replicates a real NOT_FOUND error const testPaymentHash = "nonexistent_hash_123"; - serviceMock.walletImpl.lookupInvoice = (params: { - payment_hash?: string; - invoice?: string; - }) => { - throw { - code: "NOT_FOUND", - message: `Invoice not found: Could not find ${params.payment_hash ? "payment_hash" : "invoice"}: ${params.payment_hash || params.invoice} in the wallet's database`, - }; - }; + const lookupSpy = jest + .spyOn(wallet, "lookupInvoice") + .mockImplementation(async (params) => { + throw { + code: "NOT_FOUND", + message: `Invoice not found: Could not find ${params.payment_hash ? "payment_hash" : "invoice"}: ${params.payment_hash || params.invoice} in the wallet's database`, + }; + }); try { await client.lookupInvoice({ payment_hash: testPaymentHash }); @@ -872,29 +849,23 @@ describe("NIP-47: Nostr Wallet Connect", () => { expect(nip47Error.recoveryHint).toBeDefined(); expect(nip47Error.recoveryHint).toContain("For lookupInvoice"); } finally { - // Restore original implementation - serviceMock.walletImpl.lookupInvoice = originalLookup; + lookupSpy.mockRestore(); } }); it("should handle not found errors with invoice parameter", async () => { jest.setTimeout(10000); - // Mock the wallet implementation to throw a NOT_FOUND error - const serviceMock = service as unknown as ServiceWithMockAccess; - const originalLookup = serviceMock.walletImpl.lookupInvoice; - // Create a test with invoice parameter instead of payment_hash const testInvoice = "lnbc10n1pdummy"; - serviceMock.walletImpl.lookupInvoice = (params: { - payment_hash?: string; - invoice?: string; - }) => { - throw { - code: "NOT_FOUND", - message: `Invoice not found: Could not find ${params.payment_hash ? "payment_hash" : "invoice"}: ${params.payment_hash || params.invoice} in the wallet's database`, - }; - }; + const lookupSpy = jest + .spyOn(wallet, "lookupInvoice") + .mockImplementation(async (params) => { + throw { + code: "NOT_FOUND", + message: `Invoice not found: Could not find ${params.payment_hash ? "payment_hash" : "invoice"}: ${params.payment_hash || params.invoice} in the wallet's database`, + }; + }); try { await client.lookupInvoice({ invoice: testInvoice }); @@ -913,20 +884,33 @@ describe("NIP-47: Nostr Wallet Connect", () => { /Invoice not found: Could not find invoice: .+ in the wallet's database/, ); } finally { - // Restore original implementation - serviceMock.walletImpl.lookupInvoice = originalLookup; + lookupSpy.mockRestore(); } }); - describe("Request encryption cleanup", () => { - let unauthorizedKeys: { publicKey: string; privateKey: string }; + describe("Request failure behavior", () => { + const findResponse = (requestId: string): NostrEvent | undefined => + relay.cache.find( + (candidate) => + candidate.kind === 23195 && + candidate.tags.some( + (tag) => tag[0] === "e" && tag[1] === requestId, + ), + ); - beforeEach(async () => { - // Generate unauthorized keys for testing - unauthorizedKeys = await generateKeypair(); - }); + const decodeResponse = ( + event: NostrEvent, + recipientPrivateKey: string, + ): NIP47Response => + JSON.parse( + decryptNIP04( + recipientPrivateKey, + serviceKeypair.publicKey, + event.content, + ), + ) as NIP47Response; - it("should clean up requestEncryption map on expired request", async () => { + it("publishes a correlated expiration error for an expired request", async () => { // Create an expired request const request = { method: NIP47Method.GET_INFO, @@ -951,59 +935,22 @@ describe("NIP-47: Nostr Wallet Connect", () => { clientKeypair.privateKey, ); - // Access private map for testing - const serviceWithPrivates = service as unknown as ServiceWithPrivates; - const requestEncryptionMap = serviceWithPrivates.requestEncryption; - const initialSize = requestEncryptionMap.size; - - // Handle the event - await serviceWithPrivates.handleEvent(event); - - // Wait a bit for async operations - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Verify the map was not increased (no leak) - expect(requestEncryptionMap.size).toBe(initialSize); - expect(requestEncryptionMap.has(event.id)).toBe(false); - }); - - it("should clean up requestEncryption map on unauthorized client", async () => { - const request = { - method: NIP47Method.GET_INFO, - params: {}, - }; + await dispatchNip47ServiceRequest(service, event); - const eventTemplate = { - kind: 23194, // NIP47EventKind.REQUEST - content: encryptNIP04( - unauthorizedKeys.privateKey, - serviceKeypair.publicKey, - JSON.stringify(request), - ), - tags: [["p", serviceKeypair.publicKey]], - }; - - const event = await createSignedEvent( - createEvent(eventTemplate, unauthorizedKeys.publicKey), - unauthorizedKeys.privateKey, + const responseEvent = findResponse(event.id); + expect(responseEvent).toBeDefined(); + const response = decodeResponse( + responseEvent!, + clientKeypair.privateKey, ); - - const serviceWithPrivates = service as unknown as ServiceWithPrivates; - const requestEncryptionMap = serviceWithPrivates.requestEncryption; - const initialSize = requestEncryptionMap.size; - - // Handle the event - await serviceWithPrivates.handleEvent(event); - - // Wait a bit for async operations - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Verify the map was not increased (no leak) - expect(requestEncryptionMap.size).toBe(initialSize); - expect(requestEncryptionMap.has(event.id)).toBe(false); + expect(response.result_type).toBe(NIP47Method.UNKNOWN); + expect(response.error).toMatchObject({ + code: NIP47ErrorCode.REQUEST_EXPIRED, + message: "Request has expired", + }); }); - it("should clean up requestEncryption map on decryption failure", async () => { + it("logs a decryption failure without publishing a response", async () => { const eventTemplate = { kind: 23194, // NIP47EventKind.REQUEST content: "invalid_encrypted_content", // This will fail decryption @@ -1015,37 +962,24 @@ describe("NIP-47: Nostr Wallet Connect", () => { clientKeypair.privateKey, ); - const serviceWithPrivates = service as unknown as ServiceWithPrivates; - const requestEncryptionMap = serviceWithPrivates.requestEncryption; - const initialSize = requestEncryptionMap.size; - // Spy on console.error to verify the error is logged const consoleErrorSpy = jest .spyOn(console, "error") .mockImplementation(() => {}); - // Handle the event - await serviceWithPrivates.handleEvent(event); - - // Wait a bit for async operations - await new Promise((resolve) => setTimeout(resolve, 100)); + await dispatchNip47ServiceRequest(service, event); // Verify the error was logged expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining( - "Failed to decrypt message:", - ), + expect.stringContaining("Failed to decrypt message:"), expect.any(Error), ); - // Verify the map was cleaned up - expect(requestEncryptionMap.size).toBe(initialSize); - expect(requestEncryptionMap.has(event.id)).toBe(false); - + expect(findResponse(event.id)).toBeUndefined(); consoleErrorSpy.mockRestore(); }); - it("should clean up requestEncryption map on successful request", async () => { + it("publishes a correlated response for a successful request", async () => { const request = { method: NIP47Method.GET_INFO, params: {}, @@ -1066,22 +1000,19 @@ describe("NIP-47: Nostr Wallet Connect", () => { clientKeypair.privateKey, ); - const serviceWithPrivates = service as unknown as ServiceWithPrivates; - const requestEncryptionMap = serviceWithPrivates.requestEncryption; - const initialSize = requestEncryptionMap.size; - - // Handle the event - await serviceWithPrivates.handleEvent(event); + await dispatchNip47ServiceRequest(service, event); - // Wait a bit for async operations to complete - await new Promise((resolve) => setTimeout(resolve, 200)); - - // Verify the map was cleaned up after successful processing - expect(requestEncryptionMap.size).toBe(initialSize); - expect(requestEncryptionMap.has(event.id)).toBe(false); + const responseEvent = findResponse(event.id); + expect(responseEvent).toBeDefined(); + const response = decodeResponse( + responseEvent!, + clientKeypair.privateKey, + ); + expect(response.result_type).toBe(NIP47Method.GET_INFO); + expect(response.error).toBeNull(); }); - it("should clean up requestEncryption map on JSON parse error", async () => { + it("publishes an invalid-request response after a JSON parse error", async () => { const eventTemplate = { kind: 23194, // NIP47EventKind.REQUEST content: encryptNIP04( @@ -1097,20 +1028,12 @@ describe("NIP-47: Nostr Wallet Connect", () => { clientKeypair.privateKey, ); - const serviceWithPrivates = service as unknown as ServiceWithPrivates; - const requestEncryptionMap = serviceWithPrivates.requestEncryption; - const initialSize = requestEncryptionMap.size; - // Spy on console.error to verify the error is logged const consoleErrorSpy = jest .spyOn(console, "error") .mockImplementation(() => {}); - // Handle the event - await serviceWithPrivates.handleEvent(event); - - // Wait a bit for async operations - await new Promise((resolve) => setTimeout(resolve, 100)); + await dispatchNip47ServiceRequest(service, event); // Verify the error was logged expect(consoleErrorSpy).toHaveBeenCalledWith( @@ -1118,9 +1041,16 @@ describe("NIP-47: Nostr Wallet Connect", () => { expect.any(Error), ); - // Verify the map was cleaned up - expect(requestEncryptionMap.size).toBe(initialSize); - expect(requestEncryptionMap.has(event.id)).toBe(false); + const responseEvent = findResponse(event.id); + expect(responseEvent).toBeDefined(); + const response = decodeResponse( + responseEvent!, + clientKeypair.privateKey, + ); + expect(response.result_type).toBe(NIP47Method.UNKNOWN); + expect(response.error).toMatchObject({ + code: NIP47ErrorCode.INVALID_REQUEST, + }); consoleErrorSpy.mockRestore(); }); @@ -1129,8 +1059,8 @@ describe("NIP-47: Nostr Wallet Connect", () => { describe("Response Validation", () => { it("should validate response structure according to NIP-47 specification", async () => { - // Access the private validateResponse method for testing - const validateResponse = client["validateResponse"].bind(client); + const validateResponse = (response: unknown) => + validateNIP47Response(response, (message) => new Error(message)); // Valid successful response expect(() => diff --git a/tests/nip47/notification-error-handling.test.ts b/tests/nip47/notification-error-handling.test.ts index f449a427..2d4bf738 100644 --- a/tests/nip47/notification-error-handling.test.ts +++ b/tests/nip47/notification-error-handling.test.ts @@ -18,7 +18,7 @@ import { NIP47NotificationType, NIP47EncryptionScheme, } from "../../src/nip47/types"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; describe("NIP-47: Notification error handling", () => { let relay: NostrRelay; diff --git a/tests/nip47/protocol.test.ts b/tests/nip47/protocol.test.ts new file mode 100644 index 00000000..3364d23b --- /dev/null +++ b/tests/nip47/protocol.test.ts @@ -0,0 +1,58 @@ +import { + generateNWCURL, + parseNIP47Request, + parseNIP47Response, + parseNWCURL, + validateNIP47Response, +} from "../../src/nip47/protocol"; + +describe("NIP-47 protocol codecs", () => { + test("round-trips encoded relay URLs without losing repeats", () => { + const options = { + pubkey: "wallet", + secret: "secret", + relays: ["wss://one.example/path", "wss://two.example"], + }; + + expect(parseNWCURL(generateNWCURL(options))).toEqual(options); + expect(() => + parseNWCURL("nostr+walletconnect://wallet?secret=secret"), + ).toThrow("At least one relay must be specified"); + }); + + test("keeps the caller's protocol error type", () => { + class ProtocolError extends Error {} + + expect(() => + validateNIP47Response({}, (message) => new ProtocolError(message)), + ).toThrow(ProtocolError); + expect(() => + parseNIP47Response("{", (message) => new ProtocolError(message)), + ).toThrow(ProtocolError); + }); + + test("normalizes an omitted success error field", () => { + expect( + validateNIP47Response( + { result_type: "get_balance", result: 1 }, + (message) => new Error(message), + ), + ).toEqual({ result_type: "get_balance", result: 1, error: null }); + }); + + test("parses request envelopes and rejects missing params", () => { + expect(parseNIP47Request('{"method":"get_balance","params":{}}')).toEqual({ + method: "get_balance", + params: {}, + }); + expect(() => parseNIP47Request('{"method":"get_balance"}')).toThrow( + "Invalid request: missing or invalid params", + ); + expect(() => + parseNIP47Request('{"method":"get_balance","params":[]}'), + ).toThrow("Invalid request: missing or invalid params"); + expect(() => parseNIP47Request("{")).toThrow( + "Invalid request: malformed JSON", + ); + }); +}); diff --git a/tests/nip47/requestDispatcher.test.ts b/tests/nip47/requestDispatcher.test.ts new file mode 100644 index 00000000..336bff6d --- /dev/null +++ b/tests/nip47/requestDispatcher.test.ts @@ -0,0 +1,112 @@ +import { dispatchNIP47Request } from "../../src/nip47/requestDispatcher"; +import { + NIP47EncryptionScheme, + NIP47ErrorCode, + NIP47Method, + WalletImplementation, +} from "../../src/nip47/types"; + +function wallet(): jest.Mocked { + return { + getInfo: jest.fn(async () => ({ methods: ["get_info"] })), + getBalance: jest.fn(async () => 42), + payInvoice: jest.fn(), + makeInvoice: jest.fn(), + lookupInvoice: jest.fn(), + listTransactions: jest.fn(async () => []), + signMessage: jest.fn(), + }; +} + +describe("NIP-47 request dispatcher", () => { + const supportedEncryption = [NIP47EncryptionScheme.NIP44_V2]; + + test("validates parameters before invoking the wallet", async () => { + const implementation = wallet(); + const response = await dispatchNIP47Request( + { + method: NIP47Method.PAY_INVOICE, + params: { invoice: 1 } as never, + }, + { + wallet: implementation, + supportedMethods: [NIP47Method.PAY_INVOICE], + supportedEncryption, + }, + ); + + expect(implementation.payInvoice).not.toHaveBeenCalled(); + expect(response.error?.code).toBe(NIP47ErrorCode.INVALID_REQUEST); + expect(response.error?.message).toBe( + "Invalid parameters for pay_invoice method", + ); + }); + + test("dispatches supported methods and preserves result envelopes", async () => { + const implementation = wallet(); + const response = await dispatchNIP47Request( + { method: NIP47Method.GET_BALANCE, params: {} }, + { + wallet: implementation, + supportedMethods: [NIP47Method.GET_BALANCE], + supportedEncryption, + }, + ); + + expect(response).toEqual({ + result_type: NIP47Method.GET_BALANCE, + result: 42, + error: null, + }); + }); + + test("reports unsupported methods without touching the wallet", async () => { + const implementation = wallet(); + const response = await dispatchNIP47Request( + { method: NIP47Method.GET_BALANCE, params: {} }, + { wallet: implementation, supportedMethods: [], supportedEncryption }, + ); + + expect(implementation.getBalance).not.toHaveBeenCalled(); + expect(response.error?.message).toBe("Method get_balance not supported"); + }); + + test("keeps lookup not-found compatibility context", async () => { + const implementation = wallet(); + implementation.lookupInvoice.mockRejectedValue({ + code: NIP47ErrorCode.NOT_FOUND, + }); + const response = await dispatchNIP47Request( + { + method: NIP47Method.LOOKUP_INVOICE, + params: { payment_hash: "missing" }, + }, + { + wallet: implementation, + supportedMethods: [NIP47Method.LOOKUP_INVOICE], + supportedEncryption, + }, + ); + + expect(response.error?.code).toBe(NIP47ErrorCode.NOT_FOUND); + expect(response.error?.message).toContain("payment_hash: missing"); + }); + + test("rejects an invalid secondary lookup identifier", async () => { + const implementation = wallet(); + const response = await dispatchNIP47Request( + { + method: NIP47Method.LOOKUP_INVOICE, + params: { payment_hash: "valid", invoice: 1 } as never, + }, + { + wallet: implementation, + supportedMethods: [NIP47Method.LOOKUP_INVOICE], + supportedEncryption, + }, + ); + + expect(implementation.lookupInvoice).not.toHaveBeenCalled(); + expect(response.error?.code).toBe(NIP47ErrorCode.INVALID_REQUEST); + }); +}); diff --git a/tests/nip47/service-lifecycle.test.ts b/tests/nip47/service-lifecycle.test.ts new file mode 100644 index 00000000..86470325 --- /dev/null +++ b/tests/nip47/service-lifecycle.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, jest, test } from "@jest/globals"; +import { + NIP47EncryptionScheme, + NIP47ErrorCode, + NIP47Method, + NostrWalletConnectClient, + NostrWalletService, + WalletImplementation, +} from "../../src/nip47"; +import { NostrRelay } from "../../src/testing"; +import { generateKeypair } from "../../src/utils/crypto"; +import { getUnixTime } from "../../src/utils/time"; + +class LifecycleWallet implements WalletImplementation { + public getBalance = jest.fn(async () => 42); + + public async getInfo() { + return { + alias: "Lifecycle Wallet", + methods: [NIP47Method.GET_INFO, NIP47Method.GET_BALANCE], + }; + } + + public async payInvoice(): Promise { + throw new Error("Not implemented for lifecycle tests"); + } + + public async makeInvoice(): Promise { + throw new Error("Not implemented for lifecycle tests"); + } + + public async lookupInvoice(): Promise { + throw new Error("Not implemented for lifecycle tests"); + } + + public async listTransactions(): Promise { + throw new Error("Not implemented for lifecycle tests"); + } +} + +describe("NIP-47 service lifecycle", () => { + let relay: NostrRelay; + let service: NostrWalletService; + let wallet: LifecycleWallet; + let serviceKeys: Awaited>; + const clients: NostrWalletConnectClient[] = []; + + beforeEach(async () => { + relay = new NostrRelay(0); + await relay.start(); + serviceKeys = await generateKeypair(); + wallet = new LifecycleWallet(); + service = new NostrWalletService( + { + relays: [relay.url], + pubkey: serviceKeys.publicKey, + privkey: serviceKeys.privateKey, + methods: [NIP47Method.GET_INFO, NIP47Method.GET_BALANCE], + encryptionSchemes: [NIP47EncryptionScheme.NIP44_V2], + }, + wallet, + ); + }); + + afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.disconnect())); + await service.disconnect(); + await relay.close(); + }); + + test("sequential initialization keeps one active request subscription", async () => { + await service.init(); + await service.init(); + + expect(relay.subs.size).toBe(1); + }); + + test("concurrent initialization is single-flight and keeps one subscription", async () => { + const firstInitialization = service.init(); + const secondInitialization = service.init(); + + expect(secondInitialization).toBe(firstInitialization); + await Promise.all([firstInitialization, secondInitialization]); + expect(relay.subs.size).toBe(1); + }); + + test("disconnect is idempotent and releases the request subscription", async () => { + await service.init(); + + await expect( + Promise.all([service.disconnect(), service.disconnect()]), + ).resolves.toEqual([undefined, undefined]); + await expect(service.disconnect()).resolves.toBeUndefined(); + expect(relay.subs.size).toBe(0); + }); + + test("a later disconnect invalidates initialization queued behind teardown", async () => { + await service.init(); + + const firstDisconnect = service.disconnect(); + const queuedInitialization = service.init(); + const laterDisconnect = service.disconnect(); + + await laterDisconnect; + await expect(queuedInitialization).rejects.toThrow( + "Service initialization cancelled by disconnect", + ); + await firstDisconnect; + expect(relay.subs.size).toBe(0); + + await service.init(); + expect(relay.subs.size).toBe(1); + }); + + test("disconnect invalidates initialization already in flight", async () => { + const initialization = service.init(); + const disconnection = service.disconnect(); + + await disconnection; + await expect(initialization).rejects.toThrow( + "Service initialization cancelled by disconnect", + ); + expect(relay.subs.size).toBe(0); + + await service.init(); + expect(relay.subs.size).toBe(1); + }); + + test("initialization after disconnect restores one expiration-aware subscription", async () => { + await service.init(); + await service.disconnect(); + + await service.init(); + expect(relay.subs.size).toBe(1); + + const clientKeys = await generateKeypair(); + const client = new NostrWalletConnectClient({ + pubkey: serviceKeys.publicKey, + secret: clientKeys.privateKey, + relays: [relay.url], + preferredEncryption: NIP47EncryptionScheme.NIP44_V2, + }); + clients.push(client); + await client.init(); + + await expect( + client.getBalance({ expiration: getUnixTime() - 5 }), + ).rejects.toMatchObject({ code: NIP47ErrorCode.REQUEST_EXPIRED }); + expect(wallet.getBalance).not.toHaveBeenCalled(); + }, 10000); +}); diff --git a/tests/nip50/search.test.ts b/tests/nip50/search.test.ts index 1d6dde66..02754284 100644 --- a/tests/nip50/search.test.ts +++ b/tests/nip50/search.test.ts @@ -1,7 +1,7 @@ import { Nostr } from "../../src/nip01/nostr"; import { createSignedEvent } from "../../src/nip01/event"; import { generateKeypair } from "../../src/utils/crypto"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; import { createSearchFilter } from "../../src/nip50"; import { Filter, NostrEvent } from "../../src/types/nostr"; diff --git a/tests/nip57/client.test.ts b/tests/nip57/client.test.ts index f5185110..b50937d1 100644 --- a/tests/nip57/client.test.ts +++ b/tests/nip57/client.test.ts @@ -8,10 +8,12 @@ import { NostrZapClient, SubscriptionOptions, ZapClient, + verifySignature, } from "../../src"; import { createSignedEvent } from "../../src/nip01/event"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; import type { DiagnosticLogger } from "../../src/utils/logger"; +import * as nip57 from "../../src/nip57"; function createDiagnosticLogger(): DiagnosticLogger & { error: jest.Mock; @@ -30,6 +32,7 @@ class ScriptedNostr extends Nostr { public returnedSubscriptionIds: unknown = ["relay-one", "relay-two"]; public readonly activeSubscriptionIds = new Set(); public readonly releasedSubscriptionIds: string[][] = []; + public readonly subscribedFilters: Filter[][] = []; public subscribeError: Error | null = null; public readonly unsubscribeErrors = new Map(); public reachesEoseSynchronously = false; @@ -40,13 +43,14 @@ class ScriptedNostr extends Nostr { private eoseCallback: (() => void) | null = null; override subscribe( - _filters: Filter[], + filters: Filter[], onEvent: (event: NostrEvent, relay: string) => void, onEOSE?: () => void, _options: SubscriptionOptions = {}, ): string[] { if (this.subscribeError) throw this.subscribeError; + this.subscribedFilters.push(filters); this.eventCallback = onEvent; this.eoseCallback = onEOSE ?? null; if (Array.isArray(this.returnedSubscriptionIds)) { @@ -447,6 +451,7 @@ describe("NIP-57 public clients", () => { }; afterEach(() => { + jest.useRealTimers(); jest.restoreAllMocks(); if (installedFetchForTest) { delete (globalThis as { fetch?: typeof fetch }).fetch; @@ -483,8 +488,9 @@ describe("NIP-57 public clients", () => { }); test("preserves fallback behavior when an injected diagnostic logger throws", async () => { - spyOnGlobalFetch() - .mockRejectedValue(new Error("LNURL endpoint unavailable")); + spyOnGlobalFetch().mockRejectedValue( + new Error("LNURL endpoint unavailable"), + ); const logger = createDiagnosticLogger(); logger.error.mockImplementation(() => { throw new Error("logger unavailable"); @@ -534,5 +540,408 @@ describe("NIP-57 public clients", () => { error: callbackError, }); }); + + test("signs anonymous requests with the supplied ephemeral key", async () => { + const realPrivateKey = "8".repeat(64); + const ephemeralPrivateKey = "9".repeat(64); + const nostr = new Nostr(); + nostr.setPrivateKey(realPrivateKey); + spyOnGlobalFetch() + .mockResolvedValueOnce({ + json: jest.fn().mockResolvedValue({ + callback: "https://lnurl.test/callback", + maxSendable: 10_000, + minSendable: 1, + metadata: "[]", + tag: "payRequest", + allowsNostr: true, + nostrPubkey: "7".repeat(64), + }), + } as unknown as Response) + .mockResolvedValueOnce({ + json: jest.fn().mockResolvedValue({ pr: "lnbc-valid-invoice" }), + } as unknown as Response); + const client = new ZapClient({ nostrClient: nostr }); + + const result = await client.getZapInvoice( + { + recipientPubkey: "a".repeat(64), + lnurl: "https://lnurl.test/metadata", + amount: 1_000, + anonymousZap: true, + }, + ephemeralPrivateKey, + ); + + expect(result.error).toBeUndefined(); + expect(result.zapRequest.pubkey).toBe(getPublicKey(ephemeralPrivateKey)); + expect(result.zapRequest.tags).toContainEqual([ + "P", + getPublicKey(realPrivateKey), + ]); + await expect( + verifySignature( + result.zapRequest.id, + result.zapRequest.sig, + result.zapRequest.pubkey, + ), + ).resolves.toBe(true); + }); + + test("rejects a successful callback response without a usable invoice", async () => { + const privateKey = "9".repeat(64); + const nostr = new Nostr(); + nostr.setPrivateKey(privateKey); + spyOnGlobalFetch() + .mockResolvedValueOnce({ + json: jest.fn().mockResolvedValue({ + callback: "https://lnurl.test/callback", + maxSendable: 10_000, + minSendable: 1, + metadata: "[]", + tag: "payRequest", + allowsNostr: true, + nostrPubkey: "8".repeat(64), + }), + } as unknown as Response) + .mockResolvedValueOnce({ + json: jest.fn().mockResolvedValue({ status: "OK" }), + } as unknown as Response); + const client = new ZapClient({ nostrClient: nostr }); + + await expect( + client.getZapInvoice( + { + recipientPubkey: "a".repeat(64), + lnurl: "https://lnurl.test/metadata", + amount: 1_000, + }, + privateKey, + ), + ).resolves.toMatchObject({ + invoice: "", + error: "LNURL server returned an invalid invoice response", + }); + }); + + test("bounds a stalled LNURL invoice callback", async () => { + const nativeSetTimeout = globalThis.setTimeout; + const timeoutSpy = jest + .spyOn(globalThis, "setTimeout") + .mockImplementation(((callback: () => void) => + nativeSetTimeout(callback, 0)) as typeof setTimeout); + const privateKey = "9".repeat(64); + const nostr = new Nostr(); + nostr.setPrivateKey(privateKey); + spyOnGlobalFetch() + .mockResolvedValueOnce({ + json: jest.fn().mockResolvedValue({ + callback: "https://lnurl.test/callback", + maxSendable: 10_000, + minSendable: 1, + metadata: "[]", + tag: "payRequest", + allowsNostr: true, + nostrPubkey: "8".repeat(64), + }), + } as unknown as Response) + .mockImplementationOnce( + (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + const error = new Error("aborted"); + error.name = "AbortError"; + reject(error); + }); + }), + ); + const client = new ZapClient({ nostrClient: nostr }); + await expect( + client.getZapInvoice( + { + recipientPubkey: "a".repeat(64), + lnurl: "https://lnurl.test/metadata", + amount: 1_000, + }, + privateKey, + ), + ).resolves.toMatchObject({ + invoice: "", + error: "LNURL callback timed out", + }); + expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 10_000); + }); + }); + + describe("shared facade behavior", () => { + let installedFetchForTest = false; + + const spyOnGlobalFetch = () => { + if (typeof globalThis.fetch !== "function") { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + writable: true, + value: jest.fn(), + }); + installedFetchForTest = true; + } + + return jest.spyOn(globalThis, "fetch"); + }; + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + if (installedFetchForTest) { + delete (globalThis as { fetch?: typeof fetch }).fetch; + installedFetchForTest = false; + } + }); + + test("reuses LNURL state per facade instance without sharing it between instances", async () => { + const fetchSpy = spyOnGlobalFetch().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + callback: "https://lnurl.test/callback", + maxSendable: 10_000, + minSendable: 1, + metadata: "[]", + tag: "payRequest", + allowsNostr: true, + nostrPubkey: "8".repeat(64), + }), + } as unknown as Response); + const pubkey = "7".repeat(64); + const lnurl = "https://lnurl.test/metadata"; + + const nostrFacade = new NostrZapClient({ client: new Nostr() }); + await expect(nostrFacade.canReceiveZaps(pubkey, lnurl)).resolves.toBe( + true, + ); + await expect(nostrFacade.canReceiveZaps(pubkey, lnurl)).resolves.toBe( + true, + ); + expect(fetchSpy).toHaveBeenCalledTimes(1); + await expect( + nostrFacade.canReceiveZaps(pubkey, `${lnurl}/changed`), + ).resolves.toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(2); + + fetchSpy.mockClear(); + const zapFacade = new ZapClient({ nostrClient: new Nostr() }); + await expect(zapFacade.canReceiveZaps(pubkey, lnurl)).resolves.toBe(true); + await expect(zapFacade.canReceiveZaps(pubkey, lnurl)).resolves.toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(1); + await expect( + zapFacade.canReceiveZaps(pubkey, `${lnurl}/changed`), + ).resolves.toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(2); + + fetchSpy.mockClear(); + const first = new NostrZapClient({ client: new Nostr() }); + const second = new NostrZapClient({ client: new Nostr() }); + await expect(first.canReceiveZaps(pubkey, lnurl)).resolves.toBe(true); + await expect(second.canReceiveZaps(pubkey, lnurl)).resolves.toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + test("bounds persistent LNURL state with observable eviction", async () => { + const fetchSpy = spyOnGlobalFetch().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + callback: "https://lnurl.test/callback", + maxSendable: 10_000, + minSendable: 1, + metadata: "[]", + tag: "payRequest", + allowsNostr: true, + nostrPubkey: "8".repeat(64), + }), + } as unknown as Response); + const client = new ZapClient({ nostrClient: new Nostr() }); + const cacheCapacity = 256; + + for (let index = 0; index <= cacheCapacity; index += 1) { + await client.canReceiveZaps( + index.toString(16).padStart(64, "0"), + `https://lnurl.test/${index}`, + ); + } + await client.canReceiveZaps("0".repeat(64), "https://lnurl.test/0"); + + expect(fetchSpy).toHaveBeenCalledTimes(cacheCapacity + 2); + }); + + test("both facades produce equivalent receipt filters", async () => { + jest.useFakeTimers(); + const nostrForNostrFacade = new ScriptedNostr(); + const nostrForZapFacade = new ScriptedNostr(); + nostrForNostrFacade.reachesEoseSynchronously = true; + nostrForZapFacade.reachesEoseSynchronously = true; + const nostrFacade = new NostrZapClient({ client: nostrForNostrFacade }); + const zapFacade = new ZapClient({ nostrClient: nostrForZapFacade }); + const pubkey = "a".repeat(64); + const eventId = "b".repeat(64); + const generalEventId = "d".repeat(64); + const authors = ["c".repeat(64)]; + const options = { + limit: 7, + since: 100, + until: 200, + authors, + events: [generalEventId], + }; + + await nostrFacade.fetchUserReceivedZaps(pubkey, options); + await zapFacade.fetchUserReceivedZaps(pubkey, options); + await nostrFacade.fetchEventZaps(eventId, options); + await zapFacade.fetchEventZaps(eventId, options); + await nostrFacade.fetchZapReceipts(options); + await zapFacade.fetchZapReceipts(options); + + expect(nostrForZapFacade.subscribedFilters).toEqual( + nostrForNostrFacade.subscribedFilters, + ); + expect(nostrForNostrFacade.subscribedFilters).toEqual([ + [ + { + kinds: [9735], + limit: 7, + "#p": [pubkey], + since: 100, + until: 200, + authors, + }, + ], + [ + { + kinds: [9735], + limit: 7, + "#e": [eventId], + since: 100, + until: 200, + authors, + }, + ], + [ + { + kinds: [9735], + limit: 7, + "#e": [generalEventId], + since: 100, + until: 200, + authors, + }, + ], + ]); + }); + + test("both facades preserve an explicit zero limit for every receipt filter", async () => { + jest.useFakeTimers(); + const nostrForNostrFacade = new ScriptedNostr(); + const nostrForZapFacade = new ScriptedNostr(); + nostrForNostrFacade.reachesEoseSynchronously = true; + nostrForZapFacade.reachesEoseSynchronously = true; + const nostrFacade = new NostrZapClient({ client: nostrForNostrFacade }); + const zapFacade = new ZapClient({ nostrClient: nostrForZapFacade }); + + await nostrFacade.fetchUserReceivedZaps("a".repeat(64), { limit: 0 }); + await zapFacade.fetchUserReceivedZaps("a".repeat(64), { limit: 0 }); + await nostrFacade.fetchEventZaps("b".repeat(64), { limit: 0 }); + await zapFacade.fetchEventZaps("b".repeat(64), { limit: 0 }); + await nostrFacade.fetchZapReceipts({ limit: 0 }); + await zapFacade.fetchZapReceipts({ limit: 0 }); + + expect( + nostrForNostrFacade.subscribedFilters.map(([filter]) => filter.limit), + ).toEqual([0, 0, 0]); + expect(nostrForZapFacade.subscribedFilters).toEqual( + nostrForNostrFacade.subscribedFilters, + ); + }); + + test("both facades calculate equivalent user and event statistics", async () => { + jest.spyOn(nip57, "validateZapReceipt").mockImplementation((receipt) => ({ + valid: true, + amount: receipt.id === RECEIPT.id ? 1_000 : 3_000, + })); + const secondReceipt = { + ...RECEIPT, + id: "4".repeat(64), + created_at: 3, + }; + const expected = { + total: 4_000, + count: 2, + largest: 3_000, + smallest: 1_000, + average: 2_000, + firstAt: 1, + latestAt: 3, + }; + + const runUserStats = async ( + facade: NostrZapClient | ZapClient, + nostr: ScriptedNostr, + ) => { + const statistics = facade.getTotalZapsReceived("a".repeat(64)); + nostr.emitEvent(RECEIPT); + nostr.emitEvent(secondReceipt); + nostr.emitEOSE(); + return statistics; + }; + const runEventStats = async ( + facade: NostrZapClient | ZapClient, + nostr: ScriptedNostr, + ) => { + const statistics = facade.getTotalZapsForEvent("b".repeat(64)); + nostr.emitEvent(RECEIPT); + nostr.emitEvent(secondReceipt); + nostr.emitEOSE(); + return statistics; + }; + + const nostrUser = new ScriptedNostr(); + const zapUser = new ScriptedNostr(); + await expect( + runUserStats(new NostrZapClient({ client: nostrUser }), nostrUser), + ).resolves.toEqual(expected); + await expect( + runUserStats(new ZapClient({ nostrClient: zapUser }), zapUser), + ).resolves.toEqual(expected); + + const nostrEvent = new ScriptedNostr(); + const zapEvent = new ScriptedNostr(); + await expect( + runEventStats(new NostrZapClient({ client: nostrEvent }), nostrEvent), + ).resolves.toEqual(expected); + await expect( + runEventStats(new ZapClient({ nostrClient: zapEvent }), zapEvent), + ).resolves.toEqual(expected); + }); + + test("both facades normalize all-invalid receipts to empty statistics", async () => { + jest.spyOn(nip57, "validateZapReceipt").mockReturnValue({ + valid: false, + message: "invalid receipt", + }); + + const runStatistics = async ( + facade: NostrZapClient | ZapClient, + nostr: ScriptedNostr, + ) => { + const statistics = facade.getTotalZapsReceived("a".repeat(64)); + nostr.emitEvent(RECEIPT); + nostr.emitEOSE(); + return statistics; + }; + const nostrClient = new ScriptedNostr(); + const zapClient = new ScriptedNostr(); + + await expect( + runStatistics(new NostrZapClient({ client: nostrClient }), nostrClient), + ).resolves.toEqual({ total: 0, count: 0 }); + await expect( + runStatistics(new ZapClient({ nostrClient: zapClient }), zapClient), + ).resolves.toEqual({ total: 0, count: 0 }); + }); }); }); diff --git a/tests/nip66/nip66.test.ts b/tests/nip66/nip66.test.ts index ceba946d..7e732cac 100644 --- a/tests/nip66/nip66.test.ts +++ b/tests/nip66/nip66.test.ts @@ -11,6 +11,17 @@ import { RelayMonitorAnnouncementOptions, } from "../../src/nip66"; import { NostrEvent } from "../../src/types/nostr"; +import type { DiagnosticLogger } from "../../src/utils/logger"; + +function createLogger(): jest.Mocked { + return { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + trace: jest.fn(), + }; +} // Test types that allow invalid inputs for validation testing type TestRelayDiscoveryOptions = { @@ -731,15 +742,10 @@ describe("NIP-66", () => { }); describe("Enhanced parsing validation with bounds checking", () => { - // Mock console.warn to capture warnings - let consoleWarnSpy: jest.SpyInstance; + let logger: jest.Mocked; beforeEach(() => { - consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); - }); - - afterEach(() => { - consoleWarnSpy.mockRestore(); + logger = createLogger(); }); test("parseRelayMonitorAnnouncement should handle invalid timeout values gracefully", () => { @@ -769,7 +775,7 @@ describe("NIP-66", () => { sig: "testsig", }; - const parsed = parseRelayMonitorAnnouncement(malformedEvent); + const parsed = parseRelayMonitorAnnouncement(malformedEvent, logger); expect(parsed).not.toBeNull(); // Should only have valid timeouts (note: "1.5" becomes 1 via parseInt, which is valid) @@ -779,20 +785,15 @@ describe("NIP-66", () => { expect(parsed?.timeouts[2]).toEqual({ value: 4000, test: undefined }); // Should have logged warnings for invalid entries - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining( - "NIP-66: Skipping timeout tag with missing or empty value", - ), - ); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining( - "NIP-66: Skipping timeout tag with invalid numeric value", - ), - ); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining( - "NIP-66: Skipping timeout tag with value out of bounds", - ), + expect(logger.warn).toHaveBeenCalledWith("Skipping invalid timeout tag", { + reason: "missing-value", + }); + expect(logger.warn).toHaveBeenCalledWith("Skipping invalid timeout tag", { + reason: "invalid-number", + }); + expect(logger.warn).toHaveBeenCalledWith( + "Skipping invalid timeout tag", + expect.objectContaining({ reason: "out-of-bounds" }), ); }); @@ -816,27 +817,24 @@ describe("NIP-66", () => { sig: "testsig", }; - const parsed = parseRelayMonitorAnnouncement(malformedEvent); + const parsed = parseRelayMonitorAnnouncement(malformedEvent, logger); expect(parsed).not.toBeNull(); // Should use the valid frequency (last valid one wins) expect(parsed?.frequency).toBe(3600); // Should have logged warnings for invalid entries - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining( - "NIP-66: Skipping frequency tag with missing or empty value", - ), + expect(logger.warn).toHaveBeenCalledWith( + "Skipping invalid frequency tag", + { reason: "missing-value" }, ); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining( - "NIP-66: Skipping frequency tag with invalid numeric value", - ), + expect(logger.warn).toHaveBeenCalledWith( + "Skipping invalid frequency tag", + { reason: "invalid-number" }, ); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining( - "NIP-66: Skipping frequency tag with value out of bounds", - ), + expect(logger.warn).toHaveBeenCalledWith( + "Skipping invalid frequency tag", + expect.objectContaining({ reason: "out-of-bounds" }), ); }); diff --git a/tests/scripts/test-lanes.test.ts b/tests/scripts/test-lanes.test.ts new file mode 100644 index 00000000..edbb3341 --- /dev/null +++ b/tests/scripts/test-lanes.test.ts @@ -0,0 +1,168 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ + +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import path from "path"; + +type TestLane = "all" | "routine" | "slow"; + +interface TestLaneModule { + SLOW_TEST_PATHS: string[]; + SLOW_TEST_NAME_PREFIX: string; + discoverTestFiles(repoRoot: string): string[]; + getJestArgsForLane(lane: TestLane): string[]; + getTestFilesForLane(lane: TestLane, repoRoot: string): string[]; +} + +interface TestLaneRunnerModule { + getBunArgsForLane( + lane: TestLane, + extraArgs: string[], + repoRoot: string, + ): string[]; +} + +const repoRoot = path.resolve(__dirname, "../.."); +const lanes = require("../../scripts/test-lanes.js") as TestLaneModule; + +describe("test lane contract", () => { + const expectedSlowPaths = [ + "tests/nip44/nip44-performance-security.test.ts", + "tests/nip46/performance-security.test.ts", + ]; + + test("keeps one explicit sorted slow security and performance inventory", () => { + expect(lanes.SLOW_TEST_PATHS).toEqual(expectedSlowPaths); + expect(lanes.SLOW_TEST_NAME_PREFIX).toBe("[slow]"); + for (const relativePath of lanes.SLOW_TEST_PATHS) { + const absolutePath = path.join(repoRoot, relativePath); + expect(existsSync(absolutePath)).toBe(true); + + const source = readFileSync(absolutePath, "utf8"); + const topLevelDescribes = source.match(/^describe\(/gm) ?? []; + const slowTopLevelDescribes = source.match(/^describe\("\[slow\]/gm) ?? []; + expect(topLevelDescribes.length).toBeGreaterThan(0); + expect(slowTopLevelDescribes).toHaveLength(topLevelDescribes.length); + } + }); + + test("partitions every test file into exactly one routine or slow lane", () => { + const all = lanes.discoverTestFiles(repoRoot); + const routine = lanes.getTestFilesForLane("routine", repoRoot); + const slow = lanes.getTestFilesForLane("slow", repoRoot); + + expect(slow).toEqual(expectedSlowPaths); + expect(new Set([...routine, ...slow]).size).toBe(all.length); + expect([...routine, ...slow].sort()).toEqual(all); + expect(routine.filter((file) => slow.includes(file))).toEqual([]); + }); + + test("discovers both Jest test and spec filename conventions", () => { + const fixtureRoot = mkdtempSync(path.join(tmpdir(), "snstr-test-lanes-")); + const fixtureTests = path.join(fixtureRoot, "tests"); + + try { + mkdirSync(fixtureTests); + writeFileSync(path.join(fixtureTests, "alpha.test.ts"), ""); + writeFileSync(path.join(fixtureTests, "beta.spec.ts"), ""); + writeFileSync(path.join(fixtureTests, "not-a-test.ts"), ""); + + expect(lanes.discoverTestFiles(fixtureRoot)).toEqual([ + "tests/alpha.test.ts", + "tests/beta.spec.ts", + ]); + } finally { + rmSync(fixtureRoot, { force: true, recursive: true }); + } + }); + + test("keeps targeted Jest runs compatible while excluding slow files by default", () => { + const routineArgs = lanes.getJestArgsForLane("routine"); + + expect(routineArgs).toHaveLength(1); + expect(routineArgs[0]).toContain("--testPathIgnorePatterns="); + for (const slowPath of expectedSlowPaths) { + expect(routineArgs[0]).toContain(slowPath.replace(/\./g, "\\.")); + } + expect(lanes.getJestArgsForLane("slow")).toEqual(expectedSlowPaths); + expect(lanes.getJestArgsForLane("all")).toEqual([]); + }); + + test("keeps routine Bun watch discovery dynamic for newly added tests", () => { + const runner = require("../../scripts/run-test-lane.js") as TestLaneRunnerModule; + const args = runner.getBunArgsForLane("routine", ["--watch"], repoRoot); + const nonWatchArgs = runner.getBunArgsForLane("routine", [], repoRoot); + + expect(args.slice(0, 2)).toEqual(["test", "./tests"]); + expect(args).toContain("--test-name-pattern=^(?!\\[slow\\])"); + expect(args).toEqual( + expect.arrayContaining([ + "--max-concurrency", + "1", + "--timeout", + "30000", + "--watch", + ]), + ); + expect(args).not.toContain("tests/scripts/test-lanes.test.ts"); + for (const slowPath of expectedSlowPaths) { + expect(args).not.toContain(slowPath); + } + expect(nonWatchArgs).toContain("tests/scripts/test-lanes.test.ts"); + expect(nonWatchArgs).not.toContain("./tests"); + for (const slowPath of expectedSlowPaths) { + expect(nonWatchArgs).not.toContain(slowPath); + } + }); + + test("wires routine, slow, and complete Jest and Bun commands", () => { + const packageJson = JSON.parse( + readFileSync(path.join(repoRoot, "package.json"), "utf8"), + ) as { scripts: Record }; + + expect(packageJson.scripts.test).toBe( + "node scripts/run-test-lane.js jest routine", + ); + expect(packageJson.scripts["test:slow"]).toBe( + "node scripts/run-test-lane.js jest slow", + ); + expect(packageJson.scripts["test:all"]).toBe( + "npm test && npm run test:slow", + ); + expect(packageJson.scripts["test:coverage"]).toBe( + "node scripts/run-test-lane.js jest routine --coverage", + ); + expect(packageJson.scripts["test:coverage:all"]).toBe( + "node scripts/run-test-lane.js jest all --coverage", + ); + expect(packageJson.scripts["test:bun"]).toBe( + "node scripts/run-test-lane.js bun routine", + ); + expect(packageJson.scripts["test:bun:slow"]).toBe( + "node scripts/run-test-lane.js bun slow", + ); + expect(packageJson.scripts["test:bun:all"]).toBe( + "bun run test:bun && bun run test:bun:slow", + ); + }); + + test("keeps complete assurance explicit in every hosted runtime", () => { + const workflow = readFileSync( + path.join(repoRoot, ".github/workflows/build-test.yml"), + "utf8", + ); + + expect(workflow).toContain("run: npm test"); + expect(workflow).toContain("run: npm run test:slow"); + expect(workflow).toContain("run: bun run test:bun"); + expect(workflow).toContain("run: bun run test:bun:slow"); + expect(workflow).toContain("run: npm run test:coverage:all"); + }); +}); diff --git a/tests/scripts/verify-package-manager.test.ts b/tests/scripts/verify-package-manager.test.ts new file mode 100644 index 00000000..89e2d168 --- /dev/null +++ b/tests/scripts/verify-package-manager.test.ts @@ -0,0 +1,255 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; + +interface PackageManagerVerifier { + extractRunCommands(workflow: string): Map>; + verifyRepository(repoRoot: string): string[]; +} + +const verifier = + require("../../scripts/verify-package-manager.js") as PackageManagerVerifier; + +function writeFixture(root: string): void { + mkdirSync(path.join(root, ".github/workflows"), { recursive: true }); + writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ + name: "fixture", + version: "1.0.0", + packageManager: "npm@9.8.1", + }), + ); + writeFileSync( + path.join(root, "package-lock.json"), + JSON.stringify({ + name: "fixture", + version: "1.0.0", + lockfileVersion: 3, + packages: { "": { name: "fixture", version: "1.0.0" } }, + }), + ); + writeFileSync(path.join(root, "bun.lock"), "{}"); + writeFileSync(path.join(root, ".bun-version"), "1.3.9\n"); + writeFileSync( + path.join(root, ".github/workflows/build-test.yml"), + [ + "jobs:", + " build-and-test-node:", + " steps:", + " - run: corepack prepare npm@9.8.1 --activate", + " - run: npm ci", + " build-and-test-bun:", + " steps:", + " - run: bun install --frozen-lockfile", + ].join("\n"), + ); +} + +describe("package-manager policy verifier", () => { + test("accepts the repository policy", () => { + expect(verifier.verifyRepository(process.cwd())).toEqual([]); + }); + + test("accepts a complete policy fixture", () => { + const root = mkdtempSync(path.join(tmpdir(), "snstr-package-manager-")); + try { + writeFixture(root); + expect(verifier.verifyRepository(root)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("only accepts active run-step commands", () => { + expect( + verifier.extractRunCommands( + [ + "jobs:", + " example:", + "# run: npm ci", + "name: echo example", + 'run: echo "npm ci"', + "run: |", + " # bun install --frozen-lockfile", + " npm ci", + ].join("\n"), + ), + ).toEqual(new Map([["example", new Set(['echo "npm ci"', "npm ci"])]])); + }); + + test("reports malformed required files without throwing", () => { + const root = mkdtempSync(path.join(tmpdir(), "snstr-package-manager-")); + try { + writeFixture(root); + writeFileSync(path.join(root, "package-lock.json"), "{invalid"); + expect(verifier.verifyRepository(root)).toEqual([ + expect.stringContaining("package-lock.json is not valid JSON:"), + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test.each(["null", "[]", '"manifest"'])( + "rejects a non-object package manifest root: %s", + (content) => { + const root = mkdtempSync(path.join(tmpdir(), "snstr-package-manager-")); + try { + writeFixture(root); + writeFileSync(path.join(root, "package.json"), content); + expect(verifier.verifyRepository(root)).toEqual([ + "package.json must contain a JSON object", + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + test("reports drift in metadata, lockfiles, and workflow commands", () => { + const root = mkdtempSync(path.join(tmpdir(), "snstr-package-manager-")); + try { + writeFixture(root); + writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ + name: "fixture", + version: "2.0.0", + packageManager: "pnpm@9.0.0", + }), + ); + writeFileSync(path.join(root, "pnpm-lock.yaml"), "lockfileVersion: 9\n"); + writeFileSync( + path.join(root, ".github/workflows/build-test.yml"), + "run: npm install\n", + ); + + expect(verifier.verifyRepository(root)).toEqual( + expect.arrayContaining([ + 'package.json packageManager must be npm@9.8.1; found "pnpm@9.0.0"', + "package-lock.json root version must match package.json", + "pnpm-lock.yaml is not allowed at the repository root", + 'build-test workflow job build-and-test-node must run "npm ci"', + 'build-test workflow job build-and-test-bun must run "bun install --frozen-lockfile"', + ]), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("rejects install commands placed in the wrong CI jobs", () => { + const root = mkdtempSync(path.join(tmpdir(), "snstr-package-manager-")); + try { + writeFixture(root); + writeFileSync( + path.join(root, ".github/workflows/build-test.yml"), + [ + "jobs:", + " build-and-test-node:", + " steps:", + " - run: bun install --frozen-lockfile", + " build-and-test-bun:", + " steps:", + " - run: corepack prepare npm@9.8.1 --activate", + " - run: npm ci", + ].join("\n"), + ); + expect(verifier.verifyRepository(root)).toEqual( + expect.arrayContaining([ + 'build-test workflow job build-and-test-node must run "npm ci"', + 'build-test workflow job build-and-test-bun must run "bun install --frozen-lockfile"', + ]), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test.each([ + [ + "missing Bun lock", + (root: string) => rmSync(path.join(root, "bun.lock")), + "bun.lock is required", + ], + [ + "missing Bun pin", + (root: string) => rmSync(path.join(root, ".bun-version")), + ".bun-version is required", + ], + [ + "wrong Bun pin", + (root: string) => + writeFileSync(path.join(root, ".bun-version"), "1.0.0\n"), + ".bun-version must pin Bun 1.3.9", + ], + [ + "old npm lock", + (root: string) => { + const lock = JSON.parse( + require("fs").readFileSync( + path.join(root, "package-lock.json"), + "utf8", + ), + ); + lock.lockfileVersion = 2; + writeFileSync( + path.join(root, "package-lock.json"), + JSON.stringify(lock), + ); + }, + "package-lock.json must use lockfileVersion 3", + ], + [ + "root-name drift", + (root: string) => { + const lock = JSON.parse( + require("fs").readFileSync( + path.join(root, "package-lock.json"), + "utf8", + ), + ); + lock.packages[""].name = "other"; + writeFileSync( + path.join(root, "package-lock.json"), + JSON.stringify(lock), + ); + }, + "package-lock.json root name must match package.json", + ], + [ + "Yarn lock", + (root: string) => writeFileSync(path.join(root, "yarn.lock"), ""), + "yarn.lock is not allowed", + ], + [ + "shrinkwrap", + (root: string) => + writeFileSync(path.join(root, "npm-shrinkwrap.json"), "{}"), + "npm-shrinkwrap.json is not allowed", + ], + [ + "missing workflow", + (root: string) => + rmSync(path.join(root, ".github/workflows/build-test.yml")), + ".github/workflows/build-test.yml could not be read", + ], + ] as Array<[string, (root: string) => void, string]>)( + "reports %s", + (_name, mutate, diagnostic) => { + const root = mkdtempSync(path.join(tmpdir(), "snstr-package-manager-")); + try { + writeFixture(root); + mutate(root); + expect(verifier.verifyRepository(root).join("\n")).toContain( + diagnostic, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/tests/testing/public-test-seams.test.ts b/tests/testing/public-test-seams.test.ts new file mode 100644 index 00000000..943b92db --- /dev/null +++ b/tests/testing/public-test-seams.test.ts @@ -0,0 +1,86 @@ +import { readFileSync } from "fs"; +import path from "path"; + +const repoRoot = path.resolve(__dirname, "../.."); + +function read(relativePath: string): string { + return readFileSync(path.join(repoRoot, relativePath), "utf8"); +} + +describe("public behavior test seams", () => { + test("removes the shared Nostr and Relay private-shape adapters", () => { + const testSupport = read("tests/types/index.ts"); + const behaviorTests = [ + "tests/integration.test.ts", + "tests/nip01/event/addressable-events.test.ts", + "tests/nip01/event/event-ordering-integration.test.ts", + "tests/nip01/event/nostr-publish.test.ts", + "tests/nip01/nostr.test.ts", + "tests/nip01/relay/filters.test.ts", + "tests/nip01/relay/relay-reconnect.test.ts", + "tests/nip01/relay/relay.test.ts", + "tests/nip01/relay/relayEventStore.test.ts", + ] + .map(read) + .join("\n"); + + for (const legacyAdapter of [ + "NostrInternals", + "getNostrInternals", + "RelayTestAccess", + "asTestRelay", + "NostrPrivateMembers", + "asTestable", + ]) { + expect(testSupport).not.toContain(legacyAdapter); + expect(behaviorTests).not.toContain(legacyAdapter); + } + }); + + test("removes named broad private-shape casts from targeted NIP tests", () => { + const targetedTests = [ + "tests/nip46/core-functionality.test.ts", + "tests/nip46/performance-security.test.ts", + "tests/nip46/protocol-core.test.ts", + "tests/nip47/client-encryption-tracking-simple.test.ts", + "tests/nip47/nip47.test.ts", + ] + .map(read) + .join("\n"); + + for (const privateShape of [ + "BunkerWithInternals", + "ClientWithInternals", + "ClientWithPrivateMethods", + "ClientInitializationState", + "ServiceWithMockAccess", + "ServiceWithPrivates", + "clientWithInternals", + "clientWithPrivates", + "engineInternals", + "serviceWithPrivates", + ]) { + expect(targetedTests).not.toContain(privateShape); + } + }); + + test("keeps testing-entrypoint controls narrow and behavior-oriented", () => { + const testingEntrypoints = [ + "src/testing/behavior-controls.ts", + "src/testing/index.ts", + ] + .map(read) + .join("\n"); + + for (const broadControl of [ + "installNip47ClientInitializationHooks", + "NIP47ClientInitializationHooks", + "NIP47ClientInitializationTransport", + "processNip47ServiceRequest", + "encryptionRetained", + "requestEncryption.has", + ]) { + expect(testingEntrypoints).not.toContain(broadControl); + } + }); +}); diff --git a/tests/types/index.ts b/tests/types/index.ts index 155e8a30..884cae39 100644 --- a/tests/types/index.ts +++ b/tests/types/index.ts @@ -1,15 +1,12 @@ import { - Nostr, NostrEvent, PublishOptions, PublishResponse, Relay, RelayEvent, RelayEventCallbacks, - Subscription, } from "../../src"; -import type { RelayConnectionOptions } from "../../src/types/protocol"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; import { normalizeRelayUrl as normalizeRelayUrlUtil } from "../../src/utils/relayUrl"; /** @@ -40,18 +37,10 @@ export interface MockRelay { authEvent: NostrEvent, options?: PublishOptions, ): Promise; - getLatestReplaceableEvent?(pubkey: string, kind: number): NostrEvent | undefined; -} - -/** - * Interface for accessing private members of the Nostr class during testing - * DO NOT USE THIS IN PRODUCTION CODE! - */ -export interface NostrInternals { - privateKey: string; - publicKey: string; - relays: Map; - relayOptions?: RelayConnectionOptions; + getLatestReplaceableEvent?( + pubkey: string, + kind: number, + ): NostrEvent | undefined; } /** @@ -61,60 +50,6 @@ export function isMockRelay(relay: Relay | MockRelay): relay is MockRelay { return "publishResults" in relay; } -/** - * Safe cast from Nostr to NostrInternals for testing - */ -export function getNostrInternals(client: Nostr): NostrInternals { - return client as unknown as NostrInternals; -} - -/** - * Interface for accessing private members of Relay class in tests - * This provides type-safe access to internal implementation details - * DO NOT USE THIS IN PRODUCTION CODE! - */ -export interface RelayTestAccess { - // Public properties and methods from Relay - url: string; - connect(): Promise; - disconnect(): void; - getSubscriptionIds(): Set; - setConnectionTimeout(timeout: number): void; - getConnectionTimeout(): number; - - // Private connection related properties - ws: WebSocket | null; - connectionPromise: Promise | null; - autoReconnect: boolean; - maxReconnectAttempts: number; - maxReconnectDelay: number; - reconnectAttempts: number; - reconnectTimer: NodeJS.Timeout | null; - connected: boolean; - - // Private internal state - subscriptions: Map; - eventBuffers: Map; - pendingValidationCounts: Map; - status: string; - - // Private internal methods - handleMessage(message: string[] | unknown[]): void; - processValidatedEvent(event: NostrEvent, subscriptionId: string): void; - processReplaceableEvent(event: NostrEvent): void; - processAddressableEvent(event: NostrEvent): void; - flushSubscriptionBuffer(subscriptionId: string): void; - scheduleReconnect(): void; -} - -/** - * Helper function to safely cast a Relay to RelayTestAccess for testing - * This makes the code intention clearer than using "as any" - */ -export function asTestRelay(relay: Relay): RelayTestAccess { - return relay as unknown as RelayTestAccess; -} - /** * Type definitions for Relay event callbacks used in tests. * These are aliases of the library's `RelayEventCallbacks` type diff --git a/tests/types/logger-compatibility.ts b/tests/types/logger-compatibility.ts index 6827bdaa..0d5af88d 100644 --- a/tests/types/logger-compatibility.ts +++ b/tests/types/logger-compatibility.ts @@ -6,6 +6,8 @@ import type { LoggerOptions, NIP47LogArgument, NIP47Logger, + NostrOptions, + RelayConnectionOptions, WarningLogger, } from "../../src"; import type { @@ -15,6 +17,7 @@ import type { LoggerOptions as WebLoggerOptions, NIP47LogArgument as WebNIP47LogArgument, NIP47Logger as WebNIP47Logger, + NostrOptions as WebNostrOptions, WarningLogger as WebWarningLogger, } from "../../src/entries/index.web"; import type { LogData } from "../../src/nip02"; @@ -54,6 +57,9 @@ const canonicalFromLegacyNIP47: DiagnosticLogger = legacyNIP47Logger; const webLegacyNIP47Logger: WebNIP47Logger = diagnostic; const nip47Argument: NIP47LogArgument = diagnosticArgument; const webNIP47Argument: WebNIP47LogArgument = nip47Argument; +const relayOptions: RelayConnectionOptions = { logger: diagnostic }; +const nostrOptions: NostrOptions = { logger: diagnostic, relayOptions }; +const webNostrOptions: WebNostrOptions = nostrOptions; export { diagnostic, @@ -75,4 +81,7 @@ export { webLegacyNIP47Logger, nip47Argument, webNIP47Argument, + relayOptions, + nostrOptions, + webNostrOptions, }; diff --git a/tests/types/protocol-message-types.test.ts b/tests/types/protocol-message-types.test.ts new file mode 100644 index 00000000..52c4ac95 --- /dev/null +++ b/tests/types/protocol-message-types.test.ts @@ -0,0 +1,74 @@ +import type { + NostrClientMessage, + NostrEvent, + NostrMessage, + NostrRelayMessage, +} from "../../src"; + +const event = { + id: "0".repeat(64), + pubkey: "1".repeat(64), + created_at: 1, + kind: 1, + tags: [], + content: "test", + sig: "2".repeat(128), +} satisfies NostrEvent; + +const clientMessages = [ + ["EVENT", event], + ["REQ", "subscription", { kinds: [1] }], + ["CLOSE", "subscription"], + ["AUTH", event], +] satisfies NostrClientMessage[]; + +const relayMessages = [ + ["EVENT", "subscription", event], + ["OK", event.id, true, ""], + ["EOSE", "subscription"], + ["CLOSED", "subscription", "error: unavailable"], + ["NOTICE", "maintenance"], + ["AUTH", "challenge"], +] satisfies NostrRelayMessage[]; + +// These assertions make directionality part of the compile-time contract. +// @ts-expect-error Relay EVENT messages require a subscription identifier. +const invalidRelayEvent: NostrRelayMessage = ["EVENT", event]; +// @ts-expect-error Client AUTH messages carry an event rather than a challenge. +const invalidClientAuth: NostrClientMessage = ["AUTH", "challenge"]; + +describe("canonical NIP-01 protocol message types", () => { + it("covers every supported client and relay wire tuple", () => { + const messages: NostrMessage[] = [...clientMessages, ...relayMessages]; + + expect(messages.map(([verb]) => verb)).toEqual([ + "EVENT", + "REQ", + "CLOSE", + "AUTH", + "EVENT", + "OK", + "EOSE", + "CLOSED", + "NOTICE", + "AUTH", + ]); + }); + + it("preserves JSON tuple serialization", () => { + expect(JSON.parse(JSON.stringify(clientMessages[1]))).toEqual([ + "REQ", + "subscription", + { kinds: [1] }, + ]); + expect(JSON.parse(JSON.stringify(relayMessages[1]))).toEqual([ + "OK", + event.id, + true, + "", + ]); + }); +}); + +void invalidRelayEvent; +void invalidClientAuth; diff --git a/tests/types/relay-test-context.test.ts b/tests/types/relay-test-context.test.ts new file mode 100644 index 00000000..8f26dae6 --- /dev/null +++ b/tests/types/relay-test-context.test.ts @@ -0,0 +1,51 @@ +import { RelayEvent } from "../../src"; +import type { RelayInterface } from "../../src"; +import type { RelayTestContext, RelayTestMock } from "../../src/testing"; +// @ts-expect-error Relay test helpers are available only from the testing entrypoint. +import type { RelayTestContext as RootRelayTestContext } from "../../src"; +// @ts-expect-error The web entry must not expose Node-only relay test helpers. +import type { RelayTestContext as WebRelayTestContext } from "../../src/entries/index.web"; + +void (undefined as unknown as RootRelayTestContext); +void (undefined as unknown as WebRelayTestContext); + +describe("testing entrypoint relay context types", () => { + test("accepts framework-neutral and Jest mock callables", () => { + const plainMock: RelayTestMock = (...args: unknown[]) => args.length; + const jestMock = jest.fn(); + const typedMock = (id: string, accepted: boolean): string => + `${id}:${accepted}`; + const context: RelayTestContext = { + relay: {} as RelayInterface, + originals: {}, + mocks: { + send: plainMock, + connect: typedMock, + handlers: { + [RelayEvent.OK]: jestMock, + }, + }, + capturedCallbacks: {}, + }; + + expect(context.mocks.send?.("request")).toBe(1); + expect(context.mocks.connect).toBe(typedMock); + expect(context.mocks.handlers?.[RelayEvent.OK]).toBe(jestMock); + }); + + test("preserves event-specific captured callback types", () => { + const context: RelayTestContext = { + relay: {} as RelayInterface, + originals: {}, + mocks: {}, + capturedCallbacks: { + [RelayEvent.OK]: [(_id, accepted) => void accepted], + [RelayEvent.Error]: [(_relay, error) => void error], + // @ts-expect-error Callback capture keys must be actual RelayEvent values. + message: [], + }, + }; + + expect(context.capturedCallbacks[RelayEvent.OK]).toHaveLength(1); + }); +}); diff --git a/tests/utils/crypto.test.ts b/tests/utils/crypto.test.ts index 54090ff8..c82720eb 100644 --- a/tests/utils/crypto.test.ts +++ b/tests/utils/crypto.test.ts @@ -145,22 +145,28 @@ describe("Crypto Utilities", () => { expect(decrypted).toEqual(originalMessage); }); - test("decryption should fail with wrong keys", async () => { + test("decryption with wrong keys should not recover the plaintext", () => { const originalMessage = "This is a secret message!"; - const encrypted = encryptNIP04( - alicePrivateKey, - bobPublicKey, - originalMessage, + const fixedAlicePrivateKey = `${"0".repeat(63)}1`; + const fixedBobPrivateKey = `${"0".repeat(63)}2`; + const fixedEvePrivateKey = `${"0".repeat(63)}3`; + const fixedAlicePublicKey = getPublicKey(fixedAlicePrivateKey); + + // NIP-04 uses unauthenticated AES-CBC. Wrong-key plaintext can + // occasionally have valid padding, so failure is not guaranteed. This + // fixed vector exercises that case and must only produce gibberish. + const encrypted = + "7tqJrsqTVbzTJxdfchHjKJk6QEGXcsBRxkFhsDhls3Y=?iv=ge1giUI+IDKF3ibFHhqsSA=="; + const decrypted = decryptNIP04( + fixedEvePrivateKey, + fixedAlicePublicKey, + encrypted, ); - // Generate an unrelated keypair - const eveKeypair = await generateKeypair(); - const evePrivateKey = eveKeypair.privateKey; - - // Eve tries to decrypt with her key - expect(() => - decryptNIP04(evePrivateKey, alicePublicKey, encrypted), - ).toThrow(NIP04DecryptionError); + expect( + decryptNIP04(fixedBobPrivateKey, fixedAlicePublicKey, encrypted), + ).toBe(originalMessage); + expect(decrypted).not.toBe(originalMessage); }); test("should produce different ciphertexts for the same message to different recipients", async () => { diff --git a/tests/utils/ephemeral-relay-close-ordering.test.ts b/tests/utils/ephemeral-relay-close-ordering.test.ts index 54de3a3d..9f6b95c8 100644 --- a/tests/utils/ephemeral-relay-close-ordering.test.ts +++ b/tests/utils/ephemeral-relay-close-ordering.test.ts @@ -11,7 +11,7 @@ import { resetWebSocketImplementation, useWebSocketImplementation, } from "../../src"; -import { NostrRelay } from "../../src/utils/ephemeral-relay"; +import { NostrRelay } from "../../src/testing"; const isBunRuntime = typeof (globalThis as typeof globalThis & { Bun?: unknown }).Bun !== diff --git a/tests/utils/ephemeral-relay-filter.test.ts b/tests/utils/ephemeral-relay-filter.test.ts new file mode 100644 index 00000000..5e199340 --- /dev/null +++ b/tests/utils/ephemeral-relay-filter.test.ts @@ -0,0 +1,61 @@ +import { matchesFilter } from "../../src/utils/ephemeral-relay/filter-match"; +import type { NostrEvent } from "../../src/types/nostr"; + +const event: NostrEvent = { + id: "a".repeat(64), + pubkey: "b".repeat(64), + created_at: 1_700_000_000, + kind: 1, + tags: [["t", "nostr"]], + content: "A public note", + sig: "c".repeat(128), +}; + +describe("ephemeral Relay Subscription Filter matching", () => { + test("matches identifiers, authors, kinds, and inclusive time bounds", () => { + expect( + matchesFilter(event, { + ids: [event.id], + authors: [event.pubkey], + kinds: [1], + since: event.created_at, + until: event.created_at, + }), + ).toBe(true); + + expect(matchesFilter(event, { ids: ["d".repeat(64)] })).toBe(false); + expect(matchesFilter(event, { authors: ["e".repeat(64)] })).toBe(false); + expect(matchesFilter(event, { kinds: [7] })).toBe(false); + expect(matchesFilter(event, { since: event.created_at + 1 })).toBe(false); + expect(matchesFilter(event, { until: event.created_at - 1 })).toBe(false); + }); + + test("requires every tag filter and allows any value within each tag", () => { + const taggedEvent = { + ...event, + tags: [ + ["t", "nostr"], + ["p", "friend"], + ], + }; + + expect( + matchesFilter(taggedEvent, { + "#t": ["bitcoin", "nostr"], + "#p": ["friend"], + }), + ).toBe(true); + expect( + matchesFilter(taggedEvent, { + "#t": ["nostr"], + "#p": ["stranger"], + }), + ).toBe(false); + }); + + test("searches content and tag values case-insensitively", () => { + expect(matchesFilter(event, { search: "PUBLIC NOTE" })).toBe(true); + expect(matchesFilter(event, { search: "NOSTR" })).toBe(true); + expect(matchesFilter(event, { search: "missing" })).toBe(false); + }); +}); diff --git a/tests/utils/ephemeral-relay-internals.test.ts b/tests/utils/ephemeral-relay-internals.test.ts new file mode 100644 index 00000000..57df9d72 --- /dev/null +++ b/tests/utils/ephemeral-relay-internals.test.ts @@ -0,0 +1,56 @@ +import { readFileSync } from "fs"; +import { resolve } from "path"; + +const facadeSource = readFileSync( + resolve(process.cwd(), "src/utils/ephemeral-relay.ts"), + "utf8", +); +const sessionSource = readFileSync( + resolve(process.cwd(), "src/utils/ephemeral-relay/client-session.ts"), + "utf8", +); +const packageManifest = JSON.parse( + readFileSync(resolve(process.cwd(), "package.json"), "utf8"), +) as { exports: Record }; + +describe("ephemeral Relay internal ownership", () => { + test("Subscription Filter matching is owned by its internal module", () => { + expect(sessionSource).toContain('from "./filter-match"'); + expect(facadeSource).not.toMatch(/function match_filter\s*\(/); + expect(facadeSource).not.toMatch(/function match_tags\s*\(/); + }); + + test("client-session protocol state is owned by its internal module", () => { + expect(facadeSource).toContain('from "./ephemeral-relay/client-session"'); + expect(facadeSource).not.toMatch(/class ClientSession\s*{/); + }); + + test("client sessions depend on a narrow host instead of the Relay facade", () => { + expect(sessionSource).toContain("export interface RelaySessionHost"); + expect(sessionSource).not.toContain('from "../ephemeral-relay"'); + expect(facadeSource).toContain("createClientSession"); + expect(facadeSource).not.toMatch(/instance\._(?:handler|onerr|cleanup)/); + }); + + test("connection lifecycle is owned by its internal transport module", () => { + expect(facadeSource).toContain('from "./ephemeral-relay/transport"'); + expect(facadeSource).not.toContain("new WebSocketServer"); + expect(facadeSource).not.toContain("registerInMemoryServer"); + expect(facadeSource).not.toContain("unregisterInMemoryServer"); + expect(facadeSource).not.toContain("closeWebSocketTransport"); + expect(facadeSource).not.toContain("_acceptingConnections"); + expect(facadeSource).not.toContain(".clients.forEach"); + }); + + test("private Relay owners do not become package entrypoints", () => { + expect(packageManifest.exports).not.toHaveProperty( + "./utils/ephemeral-relay/client-session", + ); + expect(packageManifest.exports).not.toHaveProperty( + "./utils/ephemeral-relay/filter-match", + ); + expect(packageManifest.exports).not.toHaveProperty( + "./utils/ephemeral-relay/transport", + ); + }); +}); diff --git a/tests/utils/ephemeral-relay-lifecycle.test.ts b/tests/utils/ephemeral-relay-lifecycle.test.ts index 80e97230..bc42f881 100644 --- a/tests/utils/ephemeral-relay-lifecycle.test.ts +++ b/tests/utils/ephemeral-relay-lifecycle.test.ts @@ -96,7 +96,7 @@ async function waitFor( } async function expectRestartClearsTransientState(): Promise { - const { NostrRelay } = await import("../../src/utils/ephemeral-relay"); + const { NostrRelay } = await import("../../src/testing"); const relay = new NostrRelay(0); await relay.start(); const publisher = new Relay(relay.url, { @@ -163,7 +163,7 @@ describe("NostrRelay lifecycle", () => { const consoleDiagnostics = spyOnConsoleDiagnostics(); try { - const { NostrRelay } = await import("../../src/utils/ephemeral-relay"); + const { NostrRelay } = await import("../../src/testing"); new NostrRelay(0); expect(consoleDiagnostics.log).not.toHaveBeenCalled(); @@ -181,7 +181,7 @@ describe("NostrRelay lifecycle", () => { try { process.env["DEBUG"] = "true"; - const { NostrRelay } = await import("../../src/utils/ephemeral-relay"); + const { NostrRelay } = await import("../../src/testing"); const relay = new NostrRelay(0); let client: Relay | null = null; @@ -214,7 +214,7 @@ describe("NostrRelay lifecycle", () => { }); test("reports lifecycle diagnostics only through an injected logger", async () => { - const { NostrRelay } = await import("../../src/utils/ephemeral-relay"); + const { NostrRelay } = await import("../../src/testing"); const { logger, info } = createDiagnosticLogger(); const relay = new NostrRelay(0, { logger }); @@ -227,8 +227,27 @@ describe("NostrRelay lifecycle", () => { ]); }); + test("reports server errors that occur after startup", async () => { + const { NostrRelay } = await import("../../src/testing"); + const { logger, warn } = createDiagnosticLogger(); + const relay = new NostrRelay(0, { logger }); + + try { + await relay.start(); + + expect(() => + relay.wss.emit("error", new Error("late failure")), + ).not.toThrow(); + expect(warn).toHaveBeenCalledWith("Relay transport error", { + error: expect.any(Error), + }); + } finally { + await relay.close(); + } + }); + test("a throwing diagnostic logger cannot alter Relay lifecycle behavior", async () => { - const { NostrRelay } = await import("../../src/utils/ephemeral-relay"); + const { NostrRelay } = await import("../../src/testing"); const relay = new NostrRelay(0, { logger: createThrowingDiagnosticLogger(), }); @@ -253,7 +272,7 @@ describe("NostrRelay lifecycle", () => { }); test("a failed fixed-port start can retry after the port is released", async () => { - const { NostrRelay } = await import("../../src/utils/ephemeral-relay"); + const { NostrRelay } = await import("../../src/testing"); const blocker = createServer(); await new Promise((resolve, reject) => { blocker.once("error", reject); @@ -287,7 +306,7 @@ describe("NostrRelay lifecycle", () => { }); test("the legacy numeric purge interval remains compatible", async () => { - const { NostrRelay } = await import("../../src/utils/ephemeral-relay"); + const { NostrRelay } = await import("../../src/testing"); const relay = new NostrRelay(0, 0.01); const event = await createRelayEvent("legacy purge interval"); @@ -303,7 +322,7 @@ describe("NostrRelay lifecycle", () => { }); test("a repeated close waits for the active shutdown", async () => { - const { NostrRelay } = await import("../../src/utils/ephemeral-relay"); + const { NostrRelay } = await import("../../src/testing"); const relay = new NostrRelay(0); await relay.start(); @@ -319,7 +338,7 @@ describe("NostrRelay lifecycle", () => { }); test("close disconnects an active Relay before it resolves", async () => { - const { NostrRelay } = await import("../../src/utils/ephemeral-relay"); + const { NostrRelay } = await import("../../src/testing"); const relay = new NostrRelay(0); await relay.start(); const client = new Relay(relay.url, { @@ -344,7 +363,7 @@ describe("NostrRelay lifecycle", () => { }); test("shutdown does not accept a new Relay connection", async () => { - const { NostrRelay } = await import("../../src/utils/ephemeral-relay"); + const { NostrRelay } = await import("../../src/testing"); const relay = new NostrRelay(0); await relay.start(); const firstClient = new Relay(relay.url, { @@ -380,7 +399,7 @@ describe("NostrRelay lifecycle", () => { test("in-memory close waits for observable Relay cleanup", async () => { await withInMemoryTransport(async () => { - const { NostrRelay } = await import("../../src/utils/ephemeral-relay"); + const { NostrRelay } = await import("../../src/testing"); const relay = new NostrRelay(0); let disconnected = false; @@ -407,7 +426,7 @@ describe("NostrRelay lifecycle", () => { }); test("restart waits for shutdown and accepts a new Relay connection", async () => { - const { NostrRelay } = await import("../../src/utils/ephemeral-relay"); + const { NostrRelay } = await import("../../src/testing"); const { logger, info } = createDiagnosticLogger(); const relay = new NostrRelay(0, { logger }); await relay.start(); @@ -455,7 +474,7 @@ describe("NostrRelay lifecycle", () => { test("a purge interval does not keep an in-memory Relay process alive", async () => { const script = [ "globalThis.Bun = {};", - "const { NostrRelay } = require('./src/utils/ephemeral-relay');", + "const { NostrRelay } = require('./src/testing');", "new NostrRelay(0, { purgeInterval: 0.01 }).start();", ].join("\n"); const child = spawn( diff --git a/tests/utils/ephemeral-relay-session.test.ts b/tests/utils/ephemeral-relay-session.test.ts new file mode 100644 index 00000000..44392557 --- /dev/null +++ b/tests/utils/ephemeral-relay-session.test.ts @@ -0,0 +1,182 @@ +import { Relay } from "../../src/nip01/relay"; +import { createEvent, createSignedEvent } from "../../src/nip01/event"; +import { NostrEvent, RelayEvent } from "../../src/types/nostr"; +import { getRelaySocket, NostrRelay } from "../../src/testing"; +import { getPublicKey } from "../../src/utils/crypto"; + +const PRIVATE_KEY = "1".repeat(64); + +async function createRelayEvent(content: string): Promise { + return createSignedEvent( + createEvent( + { + kind: 1, + tags: [], + content, + created_at: Math.floor(Date.now() / 1000), + }, + getPublicKey(PRIVATE_KEY), + ), + PRIVATE_KEY, + ); +} + +function withTimeout(promise: Promise, message: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(message)), 1000); + timeout.unref?.(); + void promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (error) => { + clearTimeout(timeout); + reject(error); + }, + ); + }); +} + +async function waitFor(condition: () => boolean): Promise { + await new Promise((resolve, reject) => { + let pollTimer: NodeJS.Timeout | null = null; + let settled = false; + const timeout = setTimeout(() => { + settled = true; + if (pollTimer) clearTimeout(pollTimer); + reject(new Error("Timed out waiting for Relay session state")); + }, 1000); + timeout.unref?.(); + + const check = () => { + if (settled) return; + if (condition()) { + settled = true; + clearTimeout(timeout); + resolve(); + return; + } + pollTimer = setTimeout(check, 10); + }; + check(); + }); +} + +function sendAndReceiveNotice(relay: Relay, message: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Timed out waiting for Relay NOTICE")), + 1000, + ); + const handler = (_relayUrl: string, notice: string) => { + clearTimeout(timeout); + relay.off(RelayEvent.Notice, handler); + resolve(notice); + }; + relay.on(RelayEvent.Notice, handler); + + const socket = getRelaySocket(relay); + if (!socket) { + clearTimeout(timeout); + relay.off(RelayEvent.Notice, handler); + reject(new Error("Relay socket is not connected")); + return; + } + socket.send(message); + }); +} + +describe("ephemeral Relay client session", () => { + test("keeps malformed protocol handling compatible", async () => { + const server = new NostrRelay(0); + let client: Relay | null = null; + + try { + await server.start(); + client = new Relay(server.url, { + autoReconnect: false, + connectionTimeout: 1000, + }); + expect(await client.connect()).toBe(true); + + await expect( + sendAndReceiveNotice(client, JSON.stringify(["EVENT"])), + ).resolves.toBe("invalid: EVENT message missing params"); + await expect( + sendAndReceiveNotice(client, JSON.stringify(["CLOSE"])), + ).resolves.toBe("invalid: CLOSE message missing params"); + await expect(sendAndReceiveNotice(client, "not-json")).resolves.toBe( + "Unable to parse message", + ); + } finally { + client?.disconnect(); + await server.close(); + } + }); + + test("routes successful REQ, EOSE, EVENT, and CLOSE messages", async () => { + const server = new NostrRelay(0); + let publisher: Relay | null = null; + let subscriber: Relay | null = null; + + try { + await server.start(); + publisher = new Relay(server.url, { + autoReconnect: false, + connectionTimeout: 1000, + }); + subscriber = new Relay(server.url, { + autoReconnect: false, + connectionTimeout: 1000, + }); + expect(await publisher.connect()).toBe(true); + expect(await subscriber.connect()).toBe(true); + + let resolveEvent!: (event: NostrEvent) => void; + let resolveEose!: () => void; + const routedEvents: NostrEvent[] = []; + const receivedEvent = new Promise((resolve) => { + resolveEvent = resolve; + }); + const receivedEose = new Promise((resolve) => { + resolveEose = resolve; + }); + const subscriptionId = subscriber.subscribe( + [{ kinds: [1] }], + (event) => { + routedEvents.push(event); + resolveEvent(event); + }, + resolveEose, + ); + + await withTimeout(receivedEose, "Timed out waiting for EOSE"); + const event = await createRelayEvent("session owner happy path"); + await expect( + publisher.publish(event, { timeout: 1000 }), + ).resolves.toMatchObject({ success: true }); + await expect( + withTimeout(receivedEvent, "Timed out waiting for EVENT"), + ).resolves.toMatchObject({ id: event.id }); + + subscriber.unsubscribe(subscriptionId); + await waitFor(() => server.subs.size === 0); + + const eventAfterClose = await createRelayEvent( + "session owner after CLOSE", + ); + await expect( + publisher.publish(eventAfterClose, { timeout: 1000 }), + ).resolves.toMatchObject({ success: true }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(routedEvents.map((routedEvent) => routedEvent.id)).toEqual([ + event.id, + ]); + } finally { + publisher?.disconnect(); + subscriber?.disconnect(); + await server.close(); + } + }); +}); diff --git a/tests/utils/ephemeral-relay-transport.test.ts b/tests/utils/ephemeral-relay-transport.test.ts new file mode 100644 index 00000000..849988d6 --- /dev/null +++ b/tests/utils/ephemeral-relay-transport.test.ts @@ -0,0 +1,47 @@ +import type { DiagnosticLogger } from "../../src/utils/logger"; +import { createRelayTransport } from "../../src/utils/ephemeral-relay/transport"; + +const logger: DiagnosticLogger = { + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, + trace: () => {}, +}; + +describe("ephemeral Relay transport", () => { + test("releases its server when session shutdown rejects", async () => { + const globals = globalThis as typeof globalThis & { Bun?: unknown }; + const isBun = typeof globals.Bun !== "undefined"; + const hadBun = Object.prototype.hasOwnProperty.call(globals, "Bun"); + const previousBun = globals.Bun; + if (!isBun) globals.Bun = {}; + const transport = createRelayTransport({ + port: 0, + logger, + onConnection: () => {}, + }); + const shutdownError = new Error("session shutdown failed"); + + try { + await transport.start(); + + await expect( + transport.close( + async () => { + throw shutdownError; + }, + () => {}, + ), + ).rejects.toBe(shutdownError); + expect(() => transport.server).toThrow( + "websocket server not initialized", + ); + } finally { + if (!isBun) { + if (hadBun) globals.Bun = previousBun; + else delete globals.Bun; + } + } + }); +}); diff --git a/tests/utils/key-validation.test.ts b/tests/utils/key-validation.test.ts new file mode 100644 index 00000000..1577845e --- /dev/null +++ b/tests/utils/key-validation.test.ts @@ -0,0 +1,57 @@ +import { + isValidPrivateKey, + isValidPublicKeyFormat, + isValidPublicKeyPoint, +} from "../../src/utils/key-validation"; +import { + isHexOfLength, + isLowercaseHexOfLength, + utf8ByteLength, +} from "../../src/utils/wire-validation"; +import { generateKeypair } from "../../src/utils/crypto"; + +describe("canonical key validation", () => { + test("validates case-insensitive fixed-width wire hex", () => { + expect(isHexOfLength("aA", 2)).toBe(true); + expect(isHexOfLength("aa", 3)).toBe(false); + expect(isHexOfLength("ag", 2)).toBe(false); + expect(isHexOfLength(null, 2)).toBe(false); + }); + + test("validates lowercase-only NIP-01 wire hex", () => { + expect(isLowercaseHexOfLength("af", 2)).toBe(true); + expect(isLowercaseHexOfLength("aF", 2)).toBe(false); + expect(isLowercaseHexOfLength("af", 3)).toBe(false); + }); + + test("measures resource limits in UTF-8 bytes", () => { + expect(utf8ByteLength("nostr")).toBe(5); + expect(utf8ByteLength("⚡")).toBe(3); + expect(utf8ByteLength("😀")).toBe(4); + }); + + test("owns generic secp256k1 key format and curve validation", async () => { + const keypair = await generateKeypair(); + const fieldPrime = + "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"; + const curveOrder = + "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"; + const inRangeOffCurveX = "5".padStart(64, "0"); + + expect(isValidPrivateKey(keypair.privateKey)).toBe(true); + expect(isValidPublicKeyFormat(keypair.publicKey)).toBe(true); + expect(isValidPublicKeyPoint(keypair.publicKey)).toBe(true); + expect(isValidPublicKeyFormat(fieldPrime)).toBe(false); + expect(isValidPublicKeyFormat(inRangeOffCurveX)).toBe(true); + expect(isValidPublicKeyPoint(inRangeOffCurveX)).toBe(false); + expect(isValidPrivateKey(curveOrder)).toBe(false); + expect(isValidPrivateKey("0".repeat(64))).toBe(false); + expect(isValidPublicKeyFormat("0".repeat(64))).toBe(false); + expect(isValidPublicKeyPoint("f".repeat(64))).toBe(false); + expect(isValidPrivateKey("1".repeat(63))).toBe(false); + expect(isValidPrivateKey("g".repeat(64))).toBe(false); + expect(isValidPublicKeyFormat("1".repeat(65))).toBe(false); + expect(isValidPublicKeyFormat("z".repeat(64))).toBe(false); + expect(isValidPublicKeyFormat("A".repeat(64))).toBe(true); + }); +}); diff --git a/tests/utils/security-validator.test.ts b/tests/utils/security-validator.test.ts index aa2a3589..c1238819 100644 --- a/tests/utils/security-validator.test.ts +++ b/tests/utils/security-validator.test.ts @@ -4,6 +4,17 @@ import { secureRandomHex, validateArrayAccess, } from "../../src/utils/security-validator"; +import type { DiagnosticLogger } from "../../src/utils/logger"; + +function createLogger(): jest.Mocked { + return { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + trace: jest.fn(), + }; +} describe("security-validator pure utilities with no public behavior route", () => { describe("direct unreachable array guards", () => { @@ -37,14 +48,10 @@ describe("security-validator pure utilities with no public behavior route", () = }); describe("direct bounded-map eviction coverage (the Relay maps are private and cannot be populated deterministically)", () => { - let warnSpy: jest.SpyInstance; + let logger: jest.Mocked; beforeEach(() => { - warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); + logger = createLogger(); }); it("does nothing at the limit and evicts least-recently-used entries above it", () => { @@ -52,9 +59,9 @@ describe("security-validator pure utilities with no public behavior route", () = ["a", 1], ["b", 2], ]); - enforceMemoryLimits(atLimit, 2, undefined, "at-limit"); + enforceMemoryLimits(atLimit, 2, undefined, "at-limit", logger); expect([...atLimit.keys()]).toEqual(["a", "b"]); - expect(warnSpy).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); const overLimit = new Map([ ["a", 1], @@ -67,13 +74,16 @@ describe("security-validator pure utilities with no public behavior route", () = ["c", 2], ]); - enforceMemoryLimits(overLimit, 2, accessTimes, "test-map"); + enforceMemoryLimits(overLimit, 2, accessTimes, "test-map", logger); expect([...overLimit.keys()]).toEqual(["b", "c"]); expect([...accessTimes.keys()]).toEqual(["b", "c"]); - expect(warnSpy).toHaveBeenCalledWith( - "Security: Enforced memory limit for test-map, removed 1 entries (3 -> 2)", - ); + expect(logger.warn).toHaveBeenCalledWith("Enforced memory limit", { + finalSize: 2, + initialSize: 3, + removedCount: 1, + scope: "test-map", + }); }); it("falls back to FIFO when access metadata cannot reach the target", () => { @@ -84,13 +94,22 @@ describe("security-validator pure utilities with no public behavior route", () = ]); const incompleteAccessTimes = new Map([["missing", 0]]); - enforceMemoryLimits(values, 1, incompleteAccessTimes, "fallback-map"); + enforceMemoryLimits( + values, + 1, + incompleteAccessTimes, + "fallback-map", + logger, + ); expect([...values.keys()]).toEqual(["c"]); expect(incompleteAccessTimes.has("missing")).toBe(true); - expect(warnSpy).toHaveBeenCalledWith( - "Security: Enforced memory limit for fallback-map, removed 2 entries (3 -> 1)", - ); + expect(logger.warn).toHaveBeenCalledWith("Enforced memory limit", { + finalSize: 1, + initialSize: 3, + removedCount: 2, + scope: "fallback-map", + }); }); }); }); diff --git a/tests/utils/shared-diagnostics.test.ts b/tests/utils/shared-diagnostics.test.ts new file mode 100644 index 00000000..fd123711 --- /dev/null +++ b/tests/utils/shared-diagnostics.test.ts @@ -0,0 +1,323 @@ +import fs from "fs"; +import path from "path"; +import { + Nostr, + Relay, + RelayPool, + resetWebSocketImplementation, + useWebSocketImplementation, +} from "../../src"; +import type { NostrEvent } from "../../src"; +import type { DiagnosticLogger } from "../../src/utils/logger"; +import { diagnosticFailureType } from "../../src/utils/diagnostics"; +import { createRelayListEvent } from "../../src/nip65"; +import { fetchRelayInformation } from "../../src/nip11"; + +function createLogger(): jest.Mocked { + return { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + trace: jest.fn(), + }; +} + +function sourceFiles(root: string): string[] { + return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) return sourceFiles(entryPath); + return entry.name.endsWith(".ts") ? [entryPath] : []; + }); +} + +class ThrowingWebSocket { + constructor() { + throw new TypeError("socket construction failed"); + } +} + +class ControlledWebSocket { + static latest: ControlledWebSocket | undefined; + readyState = 0; + onopen: ((event: unknown) => void) | null = null; + onclose: ((event: unknown) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + + constructor() { + ControlledWebSocket.latest = this; + } + + send(): void {} + + close(): void { + this.readyState = 3; + this.onclose?.({}); + } + + open(): void { + this.readyState = 1; + this.onopen?.({}); + } + + receive(data: string): void { + this.onmessage?.({ data }); + } +} + +describe("shared production diagnostic seam", () => { + afterEach(() => { + ControlledWebSocket.latest = undefined; + resetWebSocketImplementation(); + jest.restoreAllMocks(); + }); + + test("keeps console ownership inside the canonical logger", () => { + const sourceRoot = path.resolve(__dirname, "../../src"); + const loggerImplementation = path.join(sourceRoot, "utils/logger.ts"); + const offenders = sourceFiles(sourceRoot) + .filter((filePath) => filePath !== loggerImplementation) + .filter((filePath) => + /\bconsole\.(?:log|info|warn|error|debug|trace)\b/.test( + fs.readFileSync(filePath, "utf8"), + ), + ) + .map((filePath) => path.relative(sourceRoot, filePath)); + + expect(offenders).toEqual([]); + }); + + test("routes Relay connection failures through an injected non-throwing logger", async () => { + const logger = createLogger(); + useWebSocketImplementation( + ThrowingWebSocket as unknown as typeof WebSocket, + ); + const relay = new Relay("wss://relay.example", { + autoReconnect: false, + logger, + }); + + await expect(relay.connect()).resolves.toBe(false); + expect(logger.error).toHaveBeenCalledWith("Connection failed", { + failureType: "TypeError", + relay: "wss://relay.example", + }); + + logger.error.mockImplementation(() => { + throw new Error("diagnostic sink unavailable"); + }); + await expect(relay.connect()).resolves.toBe(false); + }); + + test("does not forward unknown relay wire types into warning context", async () => { + const logger = createLogger(); + const secret = "secret-token"; + useWebSocketImplementation( + ControlledWebSocket as unknown as typeof WebSocket, + ); + const relay = new Relay("wss://relay.example", { + autoReconnect: false, + logger, + }); + + const connection = relay.connect(); + ControlledWebSocket.latest?.open(); + await expect(connection).resolves.toBe(true); + ControlledWebSocket.latest?.receive( + JSON.stringify([secret, "untrusted payload"]), + ); + + expect(logger.warn).toHaveBeenCalledWith("Unknown relay message type", { + itemCount: 1, + messageType: "unknown", + relay: "wss://relay.example", + }); + expect(JSON.stringify(logger.warn.mock.calls)).not.toContain(secret); + relay.disconnect(); + }); + + test("uses one injected policy for RelayPool and its child Relays", async () => { + const logger = createLogger(); + useWebSocketImplementation( + ThrowingWebSocket as unknown as typeof WebSocket, + ); + const pool = new RelayPool(["not a relay URL"], { logger }); + + expect(logger.warn).toHaveBeenCalledWith( + "Failed to add relay during pool construction", + expect.objectContaining({ failureType: expect.any(String) }), + ); + + const relay = pool.addRelay("wss://relay.example", { + autoReconnect: false, + }); + await expect(relay.connect()).resolves.toBe(false); + expect(logger.error).toHaveBeenCalledWith( + "Connection failed", + expect.objectContaining({ relay: "wss://relay.example" }), + ); + }); + + test("applies a replacement logger when RelayPool reconfigures an existing Relay", async () => { + const originalLogger = createLogger(); + const replacementLogger = createLogger(); + useWebSocketImplementation( + ThrowingWebSocket as unknown as typeof WebSocket, + ); + const pool = new RelayPool([], { logger: originalLogger }); + + pool.addRelay("wss://relay.example", { autoReconnect: false }); + const relay = pool.addRelay("wss://relay.example", { + logger: replacementLogger, + }); + await expect(relay.connect()).resolves.toBe(false); + + expect(replacementLogger.error).toHaveBeenCalledWith( + "Connection failed", + expect.objectContaining({ relay: "wss://relay.example" }), + ); + expect(originalLogger.error).not.toHaveBeenCalled(); + }); + + test("keeps Nostr public fallback behavior when its logger throws", async () => { + const logger = createLogger(); + logger.warn.mockImplementation(() => { + throw new Error("diagnostic sink unavailable"); + }); + const client = new Nostr([], { logger }); + const event = { + id: "event-id", + pubkey: "pubkey", + created_at: 1, + kind: 1, + tags: [], + content: "private content", + sig: "signature", + } as NostrEvent; + + await expect(client.publishEvent(event)).resolves.toEqual({ + success: false, + event: null, + relayResults: new Map(), + }); + expect(logger.warn).toHaveBeenCalledWith( + "No relays configured for publishing", + { + eventId: "event-id", + eventKind: 1, + operation: "publishEvent", + }, + ); + }); + + test("configures stateless warning and error diagnostics without leaking inputs", async () => { + const logger = createLogger(); + const secret = "secret-token"; + const warn = jest + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const error = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); + + const relayList = createRelayListEvent( + [{ url: `wss://user:${secret}@relay.example`, read: true, write: true }], + "", + logger, + ); + await expect( + fetchRelayInformation("not a websocket URL", { logger }), + ).resolves.toBeNull(); + + expect(relayList.tags).toEqual([]); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledTimes(1); + expect( + JSON.stringify([logger.warn.mock.calls, logger.error.mock.calls]), + ).not.toContain(secret); + expect(warn).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + test("keeps default stateless warnings visible through ConsoleLogger", () => { + const warn = jest + .spyOn(console, "warn") + .mockImplementation(() => undefined); + + createRelayListEvent([{ url: "", read: true, write: true }]); + + expect(warn).toHaveBeenCalledTimes(1); + }); + + test("bounds Error names before using them as failure metadata", () => { + const safeError = new Error("not forwarded"); + safeError.name = "NostrValidationError"; + const unsafeError = new Error("not forwarded"); + unsafeError.name = "secret-token"; + const overlongError = new Error("not forwarded"); + overlongError.name = `${"A".repeat(64)}Error`; + const throwingNameError = new Error("not forwarded"); + Object.defineProperty(throwingNameError, "name", { + get() { + throw new Error("name getter unavailable"); + }, + }); + + expect(diagnosticFailureType(safeError)).toBe("NostrValidationError"); + expect(diagnosticFailureType(unsafeError)).toBe("Error"); + expect(diagnosticFailureType(overlongError)).toBe("Error"); + expect(() => diagnosticFailureType(throwingNameError)).not.toThrow(); + expect(diagnosticFailureType(throwingNameError)).toBe("Error"); + }); + + test("propagates default parent logger policies to child Relays", async () => { + const error = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); + useWebSocketImplementation( + ThrowingWebSocket as unknown as typeof WebSocket, + ); + + const pool = new RelayPool(); + const pooledRelay = pool.addRelay("wss://pool.example", { + autoReconnect: false, + }); + await expect(pooledRelay.connect()).resolves.toBe(false); + expect(error).toHaveBeenCalledWith( + expect.stringContaining("[RelayPool] Connection failed"), + expect.any(Object), + ); + + error.mockClear(); + const client = new Nostr(["wss://nostr.example"], { + relayOptions: { autoReconnect: false }, + }); + await client.connectToRelays(); + expect(error).not.toHaveBeenCalled(); + }); + + test("keeps default Relay and RelayPool failures visible through ConsoleLogger", async () => { + const secret = "secret-token"; + const warn = jest + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const error = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); + useWebSocketImplementation( + ThrowingWebSocket as unknown as typeof WebSocket, + ); + + new RelayPool(["not a relay URL"]); + const relay = new Relay( + `wss://user:${secret}@relay.example/private?token=${secret}`, + { autoReconnect: false }, + ); + await expect(relay.connect()).resolves.toBe(false); + + expect(warn).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledTimes(1); + expect(JSON.stringify(error.mock.calls)).not.toContain(secret); + }); +}); diff --git a/tests/utils/test-helpers.ts b/tests/utils/test-helpers.ts new file mode 100644 index 00000000..37280bf4 --- /dev/null +++ b/tests/utils/test-helpers.ts @@ -0,0 +1,15 @@ +import { NostrRelay } from "../../src/testing"; + +let relay: NostrRelay | null = null; + +export async function startEphemeralRelay(port = 0): Promise { + relay = new NostrRelay(port); + await relay.start(); + return relay.url; +} + +export async function stopEphemeralRelay(): Promise { + const activeRelay = relay; + relay = null; + await activeRelay?.close(); +}