Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
28 changes: 28 additions & 0 deletions packages/app-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/app-api/package.json
Original file line number Diff line number Diff line change
@@ -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)",
Expand Down Expand Up @@ -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",
Expand Down
14 changes: 11 additions & 3 deletions packages/app-api/src/client/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ export type ScopedDomain =
*/
const READ_METHODS: Readonly<Record<ScopedDomain, readonly string[]>> = {
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'],
Expand Down Expand Up @@ -130,9 +134,13 @@ export const DENIED_METHODS: Readonly<Record<ScopedDomain, readonly string[]>> =
],
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: [],
Expand Down
165 changes: 165 additions & 0 deletions packages/app-api/src/domains/bootstrap-config.ts
Original file line number Diff line number Diff line change
@@ -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`);
},
};
}

Loading
Loading