first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
# @suaveplan/network-plugin-intra-relay - Intra-mesh peer-to-peer relay: RelayDiscovery (topology-driven candidate set with leaf exclusion), RelaySelector (5 strategies — LowestLatency / HighestReliability / FewestHops / TierOptimized / LoadBalanced), RelayConnectionManager (pending → active → degraded → closed FSM), RelayMessageEnvelope (hop-count + visited-nodes wrapper), LoopPrevention, RelayPerformanceMonitor, RelaySecurityManager (peer authentication, ed25519 signature validation, per-peer + global token-bucket rate limiting), and IntraRelayPlugin facade.
## Summary
Intra-mesh peer-to-peer relay for the Suaveplan network. When direct WebRTC fails (symmetric NAT, restrictive firewall), `IntraRelayPlugin` routes the message through an intermediate peer in the same mesh. `RelayDiscovery` builds a candidate set from `peer:connected` bus events and explicitly excludes leaf-tier peers (which lack forwarding capacity); `RelaySelector` picks the optimal relay using one of five strategies (`lowest-latency`, `highest-reliability`, `fewest-hops`, `tier-optimized`, `load-balanced`) with a deterministic peer-id tiebreak; `RelayConnectionManager` runs a `pending → active → degraded → closed` finite-state machine per `(sourceId, targetId)` pair and auto-closes paths after a configurable idle timeout; `RelayMessageEnvelope` wraps the original `MessageEnvelope` with `relayPath` / `hopCount` / `maxHops` / `visitedNodes`; `LoopPrevention` drops envelopes that exceed `maxHops` (default 8) or whose `visitedNodes` already contain the current peer, emitting `relay:loop-detected` for observability; `RelayPerformanceMonitor` samples per-path RTT and emits `relay:degraded` / `relay:recovered` when quality crosses configurable thresholds; `RelaySecurityManager` (optional, opt-in via `IntraRelayPluginConfig.security`) enforces a trusted-peer allowlist, validates `ed25519` signatures over canonical bytes, and applies per-peer + global token-bucket rate limits to prevent DoS. Wrapped in a single `IntraRelayPlugin` satisfying the `NetworkPlugin` lifecycle.
<!-- begin:auto-docs -->
## Documentation tree
- [connection](./src/connection/relay-connection-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [relay-connection-manager](./src/connection/relay-connection-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [discovery](./src/discovery/relay-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [relay-discovery](./src/discovery/relay-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [envelope](./src/envelope/relay-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [relay-envelope](./src/envelope/relay-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [loop](./src/loop/loop-prevention.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [loop-prevention](./src/loop/loop-prevention.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [monitor](./src/monitor/relay-performance-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [relay-performance-monitor](./src/monitor/relay-performance-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/intra-relay-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [intra-relay-plugin](./src/plugin/intra-relay-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [security](./src/security/relay-security-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [relay-security-manager](./src/security/relay-security-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [selector](./src/selector/relay-selector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [relay-selector](./src/selector/relay-selector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Frozen `NET_INTRA_RELAY` error registry for `@suaveplan/network-plugin-intra-relay`. Every failure path the relay subsy…
<!-- end:auto-docs --> - [relay-connection-manager](./src/connection/relay-connection-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [discovery](./src/discovery/relay-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-discovery](./src/discovery/relay-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope](./src/envelope/relay-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-envelope](./src/envelope/relay-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [loop](./src/loop/loop-prevention.md) — <!-- begin:auto-docs --> _(no submodules)_- [loop-prevention](./src/loop/loop-prevention.md) — <!-- begin:auto-docs --> _(no submodules)_- [monitor](./src/monitor/relay-performance-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-performance-monitor](./src/monitor/relay-performance-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/intra-relay-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [intra-relay-plugin](./src/plugin/intra-relay-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [security](./src/security/relay-security-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-security-manager](./src/security/relay-security-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [selector](./src/selector/relay-selector.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-selector](./src/selector/relay-selector.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_INTRA_RELAY` error registry for `@suaveplan/network-plugin-intra-relay`. Every failure path the relay subsy…
- [relay-connection-manager](./src/connection/relay-connection-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [discovery](./src/discovery/relay-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-discovery](./src/discovery/relay-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope](./src/envelope/relay-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-envelope](./src/envelope/relay-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [loop](./src/loop/loop-prevention.md) — <!-- begin:auto-docs --> _(no submodules)_- [loop-prevention](./src/loop/loop-prevention.md) — <!-- begin:auto-docs --> _(no submodules)_- [monitor](./src/monitor/relay-performance-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-performance-monitor](./src/monitor/relay-performance-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/intra-relay-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [intra-relay-plugin](./src/plugin/intra-relay-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [security](./src/security/relay-security-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-security-manager](./src/security/relay-security-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [selector](./src/selector/relay-selector.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-selector](./src/selector/relay-selector.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_INTRA_RELAY` error registry for `@suaveplan/network-plugin-intra-relay`. Every failure path the relay subsy…
- [relay-connection-manager](./src/connection/relay-connection-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [discovery](./src/discovery/relay-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-discovery](./src/discovery/relay-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope](./src/envelope/relay-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-envelope](./src/envelope/relay-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [loop](./src/loop/loop-prevention.md) — <!-- begin:auto-docs --> _(no submodules)_- [loop-prevention](./src/loop/loop-prevention.md) — <!-- begin:auto-docs --> _(no submodules)_- [monitor](./src/monitor/relay-performance-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-performance-monitor](./src/monitor/relay-performance-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/intra-relay-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [intra-relay-plugin](./src/plugin/intra-relay-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [security](./src/security/relay-security-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-security-manager](./src/security/relay-security-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [selector](./src/selector/relay-selector.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-selector](./src/selector/relay-selector.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_INTRA_RELAY` error registry for `@suaveplan/network-plugin-intra-relay`. Every failure path the relay subsy…
- [relay-connection-manager](./src/connection/relay-connection-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [discovery](./src/discovery/relay-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ - [relay-discovery](./src/discovery/relay-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope](./src/envelope/relay-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ - [relay-envelope](./src/envelope/relay-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [loop](./src/loop/loop-prevention.md) — <!-- begin:auto-docs --> _(no submodules)_ - [loop-prevention](./src/loop/loop-prevention.md) — <!-- begin:auto-docs --> _(no submodules)_- [monitor](./src/monitor/relay-performance-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ - [relay-performance-monitor](./src/monitor/relay-performance-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/intra-relay-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [intra-relay-plugin](./src/plugin/intra-relay-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [security](./src/security/relay-security-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [relay-security-manager](./src/security/relay-security-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [selector](./src/selector/relay-selector.md) — <!-- begin:auto-docs --> _(no submodules)_ - [relay-selector](./src/selector/relay-selector.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_INTRA_RELAY` error registry for `@suaveplan/network-plugin-intra-relay`. Every failure path the relay subsy…
## Modules
- [Connection](src/connection/relay-connection-manager.md)
- [Discovery](src/discovery/relay-discovery.md)
- [Envelope](src/envelope/relay-envelope.md)
- [Error codes](src/error-codes.md)
- [Loop prevention](src/loop/loop-prevention.md)
- [Performance monitor](src/monitor/relay-performance-monitor.md)
- [Plugin facade](src/plugin/intra-relay-plugin.md)
- [Security manager](src/security/relay-security-manager.md)
- [Selector](src/selector/relay-selector.md)
- [Types — relay](src/types/relay.types.md)
- [Types — security](src/types/security.types.md)

View File

@@ -0,0 +1,71 @@
# RelayConnectionManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Tracks the lifecycle of every active relay path indexed by `(sourceId, targetId)`. Each path runs a four-state FSM: `pending` (relay is being established) → `active` (forwarding traffic) → `degraded` (quality drop, eligible for re-selection) → `closed` (terminal). The manager enforces only valid transitions, exposes `touch` to reset the idle timer when traffic flows, and supports `sweepIdle` to garbage-collect paths whose `lastActivityAt` has aged past `idleTimeoutMs`.
The manager is in-memory only; restarts lose state, which is fine because relay paths are best-effort and re-established on demand. The `(sourceId, targetId)` key uses both endpoints because a peer may be relaying for many other peers simultaneously, and the manager wants per-relationship state.
## Features
- Four-state FSM with strict transition validation: `pending → active | closed`, `active → degraded | closed`, `degraded → active | closed`, `closed → ∅`.
- `transition(s, t, next)` raises `NET_INTRA_RELAY.INVALID_TRANSITION` when the transition isn't in the allowed set.
- `open(s, t, relayId)` raises `NET_INTRA_RELAY.PATH_ALREADY_EXISTS` if a path is already tracked for the pair — callers should `transition(..., "closed")` before reopening.
- `touch(s, t)` resets `lastActivityAt` without changing state — call this on every successful forward.
- `sweepIdle()` removes every path whose `lastActivityAt` is older than `idleTimeoutMs`. Returns the closed paths so the caller can emit observability events.
- Closing a path REMOVES it from the registry rather than leaving a tombstone — the closed state is terminal but the storage is freed; the returned struct is for the caller's logs.
- `RELAY_TRANSITIONS` exported for documentation / dashboards that need to display the FSM.
## Usage: Basic Example
```ts
import { RelayConnectionManager } from "@suaveplan/network-plugin-intra-relay";
const manager = new RelayConnectionManager({ idleTimeoutMs: 60_000 });
const path = manager.open("peer-A", "peer-B", "peer-2");
manager.transition("peer-A", "peer-B", "active");
manager.touch("peer-A", "peer-B");
```
## Usage: Advanced Example
```ts
import { RelayConnectionManager } from "@suaveplan/network-plugin-intra-relay";
const manager = new RelayConnectionManager({
idleTimeoutMs: 30_000,
clock: () => 1_700_000_000_000,
});
setInterval(() => {
for (const closed of manager.sweepIdle()) {
bus.emit("relay:closed", { reason: "idle", path: closed });
}
}, 5_000);
bus.on("relay:degraded", ({ sourceId, targetId }) => {
manager.transition(sourceId, targetId, "degraded");
});
```
## API Reference
- `RelayConnectionManager(config)` — `config: { idleTimeoutMs; clock? }`.
- `open(sourceId, targetId, relayId)` — returns the new `RelayPath` in `pending` state. Raises `NET_INTRA_RELAY.PATH_ALREADY_EXISTS`.
- `transition(s, t, next)` — returns the updated `RelayPath`. Raises `NET_INTRA_RELAY.PATH_NOT_FOUND` or `NET_INTRA_RELAY.INVALID_TRANSITION`.
- `touch(s, t)` — returns the updated `RelayPath` with refreshed `lastActivityAt`. Raises `NET_INTRA_RELAY.PATH_NOT_FOUND`.
- `sweepIdle()` — returns `readonly RelayPath[]` of paths that were closed by the sweep.
- `getPath(s, t)` — returns `RelayPath | undefined`.
- `requirePath(s, t)` — returns `RelayPath` or throws `NET_INTRA_RELAY.PATH_NOT_FOUND`.
- `listPaths()` — defensive snapshot of every tracked path.
- `size()` — count of tracked paths.
- `RELAY_TRANSITIONS` — frozen `Record<RelayConnectionState, readonly RelayConnectionState[]>`.
## Implementation Notes
- The closed state is not stored — `transition(..., "closed")` removes the path from the map and returns the would-have-been-stored value. This keeps `listPaths()` honest (no zombie entries) and matches the reality that a closed path consumes no resources.
- `sweepIdle` snapshots the entries before iterating because the `delete` mutates the underlying map; without the snapshot the iterator would skip entries.
- The manager does NOT call `bus.emit` itself — the caller composes the manager's outputs with their own observability layer. This keeps the manager unit-testable without a bus mock.

View File

@@ -0,0 +1,73 @@
# RelayDiscovery
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds the candidate set of peers that could relay a message between two endpoints. A peer qualifies as a candidate when it has direct connections to both the source and the target AND its tier is below the configured leaf-exclusion threshold (leaf-tier peers lack the forwarding capacity to relay traffic, so including them would saturate the leaf's link). The discovery layer is intentionally pure topology: it does not know about strategy preferences, performance scores, or active paths beyond what the caller-supplied `PeerSnapshot` reports.
A short TTL cache keeps repeated `(sourceId, targetId)` lookups cheap during a burst of relay-eligible sends; any topology mutation (`registerPeer` / `unregisterPeer`) invalidates the entire cache so the next discover call sees fresh state.
## Features
- Tier-aware exclusion: any peer whose `tier >= excludeTier` is dropped from the candidate set. Setting `excludeTier: 2` (the typical config) drops Tier 2 leaves while keeping Tier 0 / Tier 1 hosts.
- Reachability filter: only peers whose `directlyConnectedTo` includes both endpoints are eligible — a peer that can't reach one side cannot relay.
- Self-exclusion: source and target peers themselves are never returned as candidates.
- Per-pair cache with `cacheTtlMs`; mutation methods (`registerPeer` / `unregisterPeer`) clear it wholesale rather than per-key, because a topology change can invalidate any pair.
- `knownPeers()` for diagnostics, `clearCache()` for explicit invalidation under test.
## Usage: Basic Example
```ts
import { RelayDiscovery } from "@suaveplan/network-plugin-intra-relay";
const discovery = new RelayDiscovery({
excludeTier: 2,
cacheTtlMs: 5_000,
});
discovery.registerPeer({
peerId: "peer-2",
tier: 1,
latencyMs: 30,
reliability: 0.99,
hops: 1,
activePaths: 0,
directlyConnectedTo: ["peer-A", "peer-B"],
});
const candidates = discovery.discover("peer-A", "peer-B");
console.log(candidates[0]?.peerId); // "peer-2"
```
## Usage: Advanced Example
```ts
import { RelayDiscovery } from "@suaveplan/network-plugin-intra-relay";
const discovery = new RelayDiscovery({
excludeTier: 2,
cacheTtlMs: 30_000,
clock: () => 1_700_000_000_000,
});
bus.on("peer:connected", (peer) => discovery.registerPeer(peer));
bus.on("peer:disconnected", (peerId) => discovery.unregisterPeer(peerId));
const fresh = discovery.discover("peer-A", "peer-B");
```
## API Reference
- `RelayDiscovery(config)` — `config: { excludeTier; cacheTtlMs; clock? }`.
- `registerPeer(snapshot)` — registers / updates a peer snapshot and invalidates the cache.
- `unregisterPeer(peerId)` — removes the peer and invalidates the cache.
- `discover(sourceId, targetId)` — returns `readonly RelayCandidate[]`.
- `knownPeers()` — diagnostic snapshot of registered peer ids.
- `clearCache()` — explicit cache invalidation.
- `PeerSnapshot` — `{ peerId; tier; latencyMs; reliability; hops; activePaths; directlyConnectedTo }`.
## Implementation Notes
- The cache invalidation strategy is deliberately coarse: any topology mutation clears every cached pair. The alternative — per-pair invalidation that walks the cache and removes entries touching the changed peer — costs more code for a small win in workloads where mutations are rare relative to lookups.
- `directlyConnectedTo` is `readonly string[]` rather than `Set<string>` because the snapshot is wire-stable and we want serialisation determinism; `.includes` is O(degree) but degree is bounded by the mesh-fan-out (typically dozens).
- Caller is responsible for keeping `PeerSnapshot.activePaths` and `latencyMs` reasonably fresh by re-registering the peer on each notable change. The cache TTL bounds how stale the candidate set can be even if the caller forgets.

View File

@@ -0,0 +1,72 @@
# RelayEnvelope
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Constructs and advances `RelayMessageEnvelope` records. The envelope wraps an inner `MessageEnvelope`-shaped payload with the routing metadata a relay path needs (`relayPath`, `hopCount`, `maxHops`, `visitedNodes`). Source peers call `wrapEnvelope` once at the start; intermediate relay peers call `advanceEnvelope` on each hop to increment `hopCount` and append themselves to `visitedNodes`. Target peers call `unwrapEnvelope` to recover the inner message.
The envelope is purely structural — it carries no signatures of its own. The inner payload is presumed to be already cryptographically signed by `network-plugin-crypto-messages`, so a relay node can forward without unwrapping. This keeps the relay layer fast and decoupled from the cryptographic layer.
## Features
- `wrapEnvelope({ inner, relayPath, maxHops })` builds the initial envelope with `hopCount: 0` and `visitedNodes: [senderId]`.
- Validates the relay path on construction: at least 3 nodes (source + relay + target), head matches `inner.senderId`, tail matches `inner.targetId`, `maxHops >= 1`. Each violation raises a distinct `NET_INTRA_RELAY.ENVELOPE_INVALID` with explanatory `error.context`.
- `advanceEnvelope(envelope, currentPeerId)` returns a new envelope with `hopCount + 1` and the current peer appended to `visitedNodes`. Pure — does not mutate.
- `nextHopFor(envelope, currentPeerId)` returns the peer immediately after `currentPeerId` in `relayPath`, or `null` if the current peer is the tail or not on the path.
- `unwrapEnvelope(envelope)` extracts the inner message; useful at the destination peer.
- `DEFAULT_MAX_HOPS` exported as a sane default (`8`).
## Usage: Basic Example
```ts
import { wrapEnvelope } from "@suaveplan/network-plugin-intra-relay";
const envelope = wrapEnvelope({
inner: {
type: "session.update",
senderId: "peer-A",
targetId: "peer-B",
payload: { kind: "join" },
},
relayPath: ["peer-A", "peer-2", "peer-B"],
maxHops: 8,
});
console.log(envelope.hopCount); // 0
```
## Usage: Advanced Example
```ts
import {
advanceEnvelope,
nextHopFor,
unwrapEnvelope,
} from "@suaveplan/network-plugin-intra-relay";
function forward<P>(env: RelayMessageEnvelope<P>, me: string): void {
const next = nextHopFor(env, me);
if (next === null) {
const inner = unwrapEnvelope(env);
deliver(inner);
return;
}
transport.send(next, advanceEnvelope(env, me));
}
```
## API Reference
- `wrapEnvelope(input)` — `input: { inner; relayPath; maxHops }`. Returns `RelayMessageEnvelope<TPayload>`. Raises `NET_INTRA_RELAY.ENVELOPE_INVALID` on shape violations.
- `advanceEnvelope(envelope, currentPeerId)` — pure, returns a new envelope.
- `nextHopFor(envelope, currentPeerId)` — `string | null`.
- `unwrapEnvelope(envelope)` — extracts the inner `InnerMessage<TPayload>`.
- `DEFAULT_MAX_HOPS` — `8`.
- `InnerMessage<TPayload>` — `{ type; senderId; targetId; payload }`.
## Implementation Notes
- The functions are exported as standalone helpers rather than methods on a class because they're pure and have no shared state.
- `wrapEnvelope` validates that the relayPath head matches the senderId and the tail matches the targetId — this catches the common bug of building an off-by-one path during selection.
- `advanceEnvelope` does not check whether the current peer is on the path or whether the new `hopCount` exceeds `maxHops` — those checks belong to `LoopPrevention.shouldDrop`. Splitting the responsibilities keeps each function trivial.

View File

@@ -0,0 +1,53 @@
# error-codes
## Purpose
Frozen `NET_INTRA_RELAY` error registry for `@suaveplan/network-plugin-intra-relay`. Every failure path the relay subsystem can surface — no relay candidate available, a loop detected mid-path, hop count exceeded, an invalid FSM transition, an unknown relay path, a duplicate path, an unknown strategy name, or an envelope that fails Zod validation — is mapped onto one of these stable codes.
The registry is locked at module load time via `defineCodes("NET_INTRA_RELAY", [...] as const)` so consumers downstream (logger redaction maps, i18n catalogs, the OpenSpec conformance checker) can rely on the set never silently growing without a code change. Per `AGENTS.md §2c`, every code in this registry is referenced from `src/` somewhere — typically as the argument to `new NetworkIntraRelayError(...)` for thrown failures, or carried on a `LoopDropResult.code` outcome for the loop-prevention reporter.
## Features
- Eleven codes covering candidate-set failures (`NO_RELAY_CANDIDATE`), loop semantics (`RELAY_LOOP_DETECTED`, `MAX_HOPS_EXCEEDED`), FSM violations (`INVALID_TRANSITION`, `PATH_NOT_FOUND`, `PATH_ALREADY_EXISTS`), selector misuse (`STRATEGY_UNKNOWN`), envelope validation (`ENVELOPE_INVALID`), and security enforcement (`RELAY_SIGNATURE_INVALID`, `RELAY_PEER_UNTRUSTED`, `RELAY_RATE_LIMIT_EXCEEDED`).
- Sibling `NetworkIntraRelayError` extends `SuaveplanError<NetworkIntraRelayErrorCode>` so consumers can write `instanceof NetworkIntraRelayError` checks and access `error.code` with full type narrowing.
- The two loop-detection codes (`RELAY_LOOP_DETECTED` / `MAX_HOPS_EXCEEDED`) are reported on the `LoopDropResult.code` discriminator rather than thrown — the loop prevention layer is non-throwing because dropping a relay envelope is a normal operating outcome, not an error.
## Usage: Basic Example
```ts
import {
NETWORK_INTRA_RELAY_CODES,
NetworkIntraRelayError,
} from "@suaveplan/network-plugin-intra-relay";
throw new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.NO_RELAY_CANDIDATE,
"intra_relay.no_candidate",
{ context: { sourceId: "A", targetId: "B" } }
);
```
## Usage: Advanced Example
```ts
import {
NetworkIntraRelayError,
type NetworkIntraRelayErrorCode,
} from "@suaveplan/network-plugin-intra-relay";
function classify(err: unknown): NetworkIntraRelayErrorCode | null {
return err instanceof NetworkIntraRelayError ? err.code : null;
}
```
## API Reference
- `NETWORK_INTRA_RELAY_CODES` — frozen object with eleven keys: `NO_RELAY_CANDIDATE`, `RELAY_LOOP_DETECTED`, `MAX_HOPS_EXCEEDED`, `INVALID_TRANSITION`, `PATH_NOT_FOUND`, `PATH_ALREADY_EXISTS`, `STRATEGY_UNKNOWN`, `ENVELOPE_INVALID`, `RELAY_SIGNATURE_INVALID`, `RELAY_PEER_UNTRUSTED`, `RELAY_RATE_LIMIT_EXCEEDED`. Values are `"NET_INTRA_RELAY.<KEY>"` strings.
- `NetworkIntraRelayErrorCode` — string-literal union of the registry values.
- `NetworkIntraRelayError` — error class extending `SuaveplanError<NetworkIntraRelayErrorCode>`. Constructor: `(code, messageKey, options?)`.
## Implementation Notes
- The list grows only by adding to the array literal in `error-codes.ts`; there is no programmatic `register()` API by design.
- Code messages use dotted i18n keys (e.g. `intra_relay.no_candidate`) so the logger's i18n adapter can resolve them at format time.
- `RELAY_LOOP_DETECTED` and `MAX_HOPS_EXCEEDED` are deliberately distinct codes even though both end the relay path: the former is a self-loop (visitedNodes already contains us), the latter is a depth-budget exhaustion. Splitting them lets observability dashboards distinguish a misbehaving topology (loops) from a too-tight budget (max hops).

View File

@@ -0,0 +1,60 @@
# LoopPrevention
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Decides whether to drop a relay envelope on arrival at a forwarding node. Two conditions trigger a drop: (1) `hopCount >= maxHops`, meaning the depth budget is exhausted; (2) `visitedNodes.includes(currentPeerId)`, meaning the envelope has already passed through this node and is looping back. Both conditions are normal operating outcomes (not errors) so the result is returned as a `LoopDropResult` discriminated record rather than thrown.
The two distinct codes (`MAX_HOPS_EXCEEDED` vs `RELAY_LOOP_DETECTED`) carry the policy distinction: a depth-budget exhaustion suggests the topology is too tall (or `maxHops` is too low); a loop detection suggests the topology has a cycle that the discovery layer should not have produced. Splitting them lets observability dashboards diagnose which class of failure is happening.
## Features
- Pure decision function — `shouldDrop(envelope, currentPeerId)` reads the envelope and the local peer id and returns a `LoopDropResult`.
- Optional bus emits `relay:loop-detected` with the reason, code, peer, and hop count so observability adapters can count drops without polling.
- Result carries both human-readable `reason` (`"max-hops-exceeded" | "loop-detected"`) and machine-stable `code` from the registry (`MAX_HOPS_EXCEEDED` / `RELAY_LOOP_DETECTED`) — the code is the field consumers should match on.
- Non-throwing by design: dropping a relay message is a normal outcome and would be too noisy as an exception. The caller checks `drop === true` and acts.
## Usage: Basic Example
```ts
import { LoopPrevention } from "@suaveplan/network-plugin-intra-relay";
const guard = new LoopPrevention();
const result = guard.shouldDrop(envelope, "peer-2");
if (result.drop) console.warn(`drop ${result.reason} at ${result.currentPeerId}`);
```
## Usage: Advanced Example
```ts
import { LoopPrevention } from "@suaveplan/network-plugin-intra-relay";
const guard = new LoopPrevention({
bus: { emit: (topic, payload) => observability.publish(topic, payload) },
});
bus.on("relay:envelope", (env) => {
const verdict = guard.shouldDrop(env, localPeerId);
if (verdict.drop) {
metrics.increment(`intra_relay.drop.${verdict.reason}`);
return;
}
forward(env);
});
```
## API Reference
- `LoopPrevention(config?)` — `config: { bus?: LoopPreventionBus }`.
- `shouldDrop(envelope, currentPeerId)` — returns `LoopDropResult`.
- `LoopDropResult` — `{ drop; reason; code; currentPeerId; hopCount }`. When `drop === false`, `reason` and `code` are both `null`.
- `LoopDropReason` — `"max-hops-exceeded" | "loop-detected"`.
- `LoopPreventionBus` — `emit("relay:loop-detected", payload)` shape.
## Implementation Notes
- `hopCount >= maxHops` (`>=`, not `>`) — this matches the proposal's wording and ensures that an envelope with `hopCount: 8, maxHops: 8` is dropped at the _next_ hop rather than allowed through one more time.
- The two checks run in order: `max-hops-exceeded` is reported first if both conditions apply. This is intentional because the depth-budget check is the cheaper-to-fix signal (a config bump beats a topology audit).
- The bus is only notified for drops; when `drop === false` the function returns silently.

View File

@@ -0,0 +1,73 @@
# RelayPerformanceMonitor
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Tracks per-path RTT samples in a sliding window and decides whether each relay path is `healthy` or `degraded`. Quality transitions emit `relay:degraded` / `relay:recovered` on the optional bus so the connection manager (or upstream re-selection logic) can react. The monitor uses hysteresis — distinct degraded and recovered thresholds — so a path that hovers around the threshold doesn't flap between states on every sample.
The monitor is intentionally simple: average over the last `windowSize` samples, compare against thresholds, emit on transition. Richer signals (jitter, packet loss, bandwidth) are deferred to follow-up changes; the proposal calls these out explicitly and their tracking will land separately.
## Features
- Sliding RTT window of `windowSize` samples (`>= 1`); older samples drop off the front as new ones arrive.
- Hysteresis via two thresholds: `degradedThresholdMs` (cross above to degrade) and `recoveredThresholdMs` (cross below to recover). Constructor validates `recoveredThresholdMs <= degradedThresholdMs`.
- State transitions emit `relay:degraded` / `relay:recovered` on the optional bus exactly once per crossing.
- Per-`(sourceId, targetId)` indexing — a peer may have multiple paths through different relays; each is tracked independently.
- `record(s, t, relay, rtt)` returns a `PathQualityRecord` snapshot for inline use; `get(s, t)` and `list()` are read-only inspectors.
- `reset(s, t)` drops the path's history (e.g. after the connection manager closes the path so the next `open` starts fresh).
## Usage: Basic Example
```ts
import { RelayPerformanceMonitor } from "@suaveplan/network-plugin-intra-relay";
const monitor = new RelayPerformanceMonitor({
windowSize: 16,
degradedThresholdMs: 200,
recoveredThresholdMs: 100,
});
monitor.record("peer-A", "peer-B", "peer-2", 50);
monitor.record("peer-A", "peer-B", "peer-2", 80);
console.log(monitor.get("peer-A", "peer-B")?.quality); // "healthy"
```
## Usage: Advanced Example
```ts
import { RelayPerformanceMonitor } from "@suaveplan/network-plugin-intra-relay";
const monitor = new RelayPerformanceMonitor({
windowSize: 32,
degradedThresholdMs: 250,
recoveredThresholdMs: 120,
bus: {
emit: (topic, payload) => {
observability.publish(topic, payload);
if (topic === "relay:degraded") selector.reEvaluate(payload);
},
},
});
bus.on("relay:rtt", ({ sourceId, targetId, relayId, rttMs }) => {
monitor.record(sourceId, targetId, relayId, rttMs);
});
```
## API Reference
- `RelayPerformanceMonitor(config)` — `config: { windowSize; degradedThresholdMs; recoveredThresholdMs; bus? }`. `windowSize < 1` is clamped to `1`. Throws if `recoveredThresholdMs > degradedThresholdMs`.
- `record(sourceId, targetId, relayId, rttMs)` — returns `PathQualityRecord`. Emits a transition event when quality crosses a threshold.
- `get(sourceId, targetId)` — returns `PathQualityRecord | undefined`.
- `reset(sourceId, targetId)` — drops the path's window.
- `list()` — defensive copy of every tracked path.
- `PathQuality` — `"healthy" | "degraded"`.
- `PathQualityRecord` — `{ sourceId; targetId; relayId; samples; avgRttMs; quality }`.
## Implementation Notes
- The hysteresis check (`recovered <= degraded`) is enforced at construction time so a misconfigured monitor fails fast rather than silently never recovering.
- `relayId` is captured on every `record` so that a single `(sourceId, targetId)` pair can roll over to a new relay (e.g. after a re-selection) without the monitor losing track. The most recent relay id wins.
- Transition emission happens after the sample is recorded, so the snapshot returned to the caller already reflects the new quality.

View File

@@ -0,0 +1,85 @@
# IntraRelayPlugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Single `NetworkPlugin` facade that wires `RelayDiscovery`, `RelaySelector`, `RelayConnectionManager`, `RelayPerformanceMonitor`, and `LoopPrevention` into a cohesive subsystem and registers them with a `NetworkNode` via the standard plugin lifecycle (`install` / `start` / `stop` / `dispose`). Consumers register exactly one of these per host; everything else is reachable via the public properties on the plugin instance.
The plugin is the wire-up; the modules are the work. State that's interesting to outside callers (the discovery cache, the path FSM, the RTT window) lives in the composed subsystems and survives `start` / `stop` cycles. Only `dispose` clears state, by clearing the discovery cache.
## Features
- One-shot construction wires every subsystem from a single config object.
- `registerPeer(snapshot)` / `unregisterPeer(peerId)` are convenience facades that delegate to `discovery`, intended for direct wiring against a `peer:connected` / `peer:disconnected` bus stream.
- `chooseRelay(targetId)` is the convenience composition of `discovery.discover(localPeerId, targetId)` + `selector.select(defaultStrategy, candidates)` for the common case where the caller just wants "give me a relay for this target".
- The optional `bus` is fanned out to both `RelayPerformanceMonitor` and `LoopPrevention` so consumers wire one bus and get all relevant events.
- Subsystem properties exposed read-only: `discovery`, `selector`, `connections`, `monitor`, `loop`. Access is direct so a caller can mix-and-match if `chooseRelay` is too prescriptive.
- Optional `security: RelaySecurityManagerConfig` constructs a `RelaySecurityManager` exposed via the `security` getter; when omitted, `security` returns `null` (security disabled).
## Usage: Basic Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { IntraRelayPlugin } from "@suaveplan/network-plugin-intra-relay";
const node = new NetworkNode({ id: "peer-A" });
const relay = new IntraRelayPlugin({
localPeerId: "peer-A",
defaultStrategy: "lowest-latency",
discovery: { excludeTier: 2, cacheTtlMs: 5_000 },
connection: { idleTimeoutMs: 60_000 },
monitor: {
windowSize: 16,
degradedThresholdMs: 200,
recoveredThresholdMs: 100,
},
});
await node.use(relay).start();
relay.registerPeer({ ... });
const candidate = relay.chooseRelay("peer-B");
```
## Usage: Advanced Example
```ts
import { IntraRelayPlugin } from "@suaveplan/network-plugin-intra-relay";
const relay = new IntraRelayPlugin({
localPeerId: "peer-A",
defaultStrategy: "load-balanced",
discovery: { excludeTier: 2, cacheTtlMs: 30_000 },
connection: { idleTimeoutMs: 30_000 },
monitor: {
windowSize: 32,
degradedThresholdMs: 250,
recoveredThresholdMs: 120,
},
bus: { emit: (topic, payload) => observability.publish(topic, payload) },
});
bus.on("peer:connected", (peer) => relay.registerPeer(peer));
bus.on("peer:disconnected", (peerId) => relay.unregisterPeer(peerId));
const path = relay.connections.open("peer-A", "peer-B", relay.chooseRelay("peer-B").peerId);
```
## API Reference
- `IntraRelayPlugin(config)` — `config: IntraRelayPluginConfig`. See type definition for the full surface.
- `name` — `"intra-relay"` (constant).
- `localPeerId`, `defaultStrategy` — read-only mirrors of the construction config.
- Subsystem properties: `discovery`, `selector`, `connections`, `monitor`, `loop`.
- `security` — getter returning `RelaySecurityManager | null`.
- `registerPeer(snapshot)` / `unregisterPeer(peerId)` — discovery facades.
- `chooseRelay(targetId)` — composed discover + select.
- `install(node)` / `start()` / `stop()` / `dispose()` — `NetworkPlugin` lifecycle.
- `host()` — returns the installed `NetworkNode` or `null`.
- `isStarted()` — boolean lifecycle inspector.
## Implementation Notes
- The plugin uses spread-with-conditional-key (`...(config.bus === undefined ? {} : { bus: ... })`) so that under `exactOptionalPropertyTypes` an `undefined` bus never reaches a sub-component that expects an _absent_ key.
- `chooseRelay` rethrows `NET_INTRA_RELAY.NO_RELAY_CANDIDATE` from the selector; callers that want a non-throwing path should call `discovery.discover(...)` and `selector.select(...)` directly with explicit empty-candidate handling.
- Cryptographic relay-node authentication shipped as `RelaySecurityManager` in the `network-plugin-intra-relay-security` change. Wire it via `IntraRelayPluginConfig.security`; when omitted, security is disabled and the `security` getter returns `null`.

View File

@@ -0,0 +1,94 @@
# RelaySecurityManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Authorises every inbound relay message before it is forwarded by `IntraRelayPlugin`. The manager runs three checks in order — trusted-peer allowlist, `ed25519` signature validation, and per-peer + global token-bucket rate limiting — and throws a typed `NetworkIntraRelayError` on the first failure. Wiring this layer into the relay loop is what stops a hostile peer from spoofing other peers' relay traffic, replaying signed envelopes from peers it does not control, or DOS-ing the mesh by flooding a single relay node.
The class is deliberately small and synchronous: every check is constant-time relative to the message and the peer table. Callers wire one instance per plugin; the per-peer rate-limit table grows only as new peers send messages and can be reset with `reset()`.
## Features
- **Trusted-peer allowlist** — when `RelayAuthPolicy.trustedPeers` is non-empty, only peers in the set may relay; others throw `RELAY_PEER_UNTRUSTED`. An empty set means "any peer with a registered public key".
- **Signature validation** — verifies a detached `ed25519` signature against canonical bytes using a caller-supplied `PeerKeyResolver`. Missing signatures throw `RELAY_SIGNATURE_INVALID` when policy demands signatures; missing public keys also throw the same code. The verifier is injectable for deterministic tests.
- **Token-bucket rate limit** — wraps `@suaveplan/rate-limiter`'s `TokenBucket`. Per-peer buckets are lazily created on first message; a separate global bucket caps total relay throughput. Refill rate is derived from `maxMessagesPerSecond` and capacity is `burstSize`. Exhaustion throws `RELAY_RATE_LIMIT_EXCEEDED` with `retryAfterMs` and `scope: "per-peer" | "global"` in the error context.
- **Observability hook** — `isRateLimited(peerId)` returns the current rate-limit pressure for a peer without consuming a token.
- **Reset** — `reset()` drops all per-peer buckets and refills the global bucket; useful for tests and for operator-initiated mesh resets.
## Usage: Basic Example
```ts
import {
RelaySecurityManager,
type RelayAuthPolicy,
type RelayRateLimit,
} from "@suaveplan/network-plugin-intra-relay";
const peerKeys = new Map<string, Uint8Array>();
peerKeys.set("peer-1", peer1PublicKey);
const security = new RelaySecurityManager({
policy: { requireSignature: true, trustedPeers: new Set(["peer-1"]) },
rateLimit: { maxMessagesPerSecond: 50, burstSize: 100 },
peerKeyResolver: (id) => peerKeys.get(id),
});
security.authorize({
senderPeerId: "peer-1",
canonicalBytes,
signature,
});
```
## Usage: Advanced Example
```ts
import {
NetworkIntraRelayError,
NETWORK_INTRA_RELAY_CODES,
RelaySecurityManager,
} from "@suaveplan/network-plugin-intra-relay";
const security = new RelaySecurityManager({
policy: { requireSignature: true, trustedPeers: new Set() },
rateLimit: { maxMessagesPerSecond: 100, burstSize: 200 },
peerKeyResolver: (id) => registry.lookupPublicKey(id),
clock: () => fakeClock.now(),
signatureVerifier: (sig, msg, pk) => ed25519.verify(sig, msg, pk),
});
try {
security.authorize({ senderPeerId, canonicalBytes, signature });
forward(envelope);
} catch (err) {
if (
err instanceof NetworkIntraRelayError &&
err.code === NETWORK_INTRA_RELAY_CODES.RELAY_RATE_LIMIT_EXCEEDED
) {
metrics.increment("intra_relay.dropped.rate_limit", {
scope: err.context?.scope,
});
return;
}
throw err;
}
```
## API Reference
- `RelaySecurityManager(config)` — `config: { policy, rateLimit, peerKeyResolver, clock?, signatureVerifier? }`. Throws `RangeError` when `maxMessagesPerSecond` or `burstSize` is `<= 0`.
- `authorize(input)` — runs allowlist → signature → rate-limit pipeline. Throws `NetworkIntraRelayError` with `RELAY_PEER_UNTRUSTED`, `RELAY_SIGNATURE_INVALID`, or `RELAY_RATE_LIMIT_EXCEEDED`.
- `isRateLimited(peerId)` — returns `true` when the next `authorize` call from `peerId` would fail on rate limit.
- `reset()` — clears all per-peer buckets and refills the global bucket.
- `PeerKeyResolver` — `(peerId: string) => Uint8Array | undefined`.
- `SignatureVerifier` — same shape as `ed25519.verify`.
## Implementation Notes
- The token-bucket refill rate is `maxMessagesPerSecond / 1000` because `TokenBucket` works in tokens-per-millisecond. This matches the `@suaveplan/rate-limiter` algorithm contract exactly.
- Order of checks (allowlist → signature → rate-limit) is intentional: cheaper checks first, and an untrusted peer should never consume a rate-limit token. The signature check runs before the rate-limit check so that a peer who is sending invalid signatures cannot drain the global bucket.
- The per-peer bucket map grows only as new peers send messages; there is no eviction policy. Long-lived plugins should call `reset()` periodically or wrap the manager in a higher-level cache layer.
- `signatureVerifier` defaults to `ed25519.verify` from `@suaveplan/crypto-curve`; tests inject a deterministic verifier so that verification outcomes are not sensitive to fast-check shrinking of byte sequences.
- All three failure paths surface a distinct error code so observability dashboards can split "untrusted peer" from "bad signature" from "rate limited" without parsing `messageKey` strings.

View File

@@ -0,0 +1,58 @@
# RelaySelector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Picks the optimal relay candidate from a discovery-produced set, given a strategy. The five strategies (`lowest-latency`, `highest-reliability`, `fewest-hops`, `tier-optimized`, `load-balanced`) cover the main optimization axes a relay path cares about. The selector is pure: same strategy + same candidates always yields the same winner, with `peerId` lex-order as the tie-breaker so two selectors processing the same input agree.
The selector is deliberately stateless. State that varies per session (active path counts, latency snapshots) lives on the candidate row; the selector just reads it. This keeps the selector trivially testable and lets a single instance serve every relay decision.
## Features
- Five strategies, closed enum — adding a strategy is a code change so docs and dispatch can never drift.
- Deterministic tie-breaking via `peerId.localeCompare` — two selectors seeing the same input always pick the same winner.
- `select(strategy, candidates)` is the high-level call; `rank(strategy, candidates)` returns the full ranked list for diagnostics or fallback (e.g. "if the top pick fails, try the runner-up").
- Empty candidate list raises `NET_INTRA_RELAY.NO_RELAY_CANDIDATE` rather than returning a sentinel — there is no meaningful "no winner" answer.
- Unknown strategy raises `NET_INTRA_RELAY.STRATEGY_UNKNOWN` (defensive, since the strategy union is closed at the type level but a JSON config could supply garbage).
- `STRATEGY_LIST` exported for consumers that need to enumerate (e.g. an admin UI dropdown).
## Usage: Basic Example
```ts
import { RelaySelector } from "@suaveplan/network-plugin-intra-relay";
const selector = new RelaySelector();
const winner = selector.select("lowest-latency", candidates);
console.log(winner.peerId);
```
## Usage: Advanced Example
```ts
import {
RelaySelector,
STRATEGY_LIST,
} from "@suaveplan/network-plugin-intra-relay";
const selector = new RelaySelector();
const ranked = selector.rank("load-balanced", candidates);
for (const candidate of ranked) {
if (await connectionManager.tryEstablish(candidate)) break;
}
console.log(`tested strategies: ${STRATEGY_LIST.join(", ")}`);
```
## API Reference
- `RelaySelector` — stateless class; the no-arg constructor is intentional.
- `select(strategy, candidates)` — returns the top-ranked `RelayCandidate`. Raises `NET_INTRA_RELAY.STRATEGY_UNKNOWN` or `NET_INTRA_RELAY.NO_RELAY_CANDIDATE`.
- `rank(strategy, candidates)` — returns `RelayCandidate[]` sorted best-first. Raises `NET_INTRA_RELAY.STRATEGY_UNKNOWN` (but tolerates an empty candidate list since the empty result is unambiguous).
- `STRATEGY_LIST` — `readonly RelayStrategy[]` for enumeration.
## Implementation Notes
- The compare function returns numbers in the standard `Array.prototype.sort` convention (negative = a-first, positive = b-first). For `highest-reliability` the natural order is reversed (`b - a`) since higher reliability should sort first.
- Strategies that share a primary axis (e.g. `lowest-latency` and `fewest-hops`) still produce different orderings because the candidates that tie on one axis won't necessarily tie on the lex tiebreak.
- The selector does NOT mutate the input array — `rank` returns a fresh sorted copy, so callers can hold a defensive snapshot of their candidate list and trust it isn't touched.

View File

@@ -0,0 +1,74 @@
# relay.types
## Purpose
Wire-stable shapes that flow through the intra-mesh relay subsystem. The four-state `RelayConnectionState` FSM (`pending` → `active` → `degraded` → `closed`) is the lifecycle every relay path follows. The five-strategy `RelayStrategy` union is the ranking-policy selector consumers pass into `RelaySelector`. `RelayCandidate` is the input row for that selector. `RelayPath` is the per-pair record `RelayConnectionManager` keeps. `RelayMessageEnvelope<TPayload>` is the wire shape `IntraRelayPlugin` actually sends — it inlines the inner message metadata so a relay node can forward without unwrapping the cryptographic envelope (the inner payload remains the unaltered ciphertext / signed blob produced by `network-plugin-crypto-messages`).
These types live in a shared `types/` directory because every subsystem in `@suaveplan/network-plugin-intra-relay` agrees on them.
## Features
- `RelayConnectionState` is a closed union of four states; the FSM transitions are enforced in `RelayConnectionManager` rather than via type-level discriminated states (kept simple so the type stays serialisable).
- `RelayStrategy` is intentionally five strategies, not "any function": `RelaySelector` only needs to dispatch to a fixed set, and a closed enum prevents drift between docs and code.
- `RelayCandidate` carries everything every strategy might need; not every strategy uses every field, but the cost of an extra field on a candidate row is negligible compared to the cost of swapping rows when a new strategy lands.
- `RelayMessageEnvelope.innerPayload` is generic `TPayload` so consumers wrapping a strongly-typed message don't lose type information at the relay boundary.
## Usage: Basic Example
```ts
import type {
RelayCandidate,
RelayMessageEnvelope,
RelayStrategy,
} from "@suaveplan/network-plugin-intra-relay";
const candidate: RelayCandidate = {
peerId: "peer-2",
tier: 1,
latencyMs: 30,
reliability: 0.99,
hops: 1,
activePaths: 4,
};
const strategy: RelayStrategy = "lowest-latency";
const envelope: RelayMessageEnvelope<{ kind: string }> = {
innerType: "session.update",
innerSenderId: "peer-A",
innerTargetId: "peer-B",
innerPayload: { kind: "join" },
relayPath: ["peer-A", "peer-2", "peer-B"],
hopCount: 0,
maxHops: 8,
visitedNodes: ["peer-A"],
};
```
## Usage: Advanced Example
```ts
import type {
RelayConnectionState,
RelayPath,
} from "@suaveplan/network-plugin-intra-relay";
function isHealthy(p: RelayPath): boolean {
const ok: RelayConnectionState[] = ["active", "pending"];
return ok.includes(p.state);
}
```
## API Reference
- `RelayConnectionState` — `"pending" | "active" | "degraded" | "closed"`.
- `RelayStrategy` — `"lowest-latency" | "highest-reliability" | "fewest-hops" | "tier-optimized" | "load-balanced"`.
- `RelayCandidate` — `{ peerId; tier; latencyMs; reliability; hops; activePaths }`.
- `RelayPath` — `{ sourceId; targetId; relayId; establishedAt; state; lastActivityAt }`.
- `RelayMessageEnvelope<TPayload>` — `{ innerType; innerSenderId; innerTargetId; innerPayload; relayPath; hopCount; maxHops; visitedNodes }`.
## Implementation Notes
- All fields are `readonly`. `RelayPath` updates are produced by replacing the whole struct rather than mutating in place.
- `relayPath` and `visitedNodes` are `readonly string[]` rather than `Set<string>` so the wire shape stays deterministic and serialisation-friendly; the cost of a linear `includes` scan is bounded by `maxHops` (default 8) so it's a non-issue.
- `RelayMessageEnvelope` does NOT include the inner message's signature — the assumption is that the inner payload is already cryptographically signed by `network-plugin-crypto-messages` so the relay envelope only needs to carry routing metadata.

View File

@@ -0,0 +1,73 @@
# security.types
## Purpose
Wire-stable shapes for the optional `RelaySecurityManager` subsystem. The intra-relay plugin can run with security disabled (the default in test fixtures) or with cryptographic peer verification, signature validation, and per-peer / global token-bucket rate limiting (the recommended production posture). These three types — `RelayAuthPolicy`, `RelayRateLimit`, `RelaySecurityCheckInput` — describe the configuration knobs and the inbound check payload shared between the manager, the plugin facade, and downstream observability.
These types live alongside `relay.types.ts` so consumers wiring a custom plugin instance only need a single import root.
## Features
- `RelayAuthPolicy.requireSignature` — when `true`, every inbound relay envelope MUST carry an `ed25519` signature; missing signatures are rejected with `RELAY_SIGNATURE_INVALID`.
- `RelayAuthPolicy.trustedPeers` — `ReadonlySet<string>` allowlist; empty set means "any peer with a registered public key", non-empty set means "only peers in the set may relay".
- `RelayRateLimit.maxMessagesPerSecond` — steady-state token-bucket refill rate.
- `RelayRateLimit.burstSize` — bucket capacity (tokens drawn 1-per-message).
- `RelaySecurityCheckInput` — `{ senderPeerId, canonicalBytes, signature? }` — what `RelaySecurityManager.authorize` consumes per inbound message.
## Usage: Basic Example
```ts
import type {
RelayAuthPolicy,
RelayRateLimit,
} from "@suaveplan/network-plugin-intra-relay";
const policy: RelayAuthPolicy = {
requireSignature: true,
trustedPeers: new Set(["peer-1", "peer-2"]),
};
const rateLimit: RelayRateLimit = {
maxMessagesPerSecond: 50,
burstSize: 100,
};
```
## Usage: Advanced Example
```ts
import type {
RelayAuthPolicy,
RelayRateLimit,
RelaySecurityCheckInput,
} from "@suaveplan/network-plugin-intra-relay";
function buildCheck(
peerId: string,
bytes: Uint8Array,
sig: Uint8Array
): RelaySecurityCheckInput {
return { senderPeerId: peerId, canonicalBytes: bytes, signature: sig };
}
const productionPolicy: RelayAuthPolicy = {
requireSignature: true,
trustedPeers: new Set(),
};
const productionLimit: RelayRateLimit = {
maxMessagesPerSecond: 100,
burstSize: 200,
};
```
## API Reference
- `RelayAuthPolicy` — `{ requireSignature: boolean; trustedPeers: ReadonlySet<string> }`.
- `RelayRateLimit` — `{ maxMessagesPerSecond: number; burstSize: number }`.
- `RelaySecurityCheckInput` — `{ senderPeerId: string; canonicalBytes: Uint8Array; signature?: Uint8Array }`.
## Implementation Notes
- `trustedPeers` is intentionally a `ReadonlySet<string>` rather than an array — the runtime hot path is `has(peerId)` (O(1)) and the type signals the read-only intent without copying.
- `signature` is optional on `RelaySecurityCheckInput` so callers running with `requireSignature: false` can pass plain bytes; the manager rejects missing signatures only when policy demands them.
- Rate-limit fields are `number` (not branded) because they represent simple positive integers; runtime validation lives in the `RelaySecurityManager` constructor.
- Wire compatibility: extending these interfaces with optional fields is non-breaking; adding required fields is breaking and needs a new OpenSpec change.