From 9977eb972c428c53c3e1a65349219ea04c93ffcf Mon Sep 17 00:00:00 2001 From: ebjune Date: Fri, 30 Oct 2026 21:02:37 -0700 Subject: [PATCH] =?UTF-8?q?release:=200.2.0=20=E2=80=94=20=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 41 +- SECURITY.md | 4 +- package-lock.json | 10 +- package.json | 2 +- packages/app-api/README.md | 28 ++ packages/app-api/package.json | 4 +- packages/app-api/src/client/scope.ts | 14 +- .../app-api/src/domains/bootstrap-config.ts | 165 +++++++ packages/app-api/src/domains/connectivity.ts | 168 ++++++- .../app-api/src/domains/logging-config.ts | 105 +++++ .../src/domains/relationships-graph.ts | 55 ++- packages/app-api/src/domains/relationships.ts | 13 +- packages/app-api/src/dto/connectivity.ts | 79 +++- packages/app-api/src/dto/index.ts | 4 + packages/app-api/src/index.ts | 15 + packages/app-api/src/runtime/base-agent.ts | 333 +++++++++++--- packages/app-api/src/runtime/handle.ts | 14 + .../src/runtime/modules/connectivity.ts | 433 +++++++++++------- .../app-api/src/runtime/modules/tsp-bridge.ts | 70 ++- packages/app-api/src/runtime/scid-vh-agent.ts | 16 +- packages/app-api/src/runtime/types.ts | 22 +- packages/app-api/test/agent/agent.test.ts | 2 +- .../agent/dial-ladder-containment.test.ts | 208 +++++++++ .../agent/tsp-bridge-pending-retry.test.ts | 219 +++++++++ .../credentials-issue-verify.test.ts | 27 +- .../test/trust_app_api/domains.test.ts | 7 +- .../app-api/test/trust_app_api/graph.test.ts | 52 ++- .../app-api/test/trust_app_api/scope.test.ts | 3 +- .../test/trust_app_api/surface.test.ts | 24 +- packages/core/README.md | 31 +- packages/core/package.json | 2 +- packages/core/src/config/bootstrap-env.ts | 118 ++++- packages/core/src/config/logging-env.ts | 194 ++++++++ packages/core/src/platform/p2pts-home.ts | 37 ++ .../core/src/relationship/ceremony-trace.ts | 36 +- packages/core/src/relationship/manager.ts | 48 +- .../core/src/transport/bootstrap-client.ts | 62 ++- .../core/src/transport/dial-candidates.ts | 386 ++++++++++++++++ .../core/src/transport/libp2p-transport.ts | 50 +- packages/core/src/transport/relay-health.ts | 31 ++ packages/core/src/util/logger.ts | 45 +- .../core/test/config/bootstrap-env.test.ts | 54 +++ .../core/test/config/fixtures/logging.env | 11 + packages/core/test/config/logging-env.test.ts | 200 ++++++++ .../core/test/relationship/manager.test.ts | 110 +++++ .../test/transport/dial-candidates.test.ts | 240 ++++++++++ .../libp2p-transport-dispatch.test.ts | 175 +++++++ .../core/test/transport/relay-health.test.ts | 41 ++ 48 files changed, 3657 insertions(+), 351 deletions(-) create mode 100644 packages/app-api/src/domains/bootstrap-config.ts create mode 100644 packages/app-api/src/domains/logging-config.ts create mode 100644 packages/app-api/test/agent/dial-ladder-containment.test.ts create mode 100644 packages/app-api/test/agent/tsp-bridge-pending-retry.test.ts create mode 100644 packages/core/src/config/logging-env.ts create mode 100644 packages/core/src/transport/dial-candidates.ts create mode 100644 packages/core/test/config/fixtures/logging.env create mode 100644 packages/core/test/config/logging-env.test.ts create mode 100644 packages/core/test/transport/dial-candidates.test.ts create mode 100644 packages/core/test/transport/libp2p-transport-dispatch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a98705..115bfaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2026-10-30 + ### Added - Leveled, dependency-free logger (`@p2p-trust-stack/core/util/logger`) — every log line the stack emits now routes through one gate, controlled by the `P2PTS_LOG_LEVEL` environment variable (`silent` | `error` | `warn` | `info` | `debug`) or programmatically with `setLogLevel()` / `setLogSink()`. The default is `warn`, so the packages are quiet unless a host asks for more. -- Initial public monorepo scaffold: two published packages - (`@p2p-trust-stack/core` and `@p2p-trust-stack/app-api`), dual-license - (GPL-3.0 + commercial), `exports` maps, build config, and governance docs. +- Logging from a config file (`@p2p-trust-stack/core/config/logging-env`) — an app can + keep its verbosity in a `.env` (`P2PTS_LOG_LEVEL`, `CEREMONY_TRACE`) instead of an + environment variable, because `P2PTS_LOG_LEVEL=info node …` is POSIX-shell syntax that + cannot live in a `package.json` script on Windows and does not exist where a native host + launches Node (Capacitor). Resolution order is `setLogLevel()` > config file > + `P2PTS_LOG_LEVEL` > `warn`, and a missing or malformed file is never fatal. + Entry points: `applyLoggingEnv()` / `getEffectiveLogging()`. +- `setCeremonyTrace()` / `isCeremonyTraceEnabled()` (`core/relationship/ceremony-trace`) — + the `[CEREMONY]` OOBI→RFI→RFA trace is now switchable programmatically and from a config + file, not only via the `CEREMONY_TRACE` environment variable. +- `createLoggingConfigApi()` (`@p2p-trust-stack/app-api`) — the `logging-config` domain, so + every front end reads and applies the same `.env` logging block identically. +- Bootstrap/relay configuration domain (`@p2p-trust-stack/app-api` + `domains/bootstrap-config`) — typed access to the bootstrap/relay `.env` for front ends, + including the selectable-VTN role-split `.env`. +- Ordered dial-candidate ladder (`@p2p-trust-stack/core/transport/dial-candidates`) and a + hardened relay-health check (`core/transport/relay-health`), so connections survive + relay-only and NAT'd paths. + +### Changed +- `explainFailure()` now distinguishes a `connection` failure (a join or connect — nothing + was kept locally, and a retry may be running) from a `mutation` (accepted locally and + queued for the server), so it no longer tells a user their "change has been kept locally" + after a failed connection. + +### Fixed +- Agents on separate routers behind NAT that share a `192.168.1.x` subnet are no longer + treated as if they were on the same LAN. +- Transport: `bootstrap-client` and `libp2p-transport` no longer stall or crash on slow or + interrupted server responses. ## [0.1.0] - TBD @@ -27,6 +56,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `@p2p-trust-stack/app-api` — initial public release of the agent application API (`createAgentStack`, the `TrustStack` domains, the closed event catalogue, the `Host` seam, the durable stores and the DTOs). +- Initial public monorepo scaffold: two published packages + (`@p2p-trust-stack/core` and `@p2p-trust-stack/app-api`), dual-license + (GPL-3.0 + commercial), `exports` maps, build config, and governance docs. -[Unreleased]: https://github.com/TrustStackers/p2p-trust-stack/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/TrustStackers/p2p-trust-stack/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/TrustStackers/p2p-trust-stack/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/TrustStackers/p2p-trust-stack/releases/tag/v0.1.0 diff --git a/SECURITY.md b/SECURITY.md index 41fd8e2..ed3fffb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,8 +35,8 @@ appropriate public disclosure timeline. | Version | Supported | |---|---| -| 0.1.x (latest) | ✅ | -| < 0.1.0 | ❌ | +| 0.2.x (latest) | ✅ | +| < 0.2.0 | ❌ | ## Cryptographic notice diff --git a/package-lock.json b/package-lock.json index fab8a1c..3e0681d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "p2p-trust-stack", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "p2p-trust-stack", - "version": "0.1.0", + "version": "0.2.0", "license": "(GPL-3.0-only OR LicenseRef-TrustStackers-Commercial)", "workspaces": [ "packages/*" @@ -1782,7 +1782,7 @@ }, "packages/app-api": { "name": "@p2p-trust-stack/app-api", - "version": "0.1.0", + "version": "0.2.0", "license": "(GPL-3.0-only OR LicenseRef-TrustStackers-Commercial)", "dependencies": { "@chainsafe/libp2p-noise": "^17.0.0", @@ -1795,7 +1795,7 @@ "@libp2p/tcp": "^11.0.23", "@libp2p/websockets": "^10.1.16", "@multiformats/multiaddr": "^13.0.3", - "@p2p-trust-stack/core": "^0.1.0", + "@p2p-trust-stack/core": "0.2.0", "libp2p": "^3.3.5" }, "devDependencies": { @@ -1808,7 +1808,7 @@ }, "packages/core": { "name": "@p2p-trust-stack/core", - "version": "0.1.0", + "version": "0.2.0", "license": "(GPL-3.0-only OR LicenseRef-TrustStackers-Commercial)", "dependencies": { "@hpke/chacha20poly1305": "^1.8.0", diff --git a/package.json b/package.json index 7e7a4db..772ed16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "p2p-trust-stack", - "version": "0.1.0", + "version": "0.2.0", "description": "P2P Trust Stack — a libp2p-based Trust Spanning Protocol (TSP) stack: decentralized DID creation and rotation, an encrypted SQLite keystore, relationships/OOBI, Verified Trust Communities and verifiable credentials.", "private": true, "type": "module", diff --git a/packages/app-api/README.md b/packages/app-api/README.md index 6280877..c605881 100644 --- a/packages/app-api/README.md +++ b/packages/app-api/README.md @@ -103,6 +103,34 @@ setLogLevel('silent'); setLogSink((level, args) => myLogger[level](...args)); // route instead of suppress ``` +### Or from a config file, if a script cannot set a variable + +`P2PTS_LOG_LEVEL=info node …` is POSIX-shell syntax: it cannot go in a `package.json` +script that must also run on Windows, and there is no npm script at all where a native +host launches Node (Capacitor). `createLoggingConfigApi` keeps the setting in the app's +`.env` instead — the same file the bootstrap block lives in: + +```ts +import { createLoggingConfigApi } from '@p2p-trust-stack/app-api'; + +const logging = createLoggingConfigApi({ envPath: '/etc/my-app/.env' }); + +logging.apply(); // reads the .env and applies it; cannot throw +logging.view(); // { level, ceremonyTrace, source: 'env' | 'default' } +``` + +``` +# .env +P2PTS_LOG_LEVEL=info +CEREMONY_TRACE=1 +``` + +The resolved order is **`setLogLevel()` > the app's `.env` > `P2PTS_LOG_LEVEL` > the +default**, so an explicit call always wins and the environment variable keeps working. +A missing or malformed `.env` is never fatal. `CEREMONY_TRACE=1` is independent of the +level — it emits the `[CEREMONY]` OOBI→RFI→RFA trace lines and is not silenced by +`silent`, nor switched on by `debug`. + ## License Dual-licensed under **GPL-3.0-only** or a commercial license — see the repository's diff --git a/packages/app-api/package.json b/packages/app-api/package.json index 22c9daf..b0a7dd5 100644 --- a/packages/app-api/package.json +++ b/packages/app-api/package.json @@ -1,6 +1,6 @@ { "name": "@p2p-trust-stack/app-api", - "version": "0.1.0", + "version": "0.2.0", "description": "P2P Trust Stack app API — the agent runtime, host seam, DTOs and domain interfaces that applications program against.", "type": "module", "license": "(GPL-3.0-only OR LicenseRef-TrustStackers-Commercial)", @@ -62,7 +62,7 @@ "agent" ], "dependencies": { - "@p2p-trust-stack/core": "^0.1.0", + "@p2p-trust-stack/core": "0.2.0", "@chainsafe/libp2p-noise": "^17.0.0", "@chainsafe/libp2p-yamux": "^8.0.1", "@libp2p/circuit-relay-v2": "^4.2.8", diff --git a/packages/app-api/src/client/scope.ts b/packages/app-api/src/client/scope.ts index 66d8995..d0cd8b2 100644 --- a/packages/app-api/src/client/scope.ts +++ b/packages/app-api/src/client/scope.ts @@ -67,7 +67,11 @@ export type ScopedDomain = */ const READ_METHODS: Readonly> = { identity: ['current', 'recoveryState', 'lookup'], - connectivity: ['health', 'ready', 'status', 'utilityStatus', 'networkProfile', 'networkChanged', 'explainFailure'], + connectivity: [ + 'health', 'ready', 'status', 'utilityStatus', 'networkProfile', 'networkChanged', 'explainFailure', + // Observes the bootstrap peer directory — it changes nothing on the node. + 'vtns', + ], relationships: ['oobi', 'list', 'get', 'ceremonyStatus', 'vrc', 'dtgVrc', 'graph', 'debug', 'duplicateNotice'], messaging: ['transcript', 'latest', 'unread'], profiles: ['list', 'claims', 'card', 'syncStatus'], @@ -130,9 +134,13 @@ export const DENIED_METHODS: Readonly> = ], connectivity: [ // Host operations on the transport: re-running bootstrap discovery, dialing the utility server and pushing - // this identity to it. A context may OBSERVE connectivity (`health`/`ready`/`status`/`utilityStatus`) but - // must not rewire the node it shares with the host. + // this identity to it. A context may OBSERVE connectivity (`health`/`ready`/`status`/`utilityStatus`/`vtns`) + // but must not rewire the node it shares with the host. 'refresh', 'connectUtility', 'pushIdentity', + // `connectVtn` is the same host operation with the VTN key adopted alongside it: it changes the `net` this + // agent registers under AND dials a utility server. Joining a network is the operator's choice at login, not + // something a scoped app context may do to the shared node. + 'connectVtn', ], relationships: [], messaging: [], diff --git a/packages/app-api/src/domains/bootstrap-config.ts b/packages/app-api/src/domains/bootstrap-config.ts new file mode 100644 index 0000000..7b5cdcd --- /dev/null +++ b/packages/app-api/src/domains/bootstrap-config.ts @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-TrustStackers-Commercial +// Copyright (C) 2026 Trust Stackers + +/** + * `bootstrap-config` domain — where the bootstrap server address comes from. + * + * ## Why this is a domain and not app code + * + * The bootstrap address is the one piece of configuration BOTH front ends (TUI and + * web UI) must read and write identically, and it has a real precedence chain plus + * a persistence rule. Leaving it in `agent-app/` would duplicate that chain in two + * composition roots and re-implement it for the next front end. + * + * ## The chain + * + * `P2PTS_HOME/config.yaml` (a value the user typed) → the component's `.env` + * → nothing (LAN-only mode) + * + * `.env` is the machine default; `config.yaml` records a deliberate override. The + * override is NOT written back into `.env`: that file is the component's + * configured shape, and rewriting it would silently retarget every agent on the + * machine instead of the one the user was looking at. + * + * ## A client holds no seed + * + * The agent app and the utility server are CLIENTS. They need the address and the + * peer ID to expect, and nothing else — only the bootstrap server derives its peer + * ID from a word sequence. `loadBootstrapClientEnv()` enforces that: a `.env` that + * still carries `BOOTSTRAP_SEED_HEX` is refused rather than ignored. + */ + +import { loadBootstrapClientEnv, type BootstrapClientEnv } from '@p2p-trust-stack/core/config/bootstrap-env.js'; +import { + loadP2ptsConfig, + saveP2ptsConfig, + resolveP2ptsHome, +} from '@p2p-trust-stack/core/platform/p2pts-home.js'; +import type { BootstrapConfigView } from '../dto/connectivity.js'; + +export interface BootstrapConfigApi { + /** + * Values for a startup screen's bootstrap boxes, plus WHERE they came from. + * + * Never throws: an unconfigured machine must still render its login screen with + * empty, editable boxes rather than an error. + */ + view(): BootstrapConfigView; + /** + * The bootstrap server this component should dial, or `null` for LAN-only mode. + * + * `null` is a legitimate state, not a failure — the agent still starts, it just + * cannot discover peers. + */ + resolve(): BootstrapClientEnv | null; + /** + * Record a bootstrap override the user typed, but ONLY when it differs from this + * component's `.env`. Accepting the default records nothing. + */ + recordOverride(bootstrapHost?: string, bootstrapPeerId?: string): void; + /** + * The VTN key this machine last joined, or `undefined`. + * + * Deliberately `undefined` rather than defaulted: an agent that has not chosen a + * VTN sends no `net` at all, instead of claiming another network's key. + */ + lastVtn(): string | undefined; + /** Remember the VTN the user joined, so the next launch registers under it. */ + recordVtn(vtnKey: string): void; +} + +export interface BootstrapConfigDeps { + /** Absolute path to this component's `.env` (the `init:bootstrap` block). */ + envPath: string; + /** P2PTS_HOME override. Defaults to `resolveP2ptsHome()`. */ + home?: string; + /** Notified whenever the persisted config is rewritten (for logging). */ + onPersist?: (message: string) => void; +} + +/** Build the `bootstrap-config` domain. */ +export function createBootstrapConfigApi(deps: BootstrapConfigDeps): BootstrapConfigApi { + const home = (): string => deps.home ?? resolveP2ptsHome(); + + /** Read the component's `.env`, or `null` when it is absent/unusable. */ + function readEnv(): BootstrapClientEnv | null { + try { + return loadBootstrapClientEnv({ path: deps.envPath }); + } catch { + // Absent, malformed, or still carrying a seed. None of these is fatal here: + // the caller reports `source: 'none'` and the user can type the values. + return null; + } + } + + /** The persisted override, or the `.env` value, or nothing. */ + function resolveTarget(): { host: string; peerId: string; env: BootstrapClientEnv | null } { + const cfg = loadP2ptsConfig(home()); + const env = readEnv(); + return { + host: cfg.bootstrap_host.trim() || env?.host || '', + peerId: cfg.bootstrap_peer_id.trim() || env?.peerId || '', + env, + }; + } + + return { + view(): BootstrapConfigView { + const { host, peerId, env } = resolveTarget(); + const cfg = loadP2ptsConfig(home()); + const fromConfig = Boolean(cfg.bootstrap_host.trim() && cfg.bootstrap_peer_id.trim()); + return { + host, + peerId, + tcpPort: env?.tcpPort ?? 4001, + wsPort: env?.wsPort ?? 4002, + source: fromConfig ? 'config' : host && peerId ? 'env' : 'none', + }; + }, + + resolve(): BootstrapClientEnv | null { + const { host, peerId, env } = resolveTarget(); + if (!host || !peerId) return null; + return { + version: 1, + host, + peerId, + tcpPort: env?.tcpPort ?? 4001, + wsPort: env?.wsPort ?? 4002, + enableRelay: env?.enableRelay ?? true, + }; + }, + + recordOverride(bootstrapHost?: string, bootstrapPeerId?: string): void { + const host = bootstrapHost?.trim() ?? ''; + const peerId = bootstrapPeerId?.trim() ?? ''; + if (!host && !peerId) return; // nothing typed — keep whatever is recorded + + const env = readEnv(); + // Accepted the default: there is nothing to record, and recording it would + // freeze today's `.env` against a future change to it. + if (env && host === env.host && peerId === env.peerId) return; + + const cfg = loadP2ptsConfig(home()); + cfg.bootstrap_host = host; + cfg.bootstrap_peer_id = peerId; + saveP2ptsConfig(cfg, home()); + deps.onPersist?.( + `bootstrap override ${host} (peer ${peerId.slice(0, 12)}...) recorded in config.yaml`, + ); + }, + + lastVtn(): string | undefined { + return loadP2ptsConfig(home()).vtn.trim() || undefined; + }, + + recordVtn(vtnKey: string): void { + const cfg = loadP2ptsConfig(home()); + if (cfg.vtn === vtnKey) return; + cfg.vtn = vtnKey; + saveP2ptsConfig(cfg, home()); + deps.onPersist?.(`VTN "${vtnKey}" recorded in config.yaml`); + }, + }; +} + diff --git a/packages/app-api/src/domains/connectivity.ts b/packages/app-api/src/domains/connectivity.ts index 98f6fb9..593e52d 100644 --- a/packages/app-api/src/domains/connectivity.ts +++ b/packages/app-api/src/domains/connectivity.ts @@ -11,7 +11,17 @@ */ import { requireAgent, type DomainContext } from './context.js'; import { UtilityServerUnavailableError } from '../errors.js'; -import { describeConnectivityFailure } from '@p2p-trust-stack/core/transport/relay-health.js'; +import { + describeConnectivityFailure, + type ConnectivityFailureKind, +} from '@p2p-trust-stack/core/transport/relay-health.js'; +import { + buildDialCandidates, + dialCandidateAddrs, +} from '@p2p-trust-stack/core/transport/dial-candidates.js'; +import { getLocalLanIps } from '@p2p-trust-stack/core/platform/network-detect.js'; +import { encodeOobi } from '@p2p-trust-stack/core/relationship/oobi.js'; +import type { DiscoveredPeer } from '@p2p-trust-stack/core/transport/bootstrap-client.js'; import type { BootstrapRefreshDto, BootstrapStatusDto, @@ -21,6 +31,7 @@ import type { PushIdentityDto, ReadyDto, UtilityServerStatusDto, + VtnOptionDto, } from '../dto/connectivity.js'; export interface ConnectivityApi { @@ -36,6 +47,23 @@ export interface ConnectivityApi { connectUtility(utilityServerOobi: string): Promise<{ ok: true; connected: true }>; /** Utility-server link status. */ utilityStatus(): Promise; + /** + * The VTNs this agent could join, from the bootstrap peer directory. + * + * One entry per VTN key. Empty when no utility server is discovered, or when + * the discovered ones have not bound a namespace yet — a server with no key + * could not be matched against, so it is not offered. + */ + vtns(): Promise; + /** + * Join a VTN: adopt its key (so this agent is identified by that network) and + * dial the utility server that serves it. Emits `connection.changed`. + * + * Rejects with `UtilityServerUnavailableError` when no discovered server serves + * that VTN, or when the handshake fails — the caller shows the standard + * "utility server unreachable" message. + */ + connectVtn(vtnKey: string): Promise<{ ok: true; connected: true; vtnKey: string }>; /** Push this identity's public state to the utility server. Emits `sync.completed` on success. */ pushIdentity(): Promise; /** The running node's network profile, when known. */ @@ -60,8 +88,81 @@ export interface ConnectivityApi { * Never throws. It is called on an ERROR path, so a status read that itself fails must not replace the * caller's 503 with a 500; the generic wording is the fallback (this mirrors `announceConnectionChanged`, * which also stays quiet rather than fabricating a status). + * + * `kind` selects the closing sentence: `'mutation'` (the default) promises the change is queued locally, + * which is true for a write; `'connection'` drops that promise, because a failed JOIN has nothing queued + * and the user needs to know to retry. */ - explainFailure(operation: string): Promise; + explainFailure(operation: string, kind?: ConnectivityFailureKind): Promise; +} + +/** How good a candidate a discovered utility server is, for picking one per VTN. */ +function vtnCandidateRank(option: VtnOptionDto): number { + // `reachableViaRelay` outranks `reachable`: a relay reservation is a fact the + // bootstrap verified against its own relay, whereas `reachable` can be true on + // the strength of a NAT-mapped address nothing listens on (see + // `VtnOptionDto.reachableScope`). With several servers serving one key, the one + // we can actually reach from another network should win. + return ( + (option.connected ? 4 : 0) + + (option.reachableViaRelay ? 2 : 0) + + (option.reachable ? 1 : 0) + ); +} + +/** + * Collapse the discovered `utility_server` peers into the VTN list a user picks + * from. + * + * ONE ENTRY PER VTN KEY, not per peer: several utility servers can serve the same + * network, and the user is choosing a NETWORK to join. When more than one server + * serves a key, the most reachable one wins (connected > reachable > otherwise) — + * that is the peer most likely to complete the TSP handshake. + * + * A server is skipped when it has no key yet (it registered before the setup + * wizard bound a namespace) or no DID (the strict 4-item OOBI cannot be built + * without one). Both would be undialable, so offering them would only produce a + * failure the user cannot act on. + * + * Exported for unit testing — it is pure, so it needs no running agent. + */ +export function groupVtnOptions(peers: readonly DiscoveredPeer[]): VtnOptionDto[] { + const byKey = new Map(); + + for (const peer of peers) { + if (peer.peerType !== 'utility_server') continue; + const vtnKey = (peer.net ?? '').trim(); + if (!vtnKey) continue; + if (!peer.did) continue; + + const candidate: VtnOptionDto = { + vtnKey, + ...(peer.vtnName ? { vtnName: peer.vtnName } : {}), + peerId: peer.peerId, + did: peer.did, + // `vid` is the permanent CESR Slot-A key (survives a did:scid:vh log roll); + // `peerId` is only a fallback for a server that registered without a VID. + vid: peer.vid || peer.peerId, + multiaddrs: peer.multiaddrs, + // Carried through so the JOIN can build a relay fallback. The server's own + // advertised `multiaddrs` never contain a `/p2p-circuit` address — + // `buildDialableMultiaddrs()` filters them out — so without this field the + // join had no relay to fall back to and a cross-network join could not + // succeed. See `VtnOptionDto.relayAddr`. + ...(peer.relayAddr ? { relayAddr: peer.relayAddr } : {}), + ...(peer.reachableViaRelay != null ? { reachableViaRelay: peer.reachableViaRelay } : {}), + ...(peer.reachableScope ? { reachableScope: peer.reachableScope } : {}), + reachable: peer.reachable, + connected: peer.connected, + }; + + const existing = byKey.get(vtnKey); + if (!existing || vtnCandidateRank(candidate) > vtnCandidateRank(existing)) { + byKey.set(vtnKey, candidate); + } + } + + return [...byKey.values()].sort((a, b) => a.vtnKey.localeCompare(b.vtnKey)); } /** Build the `connectivity` domain over a stack context. */ @@ -155,6 +256,65 @@ export function createConnectivityApi(ctx: DomainContext): ConnectivityApi { return { ok: true, connected: true }; }, + async vtns(): Promise { + const agent = requireAgent(ctx, 'list the VTNs available to join'); + return groupVtnOptions(agent.getDiscoveredPeers?.() ?? []); + }, + + async connectVtn(vtnKey: string): Promise<{ ok: true; connected: true; vtnKey: string }> { + const agent = requireAgent(ctx, 'connect to a VTN'); + const wanted = vtnKey?.trim(); + if (!wanted) throw new Error('vtnKey is required'); + + const option = groupVtnOptions(agent.getDiscoveredPeers?.() ?? []) + .find((v) => v.vtnKey === wanted); + // No discovered server serves this VTN. That is the SAME user-visible + // condition as an unreachable server (the network cannot be joined right + // now), so it reports the standard error rather than inventing a new one. + if (!option) throw new UtilityServerUnavailableError(`join the VTN "${wanted}"`); + if (!agent.configureUtilityServer) { + throw new UtilityServerUnavailableError(`join the VTN "${wanted}"`); + } + + // Adopt the key BEFORE the handshake: the agent must be identified by the + // network it is joining, or the bootstrap directory keeps showing it under + // whatever VTN it registered with at start() (which is none, on first run). + if (agent.setNet) await agent.setNet(wanted); + + // The strict 4-item OOBI needs the server's real DID plus a VID (or its + // peer ID as a fallback) — the same construction the auto-connect used. + // + // The `multiaddrs` slot carries the FULL ORDERED CANDIDATE LIST, not just + // the server's advertised addresses. That list includes the relay address + // (per-peer from the bootstrap, plus one derived from the server relay + // prefix), which the server never advertises itself. Without it a first + // cross-network join had nothing to dial: the advertised LAN address is + // unroutable and the advertised NAT-mapped address has nothing listening on + // it, so the handshake failed with "the relay connection to the utility + // server could not be re-established" and never recovered. + // + // Ordering matters as much as membership — see dial-candidates.ts. libp2p's + // default address sorter would try the NAT-mapped address first and the + // relay last; the same policy is installed on the node as `addressSorter`, + // so the dialer walks this list in this order. + const candidates = buildDialCandidates({ + peerId: option.peerId, + multiaddrs: option.multiaddrs, + peerRelayAddr: option.relayAddr, + serverRelayPrefix: agent.getBootstrapRelayAddr?.(), + localLanIps: getLocalLanIps(), + }); + const oobi = encodeOobi({ + cesrVid: option.vid, + peerId: option.peerId, + multiaddrs: dialCandidateAddrs(candidates), + did: option.did, + }); + await agent.configureUtilityServer({ utilityServerOobi: oobi }); + announceConnectionChanged(); + return { ok: true, connected: true, vtnKey: wanted }; + }, + async utilityStatus(): Promise { const agent = requireAgent(ctx, 'read utility-server status'); const did = agent.getUtilityServerDid ? agent.getUtilityServerDid() : null; @@ -201,12 +361,12 @@ export function createConnectivityApi(ctx: DomainContext): ConnectivityApi { return agent.isNetworkChanged(); }, - async explainFailure(operation: string): Promise { + async explainFailure(operation: string, kind?: ConnectivityFailureKind): Promise { const generic = `Utility server unavailable. Cannot ${operation} right now.`; // `getAgent()`, not `requireAgent()`: no active agent IS one of the conditions this explains, so throwing // here would replace the caller's own error with ours. const live = ctx.getAgent()?.getConnectivityStatus?.(); - return live ? describeConnectivityFailure(live, operation) : generic; + return live ? describeConnectivityFailure(live, operation, kind) : generic; }, }; } diff --git a/packages/app-api/src/domains/logging-config.ts b/packages/app-api/src/domains/logging-config.ts new file mode 100644 index 0000000..7c8a170 --- /dev/null +++ b/packages/app-api/src/domains/logging-config.ts @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-TrustStackers-Commercial +// Copyright (C) 2026 Trust Stackers + +/** + * `logging-config` domain — where a front end's log verbosity comes from. + * + * ## Why this is a domain and not app code + * + * The agent app has TWO composition roots (the TUI and the web server) and they must + * report the same thing about logging: the level in effect, whether `[CEREMONY]` + * tracing is on, and whether that came from the component's config or from a default. + * Leaving it in `agent-app/` would duplicate the wiring in both, and re-implement it + * for the next front end — the same argument `bootstrap-config.ts` makes for itself. + * + * ## What it wraps + * + * `@p2p-trust-stack/core/config/logging-env` owns the behaviour: it reads + * `P2PTS_LOG_LEVEL` + `CEREMONY_TRACE` out of this component's `.env` and applies them, + * so verbosity travels with the component instead of living in a `package.json` script + * (which cannot assign a variable on Windows, and does not exist at all in the + * Capacitor mobile build, where a native host launches Node). See that module for the + * precedence chain: `setLogLevel() > .env > P2PTS_LOG_LEVEL > warn`. + * + * A `.env` is the SAME file the bootstrap block lives in — one config file per + * component, not two. + * + * ## Never fatal + * + * `apply()` cannot throw. A missing, unreadable or malformed `.env` leaves logging at + * whatever the environment and defaults say, because logging must never be the reason an + * app fails to start. + */ + +import { + applyLoggingEnv, + getEffectiveLogging, +} from '@p2p-trust-stack/core/config/logging-env.js'; +import type { EnvSource } from '@p2p-trust-stack/core/config/bootstrap-env.js'; +import type { LogLevel } from '@p2p-trust-stack/core/util/logger.js'; + +/** What logging is doing, and where the setting came from. */ +export interface LoggingConfigView { + /** The level a line must reach to be emitted. */ + level: LogLevel; + /** Whether `[CEREMONY]` tracing lines are being emitted. */ + ceremonyTrace: boolean; + /** + * `'env'` when the component's config named a level, `'default'` when logging is + * running on the environment variable or the built-in default. + */ + source: 'env' | 'default'; +} + +export interface LoggingConfigApi { + /** + * Read this component's config and apply it, then report what is in effect. + * Safe to call once at startup; calling it again re-reads the file. + */ + apply(): LoggingConfigView; + /** What is in effect now, without re-reading the file. */ + view(): LoggingConfigView; +} + +export interface LoggingConfigDeps { + /** Absolute path to this component's `.env` (the `init:bootstrap` block). */ + envPath: string; + /** + * Override the config source. Supply this where there is no `.env` on disk — + * a Capacitor/browser build, or a test. Defaults to reading `envPath`. + */ + source?: EnvSource; + /** Notified when the config named a setting (for a startup log line). */ + onApply?: (message: string) => void; +} + +/** Build the `logging-config` domain. */ +export function createLoggingConfigApi(deps: LoggingConfigDeps): LoggingConfigApi { + /** Where the level in effect came from, as of the last `apply()`. */ + let source: LoggingConfigView['source'] = 'default'; + + function view(): LoggingConfigView { + const effective = getEffectiveLogging(); + return { level: effective.level, ceremonyTrace: effective.ceremonyTrace, source }; + } + + return { + apply(): LoggingConfigView { + const env = applyLoggingEnv( + deps.source ? { source: deps.source } : { path: deps.envPath }, + ); + source = env.level ? 'env' : 'default'; + + const effective = view(); + deps.onApply?.( + source === 'env' + ? `logging level "${effective.level}" from .env` + + (effective.ceremonyTrace ? ', ceremony trace on' : '') + : `logging at the default level "${effective.level}" (no logging block in .env)`, + ); + return effective; + }, + + view, + }; +} diff --git a/packages/app-api/src/domains/relationships-graph.ts b/packages/app-api/src/domains/relationships-graph.ts index 2d6ef72..23e3372 100644 --- a/packages/app-api/src/domains/relationships-graph.ts +++ b/packages/app-api/src/domains/relationships-graph.ts @@ -143,6 +143,24 @@ function resolveLocalUuid(canonicalize: Map, uuid: string): stri return canonicalize.get(uuid) ?? uuid; } +/** + * Append the VTN key to a display label: `Utility Server` → `Utility Server (p2pts)`. + * + * The key is the VTN this agent joined (`config.yaml` → `vtn:`), the identifier + * agents match on and the one the bootstrap dashboard renders as + * `Utl Server (p2pts)`. This mirrors the utility server's own graph so both + * views name the same network. + * + * Idempotent: a label equal to the key, or already carrying `(key)`, is left + * alone — a VTN whose configured name IS the key must not read `p2pts (p2pts)`. + */ +function withVtnKey(label: string, vtnKey: string | null | undefined): string { + const key = (vtnKey ?? '').trim(); + if (!key || !label) return label; + if (label === key || label.endsWith(`(${key})`)) return label; + return `${label} (${key})`; +} + /** * Build the ephemeral-`did:peer:4` → real-VTA uuid map, plus per-identity display metadata. * @@ -228,10 +246,12 @@ async function buildLocalCanonicalizationMap(db: DataSource): Promise<{ * @param db the active agent's database. * @param selfDid the active agent's DID — its node is always present, even with zero edges. * @param selfName the active agent's name, used as its own node label. + * @param vtnKey the VTN this agent joined (`config.yaml` → `vtn:`), appended to the + * utility-server/VTN node labels. Omitted when no network was chosen. */ export async function buildGraph( db: DataSource, - opts: { selfDid: string; selfName: string | null }, + opts: { selfDid: string; selfName: string | null; vtnKey?: string }, ): Promise { const { canonicalize, metaByUuid } = await buildLocalCanonicalizationMap(db); const selfUuid = await resolveIdentityUuid(db, opts.selfDid); @@ -377,20 +397,29 @@ export async function buildGraph( const isSelf = uuid === selfUuid; const subjectKind = meta?.subjectKind ?? 'human'; const role = meta?.role ?? 'vta'; + const baseLabel = isSelf + ? (opts.selfName || 'Me') + : friendlyLabel( + { + name: meta?.name ?? null, + registeredName: meta?.registeredName ?? null, + namespace: meta?.namespace ?? null, + subjectKind, + vid: meta?.vid ?? null, + }, + uuid, + ); + // The node that IS the utility server appears in two shapes: the VTN this + // agent joined (`role='vtn'`) and the server's own organization VTA + // (`role='vta'`, `subject_kind='organization'`). Both carry the chosen VTN + // key so the graph names the NETWORK (`utility-server (p2pts)`), matching + // the bootstrap dashboard and the utility server's own graph. Self is + // excluded — the agent's own node label is its name, never a network key. + const isUtilityServerNode = + !isSelf && (role === 'vtn' || (role === 'vta' && subjectKind === 'organization')); nodes.push({ id: uuid, - label: isSelf - ? (opts.selfName || 'Me') - : friendlyLabel( - { - name: meta?.name ?? null, - registeredName: meta?.registeredName ?? null, - namespace: meta?.namespace ?? null, - subjectKind, - vid: meta?.vid ?? null, - }, - uuid, - ), + label: isUtilityServerNode ? withVtnKey(baseLabel, opts.vtnKey) : baseLabel, role, subjectKind, isSelf, diff --git a/packages/app-api/src/domains/relationships.ts b/packages/app-api/src/domains/relationships.ts index 791f859..f1100ee 100644 --- a/packages/app-api/src/domains/relationships.ts +++ b/packages/app-api/src/domains/relationships.ts @@ -19,6 +19,7 @@ import { requireAgent, type DomainContext } from './context.js'; import { StackError } from '../errors.js'; import { resolvePeerAgentName } from '../runtime/peer-name.js'; import { SqliteDataSource } from '@p2p-trust-stack/core/db/sqlite-data-source.js'; +import { loadP2ptsConfig } from '@p2p-trust-stack/core/platform/p2pts-home.js'; import * as identitiesRepo from '@p2p-trust-stack/core/db/repositories/identities-repo.js'; import { generatePersonhoodChallenge } from '@p2p-trust-stack/core/services/verified-channel.js'; import { getDtgRelationshipCredentials, isDtgVerifiedChannel } from '@p2p-trust-stack/core/services/verified-channel.js'; @@ -456,7 +457,17 @@ export function createRelationshipsApi(ctx: DomainContext): RelationshipsApi { const agent = requireAgent(ctx, 'read the relationship graph'); const ds = ctx.host.storageFor(agent.getName()) as SqliteDataSource; try { - return await buildGraph(ds, { selfDid: agent.getDid(), selfName: agent.getName() }); + // The VTN this agent joined — `config.yaml`'s `vtn:` key, which is what + // `bootstrapConfig.recordVtn()` writes when the user picks a network and + // what the bootstrap registers. `buildGraph` appends it to the + // utility-server/VTN node labels so the graph names the network it is + // connected to. Absent when no VTN was chosen: the labels stay plain. + const vtnKey = loadP2ptsConfig(ctx.host.home).vtn.trim() || undefined; + return await buildGraph(ds, { + selfDid: agent.getDid(), + selfName: agent.getName(), + vtnKey, + }); } finally { await ds.close(); } diff --git a/packages/app-api/src/dto/connectivity.ts b/packages/app-api/src/dto/connectivity.ts index 0f5bf28..01e3d1b 100644 --- a/packages/app-api/src/dto/connectivity.ts +++ b/packages/app-api/src/dto/connectivity.ts @@ -8,6 +8,7 @@ * purpose-built (not table rows) are aliased with a note rather than re-declared. */ import type { ConnectivityStatus } from '@p2p-trust-stack/core/transport/relay-health.js'; +import type { ConnectivityFailureKind } from '@p2p-trust-stack/core/transport/relay-health.js'; import type { DiscoveredPeer } from '@p2p-trust-stack/core/transport/bootstrap-client.js'; import type { NetworkProfile } from '@p2p-trust-stack/core/platform/network-detect.js'; @@ -88,6 +89,60 @@ export interface UtilityServerStatusDto { vid: string | null; } +/** + * A VTN an agent could join, as advertised by a utility server in the bootstrap + * peer directory. + * + * One entry per VTN, not per server: several utility servers can serve the same + * network, and the user is choosing a NETWORK. `vtnKey` is what the agent matches + * on and registers as its own `net`. + */ +export interface VtnOptionDto { + /** The VTN key (namespace) — e.g. 'p2pts'. Agents match on this. */ + vtnKey: string; + /** The VTN's human-readable name (e.g. 'Trust Stackers'), when published. */ + vtnName?: string; + /** The utility server's peer ID — what the agent dials. */ + peerId: string; + /** The server's real DID — required for the strict 4-item OOBI. */ + did: string; + /** The server's CESR-encoded Slot-A VID (peer ID as a fallback). */ + vid: string; + /** The server's advertised dial addresses. */ + multiaddrs: string[]; + /** + * The per-peer relay address the bootstrap built for this server + * (`/p2p/`), when the bootstrap serves a relay. + * + * This is the ONLY address that can work when the agent is on a different + * network from the server, and it is NOT part of `multiaddrs` — the server's + * own `buildDialableMultiaddrs()` filters `/p2p-circuit` out of what it + * advertises. Dropping it here is what made a first cross-network join + * impossible: the join OOBI carried LAN/NAT addresses only. + */ + relayAddr?: string; + /** + * The bootstrap reports a live circuit-relay reservation for this server, i.e. + * a relayed HOP aimed at it will be accepted. Authoritative, unlike + * `reachable` (see `reachableScope`). + */ + reachableViaRelay?: boolean; + /** + * Reachability scope the bootstrap derived from the server's addresses + * (`wan|dns|relay|lan|loopback|unknown`). + * + * Read this instead of `reachable` when the distinction matters: the bootstrap + * records the address it OBSERVED the server connect from, which for a NAT'd + * server is a mapped public IP:ephemeral-port that nothing listens on — yet it + * classifies as `wan`, so `reachable` is true while only the relay works. + */ + reachableScope?: string; + /** The bootstrap reports the server as currently reachable. */ + reachable: boolean; + /** The bootstrap reports a live connection to the server. */ + connected: boolean; +} + /** Result of pushing this identity's public state to the utility server. */ export interface PushIdentityDto { ok: boolean; @@ -118,4 +173,26 @@ export interface ConnectionEventDto { timestamp: number; } -export type { ConnectivityStatus, DiscoveredPeer, NetworkProfile }; +export type { ConnectivityStatus, ConnectivityFailureKind, DiscoveredPeer, NetworkProfile }; + +/** + * The bootstrap client config a startup screen pre-fills its boxes with. + * + * A CLIENT view: host + peer ID, no seed — only the bootstrap server holds one. + */ +export interface BootstrapConfigView { + host: string; + peerId: string; + tcpPort: number; + wsPort: number; + /** Where the values came from — the persisted override, the `.env`, or nothing. */ + source: 'config' | 'env' | 'none'; +} + +/** + * The bootstrap client config an agent dials — host + peer ID, no seed. + * + * ALIAS: the core loader's own type, so a caller that needs to name it (e.g. to + * build an `AgentConfig`) does not have to reach into the core package. + */ +export type { BootstrapClientEnv } from '@p2p-trust-stack/core/config/bootstrap-env.js'; diff --git a/packages/app-api/src/dto/index.ts b/packages/app-api/src/dto/index.ts index 0cc8834..a159901 100644 --- a/packages/app-api/src/dto/index.ts +++ b/packages/app-api/src/dto/index.ts @@ -24,14 +24,18 @@ export type { HealthDto, ReadyDto, ConnectivityStatusDto, + ConnectivityFailureKind, DiscoveredPeerDto, NetworkProfileDto, BootstrapStatusDto, BootstrapRefreshDto, UtilityServerStatusDto, + VtnOptionDto, PushIdentityDto, OobiDto, ConnectionEventDto, + BootstrapConfigView, + BootstrapClientEnv, } from './connectivity.js'; export type { RegisterResultDto, ResolvedNameDto, NameEntryDto } from './names.js'; diff --git a/packages/app-api/src/index.ts b/packages/app-api/src/index.ts index df598be..2469258 100644 --- a/packages/app-api/src/index.ts +++ b/packages/app-api/src/index.ts @@ -71,6 +71,17 @@ export type { ActivateIdentityOpts, } from './domains/identity.js'; export type { ConnectivityApi } from './domains/connectivity.js'; +export { createBootstrapConfigApi } from './domains/bootstrap-config.js'; +export type { + BootstrapConfigApi, + BootstrapConfigDeps, +} from './domains/bootstrap-config.js'; +export { createLoggingConfigApi } from './domains/logging-config.js'; +export type { + LoggingConfigApi, + LoggingConfigDeps, + LoggingConfigView, +} from './domains/logging-config.js'; export type { CredentialsApi, CredentialsDeps, CredentialViewFilters } from './domains/credentials.js'; export type { CommunitiesApi, CommunitiesDeps } from './domains/communities.js'; export { CommunityNotFoundError } from './domains/communities.js'; @@ -140,14 +151,18 @@ export type { HealthDto, ReadyDto, ConnectivityStatusDto, + ConnectivityFailureKind, DiscoveredPeerDto, NetworkProfileDto, BootstrapStatusDto, BootstrapRefreshDto, UtilityServerStatusDto, + VtnOptionDto, PushIdentityDto, OobiDto, ConnectionEventDto, + BootstrapConfigView, + BootstrapClientEnv, DelegationDto, DelegationRelationshipType, CreateDelegationInput, diff --git a/packages/app-api/src/runtime/base-agent.ts b/packages/app-api/src/runtime/base-agent.ts index d58aded..8f8a712 100644 --- a/packages/app-api/src/runtime/base-agent.ts +++ b/packages/app-api/src/runtime/base-agent.ts @@ -43,6 +43,10 @@ import { Libp2pTransport } from '@p2p-trust-stack/core/transport/index.js'; import type { TspTransport } from '@p2p-trust-stack/core/transport/types.js'; import { AGENT_PORT_RANGE, AGENT_PORT_RANGE_LABEL } from '@p2p-trust-stack/core/transport/port-range.js'; import type { DiscoveredPeer } from '@p2p-trust-stack/core/transport/bootstrap-client.js'; +import { + buildBootstrapUrls, + type BootstrapClientEnv, +} from '@p2p-trust-stack/core/config/bootstrap-env.js'; import { RelationshipStore } from '@p2p-trust-stack/core/relationship/store.js'; import { RelationshipManager } from '@p2p-trust-stack/core/relationship/manager.js'; import { encodeOobi, decodeOobi, signOobi } from '@p2p-trust-stack/core/relationship/oobi.js'; @@ -118,6 +122,8 @@ import { TspClientModule } from './modules/tsp-client.js'; import { TspBridge } from './modules/tsp-bridge.js'; import { RecoveryModule } from './modules/recovery.js'; import type { NetworkProfile } from '@p2p-trust-stack/core/platform/network-detect.js'; +import { getLocalLanIps } from '@p2p-trust-stack/core/platform/network-detect.js'; +import { dialCandidateAddrs, makeAddressSorter } from '@p2p-trust-stack/core/transport/dial-candidates.js'; import { deriveConnectivityStatus, describeConnectivityFailure, @@ -233,8 +239,21 @@ export abstract class BaseAgent implements AgentHandle { protected readonly port: number; protected readonly registry: AgentRegistry; protected readonly bootstrapUrl: string | undefined; - /** Network/group (namespace) this agent joins, e.g. 'p2pts'. */ - protected readonly net: string; + /** + * The bootstrap server this agent dials — host + peer ID (no seed; the agent is + * a client). Supplied by the composition root; `undefined` means "use this + * component's `.env`", and if that has nothing either the agent runs in + * LAN-only mode. + */ + protected readonly bootstrapEnv?: BootstrapClientEnv; + /** + * The VTN key (namespace) this agent is using, e.g. 'p2pts'. + * + * `undefined` until a VTN is chosen — there is deliberately NO default, so an + * agent that has not picked one sends no `net` rather than claiming another + * network's key. Set through `setNet()`, which re-registers. + */ + protected net: string | undefined; protected identity!: LocalIdentity; protected meta!: AgentMeta; @@ -253,6 +272,14 @@ export abstract class BaseAgent implements AgentHandle { /** Shared database — opened in start(), closed in stop(). */ protected db?: AgentDatabase; + /** + * A cached `DataSource` view over `db`'s file (see the `ds` getter). + * + * Held so the getter does not open a NEW connection on every access — which is what + * it used to do, leaking one handle per call. Released in `stop()`. + */ + private dbView?: SqliteDataSource; + // ── Modules (Phase 3-5) ─────────────────────────────────────────────────── protected connectivityModule = new ConnectivityModule(); protected vtcApiModule = new VtcApiModule(); @@ -280,6 +307,16 @@ export abstract class BaseAgent implements AgentHandle { /** Backoff step counter for reconnects. Reset on success; never terminal. */ private serverReconnectAttempt = 0; private serverReconnectTimer?: ReturnType; + /** + * True while `reconnectToUtilityServer()` is actually running. + * + * `serverReconnectTimer` is cleared the instant the timer FIRES, so it is not + * a truthful "a reconnect is happening" signal for the whole attempt (which can + * take tens of seconds of dialing). Without this flag the connectivity layer + * flapped back to `unreachable` mid-attempt and told the user the change "could + * not be re-established" while it was still being re-established. + */ + private serverReconnectInFlight = false; /** True while a relay-grant refresh (pre-emptive re-dial) is running. */ private relayRefreshInFlight = false; /** Re-entrancy guard so ticks cannot overlap. */ @@ -347,11 +384,17 @@ export abstract class BaseAgent implements AgentHandle { this.passphrase = config.passphrase; this.port = config.port ?? 0; this.registry = registry; - this.bootstrapUrl = config.bootstrapUrl; - // Default the network/group until the first-run setup wizard supplies the - // VTN the agent is joining. 'p2pts' matches the namespace used during - // utility-server setup. - this.net = config.net ?? 'p2pts'; + this.bootstrapEnv = config.bootstrapEnv; + // Record WHERE this agent registers, for AgentMeta. An explicit + // `bootstrapEnv` (the startup screen's boxes) is the real target, so derive + // the URL from it; `bootstrapUrl` stays for a caller that supplies the string + // directly. + this.bootstrapUrl = config.bootstrapUrl + ?? (config.bootstrapEnv ? buildBootstrapUrls(config.bootstrapEnv).tcpUrl : undefined); + // The VTN key the agent identifies itself by. There is NO default: an agent + // that has not chosen a VTN yet sends no `net` at all, rather than claiming + // another network's key. The login screen's VTN picker sets it via setNet(). + this.net = config.net; this.didMethod = config.didMethod ?? this.createDefaultDidMethod(); } @@ -442,22 +485,28 @@ export abstract class BaseAgent implements AgentHandle { // 3. Store keys in the private keystore database (separate DB file). const keystore = new SqliteKeyStore(this.registry.getKeystorePath(this.name)); - await keystore.storeKeyPairAsync(vid, 'ed25519', crypto.signingKeyPair, this.passphrase); - await keystore.storeKeyPairAsync(vid, 'x25519', crypto.agreementKeyPair, this.passphrase); - // Post-quantum roots of trust (H4): the DID document ALREADY publishes these - // as #key-mldsa / #key-kem, so storing them is what makes the agent's runtime - // identity match the identity it advertises. Without this the pair existed in - // the document and nowhere else: `loadIdentity()` logged "no ML-DSA-65 key" - // for a brand-new agent, OOBI introductions could not be signed, and - // confidential traffic could never use the hybrid KEM. - if (crypto.mlDsa65KeyPair) { - await keystore.storeKeyPairAsync(vid, 'ml_dsa_65', crypto.mlDsa65KeyPair, this.passphrase); - } - if (crypto.hybridKemKeyPair) { - await keystore.storeKeyPairAsync(vid, 'mlkem768_x25519', crypto.hybridKemKeyPair, this.passphrase); + // `finally`: a failed write used to leave the keystore handle open, and on Windows an + // open handle makes the agent's directory undeletable — so a create that threw could + // not be cleaned up. `loadIdentity()` carries the same guard for the same reason. + try { + await keystore.storeKeyPairAsync(vid, 'ed25519', crypto.signingKeyPair, this.passphrase); + await keystore.storeKeyPairAsync(vid, 'x25519', crypto.agreementKeyPair, this.passphrase); + // Post-quantum roots of trust (H4): the DID document ALREADY publishes these + // as #key-mldsa / #key-kem, so storing them is what makes the agent's runtime + // identity match the identity it advertises. Without this the pair existed in + // the document and nowhere else: `loadIdentity()` logged "no ML-DSA-65 key" + // for a brand-new agent, OOBI introductions could not be signed, and + // confidential traffic could never use the hybrid KEM. + if (crypto.mlDsa65KeyPair) { + await keystore.storeKeyPairAsync(vid, 'ml_dsa_65', crypto.mlDsa65KeyPair, this.passphrase); + } + if (crypto.hybridKemKeyPair) { + await keystore.storeKeyPairAsync(vid, 'mlkem768_x25519', crypto.hybridKemKeyPair, this.passphrase); + } + await this.storeMethodSpecificKeys(keystore, crypto); + } finally { + keystore.close(); } - await this.storeMethodSpecificKeys(keystore, crypto); - keystore.close(); // Phase 2: Push public identity to utility server via TSP for distributed storage. // Private keys NEVER leave the device — only the DID, DID document, and @@ -478,40 +527,47 @@ export abstract class BaseAgent implements AgentHandle { */ protected async loadIdentity(): Promise { const keystore = new SqliteKeyStore(this.registry.getKeystorePath(this.name)); - const signingKeyPair = await keystore.retrieveKeyPairAsync(this.meta.vid, 'ed25519', this.passphrase); - const agreementKeyPair = await keystore.retrieveKeyPairAsync(this.meta.vid, 'x25519', this.passphrase); - - // Post-quantum roots of trust. ABSENT for identities created before PQ - // provisioning: the keystore simply has no such key, which is reported (not an - // error) — such an identity can still form relationships and exchange - // confidential traffic classically, it just cannot ML-DSA-sign its rotations. - const mlDsa65KeyPair = await this.tryLoadKeyPair(keystore, this.meta.vid, 'ml_dsa_65'); - const hybridKemKeyPair = await this.tryLoadKeyPair(keystore, this.meta.vid, 'mlkem768_x25519'); - if (!mlDsa65KeyPair) { - logger.warn( - `[Agent] identity ${this.meta.vid.slice(0, 12)}... has no ML-DSA-65 key — ` + - `PQ root-of-trust signing is unavailable for it (no OOBI introduction signature, ` + - `no rotation signature, classical-only confidential traffic). Either the identity was ` + - `created before PQ keys were persisted, or it was provisioned through a path that did ` + - `not store them; re-create it to provision one.`, - ); - } - - const didDocument = this.didMethod.resolve(this.meta.did); - didDocument.id = this.meta.did; + // `finally`, not a close at the end: a WRONG PASSPHRASE throws on the very first read + // below, so the handle used to be left open on exactly the path a user hits most — and + // on Windows an open handle makes the agent's directory undeletable (`EBUSY`), which is + // what failed `npm test` on the wrong-passphrase case. + try { + const signingKeyPair = await keystore.retrieveKeyPairAsync(this.meta.vid, 'ed25519', this.passphrase); + const agreementKeyPair = await keystore.retrieveKeyPairAsync(this.meta.vid, 'x25519', this.passphrase); + + // Post-quantum roots of trust. ABSENT for identities created before PQ + // provisioning: the keystore simply has no such key, which is reported (not an + // error) — such an identity can still form relationships and exchange + // confidential traffic classically, it just cannot ML-DSA-sign its rotations. + const mlDsa65KeyPair = await this.tryLoadKeyPair(keystore, this.meta.vid, 'ml_dsa_65'); + const hybridKemKeyPair = await this.tryLoadKeyPair(keystore, this.meta.vid, 'mlkem768_x25519'); + if (!mlDsa65KeyPair) { + logger.warn( + `[Agent] identity ${this.meta.vid.slice(0, 12)}... has no ML-DSA-65 key — ` + + `PQ root-of-trust signing is unavailable for it (no OOBI introduction signature, ` + + `no rotation signature, classical-only confidential traffic). Either the identity was ` + + `created before PQ keys were persisted, or it was provisioned through a path that did ` + + `not store them; re-create it to provision one.`, + ); + } - this.identity = { - cesrVid: this.meta.vid, - did: this.meta.did, - didDocument, - signingKeyPair, - agreementKeyPair, - mlDsa65KeyPair, - hybridKemKeyPair, - }; + const didDocument = this.didMethod.resolve(this.meta.did); + didDocument.id = this.meta.did; + + this.identity = { + cesrVid: this.meta.vid, + did: this.meta.did, + didDocument, + signingKeyPair, + agreementKeyPair, + mlDsa65KeyPair, + hybridKemKeyPair, + }; - await this.loadMethodSpecificState(keystore); - keystore.close(); + await this.loadMethodSpecificState(keystore); + } finally { + keystore.close(); + } } /** @@ -1005,9 +1061,13 @@ export abstract class BaseAgent implements AgentHandle { multiaddrs: this.getMultiaddrs(), onStatus, // Bootstrap-facing designation: the configured user name (available from - // first contact, before name registration) and the network/group. + // first contact, before name registration) and the VTN key in use. agentName: this.name, net: this.net, + // The bootstrap server this agent dials (host + peer ID) — from the startup + // screen / `P2PTS_HOME/config.yaml`. Absent means "use this component's + // `.env`", and if that has nothing either, LAN-only mode. + bootstrapEnv: this.bootstrapEnv, // Flag this peer as a TEST peer when a test runner is driving us (Node's // `--test` sets NODE_TEST_CONTEXT) or when explicitly opted in via // P2PTS_TEST_PEER=1. Test agents are killed without a clean disconnect, so @@ -1207,6 +1267,7 @@ export abstract class BaseAgent implements AgentHandle { this.serverReconnectTimer = setTimeout(() => { this.serverReconnectTimer = undefined; + this.serverReconnectInFlight = true; this.reconnectToUtilityServer() .then(() => { this.serverReconnectAttempt = 0; @@ -1219,6 +1280,9 @@ export abstract class BaseAgent implements AgentHandle { ); // Keep trying forever with a capped backoff. this.scheduleServerReconnect(); + }) + .finally(() => { + this.serverReconnectInFlight = false; }); }, delayMs); } @@ -1269,10 +1333,21 @@ export abstract class BaseAgent implements AgentHandle { `to ${us.peerId.slice(0, 12)}... (${us.multiaddrs.length} addr(s))`, ); + // Same ordered candidate list the explicit join and the recovery ladder use. + // It adds the relay address(es), which `us.multiaddrs` never contains — the + // utility server filters `/p2p-circuit` out of what it advertises. Building + // this OOBI from `us.multiaddrs` alone is what made this recovery path fail + // in exactly the same way as the first join: identical LAN-only address list, + // identical dead end. + const dialCandidates = this.connectivityModule.dialCandidatesFor({ + peerId: us.peerId, + multiaddrs: us.multiaddrs, + peerRelayAddr: us.relayAddr, + }); const oobi = encodeOobi({ cesrVid, peerId: us.peerId, - multiaddrs: us.multiaddrs, + multiaddrs: dialCandidateAddrs(dialCandidates), did: us.did!, }); await this.configureUtilityServer({ utilityServerOobi: oobi }); @@ -1380,13 +1455,23 @@ export abstract class BaseAgent implements AgentHandle { await this.refreshExpiringRelayConnections(); // ── 3. Utility-server connection ────────────────────────────────── - if (this.tspBridge.getUtilityServerDid() && !this.tspBridge.isConnected()) { - // Only when no reconnect is already pending — the peer:disconnect - // handler owns the fast path; this is the safety net. - if (!this.serverReconnectTimer) { - logger.info('[Agent] supervisor: utility-server bridge is down — reconnecting'); - this.scheduleServerReconnect(); - } + // Arm whenever a server has been CONFIGURED — not only when it is + // configured AND down. + // + // WHY: `tspBridge.getUtilityServerDid()` is only set once the TSP + // handshake has SUCCEEDED. A first join that failed (the cross-network + // case: LAN address unroutable, NAT address dead, relay never tried) left + // it null, so this branch was a permanent no-op and the agent sat in + // `layer: 'unreachable'` forever while the UI promised "it will be sent + // automatically once the connection is back" — a promise nothing was + // working to keep. `utilityServerOobi` is set by + // `configureUtilityServer()`, so it is the right "the user asked for this + // server" signal; the reconnect then walks + // `establishUtilityServerFromDiscovery()` → the ordered ladder. + const serverConfigured = !!this.tspBridge.getUtilityServerDid() || !!this.utilityServerOobi; + if (serverConfigured && !this.tspBridge.isConnected() && !this.serverReconnectTimer) { + logger.info('[Agent] supervisor: utility-server bridge is down — reconnecting'); + this.scheduleServerReconnect(); } } catch (err) { this.lastConnectivityError = (err as Error)?.message ?? String(err); @@ -1503,7 +1588,8 @@ export abstract class BaseAgent implements AgentHandle { this.tspBridge.isConnected() && !!this.tspBridge.getUtilityServerDid(), relay, hasRelayReservation: relay.hasReservation || this.connectivityModule.hasRelayReservation(), - reconnecting: this.connectivityTickInFlight || !!this.serverReconnectTimer, + reconnecting: + this.connectivityTickInFlight || this.serverReconnectInFlight || !!this.serverReconnectTimer, consecutiveFailures: this.serverReconnectAttempt, lastError: this.lastConnectivityError, }); @@ -1530,6 +1616,34 @@ export abstract class BaseAgent implements AgentHandle { return this.connectivityModule.getDiscoveredPeers(); } + /** The VTN key this agent identifies itself by (undefined until one is chosen). */ + getNet(): string | undefined { + return this.net; + } + + /** + * Adopt a VTN: record the key and re-register with the bootstrap so this agent + * is identified by the network it is actually using. + * + * Called from the login flow once the user has picked a VTN from the discovered + * list. A no-op when the key is unchanged, so accepting the already-current VTN + * does not churn the bootstrap connection. + */ + async setNet(net: string, onStatus?: (message: string) => void): Promise { + const next = net.trim(); + if (!next) throw new Error('Agent.setNet: a VTN key is required'); + if (this.net === next) return; + if (!this.libp2pNode) { + throw new Error('Agent.setNet: agent not started. Call start() first.'); + } + await this.connectivityModule.setNet(next, { + libp2pNode: this.libp2pNode, + multiaddrs: this.getMultiaddrs(), + onStatus, + }); + this.net = next; + } + async reRegisterWithBootstrap(onStatus?: (message: string) => void): Promise { if (!this.libp2pNode) { throw new Error('Agent.reRegisterWithBootstrap: agent not started. Call start() first.'); @@ -1674,6 +1788,17 @@ export abstract class BaseAgent implements AgentHandle { maxParallelDials: 32, dialTimeout: 10_000, addressDialTimeout: 6_000, + // Order every dial libp2p performs ON ITS OWN — i.e. + // `Libp2pTransport.send()`'s `dialProtocol(peerId, …)`, which passes + // no explicit address list and therefore relies on the peer store. + // libp2p's default sorter ranks a NAT-mapped public IP ahead of the + // LAN address and pushes the relay LAST, so a cross-network dial + // spent its whole budget on addresses nothing listens on (measured: + // [NAT-ip, relay, LAN] for a typical discovered utility server). + // Same policy as ConnectivityModule.dialCandidatesFor() — one + // implementation, so the explicit ladder and libp2p's own dialer can + // never disagree. + addressSorter: makeAddressSorter(getLocalLanIps()), }, services: { identify: identify(), @@ -1736,6 +1861,12 @@ export abstract class BaseAgent implements AgentHandle { this.db.close(); this.db = undefined; } + // Release the cached view over the same file. Both connections must go, or Windows + // refuses to delete the agent's directory (`EBUSY`) while this one is still open. + if (this.dbView) { + await this.dbView.close(); + this.dbView = undefined; + } this.store = undefined; this.manager = undefined; @@ -3469,9 +3600,27 @@ export abstract class BaseAgent implements AgentHandle { return row.uuid; } + /** + * A `DataSource` over this agent's own database, for the services that take one + * (`profiles`, the credential factory, the relationship store's app-DB paths). + * + * **Cached.** This used to be `SqliteDataSource.fromExisting(...)` on every access, + * which opened a NEW connection per call and closed none of them — the agent leaked a + * file handle for every profile read, claim write and credential-factory call, and + * several of those happen during `start()`. That is invisible on POSIX, where an open + * file can still be unlinked, but on Windows it makes the database undeletable: an + * agent's directory (or a test's temp home) fails to remove with `EBUSY`/`EPERM` + * while any handle is still open. One agent owns ONE database, so it gets one handle, + * released by `stop()`. + * + * Still guarded on `db`: the throw is what tells a caller the agent is not started. + */ private get ds(): SqliteDataSource { if (!this.db) throw new Error('Agent not started'); - return SqliteDataSource.fromExisting(this.registry.getDbPath(this.name)); + if (!this.dbView) { + this.dbView = SqliteDataSource.fromExisting(this.registry.getDbPath(this.name)); + } + return this.dbView; } async createProfile(input: { label: string; purpose?: string; visibility?: string }): Promise { @@ -4072,6 +4221,20 @@ export abstract class BaseAgent implements AgentHandle { this.utilityServerOobi = params.utilityServerOobi; + // Best-effort pre-dial through the SAME ordered ladder recovery uses, BEFORE + // the TSP handshake. Two reasons: + // + // 1. It establishes a cached connection that the RFI below then REUSES, + // instead of leaving the handshake to libp2p's dial budget (which will + // abandon a peer after `dialTimeout` even with addresses left untried). + // 2. It logs which address actually worked. That is the difference between + // "the relay is down" and "the server holds no reservation" in a bug + // report, and it was previously invisible. + // + // Never fatal: `tspBridge.connect()` below owns the real error, and a + // pre-dial that fails for an unrelated reason must not mask it. + await this.preDialConfiguredUtilityServer(); + // Wire the TSP bridge through the RelationshipManager this.tspBridge.init({ manager: this.manager, @@ -4120,6 +4283,46 @@ export abstract class BaseAgent implements AgentHandle { } } + /** + * Walk the ordered dial ladder for the configured utility server, before the + * TSP handshake, purely to warm a connection and to LOG which address worked. + * + * The candidate list is rebuilt from the configured OOBI, which — since the + * join path now seeds it via `ConnectivityModule.dialCandidatesFor()` — already + * carries the relay addresses. So this is the same policy, applied to the same + * list, that `reconnectPeer()` uses on the recovery path. + * + * Best-effort and silent on failure: `configureUtilityServer()`'s own + * `tspBridge.connect()` produces the error the user sees, and it must not be + * replaced by a pre-dial failure. + */ + private async preDialConfiguredUtilityServer(): Promise { + if (!this.libp2pNode || !this.utilityServerOobi) return; + try { + const oobi = decodeOobi(this.utilityServerOobi); + const candidates = this.connectivityModule.dialCandidatesFor({ + peerId: oobi.peerId, + multiaddrs: oobi.multiaddrs, + }); + const dialled = await this.connectivityModule.dialPeerCandidates({ + libp2pNode: this.libp2pNode, + peerId: oobi.peerId, + candidates, + onStatus: (message) => logger.info(`[Agent] pre-dial: ${message}`), + }); + if (!dialled) { + logger.warn( + `[Agent] pre-dial: no reachable address for the utility server ` + + `(tried ${candidates.length}: ${dialCandidateAddrs(candidates).join(', ') || 'none'})`, + ); + } + } catch (err) { + // A malformed OOBI or a transport hiccup is not the caller's problem — the + // real connect below reports whatever actually goes wrong. + logger.debug(`[Agent] pre-dial skipped: ${(err as Error)?.message ?? err}`); + } + } + /** * Whether the agent has an active TSP connection to a utility server. */ diff --git a/packages/app-api/src/runtime/handle.ts b/packages/app-api/src/runtime/handle.ts index f5cb2ae..0d2bdb6 100644 --- a/packages/app-api/src/runtime/handle.ts +++ b/packages/app-api/src/runtime/handle.ts @@ -79,6 +79,20 @@ export interface AgentHandle { isBootstrapConnected(): boolean; getBootstrapRelayAddr(): string | undefined; getDiscoveredPeers(): DiscoveredPeer[]; + /** + * The VTN key this agent identifies itself by (undefined until one is chosen). + * + * Sent to the bootstrap as the peer's `net` — there is no default, so an agent + * that has not picked a VTN sends none rather than claiming another network's. + */ + getNet?(): string | undefined; + /** + * Adopt a VTN and re-register with the bootstrap under that key. + * + * The login flow calls this once the user has picked from the discovered VTN + * list, so the agent is identified by the network it is actually using. + */ + setNet?(net: string, onStatus?: (message: string) => void): Promise; reRegisterWithBootstrap(onStatus?: (message: string) => void): Promise; /** * LIVE connectivity status: bootstrap reachability, the circuit-relay grant diff --git a/packages/app-api/src/runtime/modules/connectivity.ts b/packages/app-api/src/runtime/modules/connectivity.ts index a759a4e..12564e5 100644 --- a/packages/app-api/src/runtime/modules/connectivity.ts +++ b/packages/app-api/src/runtime/modules/connectivity.ts @@ -22,18 +22,60 @@ import type { Libp2p } from 'libp2p'; import { multiaddr } from '@multiformats/multiaddr'; -import { peerIdFromString } from '@libp2p/peer-id'; import { BootstrapClient, type DiscoveredPeer } from '@p2p-trust-stack/core/transport/bootstrap-client.js'; -import { loadBootstrapEnv, buildBootstrapUrls, type BootstrapEnv } from '@p2p-trust-stack/core/config/bootstrap-env.js'; +import { loadBootstrapClientEnv, buildBootstrapUrls, type BootstrapClientEnv } from '@p2p-trust-stack/core/config/bootstrap-env.js'; -import { NetworkDetectModule, type NetworkProfile, buildDialableMultiaddrs } from '@p2p-trust-stack/core/platform/network-detect.js'; +import { + NetworkDetectModule, + type NetworkProfile, + buildDialableMultiaddrs, + getLocalLanIps, +} from '@p2p-trust-stack/core/platform/network-detect.js'; import { readRelayHealth, type RelayHealthSnapshot, } from '@p2p-trust-stack/core/transport/relay-health.js'; +import { + buildDialCandidates, + dialCandidateAddrs, + type DialCandidate, +} from '@p2p-trust-stack/core/transport/dial-candidates.js'; import type { TspTransport } from '@p2p-trust-stack/core/transport/types.js'; import type { RelationshipStore } from '@p2p-trust-stack/core/relationship/store.js'; +// --------------------------------------------------------------------------- +// Dial bounds +// --------------------------------------------------------------------------- + +/** The TSP stream protocol negotiated on every connection. */ +const TSP_PROTOCOL_ID = '/tsp/1.0.0'; +/** Bound on a direct dial attempt, so one dead address cannot stall the ladder. */ +const DIRECT_DIAL_TIMEOUT_MS = 10_000; +/** Bound on the relay HOP dial — a dead relay circuit must not hang recovery. */ +const RELAY_DIAL_TIMEOUT_MS = 15_000; +/** Bound on the TSP stream open through a relay. */ +const RELAY_STREAM_TIMEOUT_MS = 10_000; +/** + * Delay before the single relay retry. The target's circuit-relay reservation is + * created asynchronously during its own `register()` (see + * `BootstrapClient.ensureRelayReservation()`), so an immediate HOP can lose that + * race; 2 s covers it without making recovery slow. + */ +const RELAY_RETRY_DELAY_MS = 2_000; + +/** + * Ensure a dial target names the peer it is meant for. + * + * A multiaddr with no `/p2p/` makes libp2p dial an UNIDENTIFIED peer: it + * has nothing to check the handshake's peer ID against, so whatever answers at + * that address is accepted as the peer. Every candidate reaching the ladder came + * from an OOBI or the bootstrap directory and should already carry the peer ID — + * this is the guard, not the common path. + */ +function withPeerId(addr: string, peerId: string): string { + return addr.includes('/p2p/') ? addr : `${addr}/p2p/${peerId}`; +} + // --------------------------------------------------------------------------- // ConnectivityModule // --------------------------------------------------------------------------- @@ -70,8 +112,13 @@ export class ConnectivityModule { net?: string; /** True when this is a test-suite agent (bootstrap TTL-prunes it). */ test?: boolean; - /** Pre-loaded bootstrap env (composition root injection). */ - bootstrapEnv?: BootstrapEnv; + /** + * Pre-loaded bootstrap client config (composition root injection). + * + * A CLIENT config — host + peer ID, no seed. The seed belongs to the bootstrap + * server alone; this module only needs the address and the identity to expect. + */ + bootstrapEnv?: BootstrapClientEnv; /** Explicit `.env` path. Defaults to `/.env`. */ bootstrapEnvPath?: string; }): Promise { @@ -87,10 +134,13 @@ export class ConnectivityModule { // `process.env` override anymore, so a missing/malformed config degrades to // LAN-only mode — the same behaviour as an unreachable server, and never a // silent wrong address. + // + // The CLIENT loader is used deliberately: an agent holds no seed, only the + // address and the peer ID to expect. let bootstrapEnv = params.bootstrapEnv; if (!bootstrapEnv) { try { - bootstrapEnv = loadBootstrapEnv( + bootstrapEnv = loadBootstrapClientEnv( params.bootstrapEnvPath ? { path: params.bootstrapEnvPath } : {}, ); } catch (err) { @@ -224,6 +274,34 @@ export class ConnectivityModule { } } + /** + * Set the VTN key (namespace) this agent identifies itself by, and re-register + * with the bootstrap so the change is visible immediately. + * + * WHY RE-REGISTER: `net` is the bootstrap-facing identity — it is what the + * peer list shows beside the agent and what tells the network which VTN this + * agent belongs to. Registration happens in `start()`, before the user has + * picked a VTN, so the picker has to update the designation and push it. + * + * A no-op when the key is unchanged (the common "already on this VTN" case), + * so re-picking the current VTN does not churn the bootstrap connection. + */ + async setNet( + net: string, + params: { libp2pNode: Libp2p; multiaddrs: string[]; onStatus?: (message: string) => void }, + ): Promise { + const next = net.trim(); + if (!next) throw new Error('ConnectivityModule.setNet: a VTN key is required'); + if (this.net === next) return; + this.net = next; + await this.reRegisterWithBootstrap(params); + } + + /** The VTN key this agent currently identifies itself by (undefined until chosen). */ + getNet(): string | undefined { + return this.net; + } + /** * Re-register with the bootstrap server using fresh addresses. * Used when the user detects a network change and wants to update registration. @@ -405,10 +483,19 @@ export class ConnectivityModule { /** * Reconnect to an already-established peer without requiring a fresh OOBI. * - * Phase 8: Clean 2-tier connection strategy via bootstrap server: - * 1. Try bootstrap-discovered direct addresses - * 2. Dial via circuit relay through the bootstrap server (NAT traversal) - * 3. Last-known direct addresses as absolute fallback + * Refreshes the bootstrap peer list, then walks ONE ordered address list + * (`dialCandidatesFor()` → `dialPeerCandidates()`) built from the bootstrap + * directory plus the last-known addresses: + * + * 1. relay — a `/p2p-circuit` address (works from any network) + * 2. same-subnet — a direct address on OUR own LAN + * 3. dns — an operator-declared hostname + * 4. lan / wan — other direct addresses, then last-known + * + * The order is deliberate and is the fix for cross-network joins: libp2p's + * default address sorter puts a NAT-mapped public IP first and the relay last, + * so the dial budget was spent on addresses nothing listens on. See + * dial-candidates.ts. * * The previous LAN port sweep has been removed — circuit relay is the * reliable fallback for internet connections. @@ -444,203 +531,193 @@ export class ConnectivityModule { params.onStatus?.('Bootstrap refresh failed, using cached peer data.'); } - // Phase 11 fix: retry helper for relay dials. The target peer's circuit- - // relay reservation may not be fully established on the bootstrap server - // immediately after it re-registers (ensureRelayReservation() is async - // and called during register()). A single retry after a short delay - // covers this race condition without making reconnect take too long. - const relayAddrSources: Array<{ label: string; getAddr: () => string | undefined }> = []; - - // Step 1: Try bootstrap-discovered direct addresses + // ONE ordered address list for this peer, and one dial ladder over it. + // + // This replaced an inline "all direct addresses, then relay, then + // last-known" ladder that existed ONLY here — the join path had no relay + // fallback at all, so a first cross-network join could never reach the + // relay. Both paths now call dialCandidatesFor()/dialPeerCandidates(). + // + // Ordering is the whole point: libp2p's default sorter ranks a NAT-mapped + // public address ahead of the LAN one and pushes the relay to the end, so + // the dial budget was spent on addresses nothing listens on. See + // dial-candidates.ts for the measured evidence and the policy. const bootstrapPeer = this.discoveredPeers.get(peerId); - if (bootstrapPeer) { - // If the peer has direct addresses, try those first - if (bootstrapPeer.multiaddrs.length > 0) { - params.onStatus?.('Reconnecting via bootstrap-discovered addresses...'); - try { - params.store.updateTransport(params.localDid, params.remoteDid, { - peerId, - multiaddrs: bootstrapPeer.multiaddrs, - }); - // Fix: dialProtocol() requires a real PeerId object (it calls the - // internal isPeerId() guard) — passing a plain string throws - // InvalidMultiaddrError immediately, before any network attempt is - // even made. This previously caused every reconnect to skip straight - // past "direct connection" with a misleading error. - // - // Second fix: runOnLimitedConnection: true is required whenever the - // dial may resolve to a circuit-relay connection (which libp2p - // always treats as "limited"). Without it, Connection.newStream() - // (called internally by dialProtocol) throws - // LimitedConnectionError('Cannot open protocol stream on limited - // connection') even though the relayed connection itself opened - // successfully — see libp2p-transport.ts's send() for the full - // explanation. This applies here too since bootstrapPeer.multiaddrs - // may include a /p2p-circuit address patched into the peer store. - const stream = await (params.libp2pNode as any).dialProtocol( - peerIdFromString(peerId), - '/tsp/1.0.0', - { runOnLimitedConnection: true }, - ); - params.onStatus?.('Reconnected via direct connection.'); - - try { await stream.close(); } catch { /* ignore */ } - return; - } catch (err) { - params.onStatus?.(`Direct connection failed (${err instanceof Error ? err.message : 'unknown'}), trying circuit relay...`); - } - - } + const candidates = this.dialCandidatesFor({ + peerId, + multiaddrs: bootstrapPeer?.multiaddrs, + peerRelayAddr: bootstrapPeer?.relayAddr, + lastKnownMultiaddrs: params.recordRemoteTransport.multiaddrs, + }); - // Step 2a: Try relay address from bootstrap-discovered peer - if (bootstrapPeer.relayAddr) { - relayAddrSources.push({ - label: 'circuit relay', - getAddr: () => bootstrapPeer.relayAddr, - }); - } - } + const dialled = await this.dialPeerCandidates({ + libp2pNode: params.libp2pNode, + peerId, + candidates, + onStatus: params.onStatus, + }); - // Step 2b: Try server-level relay address (construct from server relay prefix) - // - // this.bootstrapRelayAddr now arrives from the bootstrap server already - // correctly ordered as `/p2p/{relayPeerId}/p2p-circuit` (see - // bootstrap-server.ts's relayAddr fix). Appending `/p2p/{destPeerId}` - // after the trailing /p2p-circuit produces the standard dial-through-relay - // shape: `/p2p/{relayPeerId}/p2p-circuit/p2p/{destPeerId}`. - // The `.replace()` here is defensive only — it strips a legacy/malformed - // trailing `/p2p/{id}` if one is ever present (e.g. mid-rollout against - // an old server build); on a correctly-shaped address ending in - // /p2p-circuit it is a no-op since the regex only matches a trailing - // /p2p/{id} segment, not /p2p-circuit. - if (this.bootstrapRelayAddr) { - relayAddrSources.push({ - label: 'server relay', - getAddr: () => this.bootstrapRelayAddr!.replace(/\/p2p\/[^/]+$/, '') + `/p2p/${peerId}`, + if (dialled) { + params.store.updateTransport(params.localDid, params.remoteDid, { + peerId, + // Store the WHOLE ordered list, not just the address that won: the peer + // store then keeps every option, and the installed addressSorter picks + // the best one on the next dial. + multiaddrs: dialCandidateAddrs(candidates), }); + return; } + throw new Error( + `reconnectPeer: unable to reach ${params.remoteDid.slice(0, 12)}... ` + + `Tried ${candidates.length} address(es): ${dialCandidateAddrs(candidates).join(', ') || 'none'}. ` + + `Ensure both peers are registered with the bootstrap server.`, + ); + } - // Try each relay address source, with a single retry after a brief delay - // to cover the race where the target peer's circuit-relay reservation - // hasn't been fully established yet. - for (const source of relayAddrSources) { - for (let attempt = 0; attempt < 2; attempt++) { - const relayAddr = source.getAddr(); - if (!relayAddr) continue; + /** + * The ordered dial-target list for one destination peer — the ONE place an + * address list is built in this stack. + * + * The JOIN path calls this to seed an OOBI (so the very first RFI already + * carries the relay address), and `reconnectPeer()` calls it to drive the + * recovery ladder. Before this existed the two disagreed: the join used only + * the bootstrap's `multiaddrs`, which never contain a `/p2p-circuit` address + * because `buildDialableMultiaddrs()` filters them out — so a first + * cross-network join had no relay to fall back to at all. + * + * The server-level relay prefix is supplied automatically from the bootstrap + * registration, and this machine's LAN IPs let a same-subnet peer outrank the + * relay. See dial-candidates.ts for the ranking rationale. + */ + dialCandidatesFor(params: { + peerId: string; + multiaddrs?: readonly string[]; + peerRelayAddr?: string; + lastKnownMultiaddrs?: readonly string[]; + }): DialCandidate[] { + return buildDialCandidates({ + peerId: params.peerId, + multiaddrs: params.multiaddrs, + peerRelayAddr: params.peerRelayAddr, + serverRelayPrefix: this.bootstrapRelayAddr, + localLanIps: getLocalLanIps(), + lastKnownMultiaddrs: params.lastKnownMultiaddrs, + }); + } - if (attempt === 1) { - params.onStatus?.(`Retrying ${source.label} after brief delay (reservation may not be ready)...`); - await new Promise(resolve => setTimeout(resolve, 2000)); - } else { - params.onStatus?.(`Reconnecting via ${source.label}...`); - } + /** + * Dial `peerId` through an ordered candidate list and report the address that + * worked, or `undefined` when every candidate failed. + * + * The single dial implementation for BOTH the join path and recovery. Each + * candidate is attempted in the order `buildDialCandidates()` produced, so the + * relay is tried BEFORE a NAT-mapped public address instead of after it. + * + * Relay candidates get one retry after a short delay: the target's + * circuit-relay reservation is established asynchronously during its own + * `register()`, so an immediate HOP can lose a race a second attempt wins. + * Never throws — the caller decides what an exhausted list means. + */ + async dialPeerCandidates(params: { + libp2pNode: Libp2p; + peerId: string; + candidates: readonly DialCandidate[]; + onStatus?: (message: string) => void; + }): Promise { + const { libp2pNode, peerId, candidates, onStatus } = params; + const short = peerId.slice(0, 12); + if (candidates.length === 0) { + onStatus?.(`No dial addresses known for ${short}...`); + return undefined; + } + for (const candidate of candidates) { + const label = `${candidate.scope}/${candidate.source}`; + const isRelay = candidate.scope === 'relay'; + // A relay HOP that loses the reservation race is worth one retry; a dead + // direct address is not (it would only burn the caller's time budget). + const attempts = isRelay ? 2 : 1; + + for (let attempt = 1; attempt <= attempts; attempt++) { + if (attempt > 1) { + onStatus?.(`Retrying ${short}... via ${label} after the reservation race.`); + await new Promise((resolve) => setTimeout(resolve, RELAY_RETRY_DELAY_MS)); + } + onStatus?.(`Dialing ${short}... via ${label}: ${candidate.addr}`); try { - await this.dialViaRelay({ - libp2pNode: params.libp2pNode, - store: params.store, - localDid: params.localDid, - remoteDid: params.remoteDid, - peerId, - relayAddr, - onStatus: params.onStatus, - }); - return; - } catch { - params.onStatus?.(`${source.label} attempt ${attempt + 1} failed.`); + if (isRelay) { + await this.dialRelayAddr({ libp2pNode, relayAddr: candidate.addr }); + } else { + // Dial THIS candidate, not "the peer". + // + // `dialProtocol(peerId, …)` hands libp2p the peer's WHOLE stored + // address list and lets its dial queue pick, and the first dial that + // completes at the transport level wins — including one that opens + // and then immediately closes. libp2p returns that connection anyway + // (its `dial-queue.ts` never checks `connection.status`), and + // `openConnection` then throws + // `ConnectionClosedError('Remote closed connection during opening')`, + // aborting the ENTIRE call. So one bad address used to skip every + // remaining candidate — relay included. That is the field failure of + // 2026-10-29 on MBP2014, where the mis-ranked `192.168.1.28` (a + // DIFFERENT network's LAN address that happens to share our /24) + // aborted the dial before the relay was ever tried. + // + // Naming the address contains it: a bad candidate now costs one + // candidate, and the ladder keeps walking. It also makes the + // `onStatus` line above truthful — it always named the candidate, but + // the call it described was a dial of the peer's whole address list. + const stream = await (libp2pNode as any).dialProtocol( + multiaddr(withPeerId(candidate.addr, peerId)), + TSP_PROTOCOL_ID, + { runOnLimitedConnection: true, signal: AbortSignal.timeout(DIRECT_DIAL_TIMEOUT_MS) }, + ); + try { await stream.close(); } catch { /* ignore */ } + } + onStatus?.(`Connected to ${short}... via ${label}.`); + return candidate.addr; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + onStatus?.(`${label} failed for ${short}...: ${message}`); } } } - // Step 2c: Try last-known direct addresses as absolute fallback - const { multiaddrs } = params.recordRemoteTransport; - if (multiaddrs && multiaddrs.length > 0) { - params.onStatus?.('Trying last known addresses...'); - try { - // Same PeerId-vs-string + runOnLimitedConnection fix as Step 1 above. - const stream = await (params.libp2pNode as any).dialProtocol( - peerIdFromString(peerId), - '/tsp/1.0.0', - { runOnLimitedConnection: true }, - ); - params.onStatus?.('Reconnected via last known addresses.'); - - try { await stream.close(); } catch { /* ignore */ } - return; - } catch (err) { - params.onStatus?.(`Last-known-address connection failed: ${err instanceof Error ? err.message : 'unknown'}`); - } - } - - - throw new Error( - `reconnectPeer: unable to reach ${params.remoteDid.slice(0, 12)}... ` + - `Direct, relay, and last-known addresses all failed. ` + - `Ensure both peers are registered with the bootstrap server.`, - ); + return undefined; } /** - * Dial a peer through the bootstrap server's circuit relay. + * Dial a `/p2p-circuit` address and open (then close) a TSP stream on it. * - * Uses multiaddr() + dial() + connection.newStream() instead of - * dialProtocol(multiaddrString). The dialProtocol API expects a PeerId + - * protocol string, not a full multiaddr containing /p2p-circuit components. - * To dial through a relay, we must: + * Uses multiaddr() + dial() + connection.newStream() rather than + * dialProtocol(multiaddrString): the dialProtocol API takes a PeerId + a + * protocol string, not a full multiaddr containing `/p2p-circuit` components. + * To dial through a relay we must: * 1. Parse the relay multiaddr * 2. dial() to establish the connection through the relay - * 3. connection.newStream() to negotiate the /tsp/1.0.0 protocol + * 3. connection.newStream() to negotiate the TSP protocol * - * Fix: this previously called `libp2pNode.getProtocolStream(...)`, which is - * not a real method on the libp2p API (it does not exist anywhere in - * libp2p/@libp2p/interface) — every relay dial threw a TypeError and was - * silently swallowed by the caller's try/catch as "Relay connection - * failed.". The connection object returned by dial() already exposes - * `.newStream(protocol)` directly, which is the correct, documented way to - * open a new stream on an established connection. + * A connection dialed through `/p2p-circuit` is ALWAYS "limited" in libp2p + * (`connection.limits != null`), so `newStream()` throws + * `LimitedConnectionError` unless `runOnLimitedConnection: true` is passed. * - * Note: even with this fixed, the relay dial will still be rejected by the - * bootstrap server with NO_RESERVATION unless the *target* peer has an - * active circuit-relay reservation — see BootstrapClient.ensureRelayReservation(). + * The relay still rejects the HOP with `NO_RESERVATION` unless the *target* + * peer holds a live reservation — see + * `BootstrapClient.ensureRelayReservation()`. */ - private async dialViaRelay(params: { + private async dialRelayAddr(params: { libp2pNode: Libp2p; - store: RelationshipStore; - localDid: string; - remoteDid: string; - peerId: string; relayAddr: string; - onStatus?: (message: string) => void; }): Promise { - params.onStatus?.(`Dialing via relay: ${params.relayAddr}`); - - // Parse the relay multiaddr and dial through it (bounded so a dead relay - // circuit can't hang the reconnect flow indefinitely). const ma = multiaddr(params.relayAddr); - const connection = await (params.libp2pNode as any).dial(ma, { signal: AbortSignal.timeout(15000) }); - - // Open a stream on the /tsp/1.0.0 protocol through this relay connection. - // - // Bug fix: a connection dialed through /p2p-circuit is ALWAYS a "limited" - // connection in libp2p (connection.limits != null). newStream() throws - // LimitedConnectionError unless runOnLimitedConnection: true is passed — - // this was silently swallowed by the caller's try/catch as a generic - // "circuit relay attempt failed", masking the real cause. - const stream = await connection.newStream('/tsp/1.0.0', { - signal: AbortSignal.timeout(10000), - runOnLimitedConnection: true, + const connection = await (params.libp2pNode as any).dial(ma, { + signal: AbortSignal.timeout(RELAY_DIAL_TIMEOUT_MS), }); - - - params.onStatus?.('Reconnected via circuit relay.'); - - // Update the store with relay address for future reference - params.store.updateTransport(params.localDid, params.remoteDid, { - peerId: params.peerId, - multiaddrs: [params.relayAddr], + const stream = await connection.newStream(TSP_PROTOCOL_ID, { + signal: AbortSignal.timeout(RELAY_STREAM_TIMEOUT_MS), + runOnLimitedConnection: true, }); - try { await stream.close(); } catch { /* ignore */ } } diff --git a/packages/app-api/src/runtime/modules/tsp-bridge.ts b/packages/app-api/src/runtime/modules/tsp-bridge.ts index 02cc1c8..3a25bb8 100644 --- a/packages/app-api/src/runtime/modules/tsp-bridge.ts +++ b/packages/app-api/src/runtime/modules/tsp-bridge.ts @@ -29,9 +29,20 @@ import { encodeProtocolMessage, type ProtocolMessage } from '@p2p-trust-stack/core/tsp-handlers/types.js'; import type { RelationshipManager } from '@p2p-trust-stack/core/relationship/manager.js'; +import type { RelationshipRecord } from '@p2p-trust-stack/core/relationship/types.js'; import type { TspClientModule } from './tsp-client.js'; import { logger } from '@p2p-trust-stack/core/util/logger.js'; +/** + * How long an RFI/RFA handshake is given to complete, in milliseconds. + * + * Doubles as the age after which an outbound-pending record is treated as DEAD + * rather than in flight: once a row has been waiting longer than a whole + * handshake window, nothing is going to complete it, so waiting again can only + * burn another 30 s. + */ +const HANDSHAKE_TIMEOUT_MS = 30_000; + export interface TspBridgeDeps { /** Active relationship manager (has running transport + store). */ manager: RelationshipManager; @@ -171,11 +182,40 @@ export class TspBridge { this.connected = true; return oobi.did; } - // Relationship exists but not yet bidirectional — wait for it - const serverDid = await this.waitForBidirectional(oobi.did, 30_000); - this.utilityServerDid = serverDid; - this.connected = true; - return serverDid; + // A pending row is NOT always "in flight". If its whole handshake window + // has already elapsed, nothing will ever complete it — and while it exists + // `sendRfi` refuses to run again (`insertOutbound` throws `already + // exists`), so waiting for it is a guaranteed 30 s failure that repeats on + // every reconnect tick, forever. + // + // That was the field bug of 2026-10-29 (MBP2014): one failed RFI send left + // such a row behind (the send is what failed — see `sendRfi`'s rollback, + // which now prevents the row being orphaned in the first place), and the + // agent then logged five identical `timed out waiting for bidirectional + // relationship` errors while its bootstrap and relay connections were + // healthy. Recovery required restarting the app and waiting out + // `pruneStaleOutbound`'s 5-minute window. + // + // Clearing the row and falling through to the send path below re-drives the + // handshake with the CURRENT OOBI — which also repairs the second half of + // the problem: the stored row pins the transport as it was at RFI time, and + // a utility server that has restarted has a new peer ID. + if (this.isStalePendingOutbound(existing)) { + logger.warn( + `[TspBridge] stale outbound-pending relationship with ${oobi.did.slice(0, 24)}… ` + + `(started ${Math.round(Date.now() / 1000 - existing.createdAt)}s ago, never completed) ` + + `— clearing it and re-sending the RFI instead of waiting for it.`, + ); + this.manager.removeRelationship(oobi.did); + } else { + // Relationship exists but not yet bidirectional — wait for it. This is a + // row YOUNGER than one handshake window: an RFA may genuinely be on its + // way, and re-sending now would only duplicate the RFI. + const serverDid = await this.waitForBidirectional(oobi.did, HANDSHAKE_TIMEOUT_MS); + this.utilityServerDid = serverDid; + this.connected = true; + return serverDid; + } } // Initiate the relationship handshake. @@ -184,7 +224,7 @@ export class TspBridge { await this.manager.sendRfi(this.utilityServerOobi, 'organization'); // Wait for the relationship to become bidirectional (polling with timeout) - const serverDid = await this.waitForBidirectional(oobi.did, 30_000); + const serverDid = await this.waitForBidirectional(oobi.did, HANDSHAKE_TIMEOUT_MS); this.utilityServerDid = serverDid; this.connected = true; @@ -192,6 +232,24 @@ export class TspBridge { return serverDid; } + /** + * Whether an existing record is an outbound handshake that has already had its + * full window and can therefore never complete on its own. + * + * AGE is the test, not status alone. A row younger than + * {@link HANDSHAKE_TIMEOUT_MS} may legitimately be in flight (the RFA is on its + * way), and re-sending then would duplicate the RFI; a row older than that has + * already had its wait and failed it. + * + * `created_at` is stored in unix SECONDS (`RelationshipStore` inserts + * `unixepoch()`), hence the /1000. + */ + private isStalePendingOutbound(record: RelationshipRecord): boolean { + if (record.direction !== 'outbound' || record.status !== 'pending') return false; + const ageSeconds = Date.now() / 1000 - record.createdAt; + return ageSeconds > HANDSHAKE_TIMEOUT_MS / 1000; + } + /** * Poll for a relationship to reach bidirectional status. */ diff --git a/packages/app-api/src/runtime/scid-vh-agent.ts b/packages/app-api/src/runtime/scid-vh-agent.ts index 63f1d91..e7b4455 100644 --- a/packages/app-api/src/runtime/scid-vh-agent.ts +++ b/packages/app-api/src/runtime/scid-vh-agent.ts @@ -252,9 +252,19 @@ export class ScidVhAgent extends BaseAgent { agent.meta = meta; const unlockDb = new AgentDatabase(registry.getDbPath(config.name), registry.getKeystorePath(config.name)); - agent.scidVhMethod.setStore(unlockDb.rotationLogStore); - - await agent.loadIdentity(); + try { + agent.scidVhMethod.setStore(unlockDb.rotationLogStore); + await agent.loadIdentity(); + } finally { + // Released here for the same reason as the `tempDb` in `initIdentity()`: the store is + // needed only to resolve the identity at unlock time, and `wireUpAfterStart()` swaps in + // the LIVE database's store on start(). Holding this second connection to the agent's + // database for the agent's whole lifetime is invisible on POSIX, but on Windows it makes + // the agent's directory undeletable (`EBUSY`) even after `stop()` — which is what broke + // `npm test` on Windows. `finally` also covers the wrong-passphrase path, where no agent + // is returned for anyone else to close. + unlockDb.close(); + } return agent; } diff --git a/packages/app-api/src/runtime/types.ts b/packages/app-api/src/runtime/types.ts index 16a3b27..a1cf37c 100644 --- a/packages/app-api/src/runtime/types.ts +++ b/packages/app-api/src/runtime/types.ts @@ -11,6 +11,7 @@ import type { DidMethod } from '@p2p-trust-stack/core/did/did-method.js'; import type { TspTransport } from '@p2p-trust-stack/core/transport/types.js'; import type { KeyStore } from '@p2p-trust-stack/core/keystore/types.js'; +import type { BootstrapClientEnv } from '@p2p-trust-stack/core/config/bootstrap-env.js'; // --------------------------------------------------------------------------- // AgentConfig @@ -26,10 +27,23 @@ export interface AgentConfig { didMethod?: DidMethod; bootstrapUrl?: string; /** - * Network/group (namespace) this agent is joining, e.g. 'p2pts'. Sent to the - * bootstrap/relay server as the peer's `net` and used to build the - * bootstrap-facing name `vta::`. Defaults to 'p2pts'; the first-run - * setup wizard will set this from the chosen VTN. + * The bootstrap server this agent should dial — host + peer ID. + * + * The agent is a CLIENT: it holds no seed, only the address and the peer ID to + * expect. Supplied by the composition root (the startup screen's bootstrap + * boxes, persisted to `P2PTS_HOME/config.yaml`). When absent, the runtime falls + * back to this component's `.env`; when neither yields a usable pair the agent + * runs in LAN-only mode rather than failing to start. + */ + bootstrapEnv?: BootstrapClientEnv; + /** + * The VTN key (namespace) this agent is joining, e.g. 'p2pts'. + * + * Sent to the bootstrap as the peer's `net`, so the agent identifies itself by + * the network it is ACTUALLY using and the bootstrap's peer list shows it. + * There is deliberately NO default: an agent that has not chosen a VTN yet + * sends no `net` at all, rather than claiming some other network's key. Set + * from the login screen's VTN picker. */ net?: string; transport?: TspTransport; diff --git a/packages/app-api/test/agent/agent.test.ts b/packages/app-api/test/agent/agent.test.ts index 4623cb6..122b372 100644 --- a/packages/app-api/test/agent/agent.test.ts +++ b/packages/app-api/test/agent/agent.test.ts @@ -48,7 +48,7 @@ import { getVtaPaths } from '@p2p-trust-stack/core/platform/p2pts-home.js'; * The directory is cleaned up after the test. */ function makeTempRegistry(): { registry: AgentRegistry; dir: string; cleanup: () => void } { - const dir = mkdtempSync(join(tmpdir(), 'p2pts-test-')); + const dir = mkdtempSync(join(tmpdir(), 'lp2ptoip-test-')); // AgentRegistry(dbPath?, keystorePath?, p2ptsHome?) — passing `dir` as the // single arg would set it as dbPath while P2PTS_HOME stays the real user dir, // which pollutes/interferes with the real on-disk store. Pass it as p2ptsHome diff --git a/packages/app-api/test/agent/dial-ladder-containment.test.ts b/packages/app-api/test/agent/dial-ladder-containment.test.ts new file mode 100644 index 0000000..ce981f7 --- /dev/null +++ b/packages/app-api/test/agent/dial-ladder-containment.test.ts @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-TrustStackers-Commercial +// Copyright (C) 2026 Trust Stackers + +/** + * The dial ladder must name the address it dials, and contain a bad candidate. + * + * Field failure, MBP2014, 2026-10-29. `dialPeerCandidates` reported + * `Dialing … via same-subnet/direct: /ip4/192.168.1.28/tcp/4003/…` — but + * for a direct candidate it actually called `dialProtocol(peerId, …)`, handing + * libp2p the peer's WHOLE address list and letting its dial queue pick. The + * first dial to complete at the transport level wins, including one that opens + * and then immediately closes; libp2p returns that connection anyway and + * `openConnection` throws `Remote closed connection during opening`, aborting the + * ENTIRE call — so every remaining candidate, relay included, was skipped. + * + * Two things are pinned here: + * 1. the dial target IS the candidate address (with the peer ID, so a foreign + * host at that address cannot be accepted as the peer); + * 2. a candidate that fails costs one candidate, and the ladder keeps walking. + * + * Run with: + * node --experimental-strip-types --loader ./test-loader.mjs --test \ + * test/agent/dial-ladder-containment.test.ts + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { ConnectivityModule } from '../../src/runtime/modules/connectivity.js'; +import { buildDialCandidates } from '@p2p-trust-stack/core/transport/dial-candidates.js'; +import type { Libp2p } from 'libp2p'; + +const SERVER = '12D3KooWServerPeerIdForTheLadderTestAAAAAAAAAAAAAAA'; +const RELAY = '12D3KooWRelayPeerIdForTheLadderTestBBBBBBBBBBBBBBB'; + +/** The field address set: a /24-coincident LAN address, a relay, a NAT-mapped IP. */ +const COINCIDENT_LAN = `/ip4/192.168.1.28/tcp/4003/p2p/${SERVER}`; +const RELAY_ADDR = `/dns/relay.truststackers.com/tcp/4001/p2p/${RELAY}/p2p-circuit/p2p/${SERVER}`; +const NAT_ADDR = `/ip4/137.119.78.102/tcp/58166/p2p/${SERVER}`; + +/** Records every dial target and answers with whatever the test decides. */ +class StubNode { + public dialed: string[] = []; + private readonly verdict: (target: string) => Error | 'ok'; + + constructor(verdict: (target: string) => Error | 'ok') { + this.verdict = verdict; + } + + private record(target: { toString(): string }): Error | 'ok' { + const addr = target.toString(); + this.dialed.push(addr); + return this.verdict(addr); + } + + async dialProtocol(target: { toString(): string }): Promise<{ close(): Promise }> { + const verdict = this.record(target); + if (verdict instanceof Error) throw verdict; + return { close: async () => {} }; + } + + async dial(target: { toString(): string }): Promise<{ newStream(): Promise<{ close(): Promise }> }> { + const verdict = this.record(target); + if (verdict instanceof Error) throw verdict; + return { newStream: async () => ({ close: async () => {} }) }; + } +} + +function makeCandidates() { + return buildDialCandidates({ + peerId: SERVER, + multiaddrs: [COINCIDENT_LAN, NAT_ADDR], + peerRelayAddr: RELAY_ADDR, + localLanIps: ['192.168.1.201'], // MBP2014's LAN — same /24, different network + }); +} + +test('dialPeerCandidates: dials the CANDIDATE address, not the bare peer ID', async () => { + const node = new StubNode(() => 'ok'); + const statuses: string[] = []; + + const won = await new ConnectivityModule().dialPeerCandidates({ + libp2pNode: node as unknown as Libp2p, + peerId: SERVER, + candidates: makeCandidates(), + onStatus: (m) => statuses.push(m), + }); + + // The relay leads the order now, and it is dialed by its own address. + assert.equal(won, RELAY_ADDR, 'the relay candidate must be the one that won'); + assert.deepEqual(node.dialed, [RELAY_ADDR], 'exactly the candidate address must be dialed'); + assert.notEqual(node.dialed[0], SERVER, 'the bare peer ID must never be the dial target'); + assert.ok( + statuses.some((s) => s.includes('Connected') && s.includes('relay')), + 'the status line must name the candidate that worked', + ); +}); + +test('dialPeerCandidates: the /24-coincident address is never dialed once the relay works', async () => { + const node = new StubNode(() => 'ok'); + + const won = await new ConnectivityModule().dialPeerCandidates({ + libp2pNode: node as unknown as Libp2p, + peerId: SERVER, + candidates: makeCandidates(), + }); + + // The field failure needed TWO things to go wrong: the coincident address had + // to be dialed first, and its failure had to abort the call. Correcting the + // order removes the first; the assertion below is that the address which + // aborted the field dial is not touched at all. + assert.equal(won, RELAY_ADDR); + assert.deepEqual( + node.dialed, + [RELAY_ADDR], + 'the mis-ranked 192.168.1.28 must not be dialed while the relay is available', + ); +}); + +test('dialPeerCandidates: a failed candidate does not abort the ladder — the next one is tried', async () => { + const node = new StubNode((target) => + target === RELAY_ADDR + ? new Error('failed to connect via relay with status NO_RESERVATION') + : 'ok', + ); + const statuses: string[] = []; + + const won = await new ConnectivityModule().dialPeerCandidates({ + libp2pNode: node as unknown as Libp2p, + peerId: SERVER, + candidates: makeCandidates(), + onStatus: (m) => statuses.push(m), + }); + + assert.equal(won, COINCIDENT_LAN, 'the ladder must walk past a failed candidate'); + // The relay is retried once (reservation race), then the direct candidates follow. + assert.deepEqual(node.dialed, [RELAY_ADDR, RELAY_ADDR, COINCIDENT_LAN]); + assert.ok( + statuses.some((s) => s.includes('failed') && s.includes('NO_RESERVATION')), + 'the failure must be reported against the candidate that caused it', + ); +}); + +test('dialPeerCandidates: a connect-then-close direct candidate is contained, not fatal', async () => { + const node = new StubNode((target) => + target === COINCIDENT_LAN + ? new Error('Remote closed connection during opening') + : target === RELAY_ADDR + ? new Error('relay down') + : 'ok', + ); + + const won = await new ConnectivityModule().dialPeerCandidates({ + libp2pNode: node as unknown as Libp2p, + peerId: SERVER, + candidates: makeCandidates(), + }); + + // The exact error that aborted the whole dial in the field is now just one + // failed candidate: the NAT-mapped address is still reached afterwards. + assert.equal(won, NAT_ADDR); + assert.deepEqual(node.dialed, [RELAY_ADDR, RELAY_ADDR, COINCIDENT_LAN, NAT_ADDR]); +}); + +test('dialPeerCandidates: appends the peer ID to a candidate that lacks one', async () => { + const node = new StubNode(() => 'ok'); + const bare = { addr: '/ip4/192.168.1.28/tcp/4003', scope: 'same-subnet' as const, source: 'direct' as const }; + + await new ConnectivityModule().dialPeerCandidates({ + libp2pNode: node as unknown as Libp2p, + peerId: SERVER, + candidates: [bare], + }); + + assert.deepEqual( + node.dialed, + [`/ip4/192.168.1.28/tcp/4003/p2p/${SERVER}`], + 'a peer-less address must be completed, so the handshake is checked against the expected peer', + ); +}); + +test('dialPeerCandidates: returns undefined once every candidate has failed', async () => { + const node = new StubNode(() => new Error('ECONNREFUSED')); + + const won = await new ConnectivityModule().dialPeerCandidates({ + libp2pNode: node as unknown as Libp2p, + peerId: SERVER, + candidates: makeCandidates(), + }); + + assert.equal(won, undefined, 'an exhausted ladder resolves undefined, never throws'); + // The relay gets one retry after the reservation race; each direct candidate gets one attempt. + assert.deepEqual(node.dialed, [RELAY_ADDR, RELAY_ADDR, COINCIDENT_LAN, NAT_ADDR]); +}); + +test('dialPeerCandidates: no candidates is a no-op, not a throw', async () => { + const node = new StubNode(() => 'ok'); + + const won = await new ConnectivityModule().dialPeerCandidates({ + libp2pNode: node as unknown as Libp2p, + peerId: SERVER, + candidates: [], + }); + + assert.equal(won, undefined); + assert.deepEqual(node.dialed, []); +}); + diff --git a/packages/app-api/test/agent/tsp-bridge-pending-retry.test.ts b/packages/app-api/test/agent/tsp-bridge-pending-retry.test.ts new file mode 100644 index 0000000..fb0d9a4 --- /dev/null +++ b/packages/app-api/test/agent/tsp-bridge-pending-retry.test.ts @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-TrustStackers-Commercial +// Copyright (C) 2026 Trust Stackers + +/** + * TspBridge.connect() — the outbound-pending relationship must not be a dead end. + * + * Field bug, MBP2014, 2026-10-29. `sendRfi` writes its outbound-pending row + * BEFORE the send (so an immediately-arriving RFA can be matched), and that send + * then failed with `Remote closed connection during opening`. The row survived. + * Because `TspBridge.connect()` read an outbound-pending row as "a handshake is + * in progress" and only WAITED for it, and because `sendRfi` cannot run again + * while the row exists (`insertOutbound` throws `already exists`), the agent + * could never re-send. Its log showed ONE `[RM:sendRfi]` line followed by five + * identical 30 s `timed out waiting for bidirectional relationship` errors — on a + * machine whose bootstrap connection, relay reservation and peer discovery were + * all demonstrably healthy. It recovered only when the app was restarted and + * `pruneStaleOutbound`'s 5-minute window had elapsed. + * + * Two fixes are pinned here: + * - `RelationshipManager.sendRfi` rolls the row back when the send fails + * (pinned in `packages/core/test/relationship/manager.test.ts`). + * - `TspBridge.connect` treats a row OLDER than one handshake window as dead + * and re-drives the handshake instead of waiting on it — the tests below. + * + * The two controls matter as much as the fix: a row YOUNGER than the window may + * genuinely be in flight, and re-sending then would duplicate the RFI. + * + * Run with: + * node --experimental-strip-types --loader ./test-loader.mjs --test \ + * test/agent/tsp-bridge-pending-retry.test.ts + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { TspBridge } from '../../src/runtime/modules/tsp-bridge.js'; +import type { TspClientModule } from '../../src/runtime/modules/tsp-client.js'; +import type { RelationshipManager } from '@p2p-trust-stack/core/relationship/manager.js'; +import type { RelationshipRecord } from '@p2p-trust-stack/core/relationship/types.js'; +import { encodeOobi } from '@p2p-trust-stack/core/relationship/oobi.js'; +import { cesrEncode, CESR_CODES } from '@p2p-trust-stack/core/crypto/cesr.js'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const SERVER_DID = 'did:scid:vh:1:test-utility-server'; +const SERVER_PEER_ID = '12D3KooWServerForTest'; + +/** A strict 4-item OOBI, the shape the real connect path requires. */ +const SERVER_OOBI = encodeOobi({ + cesrVid: cesrEncode(CESR_CODES.ED25519_PUBLIC_KEY, new Uint8Array(32)), + peerId: SERVER_PEER_ID, + multiaddrs: ['/ip4/127.0.0.1/tcp/4003'], + did: SERVER_DID, +}); + +/** + * The record the failed handshake leaves behind: outbound, pending, and + * `ageSeconds` old. `created_at` is unix SECONDS in the store, so the fixture + * uses seconds too. + */ +function pendingRecord(ageSeconds: number): RelationshipRecord { + return { + localDid: 'did:peer:4:local', + remoteDid: SERVER_DID, + direction: 'outbound', + status: 'pending', + createdAt: Math.floor(Date.now() / 1000) - ageSeconds, + } as RelationshipRecord; +} + +/** The completed handshake the reconnect path is waiting for. */ +function establishedRecord(): RelationshipRecord { + return { + ...pendingRecord(0), + direction: 'bidirectional', + status: 'established', + } as RelationshipRecord; +} + +interface StubHooks { + /** Current relationship row, read by both connect() and its wait loop. */ + getRecord: () => RelationshipRecord | undefined; + /** Called by the re-drive path with the DID it is clearing. */ + onRemove?: (did: string) => void; + /** Called when the handshake is (re-)driven. Throw to fail the send. */ + onSendRfi?: () => void; +} + +/** A TspBridge over a stub manager, so no libp2p node or database is needed. */ +function makeBridge(hooks: StubHooks): TspBridge { + const manager = { + getRelationship: () => hooks.getRecord(), + removeRelationship: (did: string) => hooks.onRemove?.(did), + sendRfi: async () => { + hooks.onSendRfi?.(); + }, + updateRemoteSubjectKind: () => {}, + } as unknown as RelationshipManager; + + const tspClient = { + init: () => {}, + setRequestTimeoutHook: () => {}, + } as unknown as TspClientModule; + + const bridge = new TspBridge(); + bridge.init({ manager, tspClient, utilityServerOobi: SERVER_OOBI }); + return bridge; +} + +// --------------------------------------------------------------------------- +// The fix +// --------------------------------------------------------------------------- + +test('TspBridge.connect: an outbound-pending row past the handshake window is re-driven, not waited on', async () => { + let record: RelationshipRecord | undefined = pendingRecord(120); + const removed: string[] = []; + let rfiSends = 0; + + const bridge = makeBridge({ + getRecord: () => record, + onRemove: (did) => { + removed.push(did); + record = undefined; + }, + onSendRfi: () => { + rfiSends += 1; + record = establishedRecord(); + }, + }); + + const did = await bridge.connect(); + + assert.equal(did, SERVER_DID, 'connect must resolve the server DID'); + assert.deepEqual(removed, [SERVER_DID], 'the dead row must be cleared exactly once'); + assert.equal(rfiSends, 1, 'the handshake must be re-driven, not waited on'); + assert.equal(bridge.isConnected(), true, 'the bridge must report connected after the re-drive'); + assert.equal(bridge.getUtilityServerDid(), SERVER_DID); +}); + +test('TspBridge.connect: a re-drive that fails surfaces the send error, not a 30 s timeout', async () => { + let record: RelationshipRecord | undefined = pendingRecord(120); + + const bridge = makeBridge({ + getRecord: () => record, + onRemove: () => { + record = undefined; + }, + onSendRfi: () => { + throw new Error('Remote closed connection during opening'); + }, + }); + + // The point: the caller learns WHY, immediately. Before the fix this burned a + // full 30 s and reported a generic "timed out waiting for bidirectional + // relationship", which is what made the field log unreadable. + await assert.rejects( + () => bridge.connect(), + /Remote closed connection during opening/, + 'the transport failure must reach the caller', + ); +}); + +// --------------------------------------------------------------------------- +// Controls — the fix must NOT duplicate a handshake that is genuinely in flight +// --------------------------------------------------------------------------- + +test('TspBridge.connect: a FRESH pending row is waited for, never cleared or re-sent', async () => { + let record: RelationshipRecord | undefined = pendingRecord(0); + const removed: string[] = []; + let rfiSends = 0; + + const bridge = makeBridge({ + getRecord: () => record, + onRemove: (did) => { + removed.push(did); + record = undefined; + }, + onSendRfi: () => { + rfiSends += 1; + }, + }); + + // The RFA arrives while connect() is waiting. + const rfa = setTimeout(() => { + record = establishedRecord(); + }, 300); + + try { + const did = await bridge.connect(); + assert.equal(did, SERVER_DID, 'connect must resolve once the RFA lands'); + } finally { + clearTimeout(rfa); + } + + assert.deepEqual(removed, [], 'a row younger than the window must not be cleared'); + assert.equal(rfiSends, 0, 'a row younger than the window must not be re-sent'); +}); + +test('TspBridge.connect: an already-established relationship short-circuits (no RFI, no clearing)', async () => { + const removed: string[] = []; + let rfiSends = 0; + + const bridge = makeBridge({ + getRecord: () => establishedRecord(), + onRemove: (did) => removed.push(did), + onSendRfi: () => { + rfiSends += 1; + }, + }); + + const did = await bridge.connect(); + + assert.equal(did, SERVER_DID); + assert.deepEqual(removed, [], 'an established relationship must never be cleared'); + assert.equal(rfiSends, 0, 'an established relationship must not re-send an RFI'); +}); + diff --git a/packages/app-api/test/trust_app_api/credentials-issue-verify.test.ts b/packages/app-api/test/trust_app_api/credentials-issue-verify.test.ts index ffd48f4..43c9e7d 100644 --- a/packages/app-api/test/trust_app_api/credentials-issue-verify.test.ts +++ b/packages/app-api/test/trust_app_api/credentials-issue-verify.test.ts @@ -382,15 +382,24 @@ test('revoke() round-trips by UUID — the addressing no consumer could supply b const identitiesRepo = await import('@p2p-trust-stack/core/db/repositories/identities-repo.js'); const ds = stack.host.storageFor(name) as InstanceType; - const me = await identitiesRepo.findByRealDid(ds, (await stack.identity.current()).did!); - assert.ok(me, 'the active identity has a row'); - - const uuid = await credentialsRepo.create(ds, { - vcType: 'vrc', - issuerIdentityUuid: me.uuid, - subjectIdentityUuid: me.uuid, - validFrom: Math.floor(Date.now() / 1000), - }); + // Seeded through a direct handle that is CLOSED before the assertions below. On Windows an + // open handle makes the agent's database undeletable, so holding one until the process exits + // failed this file's temp-home teardown with EPERM — POSIX tolerates it, which is why this + // only ever showed up on Windows. + let uuid: string; + try { + const me = await identitiesRepo.findByRealDid(ds, (await stack.identity.current()).did!); + assert.ok(me, 'the active identity has a row'); + + uuid = await credentialsRepo.create(ds, { + vcType: 'vrc', + issuerIdentityUuid: me.uuid, + subjectIdentityUuid: me.uuid, + validFrom: Math.floor(Date.now() / 1000), + }); + } finally { + await ds.close(); + } // The uuid IS the address, and it is published: `StoredCredentialDto.uuid`. const listed = (await stack.credentials.list()).find((c) => c.source === 'dtg' && c.uuid === uuid); diff --git a/packages/app-api/test/trust_app_api/domains.test.ts b/packages/app-api/test/trust_app_api/domains.test.ts index 3005edb..7b74e41 100644 --- a/packages/app-api/test/trust_app_api/domains.test.ts +++ b/packages/app-api/test/trust_app_api/domains.test.ts @@ -12,6 +12,7 @@ import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, readFileSync, readdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { createAgentStack, type TrustStack } from '../../src/index.js'; @@ -200,7 +201,7 @@ test('the bus is wired to every runtime callback the catalogue promises', () => // show up in behaviour until a ceremony actually ran. Asserted at the source, in the same spirit as the // delegation guards. const src = readFileSync( - new URL('../../src/client/trust-stack.ts', import.meta.url).pathname, + fileURLToPath(new URL('../../src/client/trust-stack.ts', import.meta.url)), 'utf8', ); const REQUIRED = [ @@ -241,7 +242,7 @@ const KNOWN_UNEMITTED: ReadonlyArray<[string, string]> = [ test('every catalogue event that has a declared payload is emitted by SOMETHING', async () => { const catalogue = readFileSync( - new URL('../../src/events/catalog.ts', import.meta.url).pathname, + fileURLToPath(new URL('../../src/events/catalog.ts', import.meta.url)), 'utf8', ); // Every `'name': { payload }` line is an event with a declared payload, i.e. one a caller can subscribe to. @@ -252,7 +253,7 @@ test('every catalogue event that has a declared payload is emitted by SOMETHING' // client root all count. const emitted = new Set(); for (const dir of ['client', 'domains', 'events']) { - const base = new URL(`../../src/${dir}/`, import.meta.url).pathname; + const base = fileURLToPath(new URL(`../../src/${dir}/`, import.meta.url)); for (const entry of readdirSync(base)) { if (!entry.endsWith('.ts')) continue; const src = readFileSync(base + entry, 'utf8'); diff --git a/packages/app-api/test/trust_app_api/graph.test.ts b/packages/app-api/test/trust_app_api/graph.test.ts index b171ed4..59ed951 100644 --- a/packages/app-api/test/trust_app_api/graph.test.ts +++ b/packages/app-api/test/trust_app_api/graph.test.ts @@ -30,7 +30,7 @@ */ import { test, after } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -419,4 +419,54 @@ test('debug() returns null for an unknown peer, and a truncated transport view f } finally { await ds.close(); } }); +// --------------------------------------------------------------------------- +// 7. The joined VTN's key on the utility-server / VTN node labels +// --------------------------------------------------------------------------- + +/** + * The agent graph names the network it joined — `utility-server (p2pts)` — just + * as the bootstrap dashboard does (`Utl Server (p2pts)`), and just as the + * utility server's own graph names the network it serves. The key is the + * `vtn:` value `bootstrapConfig.recordVtn()` writes to `config.yaml` when the + * user picks a VTN. + * + * Both shapes of the server are covered: the VTN identity the agent holds + * (`role='vtn'`) and the server's own organization VTA (`role='vta'`, + * `subject_kind='organization'`). A label that already IS the key must not + * double up, and an agent with no VTN chosen must keep the plain labels. + */ +test('the utility-server and VTN nodes carry the joined VTN key in their labels', async () => { + const { stack, ds, selfUuid } = await makeStack(); + const SERVER = 'server-000'; + const VTN = 'vtn-00000'; + try { + // Nodes come from edge endpoints plus self, so each needs an edge: a peer + // relationship to the server, and a membership credential from the VTN. + await seedIdentity(ds, { uuid: SERVER, name: 'utility-server', subjectKind: 'organization' }); + await seedIdentity(ds, { uuid: VTN, name: 'Trust Stackers', role: 'vtn', subjectKind: 'organization' }); + await seedRelationship(ds, { uuid: 'rel-server', local: selfUuid, remote: SERVER }); + await seedCredential(ds, { uuid: 'vmc-vtn', vcType: 'vmc', issuer: VTN, subject: selfUuid }); + + const configPath = join(stack.host.home, 'config.yaml'); + writeFileSync(configPath, 'vtn: "p2pts"\n', 'utf-8'); + + const g = await stack.relationships.graph(); + assert.equal(g.nodes.find((n) => n.id === SERVER)!.label, 'utility-server (p2pts)'); + assert.equal(g.nodes.find((n) => n.id === VTN)!.label, 'Trust Stackers (p2pts)'); + // Only the utility-server/VTN nodes are annotated — the agent names itself. + assert.equal(g.nodes.find((n) => n.id === selfUuid)!.label, AGENT); + + // No VTN chosen → the labels are exactly what they were before the suffix existed. + rmSync(configPath, { force: true }); + const plain = await stack.relationships.graph(); + assert.equal(plain.nodes.find((n) => n.id === SERVER)!.label, 'utility-server'); + assert.equal(plain.nodes.find((n) => n.id === VTN)!.label, 'Trust Stackers'); + + // A VTN whose own name IS the key must not read `p2pts (p2pts)`. + await ds.run(`UPDATE identities SET name = 'p2pts' WHERE uuid = ?`, VTN); + writeFileSync(configPath, 'vtn: "p2pts"\n', 'utf-8'); + const named = await stack.relationships.graph(); + assert.equal(named.nodes.find((n) => n.id === VTN)!.label, 'p2pts'); + } finally { await ds.close(); } +}); diff --git a/packages/app-api/test/trust_app_api/scope.test.ts b/packages/app-api/test/trust_app_api/scope.test.ts index 53faf37..a17f0bb 100644 --- a/packages/app-api/test/trust_app_api/scope.test.ts +++ b/packages/app-api/test/trust_app_api/scope.test.ts @@ -26,6 +26,7 @@ import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { createAgentStack, METHOD_CLASSIFICATION, @@ -138,7 +139,7 @@ test('every method of every exposed domain is classified — an unclassified one // Structural, against the domain SOURCES. This is what makes the scope robust to a domain gaining a method: a // new method is not reachable by accident, it is refused until someone classifies it — and this test fails so // the omission is noticed rather than shipped as an accidental grant. - const domainsDir = new URL('../../src/domains/', import.meta.url).pathname; + const domainsDir = fileURLToPath(new URL('../../src/domains/', import.meta.url)); const unclassified: string[] = []; for (const domain of Object.keys(METHOD_CLASSIFICATION.read) as ScopedDomain[]) { diff --git a/packages/app-api/test/trust_app_api/surface.test.ts b/packages/app-api/test/trust_app_api/surface.test.ts index 71b15b1..c278237 100644 --- a/packages/app-api/test/trust_app_api/surface.test.ts +++ b/packages/app-api/test/trust_app_api/surface.test.ts @@ -28,9 +28,10 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; /** The interface moved here in P1 (`agent-app/src/` → `trust-app-api/runtime/`). */ -const HANDLE_PATH = new URL('../../src/runtime/handle.ts', import.meta.url).pathname; +const HANDLE_PATH = fileURLToPath(new URL('../../src/runtime/handle.ts', import.meta.url)); const INTERFACE_HEADER = 'export interface AgentHandle {'; @@ -44,6 +45,11 @@ const EXPECTED_MEMBERS: string[] = [ 'sendMessage', 'sendConfidentialMessage', 'onMessage', 'onRelationshipChange', 'onConnectionEvent', 'onNetworkChange', 'refreshBootstrapPeers', 'isBootstrapConnected', 'getBootstrapRelayAddr', 'getDiscoveredPeers', + // Added 2026-10-19: the VTN identity. `getNet` reports the VTN key this agent + // registers as (`net`); `setNet` adopts a different one and re-registers. Both + // optional so an agent implementation without VTN switching still satisfies the + // contract, and the login flow null-checks them. + 'getNet', 'setNet', 'reRegisterWithBootstrap', 'getConnectivityStatus', 'getNetworkProfile', 'isNetworkChanged', 'getClientSourceAddr', 'createProfile', 'listProfiles', 'updateProfile', 'removeProfile', @@ -96,6 +102,10 @@ const EXPECTED_MEMBERS: string[] = [ */ const EXPECTED_OPTIONAL: string[] = [ 'getDbHandle', + // Added 2026-10-19: the VTN identity (see the members list above). Optional for + // the same reason as the other capability methods — a stack that cannot switch + // VTN must still be usable, so every caller null-checks them. + 'getNet', 'setNet', 'getConnectivityStatus', 'createVtn', 'listVtcs', 'affiliateVtc', 'unaffiliateVtc', 'getVtnHierarchy', // Added 2026-10-09 (Group A): the single-admin issuance path. Optional because it is a CAPABILITY — a @@ -177,24 +187,24 @@ test('AgentHandle exists at the expected path', () => { assert.ok(existsSync(HANDLE_PATH), `${HANDLE_PATH} is missing`); }); -test('AgentHandle member list is unchanged (101 members, in order)', () => { +test('AgentHandle member list is unchanged (103 members, in order)', () => { const { members } = parseAgentHandle(readFileSync(HANDLE_PATH, 'utf8')); assert.deepEqual( members, EXPECTED_MEMBERS, 'AgentHandle changed shape — a contract change, not a test fix', ); - assert.equal(members.length, 101); + assert.equal(members.length, 103); }); -test('AgentHandle optional-member count is unchanged (48 optional)', () => { +test('AgentHandle optional-member count is unchanged (50 optional)', () => { const { optional } = parseAgentHandle(readFileSync(HANDLE_PATH, 'utf8')); assert.deepEqual( optional, EXPECTED_OPTIONAL, 'the set of optional members changed — callers null-check exactly these', ); - assert.equal(optional.length, 48); + assert.equal(optional.length, 50); }); @@ -202,7 +212,7 @@ test('AgentHandle optional-member count is unchanged (48 optional)', () => { // P2: the trust-app-api surface itself // ───────────────────────────────────────────────────────────────────────────── -const AGENT_API = new URL('../../src/', import.meta.url).pathname; +const AGENT_API = fileURLToPath(new URL('../../src/', import.meta.url)); function readDir(dir: string, ext = '.ts'): string[] { return readdirSync(dir) @@ -241,7 +251,7 @@ test('the barrel re-exports NO runtime value — only the types `Host` signature test('API tests import only the public barrel — no runtime internals (R7)', () => { const offenders: string[] = []; - for (const file of readDir(new URL('.', import.meta.url).pathname)) { + for (const file of readDir(fileURLToPath(new URL('.', import.meta.url)))) { if (!file.endsWith('.test.ts') && !file.endsWith('harness.ts')) continue; const src = readFileSync(file, 'utf8'); for (const m of src.matchAll(/from\s+'([^']+)'/g)) { diff --git a/packages/core/README.md b/packages/core/README.md index fa4d599..6a2096c 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -51,7 +51,7 @@ const transport = new Libp2pTransport(node); | `@p2p-trust-stack/core/db` | DataSource abstraction, migrations (20 shipped `.sql` files), typed repositories | | `@p2p-trust-stack/core/services` | Shared business logic (VTA/VTC creation, profiles, recovery, policy engine, credential factory) | | `@p2p-trust-stack/core/platform` | `P2PTS_HOME` resolution and network detection | -| `@p2p-trust-stack/core/config` | Bootstrap/relay config loader (typed `.env` → `BootstrapEnv`, `buildBootstrapUrls`) | +| `@p2p-trust-stack/core/config` | Config loaders (typed `.env` → `BootstrapEnv`, `buildBootstrapUrls`; logging block → `applyLoggingEnv`) | | `@p2p-trust-stack/core/tsp-handlers` | TSP protocol envelope types | | `@p2p-trust-stack/core/util` | Small shared utilities (leveled `logger`, dedup log) | @@ -98,6 +98,35 @@ setLogLevel('debug'); // or 'silent' setLogSink((level, args) => myLogger[level](...args)); // route instead of suppress ``` +### Setting it from a config file instead + +`P2PTS_LOG_LEVEL=info node …` is POSIX-shell syntax, so it cannot live in a +`package.json` script that has to run on Windows, and it does not exist at all where a +native host launches Node (Capacitor). `config/logging-env.ts` reads the same two keys +out of a `.env`, so an app can keep its verbosity in a file: + +```ts +import { applyLoggingEnv } from '@p2p-trust-stack/core/config/logging-env'; + +applyLoggingEnv({ path: '/etc/my-app/.env' }); // cannot throw +``` + +``` +# .env +P2PTS_LOG_LEVEL=info +CEREMONY_TRACE=1 +``` + +The resolved order is **`setLogLevel()` > the config file > `P2PTS_LOG_LEVEL` > the +default**, so an embedder's explicit call always wins, and the environment variable +still works exactly as documented above. A missing, unreadable or malformed file is +never fatal — it just leaves logging at the next rung. `getEffectiveLogging()` reports +what is actually in force. + +`CEREMONY_TRACE=1` is separate from the level on purpose: it emits the `[CEREMONY]` +OOBI→RFI→RFA trace lines and is **not** silenced by `P2PTS_LOG_LEVEL=silent`, nor +switched on by `debug`. `setCeremonyTrace()` overrides it programmatically. + ## License Dual-licensed under **GPL-3.0-only** or a commercial license — see the repository's diff --git a/packages/core/package.json b/packages/core/package.json index ddb233a..b8a8044 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@p2p-trust-stack/core", - "version": "0.1.0", + "version": "0.2.0", "description": "P2P Trust Stack core — libp2p-based Trust Spanning Protocol (TSP), decentralized DID creation/rotation, an encrypted SQLite keystore, relationships/OOBI, Verified Trust Communities (VTCs) and verifiable credentials.", "type": "module", "license": "(GPL-3.0-only OR LicenseRef-TrustStackers-Commercial)", diff --git a/packages/core/src/config/bootstrap-env.ts b/packages/core/src/config/bootstrap-env.ts index aca3f54..9ef2884 100644 --- a/packages/core/src/config/bootstrap-env.ts +++ b/packages/core/src/config/bootstrap-env.ts @@ -23,7 +23,12 @@ * Node (`.env` file) and, later, in a Capacitor/browser build * (`import.meta.env` / an injected global). * - * ## The `.env` shape (per component) + * ## Two `.env` shapes — one per role + * + * There are exactly TWO kinds of component, and they need different things: + * + * **The bootstrap server** (`bootstrap/.env`) — the ONLY component whose identity + * is derived from a word phrase. It holds the seed and derives the peer ID: * * BOOTSTRAP_SEED_HEX=<64 hex chars> # derived from the word sequence * BOOTSTRAP_PEER_ID=12D3KooW... # derived from the seed (optional) @@ -32,10 +37,24 @@ * BOOTSTRAP_WS_PORT=4002 * BOOTSTRAP_ENABLE_RELAY=true * - * `BOOTSTRAP_PEER_ID` is optional: when omitted it is derived from + * `BOOTSTRAP_PEER_ID` is optional there: when omitted it is derived from * `BOOTSTRAP_SEED_HEX` (see {@link derivePeerIdFromSeedHex}). When both are * present they must agree — a mismatch is a corrupt config, not a preference, - * so {@link verifyBootstrapEnv} rejects it. + * so {@link verifyBootstrapEnv} rejects it. Read with {@link loadBootstrapEnv}. + * + * **A client** (`agent-app/.env`, `utility-server/.env`) — every other component + * only has to know WHERE the server is and WHICH peer ID to expect. It holds no + * key material at all: + * + * BOOTSTRAP_PEER_ID=12D3KooW... # required — there is no seed to derive it from + * BOOTSTRAP_HOST=mediator.example.com + * BOOTSTRAP_TCP_PORT=4001 + * BOOTSTRAP_WS_PORT=4002 + * BOOTSTRAP_ENABLE_RELAY=true + * + * Read with {@link loadBootstrapClientEnv}, which REJECTS a client `.env` that + * still carries `BOOTSTRAP_SEED_HEX` — a seed in a client file is a leaked + * private key, not a preference, and a client has no use for it. */ import { readFileSync } from 'node:fs'; @@ -65,7 +84,7 @@ export interface BootstrapEnv { enableRelay: boolean; } -/** The multiaddrs derived from a {@link BootstrapEnv}. */ +/** The multiaddrs derived from a {@link BootstrapEnv} or {@link BootstrapClientEnv}. */ export interface BootstrapUrls { /** TCP dial address, e.g. `/dns/host/tcp/4001/p2p/`. */ tcpUrl: string; @@ -75,6 +94,40 @@ export interface BootstrapUrls { relayAddr: string; } +/** + * The bootstrap identity + address a **client** component needs — deliberately + * WITHOUT the seed. + * + * Only the bootstrap server holds `BOOTSTRAP_SEED_HEX`: it is the one component + * whose peer ID is derived from a word sequence. The agent app and the utility + * server are clients — they only have to know where the server is and which peer + * ID to expect — so their `.env` carries `BOOTSTRAP_PEER_ID` + `BOOTSTRAP_HOST` + * and never any key material. + */ +export interface BootstrapClientEnv { + /** Schema version, so the shape can evolve. */ + version: 1; + /** The libp2p peer ID (base58btc `12D3Koo...`) to dial and to expect. */ + peerId: string; + /** DNS hostname or fixed public IP the server is reachable at. */ + host: string; + /** TCP dial port (default 4001). */ + tcpPort: number; + /** WebSocket dial port (default 4002). */ + wsPort: number; + /** Whether the server advertises a circuit-relay service. */ + enableRelay: boolean; +} + +/** + * The subset {@link buildBootstrapUrls} needs. + * + * Satisfied by BOTH {@link BootstrapEnv} (the server) and + * {@link BootstrapClientEnv} (a client), so the one URL builder serves both roles + * and neither has to convert into the other. + */ +export type BootstrapUrlsInput = Pick; + /** * A pluggable source of raw `KEY=value` config. * @@ -172,7 +225,6 @@ export function browserEnvSource(): EnvSource { if (injected) return { ...injected }; if (viteEnv) { return { - BOOTSTRAP_SEED_HEX: viteEnv.VITE_BOOTSTRAP_SEED_HEX ?? '', BOOTSTRAP_PEER_ID: viteEnv.VITE_BOOTSTRAP_PEER_ID ?? '', BOOTSTRAP_HOST: viteEnv.VITE_BOOTSTRAP_HOST ?? '', BOOTSTRAP_TCP_PORT: viteEnv.VITE_BOOTSTRAP_TCP_PORT ?? '', @@ -233,8 +285,8 @@ export function hostToMultiaddrPrefix(host: string): string { return `/dns/${host}`; } -/** Build the TCP/WS/relay multiaddrs from a validated env. */ -export function buildBootstrapUrls(env: BootstrapEnv): BootstrapUrls { +/** Build the TCP/WS/relay multiaddrs from a validated env (server OR client). */ +export function buildBootstrapUrls(env: BootstrapUrlsInput): BootstrapUrls { const hostPrefix = hostToMultiaddrPrefix(env.host); const peerSuffix = `/p2p/${env.peerId}`; return { @@ -287,7 +339,12 @@ function parseBoolKey(raw: Record, key: string, fallback: boolea } /** - * Load and validate the bootstrap env from the configured source. + * Load and validate the bootstrap **server** env from the configured source. + * + * SERVER ONLY — this is the one loader that requires `BOOTSTRAP_SEED_HEX`, + * because the bootstrap server is the one component whose peer ID is derived from + * a word sequence. A client component (agent app, utility server) must use + * {@link loadBootstrapClientEnv} instead, which never reads a seed. * * Throws with an actionable message on any missing/malformed field — never * returns a partially-populated config, and never falls back to a hardcoded @@ -321,9 +378,54 @@ export function loadBootstrapEnv(opts: LoadBootstrapEnvOptions = {}): BootstrapE }; } +/** + * Load and validate a bootstrap **client** env from the configured source. + * + * This is the loader for the agent app and the utility server. Both are clients: + * they only need to know where the bootstrap server is and which peer ID to + * expect, so they read `BOOTSTRAP_HOST` + `BOOTSTRAP_PEER_ID` (both REQUIRED — + * there is no seed to derive the peer ID from) plus the ports. + * + * A `BOOTSTRAP_SEED_HEX` in a client `.env` is REJECTED rather than ignored: a + * seed in a client file is a leaked private key with no client use, and silently + * tolerating it is how it stays there. Re-running + * `npm run init:bootstrap -- --component ` removes it. + */ +export function loadBootstrapClientEnv(opts: LoadBootstrapEnvOptions = {}): BootstrapClientEnv { + const source = opts.source ?? nodeEnvSource(); + const raw = source.read(opts.path); + + if (raw.BOOTSTRAP_SEED_HEX?.trim()) { + throw new Error( + `bootstrap env (${source.name}): BOOTSTRAP_SEED_HEX is present, but this component is a ` + + 'CLIENT. Only the bootstrap server holds the seed — remove the line and keep ' + + 'BOOTSTRAP_PEER_ID + BOOTSTRAP_HOST (re-run `npm run init:bootstrap -- --component ' + + '` to rewrite the file).', + ); + } + + const host = requireKey(raw, 'BOOTSTRAP_HOST', source.name); + const peerId = requireKey(raw, 'BOOTSTRAP_PEER_ID', source.name); + const tcpPort = parseIntKey(raw, 'BOOTSTRAP_TCP_PORT', 4001, source.name); + const wsPort = parseIntKey(raw, 'BOOTSTRAP_WS_PORT', 4002, source.name); + const enableRelay = parseBoolKey(raw, 'BOOTSTRAP_ENABLE_RELAY', true); + + return { + version: 1, + peerId, + host, + tcpPort, + wsPort, + enableRelay, + }; +} + /** * Verify that `env.peerId` (when present) matches `env.seedHex`. * + * SERVER ONLY — the seed is what the check is against, so there is nothing for a + * client to verify here. + * * Async because peer-ID derivation is async. Returns the resolved peer ID — * filling it in when the `.env` omitted `BOOTSTRAP_PEER_ID` — so a caller can * `env.peerId = await verifyBootstrapEnv(env)`. diff --git a/packages/core/src/config/logging-env.ts b/packages/core/src/config/logging-env.ts new file mode 100644 index 0000000..a08c023 --- /dev/null +++ b/packages/core/src/config/logging-env.ts @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-TrustStackers-Commercial +// Copyright (C) 2026 Trust Stackers + +/** + * logging-env — where a component's logging verbosity comes from. + * + * ## Why this exists + * + * `util/logger.ts` resolves its level from `P2PTS_LOG_LEVEL`, and the reference apps + * used to set that variable **inline in their `package.json` scripts**: + * + * "start": "P2PTS_LOG_LEVEL=info node index.ts" + * + * That syntax is POSIX-shell only. `cmd.exe` and PowerShell cannot parse it, so the + * script fails on Windows — and an app launched by a native host rather than npm (the + * Capacitor mobile build) has no `package.json` script to put it in at all. A variable + * cannot be assigned from a `package.json` entry portably either: a script is ONE shell + * string, so a "line" that sets a variable runs in its own subprocess and dies with it. + * + * A **config file** has neither problem, and this project already has one per component: + * the same `.env` that carries the bootstrap block (see `bootstrap-env.ts`). This module + * reads the logging keys out of it and applies them, so verbosity travels with the + * component and every platform gets the same behaviour. + * + * ## The keys + * + * P2PTS_LOG_LEVEL=silent | error | warn | info | debug (default: warn) + * CEREMONY_TRACE=1 (default: off) + * + * ## Precedence + * + * Applied through {@link applyLoggingEnv}, highest first: + * + * 1. an explicit `setLogLevel()` / `setCeremonyTrace()` call by an embedder, else + * 2. the component's config file (this module), else + * 3. the `P2PTS_LOG_LEVEL` / `CEREMONY_TRACE` environment variable, else + * 4. the defaults — `warn`, and tracing off. + * + * Rung 3 is kept so the contract published in the package READMEs still holds for an + * embedder that sets the variable itself. + * + * ## Never fatal + * + * Every failure here is tolerated: a missing file, an unreadable file, an unknown key + * or a nonsense value all resolve to "no opinion", and the level falls through to the + * next rung. Logging must never be the thing that stops an app from starting — the same + * rule `relationship/ceremony-trace.ts` and the mobile entry already follow. + * + * ## One source, every platform + * + * The file read is behind {@link EnvSource} (from `bootstrap-env.ts`), so the same API + * serves a Node `.env`, a build-time injected map, or a test's in-memory map. A component + * that is not a Node process supplies its own source instead of a path. + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { parseEnvText, type EnvSource } from './bootstrap-env.js'; +import { + LOG_LEVEL_ENV, + getLogLevel, + isLogLevel, + setConfigLogLevel, + type LogLevel, +} from '../util/logger.js'; +import { + CEREMONY_TRACE_ENV, + isCeremonyTraceEnabled, + setCeremonyTrace, +} from '../relationship/ceremony-trace.js'; + +// --------------------------------------------------------------------------- +// Keys +// --------------------------------------------------------------------------- + +/** The `.env` key naming the stack-wide log level. */ +export const LOG_LEVEL_KEY = LOG_LEVEL_ENV; + +/** The `.env` key turning `[CEREMONY]` tracing on. */ +export const CEREMONY_TRACE_KEY = CEREMONY_TRACE_ENV; + +/** The values that read as `true` for a boolean key. */ +const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); +/** The values that read as `false` for a boolean key. */ +const FALSE_VALUES = new Set(['0', 'false', 'no', 'off']); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** What a config file says about logging. Absent fields mean "no opinion". */ +export interface LoggingEnv { + /** The level the file named, when it named a valid one. */ + level?: LogLevel; + /** Whether the file asked for `[CEREMONY]` tracing. */ + ceremonyTrace?: boolean; +} + +export interface LoggingEnvOptions { + /** + * Explicit `.env` path. Defaults to `/.env` — the same file + * `bootstrap-env.ts` reads, so a component has ONE config file. + */ + path?: string; + /** + * Override the source. Defaults to a file read. Supply this where there is no + * `.env` on disk — a browser/Capacitor build, or a test. + */ + source?: EnvSource; +} + +// --------------------------------------------------------------------------- +// Loading +// --------------------------------------------------------------------------- + +/** Parse a boolean key: a known true/false word, or `undefined` for anything else. */ +function parseBoolKey(raw: Record, key: string): boolean | undefined { + const value = raw[key]?.trim().toLowerCase(); + if (!value) return undefined; + if (TRUE_VALUES.has(value)) return true; + if (FALSE_VALUES.has(value)) return false; + return undefined; +} + +/** + * Read the logging block from a component's config. + * + * Never throws and never returns a partial guess: an unreadable source, a missing + * key, or a value outside the documented set all come back as "no opinion" so the + * caller falls through to the next precedence rung. + */ +export function loadLoggingEnv(opts: LoggingEnvOptions = {}): LoggingEnv { + let raw: Record; + + if (opts.source) { + try { + raw = opts.source.read(opts.path); + } catch { + // An injected source that cannot read is a deployment fact, not a crash: + // logging falls back rather than taking the app down with it. + return {}; + } + } else { + const file = opts.path ?? resolve(process.cwd(), '.env'); + let text: string; + try { + text = readFileSync(file, 'utf8'); + } catch { + return {}; + } + raw = parseEnvText(text); + } + + const out: LoggingEnv = {}; + + const level = raw[LOG_LEVEL_KEY]?.trim().toLowerCase(); + if (level && isLogLevel(level)) out.level = level; + + const trace = parseBoolKey(raw, CEREMONY_TRACE_KEY); + if (trace !== undefined) out.ceremonyTrace = trace; + + return out; +} + +/** + * Read a component's logging block and apply it, then report what the file said. + * + * Applying `undefined` clears any previously applied config value rather than + * pinning a default, which is what keeps the precedence chain honest across calls. + */ +export function applyLoggingEnv(opts: LoggingEnvOptions = {}): LoggingEnv { + const env = loadLoggingEnv(opts); + setConfigLogLevel(env.level); + setCeremonyTrace(env.ceremonyTrace); + return env; +} + +// --------------------------------------------------------------------------- +// Reporting +// --------------------------------------------------------------------------- + +/** What logging is actually doing right now, after the whole chain is resolved. */ +export interface EffectiveLogging { + /** The level a line must reach to be emitted. */ + level: LogLevel; + /** Whether `[CEREMONY]` tracing lines are being emitted. */ + ceremonyTrace: boolean; +} + +/** The resolved state — useful for a startup line, or a UI status field. */ +export function getEffectiveLogging(): EffectiveLogging { + return { level: getLogLevel(), ceremonyTrace: isCeremonyTraceEnabled() }; +} diff --git a/packages/core/src/platform/p2pts-home.ts b/packages/core/src/platform/p2pts-home.ts index 2577441..60494f9 100644 --- a/packages/core/src/platform/p2pts-home.ts +++ b/packages/core/src/platform/p2pts-home.ts @@ -371,6 +371,28 @@ export interface P2ptsConfig { default_vta_name: string; utility_server_url: string; passphrase_session_timeout_sec: number; + /** + * Bootstrap host this machine dials — DNS name or fixed public IP. + * + * The DEFAULT comes from the component's `.env`; this field records an + * override the user typed into the agent app's startup screen. Empty means + * "use the `.env` value". + */ + bootstrap_host: string; + /** + * Bootstrap peer ID to expect — the client's only copy of the server's + * identity (a client holds no seed). Empty means "use the `.env` value". + */ + bootstrap_peer_id: string; + /** + * The VTN key (namespace) this machine last connected to, e.g. `p2pts`. + * + * Sent to the bootstrap as the peer's `net` so the agent identifies itself by + * the VTN it is actually using. Empty means "not chosen yet" — the `net` field + * is then omitted from registration rather than defaulted to some other + * network's key. + */ + vtn: string; } const DEFAULT_CONFIG: P2ptsConfig = { @@ -378,6 +400,9 @@ const DEFAULT_CONFIG: P2ptsConfig = { default_vta_name: '', utility_server_url: '', passphrase_session_timeout_sec: 900, + bootstrap_host: '', + bootstrap_peer_id: '', + vtn: '', }; /** @@ -408,6 +433,15 @@ export function loadP2ptsConfig(p2ptsHome?: string): P2ptsConfig { passphrase_session_timeout_sec: typeof parsed.passphrase_session_timeout_sec === 'number' ? parsed.passphrase_session_timeout_sec : DEFAULT_CONFIG.passphrase_session_timeout_sec, + bootstrap_host: typeof parsed.bootstrap_host === 'string' + ? parsed.bootstrap_host + : DEFAULT_CONFIG.bootstrap_host, + bootstrap_peer_id: typeof parsed.bootstrap_peer_id === 'string' + ? parsed.bootstrap_peer_id + : DEFAULT_CONFIG.bootstrap_peer_id, + vtn: typeof parsed.vtn === 'string' + ? parsed.vtn + : DEFAULT_CONFIG.vtn, }; } catch { return { ...DEFAULT_CONFIG }; @@ -432,6 +466,9 @@ export function saveP2ptsConfig(config: P2ptsConfig, p2ptsHome?: string): void { lines.push(`default_vta_name: "${config.default_vta_name}"`); lines.push(`utility_server_url: "${config.utility_server_url}"`); lines.push(`passphrase_session_timeout_sec: ${config.passphrase_session_timeout_sec}`); + lines.push(`bootstrap_host: "${config.bootstrap_host}"`); + lines.push(`bootstrap_peer_id: "${config.bootstrap_peer_id}"`); + lines.push(`vtn: "${config.vtn}"`); lines.push(''); writeFileSync(configPath, lines.join('\n'), 'utf-8'); diff --git a/packages/core/src/relationship/ceremony-trace.ts b/packages/core/src/relationship/ceremony-trace.ts index f57ad68..26f448a 100644 --- a/packages/core/src/relationship/ceremony-trace.ts +++ b/packages/core/src/relationship/ceremony-trace.ts @@ -10,9 +10,43 @@ * Read **per call**, not at import time: a module-level constant would freeze the flag when this * module is first loaded, so a process that hosts an embedder (or a test) could not turn tracing * on or off afterwards. The read is a cheap property lookup on an already-populated object. + * + * ## Why there is also a setter + * + * The environment variable is not reachable everywhere this module runs. An app whose + * `package.json` script cannot assign one (Windows), or that is launched by a native + * host rather than npm (the Capacitor mobile build), has no way to set it — so the + * flag is also settable programmatically and from a config file, via + * {@link setCeremonyTrace} / `config/logging-env.ts`. Precedence mirrors the logger: + * + * setCeremonyTrace() override > CEREMONY_TRACE env var > off + * + * Tracing stays independent of the log level on purpose: it is an opt-in diagnostic + * that must not be silenced by `P2PTS_LOG_LEVEL=silent`, and not be switched on by + * `P2PTS_LOG_LEVEL=debug`. */ + +/** The environment variable that turns tracing on when no override is set. */ +export const CEREMONY_TRACE_ENV = 'CEREMONY_TRACE'; + +let overrideTrace: boolean | undefined; + +/** + * Turn tracing on/off for this process, or pass `undefined` to fall back to the + * `CEREMONY_TRACE` environment variable. + */ +export function setCeremonyTrace(enabled: boolean | undefined): void { + overrideTrace = enabled; +} + +/** Whether a `[CEREMONY]` line would be emitted right now. */ +export function isCeremonyTraceEnabled(): boolean { + if (overrideTrace !== undefined) return overrideTrace; + return process.env[CEREMONY_TRACE_ENV] === '1' || process.env[CEREMONY_TRACE_ENV] === 'true'; +} + function isEnabled(): boolean { - return process.env.CEREMONY_TRACE === '1' || process.env.CEREMONY_TRACE === 'true'; + return isCeremonyTraceEnabled(); } function short(v: string | undefined, len = 8): string { diff --git a/packages/core/src/relationship/manager.ts b/packages/core/src/relationship/manager.ts index b2bbd18..d3d7ee1 100644 --- a/packages/core/src/relationship/manager.ts +++ b/packages/core/src/relationship/manager.ts @@ -440,7 +440,35 @@ export class RelationshipManager { }); ceremonyTrace.rfiSent({ remoteDid: oobi.cesrVid, localDid: this.identity.did }); - await this.transport.send(sealed, remoteTransport); + try { + await this.transport.send(sealed, remoteTransport); + } catch (err) { + // ── ROLLBACK (field fix 2026-10-29) ────────────────────────────────── + // The row is written BEFORE the send so an RFA that arrives immediately + // can be matched. But if the send itself fails, that row is a LIE: nothing + // is in flight, and no RFA will ever arrive to complete it. + // + // Leaving it behind is not a cosmetic leak. An outbound-pending row is + // read as "a handshake is in progress" by the reconnect path, which then + // only WAITS for it (`TspBridge.connect` → `waitForBidirectional`), and a + // re-send is impossible while the row exists (`insertOutbound` throws + // `already exists`). So ONE transient dial failure used to disable the + // handshake for the life of the process — the row is only reclaimed by + // `pruneStaleOutbound`, which runs at the NEXT agent start. + // + // Field symptom (MBP2014, 2026-10-29): a single `[RM:sendRfi]` line + // followed by five identical 30 s `TspBridge: timed out waiting for + // bidirectional relationship` errors, on a machine whose bootstrap and + // relay connections were demonstrably healthy. Recoverable only by + // restarting the app and waiting out the 5-minute prune window. + // + // A partial send (the frame was written, then the stream close threw) is + // safe to roll back: the peer holds a relationship and answers the fresh + // RFI this rollback makes possible, and the new row carries the new digest + // that the new RFA matches. + this.store.deleteRecord(this.identity.did, remoteDid); + throw err; + } } // --------------------------------------------------------------------------- @@ -472,6 +500,24 @@ export class RelationshipManager { this.store.updateTransport(this.identity.did, remoteDid, transport); } + /** + * Remove the relationship record with `remoteDid`, if one exists. + * + * Exists for the RECONNECT path (`TspBridge.connect`). An outbound-pending row + * that never completed has to be cleared before the handshake can be re-driven, + * because `sendRfi` refuses to insert a duplicate — `insertOutbound` throws + * `relationship already exists`. Without this, the only way out of a stuck + * pending row was to restart the process and wait out `pruneStaleOutbound`'s + * 5-minute window (field bug, MBP2014, 2026-10-29 — see `sendRfi`'s rollback). + * + * Deliberately unconditional: it does NOT check status, so a caller can also + * use it to drop an ESTABLISHED row when the peer's identity is known to have + * been replaced. Callers own that judgement. + */ + removeRelationship(remoteDid: Vid): void { + this.store.deleteRecord(this.identity.did, remoteDid); + } + /** * Update the remote identity's subject_kind for a relationship. * diff --git a/packages/core/src/transport/bootstrap-client.ts b/packages/core/src/transport/bootstrap-client.ts index 961c4f6..5d013a8 100644 --- a/packages/core/src/transport/bootstrap-client.ts +++ b/packages/core/src/transport/bootstrap-client.ts @@ -140,6 +140,12 @@ export interface DiscoveredPeer { net?: string; /** The peer's user name (bootstrap-facing), resolvable as vta::. */ agentName?: string; + /** + * UTILITY SERVER ONLY: the display name of the VTN it serves (e.g. 'Trust + * Stackers'). `net` is the machine key ('p2pts') an agent matches on; this is + * only the human label beside it. + */ + vtnName?: string; /** True when the peer currently holds a circuit-relay reservation on the * bootstrap/relay server (authoritative "reachable via relay" signal). */ reachableViaRelay?: boolean; @@ -363,7 +369,7 @@ export class BootstrapClient { peerType?: 'agent' | 'utility_server', vid?: string, identity?: { vtaUuid?: string; realDid?: string; name?: string; subjectKind?: string }, - designation?: { net?: string; agentName?: string; nameUri?: string; test?: boolean }, + designation?: { net?: string; agentName?: string; vtnName?: string; nameUri?: string; test?: boolean }, ): Promise { const peerId = node.peerId.toString(); @@ -394,6 +400,12 @@ export class BootstrapClient { if (designation?.agentName) { msgObj.agentName = designation.agentName; } + // UTILITY SERVER ONLY: the VTN's display name. `net` above is the machine + // key agents match on; this is the human label the bootstrap renders beside + // it, kept in its own field so it can never be read as an agent name. + if (designation?.vtnName) { + msgObj.vtnName = designation.vtnName; + } if (designation?.nameUri) { msgObj.nameUri = designation.nameUri; } @@ -543,25 +555,41 @@ export class BootstrapClient { const lp = lpStream(stream, { maxDataLength: 65536 }); - // Send the request - await lp.write(payload); + try { + // Send the request + await lp.write(payload); - // Read the response - const data = await lp.read(); - if (data === null) { - throw new Error('Bootstrap server closed connection before responding'); - } + // Read the response + const data = await lp.read(); + if (data === null) { + throw new Error('Bootstrap server closed connection before responding'); + } - // Close the stream only — the underlying TCP connection stays alive for keepalive - try { - await stream.close(); - } catch { - // Stream may already be closed + // Parse the response + const response = JSON.parse(new TextDecoder().decode(data.slice(0, data.length))); + return response as BootstrapResponse; + } finally { + // Close the stream on EVERY path — the STREAM only, so the underlying TCP + // connection stays alive for keepalive and the relay reservation. + // + // This used to be a bare `await stream.close()` on the SUCCESS path only, so + // the `data === null` throw above (and any lp.write()/lp.read() failure) + // leaked the stream. A leak here is not untidy, it is the wedge: every + // leaked stream occupies a slot on the long-lived bootstrap connection, and + // libp2p caps streams per protocol (`maxInboundStreams`, 32 by default) and + // then resets every subsequent one. register() runs on every reconnect + // cycle, so a flapping connection leaks one stream per cycle until the + // bootstrap protocol codec is dead for the life of the connection — which + // is the "Unexpected EOF - stream closed while reading 0/1 bytes" / + // "Bootstrap connection lost after 3 keepalive failures" flap observed on + // 2026-10-29. The server side was silent throughout, because its handlers + // swallow their own errors by design. + try { + await stream.close(); + } catch { + // Stream may already be closed + } } - - // Parse the response - const response = JSON.parse(new TextDecoder().decode(data.slice(0, data.length))); - return response as BootstrapResponse; } /** diff --git a/packages/core/src/transport/dial-candidates.ts b/packages/core/src/transport/dial-candidates.ts new file mode 100644 index 0000000..53fe48b --- /dev/null +++ b/packages/core/src/transport/dial-candidates.ts @@ -0,0 +1,386 @@ +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-TrustStackers-Commercial +// Copyright (C) 2026 Trust Stackers + +/** + * dial-candidates — ONE address-selection policy for every dial this stack makes. + * + * WHY THIS EXISTS + * --------------- + * Before this module the stack had TWO address-selection implementations that + * disagreed, and neither ranked addresses by whether they could actually work: + * + * 1. The INITIAL connection (an agent joining a VTN, or a discovery-based + * recovery) put only `peer.multiaddrs` into the OOBI and let libp2p dial + * them with its default sorter. + * 2. `ConnectivityModule.reconnectPeer()` (recovery) had its own inline + * direct → relay → last-known ladder, which was the only place a + * `/p2p-circuit` address was ever tried. + * + * So a FIRST cross-network join could not use the relay at all, and even when a + * relay address was present libp2p's `defaultAddressSorter` ordered the list by + * `loopbackAddressLast || publicAddressesFirst || circuitRelayAddressesLast || + * certifiedAddressesFirst || reliableTransportsFirst` — which for a typical + * discovered utility server is exactly backwards: + * + * peer.multiaddrs = [ + * '/ip4/192.168.1.246/tcp/4001/p2p/', // LAN — dead off-LAN + * '/ip4/24.5.6.7/tcp/52344/p2p/', // NAT-mapped, dead everywhere + * '/dns/relay.…/tcp/4001/p2p//p2p-circuit/p2p/', // the ONLY usable one + * ] + * defaultAddressSorter → [ 24.5.6.7:52344, relay, 192.168.1.246:4001 ] + * + * `publicAddressesFirst` ranks the NAT-mapped address ahead of the LAN one + * (`isPrivate` is true for 192.168.x but false for 24.5.6.7), so the dial spends + * its `addressDialTimeout` (6 s in the agent's node config) on an address + * nothing listens on — and the relay, when it is reached at all, is last. + * Measured against the real sorters (`@libp2p/utils` 7.x) on 2026-10-28. + * + * THE POLICY HERE + * --------------- + * Rank by "how likely is this address to actually work for the peer I am + * dialing", not by "is this address publicly routable": + * + * 0. relay — a `/p2p-circuit` address. Works from ANY network whenever + * the destination holds a reservation, which is the whole reason the + * bootstrap server is also a relay. FIRST, because it is the only entry + * that is not a guess about where the peer is. + * 1. same-subnet — a direct address inside one of OUR OWN local subnets. + * RANKED SECOND, not first (field correction, 2026-10-29). A /24 match is + * NOT proof of shared L2: two routers on different WANs both hand out + * `192.168.1.x` by default, so a peer's LAN address can look local while + * being a different host — or nothing at all — on OUR lan. That is exactly + * the MBP2014 field log: the agent was `192.168.1.201`, the utility server + * advertised `192.168.1.28` on a different network, and the mis-ranked + * address was dialed FIRST on every attempt. + * + * The cost of this correction is one relay round-trip for a same-LAN join + * in a relay-enabled deployment (a LAN-only rig has no relay candidate at + * all, so nothing changes there), and it buys a dial order that is right + * from every network. It still outranks every other direct scope. + * 2. dns — an operator-declared hostname. A deliberate public name + * (a port-forwarded or cloud-hosted server) is worth trying directly. + * 3. lan — a private address outside our subnets. Almost always + * unreachable, but cheap to try before the guesswork below. + * 4. wan — a bare public IP. Usually the bootstrap's OBSERVED + * remote address, i.e. a NAT-mapped ephemeral port — the least trustworthy + * entry in the list, which is why it ranks near the bottom. + * 5. unknown — unparseable, kept so nothing is silently dropped. + * 6. loopback — only ever correct for a peer on this same machine. + * + * If the relay ever needs to rank lower (e.g. a deployment where direct DNS + * always works and the relay hop's latency matters), change + * {@link DIAL_SCOPE_RANK} — it is the single place the order is expressed, and + * both the explicit dial ladder and the libp2p `addressSorter` read it. + * + * Kept pure and dependency-free (no libp2p, no node APIs, no clock) so it is + * cheap to unit test and safe to call from a status route. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** How reachable an address is likely to be, from the dialing peer's position. */ +export type DialScope = + | 'same-subnet' + | 'relay' + | 'dns' + | 'lan' + | 'wan' + | 'unknown' + | 'loopback'; + +/** + * The dial order, as a rank per scope (LOWER is tried FIRST). + * + * Exported so a caller — or a test — can assert the policy itself rather than + * reverse-engineering it from an ordered list. + */ +export const DIAL_SCOPE_RANK: Readonly> = { + relay: 0, + 'same-subnet': 1, + dns: 2, + lan: 3, + wan: 4, + unknown: 5, + loopback: 6, +}; + +/** Where a candidate came from, so a caller can log/act on the distinction. */ +export type DialCandidateSource = 'direct' | 'relay' | 'last-known'; + +/** One address to try, with the scope that produced its rank. */ +export interface DialCandidate { + /** The multiaddr string, exactly as it should be dialed. */ + addr: string; + scope: DialScope; + source: DialCandidateSource; +} + +/** Everything {@link buildDialCandidates} needs. Every field but `peerId` is optional. */ +export interface DialCandidateInput { + /** The destination peer's libp2p peer ID (appended to the server relay prefix). */ + peerId: string; + /** Direct addresses advertised for the peer (bootstrap directory / OOBI). */ + multiaddrs?: readonly string[]; + /** + * The PER-PEER relay address from the bootstrap directory. Already carries the + * destination peer ID (`/p2p/`). + */ + peerRelayAddr?: string; + /** + * The SERVER-LEVEL relay prefix, ending in `/p2p-circuit` + * (`/dns//tcp//p2p//p2p-circuit`). The + * destination peer ID is appended to build a dialable address. + */ + serverRelayPrefix?: string; + /** + * This machine's own LAN IPs (`getLocalLanIps()`). Used to recognise a direct + * address that is on OUR subnet. Omit to skip the `same-subnet` tier — the + * policy degrades to `relay → dns → lan → wan`, which is still correct, only + * slightly less fast for a same-LAN peer. + */ + localLanIps?: readonly string[]; + /** Previously stored addresses, tried last. Never outranks a fresh one. */ + lastKnownMultiaddrs?: readonly string[]; +} + +// --------------------------------------------------------------------------- +// Address parsing helpers (pure) +// --------------------------------------------------------------------------- + +/** The first host component of a multiaddr, e.g. `/ip4/1.2.3.4/…` → ip4 + 1.2.3.4. */ +interface HostComponent { + kind: 'ip4' | 'ip6' | 'dns'; + value: string; +} + +const HOST_RE = /^\/(ip4|ip6|dns|dns4|dns6|dnsaddr)\/([^/]+)/; + +function firstHostComponent(addr: string): HostComponent | undefined { + const m = HOST_RE.exec(addr); + if (!m) return undefined; + const kind = m[1]; + // Strip an IPv6 zone index (`fe80::1%en0` → `fe80::1`): it is interface-local + // and would make every comparison against another host fail. + const value = m[2].split('%')[0]; + if (kind === 'ip4') return { kind: 'ip4', value }; + if (kind === 'ip6') return { kind: 'ip6', value }; + return { kind: 'dns', value }; +} + +/** Octets of a dotted-quad IPv4, or undefined when it is not one. */ +function ipv4Octets(ip: string): number[] | undefined { + const parts = ip.split('.'); + if (parts.length !== 4) return undefined; + const octets: number[] = []; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return undefined; + const n = Number(part); + if (n > 255) return undefined; + octets.push(n); + } + return octets; +} + +function isLoopbackIp(ip: string): boolean { + if (ip === '::1') return true; + const v4 = ipv4Octets(ip); + return v4 ? v4[0] === 127 : false; +} + +/** + * Private / non-routable IPv4 and IPv6. + * + * `100.64.0.0/10` (carrier-grade NAT) is included as NOT public: it is the + * address family a Starlink or mobile-carrier peer is handed, and no other host + * can dial it. `@libp2p/utils`'s `isPrivateIp` agrees — but its + * `publicAddressesFirst` sorter then ranks a genuinely routable address ahead of + * a LAN one, which is the inversion this module exists to undo. + */ +function isPrivateIp(ip: string): boolean { + if (ip.includes(':')) { + const lower = ip.toLowerCase(); + if (lower === '::1') return true; + if (lower.startsWith('fe80')) return true; // link-local + if (/^f[cd]/.test(lower)) return true; // fc00::/7 unique-local + return false; + } + const v4 = ipv4Octets(ip); + if (!v4) return false; + const [a, b] = v4; + if (a === 10) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 169 && b === 254) return true; // link-local + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64/10 + return false; +} + +/** + * Whether `ip` is on the same subnet as one of `localLanIps`. + * + * IPv4 compares the first three octets (a /24), IPv6 the first four hextets (a + * /64) — the granularity a home/office LAN and a typical mobile carrier + * allocation actually use. An IPv6 address with fewer than four explicit + * hextets before its `::` is treated as NOT same-subnet rather than guessed at; + * it still classifies as `lan`/`wan` and still outranks nothing important. + */ +function sharesSubnet(ip: string, localLanIps: readonly string[]): boolean { + if (localLanIps.length === 0) return false; + + const v4 = ipv4Octets(ip); + if (v4) { + const prefix = `${v4[0]}.${v4[1]}.${v4[2]}.`; + return localLanIps.some((local) => { + const lv4 = ipv4Octets(local); + if (!lv4) return false; + return `${lv4[0]}.${lv4[1]}.${lv4[2]}.` === prefix; + }); + } + + if (!ip.includes(':')) return false; + const hextets = ipHextets(ip); + if (!hextets) return false; + return localLanIps.some((local) => { + const lh = ipHextets(local); + if (!lh) return false; + return hextets.join(':') === lh.join(':'); + }); +} + +/** The first four hextets of an IPv6 address, or undefined when it has too few. */ +function ipHextets(ip: string): string[] | undefined { + const head = ip.split('::')[0]; + const parts = head.split(':').filter((h) => h.length > 0); + if (parts.length < 4) return undefined; + return parts.slice(0, 4).map((h) => h.toLowerCase()); +} + +// --------------------------------------------------------------------------- +// Classification + comparison +// --------------------------------------------------------------------------- + +/** + * Classify one address for a dialer sitting behind `localLanIps`. + * + * `relay` is tested FIRST: a circuit address also starts with `/dns/…` or + * `/ip4/…`, so testing the host kind first would misclassify the relay as a + * direct DNS/IP address — the exact mistake `isPrivate()`-style sorting makes. + */ +export function classifyDialAddr(addr: string, localLanIps: readonly string[] = []): DialScope { + if (typeof addr !== 'string' || addr.length === 0) return 'unknown'; + if (addr.includes('/p2p-circuit')) return 'relay'; + + const host = firstHostComponent(addr); + if (!host) return 'unknown'; + + if (host.kind === 'dns') { + const lower = host.value.toLowerCase(); + if (lower === 'localhost' || lower.endsWith('.localhost')) return 'loopback'; + return 'dns'; + } + + if (isLoopbackIp(host.value)) return 'loopback'; + if (sharesSubnet(host.value, localLanIps)) return 'same-subnet'; + return isPrivateIp(host.value) ? 'lan' : 'wan'; +} + +/** + * Compare two addresses by the policy in {@link DIAL_SCOPE_RANK}. + * + * Returns `-1 | 0 | 1`, so it is usable BOTH as the comparator for the explicit + * candidate list AND (through {@link makeAddressSorter}) as libp2p's + * `AddressSorter`. Same-scope addresses keep their relative order — + * `Array.prototype.sort` is stable, and the caller feeds the list in source + * order (per-peer relay before server-derived relay, advertised before + * last-known). + */ +export function compareDialAddrs( + a: string, + b: string, + localLanIps: readonly string[] = [], +): -1 | 0 | 1 { + const ra = DIAL_SCOPE_RANK[classifyDialAddr(a, localLanIps)]; + const rb = DIAL_SCOPE_RANK[classifyDialAddr(b, localLanIps)]; + if (ra < rb) return -1; + if (ra > rb) return 1; + return 0; +} + +/** + * libp2p `AddressSorter` built from the same policy. + * + * Install it as `connectionManager.addressSorter` so a dial libp2p performs ON + * ITS OWN — `Libp2pTransport.send()`'s `dialProtocol(peerId, …)` with no + * explicit address list — also walks the addresses in our order instead of the + * default sorter's (which puts a NAT-mapped public IP first and the relay last). + * Without this, the explicit ladder below is defeated the moment a send falls + * back to a peer-store dial. + * + * `localLanIps` is a plain array rather than a getter: it is captured when the + * node is built, and a LAN address that has since gone stale is ranked `lan` + * (still ahead of `wan`), so a network change can only cost a little order, + * never correctness. + */ +export function makeAddressSorter( + localLanIps: readonly string[] = [], +): (a: { multiaddr: { toString(): string } }, b: { multiaddr: { toString(): string } }) => -1 | 0 | 1 { + return (a, b) => { + try { + return compareDialAddrs(a.multiaddr.toString(), b.multiaddr.toString(), localLanIps); + } catch { + // A throwing getter must never break a dial — treat it as a tie. + return 0; + } + }; +} + +// --------------------------------------------------------------------------- +// Candidate list +// --------------------------------------------------------------------------- + +/** + * Build the ordered list of addresses to try for one destination peer. + * + * Ordering is {@link compareDialAddrs}; duplicates are removed, keeping the + * FIRST occurrence so a per-peer relay address (which the bootstrap resolved + * against its own dialable prefix) outranks the one we construct from the + * server-level prefix. Empty and non-string entries are dropped. + */ +export function buildDialCandidates(input: DialCandidateInput): DialCandidate[] { + const localLanIps = input.localLanIps ?? []; + const peerId = typeof input.peerId === 'string' ? input.peerId : ''; + + const collected: DialCandidate[] = []; + const push = (addr: unknown, source: DialCandidateSource): void => { + if (typeof addr !== 'string' || addr.length === 0) return; + collected.push({ addr, scope: classifyDialAddr(addr, localLanIps), source }); + }; + + // Direct first in source order, then relay, then last-known: the sort below is + // stable, so this input order is what breaks ties inside a scope. + for (const addr of input.multiaddrs ?? []) push(addr, 'direct'); + + push(input.peerRelayAddr, 'relay'); + if (peerId && typeof input.serverRelayPrefix === 'string' && input.serverRelayPrefix.length > 0) { + push(`${input.serverRelayPrefix}/p2p/${peerId}`, 'relay'); + } + + for (const addr of input.lastKnownMultiaddrs ?? []) push(addr, 'last-known'); + + const seen = new Set(); + const unique: DialCandidate[] = []; + for (const candidate of collected) { + if (seen.has(candidate.addr)) continue; + seen.add(candidate.addr); + unique.push(candidate); + } + + return unique.sort((a, b) => compareDialAddrs(a.addr, b.addr, localLanIps)); +} + +/** The addresses of {@link buildDialCandidates}, in dial order. */ +export function dialCandidateAddrs(candidates: readonly DialCandidate[]): string[] { + return candidates.map((c) => c.addr); +} diff --git a/packages/core/src/transport/libp2p-transport.ts b/packages/core/src/transport/libp2p-transport.ts index 07efb6b..611fb32 100644 --- a/packages/core/src/transport/libp2p-transport.ts +++ b/packages/core/src/transport/libp2p-transport.ts @@ -161,7 +161,7 @@ export class Libp2pTransport implements TspTransport { const known = await this.node.peerStore.get(connection.remotePeer); multiaddrs = (known?.addresses ?? []).map((a) => a.multiaddr.toString()); } catch { /* no address-book entry for this peer yet */ } - this.messageHandler?.(data.subarray(), { + this.dispatchMessage(data.subarray(), { peerId, multiaddrs: multiaddrs.length > 0 ? multiaddrs : [connection.remoteAddr.toString()], }); @@ -339,6 +339,54 @@ export class Libp2pTransport implements TspTransport { // onMessage() — register the incoming message handler // --------------------------------------------------------------------------- + /** + * Dispatch one inbound frame to the registered handler WITHOUT letting a + * handler failure escape this read loop. + * + * WHY THIS IS NOT A BARE CALL — and why it is deliberately NOT `await`ed. + * + * `onMessage()` handlers are written as `async` (the utility server's TSP + * bridge is), so a bare call returns a promise nobody holds. A rejection inside + * the handler is then an UNHANDLED REJECTION, and Node's default + * (`--unhandled-rejections=throw`) escalates that to a fatal error — the + * process exits. + * + * Observed 2026-10-29 on the utility server: a transient YugabyteDB error + * (`XX000`, `tserver_shared_mem.cc`: "Timed out waiting kResponseSent, state: + * kProcessingRequest") propagated out of the bridge's `routeProtocolMessage()`, + * escaped the fire-and-forget dispatch, and terminated the server after ~a + * night of otherwise-normal operation. A storage hiccup must not be fatal. + * + * It is NOT `await`ed on purpose: handler work is per-message and concurrent + * by design, so awaiting here would serialise frames and back-pressure the + * stream. This keeps the concurrency contract and adds only the missing error + * boundary. + */ + private dispatchMessage(message: Uint8Array, fromAddr: TransportAddress): void { + let result: unknown; + try { + result = this.messageHandler?.(message, fromAddr); + } catch (err) { + // A handler that throws SYNCHRONOUSLY (before its first `await`) is the + // same failure as a rejection, just delivered by a different mechanism. + logger.error( + `[Libp2pTransport] inbound handler threw peerId=${fromAddr.peerId.slice(0, 12)}...:`, + (err as Error)?.message ?? err, + ); + return; + } + // A handler may be sync (returns undefined) or async (returns a promise); + // only a thenable can reject. + if (result != null && typeof (result as PromiseLike).then === 'function') { + void (result as Promise).catch((err: unknown) => { + logger.error( + `[Libp2pTransport] inbound handler failed peerId=${fromAddr.peerId.slice(0, 12)}...:`, + (err as Error)?.message ?? err, + ); + }); + } + } + /** * Register a handler for incoming TSP messages. * Replaces any previously registered handler. diff --git a/packages/core/src/transport/relay-health.ts b/packages/core/src/transport/relay-health.ts index d4edc94..d953073 100644 --- a/packages/core/src/transport/relay-health.ts +++ b/packages/core/src/transport/relay-health.ts @@ -502,14 +502,32 @@ export function deriveConnectivityStatus(inputs: ConnectivityInputs): Connectivi }; } +/** + * What kind of operation is being gated. + * + * The distinction matters because the two have different "what happens next": + * + * - `mutation` — a change was accepted locally and is queued for the server, so + * "your change has been kept locally" is true and useful. + * - `connection` — a JOIN or a connect. Nothing was kept locally, because the + * connection itself is what is missing. Telling the user their "change has + * been kept locally" for a failed join is simply wrong, and it also hides the + * fact that they must retry (or that a retry is already running). + */ +export type ConnectivityFailureKind = 'mutation' | 'connection'; + /** * Copy for a gated operation that cannot proceed. Names the failed layer and * what happens next, instead of the previous context-free * "Not connected to utility server." + * + * Defaults to `mutation`, which is the wording every pre-existing caller (and + * test) expects; pass `connection` for joins and connects. */ export function describeConnectivityFailure( status: ConnectivityStatus, operation: string, + kind: ConnectivityFailureKind = 'mutation', ): string { switch (status.layer) { case 'bootstrap-unreachable': @@ -518,6 +536,13 @@ export function describeConnectivityFailure( `server cannot be reached. Reconnection is running in the background — try again shortly.` ); case 'unreachable': + if (kind === 'connection') { + return ( + `Cannot ${operation}: the utility server could not be reached. ` + + `Reconnection is running in the background — try again shortly.` + + (status.lastError ? ` (last error: ${status.lastError})` : '') + ); + } return ( `Cannot ${operation}: the relay connection to the utility server could not be ` + `re-established. Your change has been kept locally and will be sent automatically once ` + @@ -525,6 +550,12 @@ export function describeConnectivityFailure( (status.lastError ? ` (last error: ${status.lastError})` : '') ); case 'reconnecting': + if (kind === 'connection') { + return ( + `Cannot ${operation} yet: the connection to the utility server is being established. ` + + `Retrying automatically — try again shortly.` + ); + } return ( `Cannot ${operation} yet: the connection to the utility server is being re-established. ` + `Retrying automatically — no data has been lost.` diff --git a/packages/core/src/util/logger.ts b/packages/core/src/util/logger.ts index acff6d3..e3396b8 100644 --- a/packages/core/src/util/logger.ts +++ b/packages/core/src/util/logger.ts @@ -12,8 +12,16 @@ * ## Precedence * * 1. an explicit {@link setLogLevel} override, else - * 2. the `P2PTS_LOG_LEVEL` environment variable, else - * 3. the default — `warn`. + * 2. the level a host read from its own config file ({@link setConfigLogLevel}, + * applied by `config/logging-env.ts` from a component's `.env`), else + * 3. the `P2PTS_LOG_LEVEL` environment variable, else + * 4. the default — `warn`. + * + * Rung 2 exists because an environment variable cannot be set portably: a + * `package.json` script cannot assign one on Windows, and an app that launches + * its own Node runtime (the Capacitor mobile build) has no npm script at all. A + * config file can, so the level travels with the component — while rung 3 stays + * for embedders who documented `P2PTS_LOG_LEVEL` and still set it that way. * * The default is deliberately quiet: a library that logs on import makes its host * noisy, so only warnings and errors are emitted unless the host asks for more. @@ -53,16 +61,19 @@ export const LOG_LEVEL_ENV = 'P2PTS_LOG_LEVEL'; /** The level used when neither an override nor a valid env value is present. */ export const DEFAULT_LOG_LEVEL: LogLevel = 'warn'; -function isLogLevel(v: string): v is LogLevel { - return v === 'silent' || v === 'error' || v === 'warn' || v === 'info' || v === 'debug'; -} - let overrideLevel: LogLevel | undefined; +let configLevel: LogLevel | undefined; let sink: LogSink | undefined; -/** The level in effect right now (override > `P2PTS_LOG_LEVEL` > default). */ +/** + * The level in effect right now. + * + * `setLogLevel()` override > {@link setConfigLogLevel} (a component's config + * file) > `P2PTS_LOG_LEVEL` > {@link DEFAULT_LOG_LEVEL}. + */ export function getLogLevel(): LogLevel { if (overrideLevel) return overrideLevel; + if (configLevel) return configLevel; const raw = process.env[LOG_LEVEL_ENV]?.trim().toLowerCase(); return raw && isLogLevel(raw) ? raw : DEFAULT_LOG_LEVEL; } @@ -76,6 +87,26 @@ export function setLogLevel(level: LogLevel | undefined): LogLevel { return getLogLevel(); } +/** + * Record the level read from a component's config file. + * + * Deliberately a SEPARATE slot from {@link setLogLevel}, so the documented + * precedence holds: an embedder that calls `setLogLevel()` explicitly still wins + * over whatever the app's `.env` says, and a config file that names no level + * leaves `P2PTS_LOG_LEVEL` and the default in charge. Applied for you by + * `config/logging-env.ts`; call it directly only to drive the level from some + * other config source. Pass `undefined` to clear it. + */ +export function setConfigLogLevel(level: LogLevel | undefined): LogLevel { + configLevel = level; + return getLogLevel(); +} + +/** True when `v` is one of the levels this module understands. */ +export function isLogLevel(v: string): v is LogLevel { + return v === 'silent' || v === 'error' || v === 'warn' || v === 'info' || v === 'debug'; +} + /** * Redirect every line to `fn` instead of the console, or pass `undefined` to * restore console output. diff --git a/packages/core/test/config/bootstrap-env.test.ts b/packages/core/test/config/bootstrap-env.test.ts index 3748535..1655be0 100644 --- a/packages/core/test/config/bootstrap-env.test.ts +++ b/packages/core/test/config/bootstrap-env.test.ts @@ -19,6 +19,7 @@ import assert from 'node:assert/strict'; import { parseEnvText, loadBootstrapEnv, + loadBootstrapClientEnv, derivePeerIdFromSeedHex, buildBootstrapUrls, verifyBootstrapEnv, @@ -122,6 +123,59 @@ test('loadBootstrapEnv from a missing file throws an actionable error', () => { ); }); +// --------------------------------------------------------------------------- +// loadBootstrapClientEnv — the CLIENT shape (agent app / utility server) +// --------------------------------------------------------------------------- + +test('loadBootstrapClientEnv reads host + peer ID and applies port defaults', () => { + const env = loadBootstrapClientEnv({ + source: sourceOf({ BOOTSTRAP_PEER_ID: PEER_ID, BOOTSTRAP_HOST: 'mediator.example.com' }), + }); + assert.equal(env.peerId, PEER_ID); + assert.equal(env.host, 'mediator.example.com'); + assert.equal(env.tcpPort, 4001); + assert.equal(env.wsPort, 4002); + assert.equal(env.enableRelay, true); +}); + +test('loadBootstrapClientEnv requires the peer ID (there is no seed to derive it from)', () => { + assert.throws( + () => loadBootstrapClientEnv({ source: sourceOf({ BOOTSTRAP_HOST: 'x.example' }) }), + /missing BOOTSTRAP_PEER_ID/, + ); +}); + +test('loadBootstrapClientEnv rejects a client .env that still carries the seed', () => { + // A seed in a client file is a leaked private key, and the client has no use + // for it — so it is a hard error, not something to quietly ignore. + assert.throws( + () => loadBootstrapClientEnv({ + source: sourceOf({ ...validVars(), BOOTSTRAP_SEED_HEX: SEED }), + }), + /BOOTSTRAP_SEED_HEX is present, but this component is a CLIENT/, + ); +}); + +test('loadBootstrapClientEnv rejects a missing host', () => { + assert.throws( + () => loadBootstrapClientEnv({ source: sourceOf({ BOOTSTRAP_PEER_ID: PEER_ID }) }), + /missing BOOTSTRAP_HOST/, + ); +}); + +test('buildBootstrapUrls serves both env shapes', () => { + const client = loadBootstrapClientEnv({ + source: sourceOf({ BOOTSTRAP_PEER_ID: PEER_ID, BOOTSTRAP_HOST: '203.0.113.5' }), + }); + assert.equal(buildBootstrapUrls(client).tcpUrl, `/ip4/203.0.113.5/tcp/4001/p2p/${PEER_ID}`); + + const server = loadBootstrapEnv({ source: sourceOf(validVars()) }); + assert.equal( + buildBootstrapUrls(server).relayAddr, + `/dns/mediator.example.com/tcp/4001/p2p/${PEER_ID}/p2p-circuit`, + ); +}); + // --------------------------------------------------------------------------- // derivation + URL building // --------------------------------------------------------------------------- diff --git a/packages/core/test/config/fixtures/logging.env b/packages/core/test/config/fixtures/logging.env new file mode 100644 index 0000000..dfab7e7 --- /dev/null +++ b/packages/core/test/config/fixtures/logging.env @@ -0,0 +1,11 @@ +# A component .env as `logging-env` reads it: comments, blank lines and +# unrelated keys are all present, because a real component's .env carries the +# bootstrap block too. + +# ── Bootstrap / relay ── +BOOTSTRAP_PEER_ID=12D3KooWKF2Tmh21NNSH5gWazfbmFEtTZS9XT1uXk8njFAy5jUda +BOOTSTRAP_HOST=relay.example.com + +# ── Logging ── +P2PTS_LOG_LEVEL=debug +CEREMONY_TRACE=1 diff --git a/packages/core/test/config/logging-env.test.ts b/packages/core/test/config/logging-env.test.ts new file mode 100644 index 0000000..906a108 --- /dev/null +++ b/packages/core/test/config/logging-env.test.ts @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-TrustStackers-Commercial +// Copyright (C) 2026 Trust Stackers + +/** + * logging-env tests — the config-file logging switch. + * + * These pin the contract that replaced the POSIX-only `P2PTS_LOG_LEVEL=info node …` + * prefix in the reference apps' `package.json` scripts: a component's `.env` names the + * level and the ceremony trace, the chain resolves as + * `setLogLevel() > config file > P2PTS_LOG_LEVEL > warn`, and NOTHING about a missing, + * unreadable or malformed config is fatal. + * + * Run with: + * node --experimental-strip-types --loader ./test-loader.mjs --test test/config/logging-env.test.ts + */ +import { test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; + +import { + loadLoggingEnv, + applyLoggingEnv, + getEffectiveLogging, + LOG_LEVEL_KEY, + CEREMONY_TRACE_KEY, +} from '../../src/config/logging-env.js'; +import { + DEFAULT_LOG_LEVEL, + LOG_LEVEL_ENV, + getLogLevel, + setConfigLogLevel, + setLogLevel, +} from '../../src/util/logger.js'; +import { + CEREMONY_TRACE_ENV, + isCeremonyTraceEnabled, + setCeremonyTrace, +} from '../../src/relationship/ceremony-trace.js'; +import type { EnvSource } from '../../src/config/bootstrap-env.js'; + +/** An in-memory EnvSource — the pluggable seam, so tests never touch disk. */ +function sourceOf(vars: Record): EnvSource { + return { name: 'test', read: () => vars }; +} + +/** An EnvSource that cannot read, standing in for an absent/unreadable config. */ +const brokenSource: EnvSource = { + name: 'broken', + read() { + throw new Error('no config here'); + }, +}; + +/** + * Return the process to a known state between tests. + * + * The logger's override slots and the ceremony flag are module state, and the two + * environment variables are process state — all of which a test in this file may + * have moved. Clearing them is what makes each assertion independent of the last. + */ +beforeEach(() => { + setLogLevel(undefined); + setConfigLogLevel(undefined); + setCeremonyTrace(undefined); + delete process.env[LOG_LEVEL_ENV]; + delete process.env[CEREMONY_TRACE_ENV]; +}); + +// --------------------------------------------------------------------------- +// loadLoggingEnv +// --------------------------------------------------------------------------- + +test('the exported keys are the documented ones', () => { + assert.equal(LOG_LEVEL_KEY, 'P2PTS_LOG_LEVEL'); + assert.equal(CEREMONY_TRACE_KEY, 'CEREMONY_TRACE'); +}); + +test('loadLoggingEnv reads a level and a trace flag from a source', () => { + const env = loadLoggingEnv({ + source: sourceOf({ P2PTS_LOG_LEVEL: 'info', CEREMONY_TRACE: '1' }), + }); + assert.deepEqual(env, { level: 'info', ceremonyTrace: true }); +}); + +test('loadLoggingEnv reads only the keys it owns', () => { + const env = loadLoggingEnv({ + source: sourceOf({ BOOTSTRAP_HOST: 'relay.example.com', P2PTS_LOG_LEVEL: 'debug' }), + }); + assert.deepEqual(env, { level: 'debug' }); +}); + +test('loadLoggingEnv trims and lowercases the level', () => { + const env = loadLoggingEnv({ source: sourceOf({ P2PTS_LOG_LEVEL: ' INFO ' }) }); + assert.equal(env.level, 'info'); +}); + +test('loadLoggingEnv ignores an unknown level instead of guessing', () => { + assert.deepEqual(loadLoggingEnv({ source: sourceOf({ P2PTS_LOG_LEVEL: 'chatty' }) }), {}); + assert.deepEqual(loadLoggingEnv({ source: sourceOf({ P2PTS_LOG_LEVEL: '' }) }), {}); +}); + +test('loadLoggingEnv accepts the documented true/false words', () => { + for (const word of ['1', 'true', 'yes', 'on', 'TRUE', 'On']) { + assert.equal( + loadLoggingEnv({ source: sourceOf({ CEREMONY_TRACE: word }) }).ceremonyTrace, + true, + `${word} should read as true`, + ); + } + for (const word of ['0', 'false', 'no', 'off', 'FALSE']) { + assert.equal( + loadLoggingEnv({ source: sourceOf({ CEREMONY_TRACE: word }) }).ceremonyTrace, + false, + `${word} should read as false`, + ); + } +}); + +test('loadLoggingEnv ignores a nonsense trace value', () => { + assert.deepEqual(loadLoggingEnv({ source: sourceOf({ CEREMONY_TRACE: 'maybe' }) }), {}); +}); + +test('loadLoggingEnv returns no opinion for an unreadable source', () => { + assert.deepEqual(loadLoggingEnv({ source: brokenSource }), {}); +}); + +test('loadLoggingEnv returns no opinion for a missing file (never fatal)', () => { + assert.deepEqual(loadLoggingEnv({ path: '/nonexistent/definitely/not/here/.env' }), {}); +}); + +test('loadLoggingEnv reads a real file, tolerating comments and blanks', () => { + const fixture = fileURLToPath(new URL('./fixtures/logging.env', import.meta.url)); + assert.deepEqual(loadLoggingEnv({ path: fixture }), { level: 'debug', ceremonyTrace: true }); +}); + +// --------------------------------------------------------------------------- +// applyLoggingEnv — the precedence chain +// --------------------------------------------------------------------------- + +test('applyLoggingEnv applies the level the file names', () => { + applyLoggingEnv({ source: sourceOf({ P2PTS_LOG_LEVEL: 'info' }) }); + assert.equal(getLogLevel(), 'info'); + assert.deepEqual(getEffectiveLogging(), { level: 'info', ceremonyTrace: false }); +}); + +test('applyLoggingEnv turns ceremony tracing on', () => { + applyLoggingEnv({ source: sourceOf({ CEREMONY_TRACE: '1' }) }); + assert.equal(isCeremonyTraceEnabled(), true); +}); + +test('a config file beats the P2PTS_LOG_LEVEL environment variable', () => { + process.env[LOG_LEVEL_ENV] = 'error'; + applyLoggingEnv({ source: sourceOf({ P2PTS_LOG_LEVEL: 'debug' }) }); + assert.equal(getLogLevel(), 'debug'); +}); + +test('an explicit setLogLevel() still beats the config file', () => { + applyLoggingEnv({ source: sourceOf({ P2PTS_LOG_LEVEL: 'debug' }) }); + setLogLevel('error'); + assert.equal(getLogLevel(), 'error'); +}); + +test('an explicit setCeremonyTrace() still beats the config file', () => { + applyLoggingEnv({ source: sourceOf({ CEREMONY_TRACE: '1' }) }); + setCeremonyTrace(false); + assert.equal(isCeremonyTraceEnabled(), false); +}); + +test('a config file with no level leaves the environment variable in charge', () => { + process.env[LOG_LEVEL_ENV] = 'info'; + applyLoggingEnv({ source: sourceOf({ BOOTSTRAP_HOST: 'relay.example.com' }) }); + assert.equal(getLogLevel(), 'info'); +}); + +test('a config file with no level and no env var falls back to the default', () => { + applyLoggingEnv({ source: sourceOf({}) }); + assert.equal(getLogLevel(), DEFAULT_LOG_LEVEL); + assert.equal(DEFAULT_LOG_LEVEL, 'warn'); +}); + +test('a missing config file is not fatal and falls back to the environment', () => { + process.env[LOG_LEVEL_ENV] = 'error'; + assert.deepEqual(applyLoggingEnv({ path: '/nonexistent/definitely/not/here/.env' }), {}); + assert.equal(getLogLevel(), 'error'); +}); + +test('re-applying clears a level the previous config had set', () => { + applyLoggingEnv({ source: sourceOf({ P2PTS_LOG_LEVEL: 'debug' }) }); + assert.equal(getLogLevel(), 'debug'); + // A later apply with no level must NOT leave 'debug' pinned. + applyLoggingEnv({ source: sourceOf({}) }); + assert.equal(getLogLevel(), DEFAULT_LOG_LEVEL); +}); + +test('the environment variable still works with no config applied at all', () => { + process.env[LOG_LEVEL_ENV] = 'debug'; + assert.equal(getLogLevel(), 'debug'); + process.env[CEREMONY_TRACE_ENV] = '1'; + assert.equal(isCeremonyTraceEnabled(), true); +}); diff --git a/packages/core/test/relationship/manager.test.ts b/packages/core/test/relationship/manager.test.ts index b05c775..b75d56b 100644 --- a/packages/core/test/relationship/manager.test.ts +++ b/packages/core/test/relationship/manager.test.ts @@ -59,10 +59,25 @@ class MockTransport implements TspTransport { public sent: Array<{ message: Uint8Array; to: TransportAddress }> = []; private handler?: (message: Uint8Array, fromAddr: TransportAddress) => void; + /** + * When set, the NEXT send throws this message and the flag clears itself. + * + * Reproduces a transport-level send failure without a network — the exact + * shape of the field failure of 2026-10-29, where libp2p's dial returned a + * connection that had already closed and `transport.send` threw + * `Remote closed connection during opening`. + */ + public failNextSendWith?: string; + async start(): Promise {} async stop(): Promise {} async send(message: Uint8Array, remoteAddr: TransportAddress): Promise { + if (this.failNextSendWith !== undefined) { + const reason = this.failNextSendWith; + this.failNextSendWith = undefined; + throw new Error(reason); + } this.sent.push({ message: new Uint8Array(message), to: remoteAddr }); } @@ -222,6 +237,101 @@ test('RelationshipManager.sendRfi: stores outbound-pending record and sends RFI assert.equal(record!.status, 'pending', 'record status must be pending'); }); +// --------------------------------------------------------------------------- +// sendRfi: a FAILED send must not orphan the outbound-pending row +// +// Field bug, MBP2014, 2026-10-29. The row is inserted before the send (so an +// immediately-arriving RFA can be matched), and the send then threw +// `Remote closed connection during opening`. The row survived, and because an +// outbound-pending row means "handshake in progress" to the reconnect path — +// and `insertOutbound` refuses a duplicate — every later attempt could only +// WAIT for a handshake that had never started. The agent logged five identical +// 30 s timeouts and stayed down until the app was restarted. +// --------------------------------------------------------------------------- + +test('RelationshipManager.sendRfi: a FAILED send leaves NO outbound-pending record', async () => { + const a = makeIdentity('12D3KooWPeerA', ['/ip4/127.0.0.1/tcp/4001']); + const b = makeIdentity('12D3KooWPeerB', ['/ip4/127.0.0.1/tcp/4002']); + + const bOobi = encodeOobi({ + cesrVid: b.identity.cesrVid, + did: b.identity.did, + peerId: '12D3KooWPeerB', + multiaddrs: ['/ip4/127.0.0.1/tcp/4002'], + }); + + // Reproduce the field failure exactly: the transport dies while opening. + a.transport.failNextSendWith = 'Remote closed connection during opening'; + await assert.rejects( + () => a.manager.sendRfi(bOobi), + /Remote closed connection during opening/, + 'the send failure must propagate to the caller', + ); + + // The row must be gone: nothing is in flight, so nothing may claim it is. + assert.equal( + a.store.get(a.identity.did, b.identity.did), + undefined, + 'a failed send must not leave an outbound-pending row behind', + ); + assert.equal(a.transport.sent.length, 0, 'no message may have been recorded as sent'); + + // And a retry must be POSSIBLE. Before the rollback this threw + // `RelationshipStore.insertOutbound: relationship already exists` — which is + // what made the agent unable to reconnect until it was restarted. + await a.manager.sendRfi(bOobi); + + const record = a.store.get(a.identity.did, b.identity.did); + assert.ok(record, 'the retry must store a fresh outbound-pending record'); + assert.equal(record!.direction, 'outbound', 'retry record direction must be outbound'); + assert.equal(record!.status, 'pending', 'retry record status must be pending'); + assert.equal(a.transport.sent.length, 1, 'the retry must have sent exactly one RFI'); +}); + +test('RelationshipManager.removeRelationship: clears the row so the handshake can be re-driven', async () => { + const a = makeIdentity('12D3KooWPeerA', ['/ip4/127.0.0.1/tcp/4001']); + const b = makeIdentity('12D3KooWPeerB', ['/ip4/127.0.0.1/tcp/4002']); + + const bOobi = encodeOobi({ + cesrVid: b.identity.cesrVid, + did: b.identity.did, + peerId: '12D3KooWPeerB', + multiaddrs: ['/ip4/127.0.0.1/tcp/4002'], + }); + + // A completed send leaves the row pending — the state the reconnect path has + // to be able to clear when the RFA never arrives. + await a.manager.sendRfi(bOobi); + assert.ok(a.store.get(a.identity.did, b.identity.did), 'precondition: row exists'); + + a.manager.removeRelationship(b.identity.did); + assert.equal( + a.store.get(a.identity.did, b.identity.did), + undefined, + 'removeRelationship must clear the row', + ); + + // Re-driving the handshake now works instead of throwing `already exists`. + await a.manager.sendRfi(bOobi); + assert.equal(a.transport.sent.length, 2, 'the re-send must have gone out'); + assert.equal( + a.store.get(a.identity.did, b.identity.did)!.status, + 'pending', + 'the re-driven handshake must be pending again', + ); +}); + +test('RelationshipManager.removeRelationship: a no-op when there is no relationship', () => { + const a = makeIdentity('12D3KooWPeerA', ['/ip4/127.0.0.1/tcp/4001']); + const b = makeIdentity('12D3KooWPeerB', ['/ip4/127.0.0.1/tcp/4002']); + + // Must not throw: the reconnect path calls it unconditionally on a row it + // believes is stale, and an unresolvable DID must not break the retry. + a.manager.removeRelationship(b.identity.did); + a.manager.removeRelationship('did:scid:vh:1:never-seen'); + assert.equal(a.store.get(a.identity.did, b.identity.did), undefined); +}); + // --------------------------------------------------------------------------- // Full handshake: A → B (RFI) → A (RFA) → bidirectional // --------------------------------------------------------------------------- diff --git a/packages/core/test/transport/dial-candidates.test.ts b/packages/core/test/transport/dial-candidates.test.ts new file mode 100644 index 0000000..33a1679 --- /dev/null +++ b/packages/core/test/transport/dial-candidates.test.ts @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-TrustStackers-Commercial +// Copyright (C) 2026 Trust Stackers + +/** + * Unit tests for the shared address-selection policy. + * + * These pin the ONE thing the cross-network join bug turned on: the dial order + * for a discovered peer. The regression under test is the real + * `@libp2p/utils` `defaultAddressSorter`, which ranks a NAT-mapped public + * address ahead of the LAN one and pushes the relay to the end — see the module + * header for the measured output. + * + * Pure module, no libp2p node and no network: every case is a string in, + * ordering out. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + DIAL_SCOPE_RANK, + buildDialCandidates, + classifyDialAddr, + compareDialAddrs, + dialCandidateAddrs, + makeAddressSorter, +} from '../../src/transport/dial-candidates.js'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const SERVER = '12D3KooWSrvPeerIdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const RELAY = '12D3KooWRelayPeerIdBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; + +const LAN_ADDR = `/ip4/192.168.1.246/tcp/4001/p2p/${SERVER}`; +const NAT_ADDR = `/ip4/24.5.6.7/tcp/52344/p2p/${SERVER}`; +const RELAY_PREFIX = `/dns/relay.truststackers.com/tcp/4001/p2p/${RELAY}/p2p-circuit`; +const RELAY_ADDR = `${RELAY_PREFIX}/p2p/${SERVER}`; + +// --------------------------------------------------------------------------- +// classifyDialAddr +// --------------------------------------------------------------------------- + +describe('classifyDialAddr', () => { + it('classifies a /p2p-circuit address as relay, whatever its host kind', () => { + assert.equal(classifyDialAddr(RELAY_ADDR), 'relay'); + assert.equal(classifyDialAddr(`/ip4/24.5.6.7/tcp/4001/p2p/${RELAY}/p2p-circuit/p2p/${SERVER}`), 'relay'); + }); + + it('classifies a direct address on our own subnet as same-subnet', () => { + assert.equal(classifyDialAddr(LAN_ADDR, ['192.168.1.5']), 'same-subnet'); + }); + + it('classifies a private address on a DIFFERENT subnet as lan', () => { + // The Starlink case: we are 192.168.0.x, the server is 192.168.1.x. + assert.equal(classifyDialAddr(LAN_ADDR, ['192.168.0.24']), 'lan'); + }); + + it('classifies a public IP as wan', () => { + assert.equal(classifyDialAddr(NAT_ADDR), 'wan'); + }); + + it('classifies CGNAT (100.64/10) as lan, not wan', () => { + assert.equal(classifyDialAddr(`/ip4/100.64.1.7/tcp/4001/p2p/${SERVER}`), 'lan'); + }); + + it('classifies a hostname as dns', () => { + assert.equal(classifyDialAddr(`/dns/server.example.com/tcp/4001/p2p/${SERVER}`), 'dns'); + }); + + it('classifies loopback forms as loopback', () => { + assert.equal(classifyDialAddr(`/ip4/127.0.0.1/tcp/4001/p2p/${SERVER}`), 'loopback'); + assert.equal(classifyDialAddr(`/ip6/::1/tcp/4001/p2p/${SERVER}`), 'loopback'); + assert.equal(classifyDialAddr(`/dns/localhost/tcp/4001/p2p/${SERVER}`), 'loopback'); + }); + + it('degrades unparseable input to unknown rather than throwing', () => { + assert.equal(classifyDialAddr(''), 'unknown'); + assert.equal(classifyDialAddr('not-a-multiaddr'), 'unknown'); + assert.equal(classifyDialAddr('/unix/var/run/x.sock'), 'unknown'); + }); + + it('does not treat an absent localLanIps list as "everything is same-subnet"', () => { + assert.equal(classifyDialAddr(LAN_ADDR, []), 'lan'); + }); +}); + +// --------------------------------------------------------------------------- +// DIAL_SCOPE_RANK / compareDialAddrs +// --------------------------------------------------------------------------- + +describe('DIAL_SCOPE_RANK', () => { + it('ranks relay first, above every direct scope', () => { + assert.ok(DIAL_SCOPE_RANK.relay < DIAL_SCOPE_RANK['same-subnet']); + assert.ok(DIAL_SCOPE_RANK.relay < DIAL_SCOPE_RANK.dns); + assert.ok(DIAL_SCOPE_RANK.relay < DIAL_SCOPE_RANK.lan); + assert.ok(DIAL_SCOPE_RANK.relay < DIAL_SCOPE_RANK.wan); + assert.ok(DIAL_SCOPE_RANK['same-subnet'] < DIAL_SCOPE_RANK.dns); + assert.ok(DIAL_SCOPE_RANK.wan < DIAL_SCOPE_RANK.unknown); + assert.ok(DIAL_SCOPE_RANK.unknown < DIAL_SCOPE_RANK.loopback); + }); +}); + +describe('compareDialAddrs', () => { + it('returns -1 | 0 | 1 only', () => { + const cmp = compareDialAddrs(RELAY_ADDR, NAT_ADDR); + assert.ok(cmp === -1 || cmp === 0 || cmp === 1); + assert.equal(cmp, -1, 'relay must sort before a NAT-mapped public IP'); + }); + + it('is a tie for two addresses in the same scope', () => { + assert.equal(compareDialAddrs(LAN_ADDR, `/ip4/10.0.0.9/tcp/4001/p2p/${SERVER}`), 0); + }); +}); + +// --------------------------------------------------------------------------- +// buildDialCandidates +// --------------------------------------------------------------------------- + +describe('buildDialCandidates', () => { + it('REGRESSION: puts the relay ahead of a NAT-mapped public IP and an off-subnet LAN IP', () => { + // This is the exact address set a discovered utility server yields, and the + // exact case libp2p's default sorter gets backwards. + const candidates = buildDialCandidates({ + peerId: SERVER, + multiaddrs: [LAN_ADDR, NAT_ADDR], + peerRelayAddr: RELAY_ADDR, + localLanIps: ['192.168.0.24'], // Starlink: different subnet from the server + }); + + assert.deepEqual(dialCandidateAddrs(candidates), [RELAY_ADDR, LAN_ADDR, NAT_ADDR]); + assert.deepEqual(candidates.map((c) => c.scope), ['relay', 'lan', 'wan']); + }); + + it('still prefers a same-subnet LAN address over every OTHER direct address', () => { + const candidates = buildDialCandidates({ + peerId: SERVER, + multiaddrs: [LAN_ADDR, NAT_ADDR], + peerRelayAddr: RELAY_ADDR, + localLanIps: ['192.168.1.5'], + }); + + // The relay leads (see the module header: a /24 match is not proof of shared + // L2), but the same-subnet address still beats the NAT-mapped one. + assert.deepEqual(dialCandidateAddrs(candidates), [RELAY_ADDR, LAN_ADDR, NAT_ADDR]); + }); + + it('REGRESSION (MBP2014, 2026-10-29): two networks sharing 192.168.1.0/24 no longer dial each other first', () => { + // The field case, exactly: the agent was 192.168.1.201 on one router, and the + // utility server advertised 192.168.1.28 from ANOTHER router on a different + // WAN. `classifyDialAddr` cannot tell that apart from a genuine same-LAN peer + // — which is the whole point: the order must not depend on the distinction. + // The relay leads, so the cross-network join reaches the one address that can + // work before spending the attempt on a host that is not there. + const candidates = buildDialCandidates({ + peerId: SERVER, + multiaddrs: ['/ip4/192.168.1.28/tcp/4003/p2p/' + SERVER, NAT_ADDR], + peerRelayAddr: RELAY_ADDR, + localLanIps: ['192.168.1.201'], + }); + + assert.deepEqual(dialCandidateAddrs(candidates), [ + RELAY_ADDR, + `/ip4/192.168.1.28/tcp/4003/p2p/${SERVER}`, + NAT_ADDR, + ]); + assert.equal(candidates[1].scope, 'same-subnet', 'the /24 match is still reported as such'); + }); + + it('derives a relay address from the server-level prefix, appending the peer ID', () => { + const candidates = buildDialCandidates({ + peerId: SERVER, + multiaddrs: [], + serverRelayPrefix: RELAY_PREFIX, + }); + + assert.deepEqual(dialCandidateAddrs(candidates), [`${RELAY_PREFIX}/p2p/${SERVER}`]); + assert.equal(candidates[0].source, 'relay'); + }); + + it('de-duplicates the per-peer and server-derived relay address, keeping the first', () => { + const candidates = buildDialCandidates({ + peerId: SERVER, + peerRelayAddr: RELAY_ADDR, + serverRelayPrefix: RELAY_PREFIX, + }); + + assert.equal(candidates.length, 1); + assert.equal(candidates[0].addr, RELAY_ADDR); + }); + + it('never lets a last-known address outrank a fresh one', () => { + const stale = `/ip4/192.168.9.9/tcp/4001/p2p/${SERVER}`; + const candidates = buildDialCandidates({ + peerId: SERVER, + multiaddrs: [LAN_ADDR], + peerRelayAddr: RELAY_ADDR, + lastKnownMultiaddrs: [stale], + localLanIps: ['192.168.0.24'], + }); + + assert.deepEqual(dialCandidateAddrs(candidates), [RELAY_ADDR, LAN_ADDR, stale]); + assert.equal(candidates[2].source, 'last-known'); + }); + + it('drops empty, non-string and duplicate entries without throwing', () => { + const candidates = buildDialCandidates({ + peerId: SERVER, + multiaddrs: [LAN_ADDR, '', LAN_ADDR, undefined as unknown as string, 'garbage-no-host'], + }); + + assert.deepEqual(dialCandidateAddrs(candidates), [LAN_ADDR, 'garbage-no-host']); + }); + + it('omits the server-derived relay when no prefix is supplied', () => { + const candidates = buildDialCandidates({ peerId: SERVER, multiaddrs: [LAN_ADDR] }); + assert.deepEqual(dialCandidateAddrs(candidates), [LAN_ADDR]); + }); +}); + +// --------------------------------------------------------------------------- +// makeAddressSorter — the libp2p hook +// --------------------------------------------------------------------------- + +describe('makeAddressSorter', () => { + const ma = (addr: string) => ({ multiaddr: { toString: () => addr } }); + + it('orders a libp2p address list by the same policy as the explicit ladder', () => { + const sorter = makeAddressSorter(['192.168.0.24']); + const sorted = [LAN_ADDR, NAT_ADDR, RELAY_ADDR].sort((a, b) => sorter(ma(a), ma(b))); + assert.deepEqual(sorted, [RELAY_ADDR, LAN_ADDR, NAT_ADDR]); + }); + + it('is a tie when a multiaddr getter throws, so a dial is never broken', () => { + const sorter = makeAddressSorter([]); + const throwing = { multiaddr: { toString: () => { throw new Error('boom'); } } }; + assert.equal(sorter(throwing, ma(RELAY_ADDR)), 0); + }); +}); diff --git a/packages/core/test/transport/libp2p-transport-dispatch.test.ts b/packages/core/test/transport/libp2p-transport-dispatch.test.ts new file mode 100644 index 0000000..6a688f0 --- /dev/null +++ b/packages/core/test/transport/libp2p-transport-dispatch.test.ts @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-TrustStackers-Commercial +// Copyright (C) 2026 Trust Stackers + +/** + * Dispatch-containment tests for Libp2pTransport. + * + * WHY: `onMessage()` handlers are written as `async`, and the transport used to + * call the handler bare — no `await`, no `.catch()`. A rejection inside a handler + * was therefore an UNHANDLED REJECTION, and Node's default + * (`--unhandled-rejections=throw`) escalated it to a fatal error. + * + * Field failure, 2026-10-29: a transient YugabyteDB error + * (`XX000` / `tserver_shared_mem.cc`: "Timed out waiting kResponseSent, state: + * kProcessingRequest") was thrown out of the utility server's TSP bridge handler + * and TERMINATED the process — the log ended with the Node version banner and a + * shell prompt. Every agent delivery queued behind it stalled. + * + * These tests assert the containment contract at the transport boundary: + * 1. a REJECTING async handler does not produce an unhandledRejection; + * 2. a handler that throws SYNCHRONOUSLY is contained the same way; + * 3. the read loop survives both and keeps dispatching later frames. + * + * Run with: + * node --conditions=development --experimental-strip-types --loader ./test-loader.mjs \ + * --test packages/core/test/transport/libp2p-transport-dispatch.test.ts + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createLibp2p, type Libp2p } from 'libp2p'; +import { tcp } from '@libp2p/tcp'; +import { noise } from '@chainsafe/libp2p-noise'; +import { yamux } from '@chainsafe/libp2p-yamux'; +import { identify } from '@libp2p/identify'; +import { Libp2pTransport } from '../../src/transport/index.js'; +import type { TransportAddress } from '../../src/transport/index.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function makeNode(): Promise { + return createLibp2p({ + addresses: { listen: ['/ip4/127.0.0.1/tcp/0'] }, + transports: [tcp()], + streamMuxers: [yamux()], + connectionEncrypters: [noise()], + services: { identify: identify() }, + }); +} + +function firstMultiaddr(node: Libp2p): string { + const addrs = node.getMultiaddrs(); + if (addrs.length === 0) throw new Error('Node has no multiaddrs'); + return addrs[0].toString(); +} + +/** The exact error the field log carried. */ +const YB_RPC_TIMEOUT = + 'Timed out waiting kResponseSent, state: kProcessingRequest'; + +/** Poll until `predicate()` holds, or fail after `timeoutMs`. */ +async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((r) => setTimeout(r, 25)); + } + throw new Error('waitFor: condition not met within timeout'); +} + +// --------------------------------------------------------------------------- +// Containment +// --------------------------------------------------------------------------- + +test('a rejecting or throwing onMessage handler cannot escape the transport', async () => { + const nodeA = await makeNode(); + const nodeB = await makeNode(); + const transportA = new Libp2pTransport(nodeA); + const transportB = new Libp2pTransport(nodeB); + await transportA.start(); + await transportB.start(); + + const seen: string[] = []; + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + + try { + // A NON-async handler, so both failure mechanisms are exercised through the + // same registration: + // 'reject' → returns a rejected promise (the async-handler shape that + // killed the utility server); + // 'throw-sync' → throws before returning anything at all. + transportB.onMessage((message) => { + const text = new TextDecoder().decode(message); + seen.push(text); + if (text === 'reject') return Promise.reject(new Error(YB_RPC_TIMEOUT)); + if (text === 'throw-sync') throw new Error(YB_RPC_TIMEOUT); + return undefined; + }); + + const addrB: TransportAddress = { + peerId: nodeB.peerId.toString(), + multiaddrs: [firstMultiaddr(nodeB)], + }; + const enc = (s: string): Uint8Array => new TextEncoder().encode(s); + + await transportA.send(enc('reject'), addrB); + await transportA.send(enc('throw-sync'), addrB); + // The frame AFTER both failures must still arrive: containment must not cost + // the read loop its liveness. + await transportA.send(enc('after'), addrB); + + await waitFor(() => seen.length >= 3); + + assert.deepEqual( + seen, + ['reject', 'throw-sync', 'after'], + 'every frame must reach the handler, including the one after two failures', + ); + + // Give the rejection handlers a macrotask turn before asserting. + await new Promise((r) => setTimeout(r, 100)); + assert.equal( + unhandled.length, + 0, + 'handler failures must be contained by the transport, not surface as ' + + `unhandledRejection (saw ${unhandled.length})`, + ); + } finally { + process.off('unhandledRejection', onUnhandled); + await transportA.stop().catch(() => {}); + await transportB.stop().catch(() => {}); + await nodeA.stop(); + await nodeB.stop(); + } +}); + +test('a handler that rejects on EVERY frame does not degrade later frames', async () => { + const nodeA = await makeNode(); + const nodeB = await makeNode(); + const transportA = new Libp2pTransport(nodeA); + const transportB = new Libp2pTransport(nodeB); + await transportA.start(); + await transportB.start(); + + let dispatches = 0; + try { + // The realistic shape: an async handler whose awaited work fails every time + // while the dependency is down (a stalled tablet server in the field log). + transportB.onMessage(async () => { + dispatches++; + await Promise.resolve(); + throw new Error(YB_RPC_TIMEOUT); + }); + + const addrB: TransportAddress = { + peerId: nodeB.peerId.toString(), + multiaddrs: [firstMultiaddr(nodeB)], + }; + const frame = new TextEncoder().encode('x'); + for (let i = 0; i < 10; i++) await transportA.send(frame, addrB); + + await waitFor(() => dispatches >= 10); + assert.equal(dispatches, 10, 'a sustained failure must not stall the read loop'); + } finally { + await transportA.stop().catch(() => {}); + await transportB.stop().catch(() => {}); + await nodeA.stop(); + await nodeB.stop(); + } +}); diff --git a/packages/core/test/transport/relay-health.test.ts b/packages/core/test/transport/relay-health.test.ts index 2a6a5d4..631fdf6 100644 --- a/packages/core/test/transport/relay-health.test.ts +++ b/packages/core/test/transport/relay-health.test.ts @@ -367,5 +367,46 @@ describe('relay-health: messages', () => { assert.match(copy, /kept locally/); assert.match(copy, /dial timeout/); }); + + it('does NOT promise "kept locally" for a CONNECTION (join) failure', () => { + // A failed JOIN queues nothing, so the mutation wording is a false promise — + // and it was the exact text users saw when a cross-network VTN join failed. + const down = deriveConnectivityStatus({ + bootstrapConnected: true, + utilityServerConnected: false, + relay: snapshot([]), + hasRelayReservation: false, + lastError: 'dial timeout', + }); + + const joinCopy = describeConnectivityFailure(down, 'join the VTN "p2pts"', 'connection'); + assert.match(joinCopy, /join the VTN "p2pts"/); + assert.doesNotMatch(joinCopy, /kept locally/); + assert.match(joinCopy, /try again shortly/i); + assert.match(joinCopy, /dial timeout/); + + // The default kind keeps the mutation promise — pre-existing callers and + // their tests must not change behaviour. + assert.match(describeConnectivityFailure(down, 'publish this credential'), /kept locally/); + }); + + it('words the reconnecting layer per kind too', () => { + const reconnecting = deriveConnectivityStatus({ + bootstrapConnected: true, + utilityServerConnected: false, + relay: snapshot([]), + hasRelayReservation: false, + reconnecting: true, + }); + + assert.match( + describeConnectivityFailure(reconnecting, 'join the VTN "p2pts"', 'connection'), + /being established/, + ); + assert.match( + describeConnectivityFailure(reconnecting, 'publish this credential'), + /no data has been lost/, + ); + }); });