Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 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,57 @@
{
"name": "@suaveplan/network-plugin-intra-relay",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./security/security": {
"import": {
"types": "./dist/security/relay-security-manager.d.ts",
"default": "./dist/security/relay-security-manager.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/crypto-curve": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/network": "workspace:^",
"@suaveplan/rate-limiter": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

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,163 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_INTRA_RELAY_CODES,
NetworkIntraRelayError,
} from "../error-codes.js";
import {
RELAY_TRANSITIONS,
RelayConnectionManager,
} from "./relay-connection-manager.js";
describe("RelayConnectionManager.open / transition", () => {
it("opens a path in pending state", () => {
const m = new RelayConnectionManager({
idleTimeoutMs: 1_000,
clock: () => 100,
});
const path = m.open("A", "B", "R");
expect(path.state).toBe("pending");
expect(path.relayId).toBe("R");
expect(path.establishedAt).toBe(100);
});
it("rejects opening a duplicate pair", () => {
const m = new RelayConnectionManager({ idleTimeoutMs: 1_000 });
m.open("A", "B", "R");
try {
m.open("A", "B", "R");
throw new Error("expected throw");
} catch (err) {
expect((err as NetworkIntraRelayError).code).toBe(
NETWORK_INTRA_RELAY_CODES.PATH_ALREADY_EXISTS
);
}
});
it("transitions pending → active → degraded → active → closed", () => {
const m = new RelayConnectionManager({
idleTimeoutMs: 1_000,
clock: () => 100,
});
m.open("A", "B", "R");
expect(m.transition("A", "B", "active").state).toBe("active");
expect(m.transition("A", "B", "degraded").state).toBe("degraded");
expect(m.transition("A", "B", "active").state).toBe("active");
expect(m.transition("A", "B", "closed").state).toBe("closed");
expect(m.getPath("A", "B")).toBeUndefined();
});
it("rejects an invalid transition", () => {
const m = new RelayConnectionManager({ idleTimeoutMs: 1_000 });
m.open("A", "B", "R");
try {
m.transition("A", "B", "degraded");
throw new Error("expected throw");
} catch (err) {
expect((err as NetworkIntraRelayError).code).toBe(
NETWORK_INTRA_RELAY_CODES.INVALID_TRANSITION
);
}
});
it("transition raises PATH_NOT_FOUND when the pair is unknown", () => {
const m = new RelayConnectionManager({ idleTimeoutMs: 1_000 });
try {
m.transition("A", "B", "active");
throw new Error("expected throw");
} catch (err) {
expect((err as NetworkIntraRelayError).code).toBe(
NETWORK_INTRA_RELAY_CODES.PATH_NOT_FOUND
);
}
});
});
describe("RelayConnectionManager.touch", () => {
it("refreshes lastActivityAt", () => {
let now = 100;
const m = new RelayConnectionManager({
idleTimeoutMs: 1_000,
clock: () => now,
});
m.open("A", "B", "R");
now = 200;
const updated = m.touch("A", "B");
expect(updated.lastActivityAt).toBe(200);
});
it("raises PATH_NOT_FOUND for unknown pairs", () => {
const m = new RelayConnectionManager({ idleTimeoutMs: 1_000 });
try {
m.touch("A", "B");
throw new Error("expected throw");
} catch (err) {
expect((err as NetworkIntraRelayError).code).toBe(
NETWORK_INTRA_RELAY_CODES.PATH_NOT_FOUND
);
}
});
});
describe("RelayConnectionManager.sweepIdle", () => {
it("closes paths whose lastActivityAt is older than the timeout", () => {
let now = 100;
const m = new RelayConnectionManager({
idleTimeoutMs: 50,
clock: () => now,
});
m.open("A", "B", "R");
m.open("C", "D", "R2");
now = 200;
m.touch("C", "D");
const closed = m.sweepIdle();
expect(closed.map((p) => p.sourceId)).toEqual(["A"]);
expect(m.getPath("A", "B")).toBeUndefined();
expect(m.getPath("C", "D")).toBeDefined();
});
it("returns an empty list when no paths are idle", () => {
let now = 100;
const m = new RelayConnectionManager({
idleTimeoutMs: 50,
clock: () => now,
});
m.open("A", "B", "R");
now = 110;
expect(m.sweepIdle()).toEqual([]);
});
});
describe("RelayConnectionManager inspectors", () => {
it("requirePath throws when missing, returns when present", () => {
const m = new RelayConnectionManager({ idleTimeoutMs: 1_000 });
expect(() => m.requirePath("A", "B")).toThrow(NetworkIntraRelayError);
m.open("A", "B", "R");
expect(m.requirePath("A", "B").relayId).toBe("R");
});
it("listPaths and size reflect tracked paths", () => {
const m = new RelayConnectionManager({ idleTimeoutMs: 1_000 });
m.open("A", "B", "R");
m.open("C", "D", "R2");
expect(m.size()).toBe(2);
expect(m.listPaths().length).toBe(2);
});
it("uses Date.now when no clock is injected", () => {
const m = new RelayConnectionManager({ idleTimeoutMs: 1_000 });
const before = Date.now();
const path = m.open("A", "B", "R");
const after = Date.now();
expect(path.establishedAt).toBeGreaterThanOrEqual(before);
expect(path.establishedAt).toBeLessThanOrEqual(after);
});
});
describe("RELAY_TRANSITIONS", () => {
it("encodes the documented FSM", () => {
expect(RELAY_TRANSITIONS.pending).toEqual(["active", "closed"]);
expect(RELAY_TRANSITIONS.active).toEqual(["degraded", "closed"]);
expect(RELAY_TRANSITIONS.degraded).toEqual(["active", "closed"]);
expect(RELAY_TRANSITIONS.closed).toEqual([]);
});
});

View File

@@ -0,0 +1,146 @@
import {
NETWORK_INTRA_RELAY_CODES,
NetworkIntraRelayError,
} from "../error-codes.js";
import type { RelayConnectionState, RelayPath } from "../types/relay.types.js";
const TRANSITIONS: Readonly<
Record<RelayConnectionState, readonly RelayConnectionState[]>
> = Object.freeze({
pending: ["active", "closed"],
active: ["degraded", "closed"],
degraded: ["active", "closed"],
closed: [],
});
export interface RelayConnectionManagerConfig {
readonly idleTimeoutMs: number;
readonly clock?: () => number;
}
const pairKey = (sourceId: string, targetId: string): string =>
`${sourceId}|${targetId}`;
export class RelayConnectionManager {
private readonly idleTimeoutMs: number;
private readonly clock: () => number;
private readonly paths = new Map<string, RelayPath>();
public constructor(config: RelayConnectionManagerConfig) {
this.idleTimeoutMs = config.idleTimeoutMs;
this.clock = config.clock ?? (() => Date.now());
}
public open(
sourceId: string,
targetId: string,
relayId: string
): RelayPath {
const key = pairKey(sourceId, targetId);
if (this.paths.has(key)) {
throw new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.PATH_ALREADY_EXISTS,
"intra_relay.path_already_exists",
{ context: { sourceId, targetId } }
);
}
const now = this.clock();
const path: RelayPath = {
sourceId,
targetId,
relayId,
establishedAt: now,
state: "pending",
lastActivityAt: now,
};
this.paths.set(key, path);
return path;
}
public transition(
sourceId: string,
targetId: string,
next: RelayConnectionState
): RelayPath {
const path = this.requirePath(sourceId, targetId);
const allowed = TRANSITIONS[path.state];
if (!allowed.includes(next)) {
throw new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.INVALID_TRANSITION,
"intra_relay.invalid_transition",
{
context: {
sourceId,
targetId,
from: path.state,
to: next,
},
}
);
}
const updated: RelayPath = {
...path,
state: next,
lastActivityAt: this.clock(),
};
const key = pairKey(sourceId, targetId);
if (next === "closed") {
this.paths.delete(key);
} else {
this.paths.set(key, updated);
}
return updated;
}
public touch(sourceId: string, targetId: string): RelayPath {
const path = this.requirePath(sourceId, targetId);
const updated: RelayPath = {
...path,
lastActivityAt: this.clock(),
};
this.paths.set(pairKey(sourceId, targetId), updated);
return updated;
}
public sweepIdle(): readonly RelayPath[] {
const now = this.clock();
const closed: RelayPath[] = [];
for (const [key, path] of [...this.paths]) {
if (now - path.lastActivityAt < this.idleTimeoutMs) continue;
const updated: RelayPath = {
...path,
state: "closed",
lastActivityAt: now,
};
this.paths.delete(key);
closed.push(updated);
}
return closed;
}
public getPath(sourceId: string, targetId: string): RelayPath | undefined {
return this.paths.get(pairKey(sourceId, targetId));
}
public requirePath(sourceId: string, targetId: string): RelayPath {
const path = this.paths.get(pairKey(sourceId, targetId));
if (path === undefined) {
throw new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.PATH_NOT_FOUND,
"intra_relay.path_not_found",
{ context: { sourceId, targetId } }
);
}
return path;
}
public listPaths(): readonly RelayPath[] {
return [...this.paths.values()];
}
public size(): number {
return this.paths.size;
}
}
export const RELAY_TRANSITIONS = TRANSITIONS;

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,134 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { type PeerSnapshot, RelayDiscovery } from "./relay-discovery.js";
const peer = (overrides: Partial<PeerSnapshot> = {}): PeerSnapshot => ({
peerId: "peer-r",
tier: 1,
latencyMs: 30,
reliability: 0.99,
hops: 1,
activePaths: 0,
directlyConnectedTo: ["peer-A", "peer-B"],
...overrides,
});
describe("RelayDiscovery.discover", () => {
it("returns a peer that connects to both endpoints", () => {
const d = new RelayDiscovery({ excludeTier: 2, cacheTtlMs: 1_000 });
d.registerPeer(peer({ peerId: "peer-r" }));
const candidates = d.discover("peer-A", "peer-B");
expect(candidates.map((c) => c.peerId)).toEqual(["peer-r"]);
});
it("excludes tier >= excludeTier", () => {
const d = new RelayDiscovery({ excludeTier: 2, cacheTtlMs: 1_000 });
d.registerPeer(peer({ peerId: "peer-r-leaf", tier: 2 }));
d.registerPeer(peer({ peerId: "peer-r-mid", tier: 1 }));
const candidates = d.discover("peer-A", "peer-B");
expect(candidates.map((c) => c.peerId)).toEqual(["peer-r-mid"]);
});
it("excludes peers without a connection to one endpoint", () => {
const d = new RelayDiscovery({ excludeTier: 2, cacheTtlMs: 1_000 });
d.registerPeer(
peer({
peerId: "peer-half",
directlyConnectedTo: ["peer-A"],
})
);
d.registerPeer(peer({ peerId: "peer-full" }));
const candidates = d.discover("peer-A", "peer-B");
expect(candidates.map((c) => c.peerId)).toEqual(["peer-full"]);
});
it("excludes the source and target themselves", () => {
const d = new RelayDiscovery({ excludeTier: 2, cacheTtlMs: 1_000 });
d.registerPeer(
peer({
peerId: "peer-A",
directlyConnectedTo: ["peer-A", "peer-B"],
})
);
d.registerPeer(peer({ peerId: "peer-r" }));
const candidates = d.discover("peer-A", "peer-B");
expect(candidates.map((c) => c.peerId)).toEqual(["peer-r"]);
});
it("caches results within the TTL window", () => {
let now = 1000;
const d = new RelayDiscovery({
excludeTier: 2,
cacheTtlMs: 1_000,
clock: () => now,
});
d.registerPeer(peer({ peerId: "peer-r" }));
const first = d.discover("peer-A", "peer-B");
// mutate via direct cache bust... actually a re-register changes the
// cache, so simulate "no change" via the same peer being re-fetched
// before the TTL expires.
now = 1_500;
const second = d.discover("peer-A", "peer-B");
expect(second).toBe(first);
});
it("re-evaluates after the cache TTL", () => {
let now = 1000;
const d = new RelayDiscovery({
excludeTier: 2,
cacheTtlMs: 100,
clock: () => now,
});
d.registerPeer(peer({ peerId: "peer-r" }));
const first = d.discover("peer-A", "peer-B");
now = 2_000;
const second = d.discover("peer-A", "peer-B");
expect(second).not.toBe(first);
expect(second.map((c) => c.peerId)).toEqual(["peer-r"]);
});
it("invalidates the cache on registerPeer", () => {
const d = new RelayDiscovery({ excludeTier: 2, cacheTtlMs: 1_000 });
d.registerPeer(peer({ peerId: "peer-r" }));
const first = d.discover("peer-A", "peer-B");
d.registerPeer(peer({ peerId: "peer-r2" }));
const second = d.discover("peer-A", "peer-B");
expect(second).not.toBe(first);
expect(second.map((c) => c.peerId).sort()).toEqual([
"peer-r",
"peer-r2",
]);
});
it("invalidates the cache on unregisterPeer", () => {
const d = new RelayDiscovery({ excludeTier: 2, cacheTtlMs: 1_000 });
d.registerPeer(peer({ peerId: "peer-r" }));
d.discover("peer-A", "peer-B");
d.unregisterPeer("peer-r");
const after = d.discover("peer-A", "peer-B");
expect(after.length).toBe(0);
});
it("clearCache forces a fresh evaluation", () => {
const d = new RelayDiscovery({ excludeTier: 2, cacheTtlMs: 1_000 });
d.registerPeer(peer({ peerId: "peer-r" }));
const first = d.discover("peer-A", "peer-B");
d.clearCache();
const second = d.discover("peer-A", "peer-B");
expect(second).not.toBe(first);
});
it("knownPeers reflects registered peers", () => {
const d = new RelayDiscovery({ excludeTier: 2, cacheTtlMs: 1_000 });
d.registerPeer(peer({ peerId: "peer-r" }));
d.registerPeer(peer({ peerId: "peer-r2" }));
expect(d.knownPeers().sort()).toEqual(["peer-r", "peer-r2"]);
});
it("uses Date.now when no clock is injected", () => {
const d = new RelayDiscovery({ excludeTier: 2, cacheTtlMs: 1_000 });
d.registerPeer(peer({ peerId: "peer-r" }));
const first = d.discover("peer-A", "peer-B");
const second = d.discover("peer-A", "peer-B");
expect(second).toBe(first);
});
});

View File

@@ -0,0 +1,91 @@
import type { RelayCandidate } from "../types/relay.types.js";
export interface PeerSnapshot {
readonly peerId: string;
readonly tier: number;
readonly latencyMs: number;
readonly reliability: number;
readonly hops: number;
readonly activePaths: number;
readonly directlyConnectedTo: readonly string[];
}
export interface RelayDiscoveryConfig {
readonly excludeTier: number;
readonly cacheTtlMs: number;
readonly clock?: () => number;
}
interface CacheEntry {
readonly key: string;
readonly candidates: readonly RelayCandidate[];
readonly cachedAt: number;
}
export class RelayDiscovery {
public readonly excludeTier: number;
private readonly cacheTtlMs: number;
private readonly clock: () => number;
private readonly peers = new Map<string, PeerSnapshot>();
private readonly cache = new Map<string, CacheEntry>();
public constructor(config: RelayDiscoveryConfig) {
this.excludeTier = config.excludeTier;
this.cacheTtlMs = config.cacheTtlMs;
this.clock = config.clock ?? (() => Date.now());
}
public registerPeer(snapshot: PeerSnapshot): void {
this.peers.set(snapshot.peerId, snapshot);
this.cache.clear();
}
public unregisterPeer(peerId: string): void {
this.peers.delete(peerId);
this.cache.clear();
}
public discover(
sourceId: string,
targetId: string
): readonly RelayCandidate[] {
const cacheKey = `${sourceId}|${targetId}`;
const cached = this.cache.get(cacheKey);
if (cached !== undefined) {
const age = this.clock() - cached.cachedAt;
if (age < this.cacheTtlMs) return cached.candidates;
}
const candidates: RelayCandidate[] = [];
for (const [peerId, snapshot] of this.peers) {
if (peerId === sourceId || peerId === targetId) continue;
if (snapshot.tier >= this.excludeTier) continue;
const reachesSource =
snapshot.directlyConnectedTo.includes(sourceId);
const reachesTarget =
snapshot.directlyConnectedTo.includes(targetId);
if (!reachesSource || !reachesTarget) continue;
candidates.push({
peerId: snapshot.peerId,
tier: snapshot.tier,
latencyMs: snapshot.latencyMs,
reliability: snapshot.reliability,
hops: snapshot.hops,
activePaths: snapshot.activePaths,
});
}
this.cache.set(cacheKey, {
key: cacheKey,
candidates,
cachedAt: this.clock(),
});
return candidates;
}
public knownPeers(): readonly string[] {
return [...this.peers.keys()];
}
public clearCache(): void {
this.cache.clear();
}
}

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,165 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_INTRA_RELAY_CODES,
type NetworkIntraRelayError,
} from "../error-codes.js";
import {
advanceEnvelope,
DEFAULT_MAX_HOPS,
nextHopFor,
unwrapEnvelope,
wrapEnvelope,
} from "./relay-envelope.js";
const inner = {
type: "session.update",
senderId: "peer-A",
targetId: "peer-B",
payload: { kind: "join" },
};
describe("wrapEnvelope", () => {
it("constructs a valid envelope", () => {
const env = wrapEnvelope({
inner,
relayPath: ["peer-A", "peer-2", "peer-B"],
maxHops: 8,
});
expect(env.hopCount).toBe(0);
expect(env.visitedNodes).toEqual(["peer-A"]);
expect(env.relayPath).toEqual(["peer-A", "peer-2", "peer-B"]);
expect(env.innerType).toBe("session.update");
});
it("rejects a path shorter than 3", () => {
try {
wrapEnvelope({
inner,
relayPath: ["peer-A", "peer-B"],
maxHops: 8,
});
throw new Error("expected throw");
} catch (err) {
expect((err as NetworkIntraRelayError).code).toBe(
NETWORK_INTRA_RELAY_CODES.ENVELOPE_INVALID
);
}
});
it("rejects maxHops < 1", () => {
try {
wrapEnvelope({
inner,
relayPath: ["peer-A", "peer-2", "peer-B"],
maxHops: 0,
});
throw new Error("expected throw");
} catch (err) {
expect((err as NetworkIntraRelayError).code).toBe(
NETWORK_INTRA_RELAY_CODES.ENVELOPE_INVALID
);
}
});
it("rejects path head ≠ senderId", () => {
try {
wrapEnvelope({
inner,
relayPath: ["peer-X", "peer-2", "peer-B"],
maxHops: 8,
});
throw new Error("expected throw");
} catch (err) {
expect((err as NetworkIntraRelayError).code).toBe(
NETWORK_INTRA_RELAY_CODES.ENVELOPE_INVALID
);
}
});
it("rejects path tail ≠ targetId", () => {
try {
wrapEnvelope({
inner,
relayPath: ["peer-A", "peer-2", "peer-X"],
maxHops: 8,
});
throw new Error("expected throw");
} catch (err) {
expect((err as NetworkIntraRelayError).code).toBe(
NETWORK_INTRA_RELAY_CODES.ENVELOPE_INVALID
);
}
});
});
describe("advanceEnvelope", () => {
it("increments hopCount and appends the current peer", () => {
const env = wrapEnvelope({
inner,
relayPath: ["peer-A", "peer-2", "peer-B"],
maxHops: 8,
});
const next = advanceEnvelope(env, "peer-2");
expect(next.hopCount).toBe(1);
expect(next.visitedNodes).toEqual(["peer-A", "peer-2"]);
});
it("does not mutate the input", () => {
const env = wrapEnvelope({
inner,
relayPath: ["peer-A", "peer-2", "peer-B"],
maxHops: 8,
});
advanceEnvelope(env, "peer-2");
expect(env.hopCount).toBe(0);
expect(env.visitedNodes).toEqual(["peer-A"]);
});
});
describe("nextHopFor", () => {
it("returns the peer immediately after the current peer in the path", () => {
const env = wrapEnvelope({
inner,
relayPath: ["peer-A", "peer-2", "peer-B"],
maxHops: 8,
});
expect(nextHopFor(env, "peer-A")).toBe("peer-2");
expect(nextHopFor(env, "peer-2")).toBe("peer-B");
});
it("returns null when the current peer is the tail", () => {
const env = wrapEnvelope({
inner,
relayPath: ["peer-A", "peer-2", "peer-B"],
maxHops: 8,
});
expect(nextHopFor(env, "peer-B")).toBeNull();
});
it("returns null when the current peer is not on the path", () => {
const env = wrapEnvelope({
inner,
relayPath: ["peer-A", "peer-2", "peer-B"],
maxHops: 8,
});
expect(nextHopFor(env, "peer-X")).toBeNull();
});
});
describe("unwrapEnvelope", () => {
it("recovers the inner message", () => {
const env = wrapEnvelope({
inner,
relayPath: ["peer-A", "peer-2", "peer-B"],
maxHops: 8,
});
const recovered = unwrapEnvelope(env);
expect(recovered).toEqual(inner);
});
});
describe("DEFAULT_MAX_HOPS", () => {
it("is 8", () => {
expect(DEFAULT_MAX_HOPS).toBe(8);
});
});

View File

@@ -0,0 +1,105 @@
import {
NETWORK_INTRA_RELAY_CODES,
NetworkIntraRelayError,
} from "../error-codes.js";
import type { RelayMessageEnvelope } from "../types/relay.types.js";
export interface InnerMessage<TPayload = unknown> {
readonly type: string;
readonly senderId: string;
readonly targetId: string;
readonly payload: TPayload;
}
export interface WrapInput<TPayload = unknown> {
readonly inner: InnerMessage<TPayload>;
readonly relayPath: readonly string[];
readonly maxHops: number;
}
export const DEFAULT_MAX_HOPS = 8;
export function wrapEnvelope<TPayload>(
input: WrapInput<TPayload>
): RelayMessageEnvelope<TPayload> {
if (input.relayPath.length < 3) {
throw new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.ENVELOPE_INVALID,
"intra_relay.envelope.path_too_short",
{ context: { relayPath: input.relayPath } }
);
}
if (input.maxHops < 1) {
throw new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.ENVELOPE_INVALID,
"intra_relay.envelope.max_hops_invalid",
{ context: { maxHops: input.maxHops } }
);
}
if (input.relayPath[0] !== input.inner.senderId) {
throw new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.ENVELOPE_INVALID,
"intra_relay.envelope.path_head_mismatch",
{
context: {
head: input.relayPath[0],
senderId: input.inner.senderId,
},
}
);
}
const tail = input.relayPath[input.relayPath.length - 1];
if (tail !== input.inner.targetId) {
throw new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.ENVELOPE_INVALID,
"intra_relay.envelope.path_tail_mismatch",
{
context: {
tail,
targetId: input.inner.targetId,
},
}
);
}
return {
innerType: input.inner.type,
innerSenderId: input.inner.senderId,
innerTargetId: input.inner.targetId,
innerPayload: input.inner.payload,
relayPath: [...input.relayPath],
hopCount: 0,
maxHops: input.maxHops,
visitedNodes: [input.inner.senderId],
};
}
export function advanceEnvelope<TPayload>(
envelope: RelayMessageEnvelope<TPayload>,
currentPeerId: string
): RelayMessageEnvelope<TPayload> {
return {
...envelope,
hopCount: envelope.hopCount + 1,
visitedNodes: [...envelope.visitedNodes, currentPeerId],
};
}
export function nextHopFor<TPayload>(
envelope: RelayMessageEnvelope<TPayload>,
currentPeerId: string
): string | null {
const idx = envelope.relayPath.indexOf(currentPeerId);
if (idx === -1 || idx === envelope.relayPath.length - 1) return null;
return envelope.relayPath[idx + 1] as string;
}
export function unwrapEnvelope<TPayload>(
envelope: RelayMessageEnvelope<TPayload>
): InnerMessage<TPayload> {
return {
type: envelope.innerType,
senderId: envelope.innerSenderId,
targetId: envelope.innerTargetId,
payload: envelope.innerPayload,
};
}

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,56 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_INTRA_RELAY_CODES,
NetworkIntraRelayError,
} from "./error-codes.js";
describe("NETWORK_INTRA_RELAY_CODES", () => {
it("registers under NET_INTRA_RELAY", () => {
for (const value of Object.values(NETWORK_INTRA_RELAY_CODES)) {
expect(value).toMatch(/^NET_INTRA_RELAY\./);
}
});
it("is frozen", () => {
expect(Object.isFrozen(NETWORK_INTRA_RELAY_CODES)).toBe(true);
});
it("contains the documented codes", () => {
for (const key of [
"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",
] as const) {
expect(NETWORK_INTRA_RELAY_CODES).toHaveProperty(key);
}
});
});
describe("NetworkIntraRelayError", () => {
it("extends SuaveplanError and preserves the code", () => {
const err = new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.NO_RELAY_CANDIDATE,
"intra_relay.no_candidate"
);
expect(err.name).toBe("NetworkIntraRelayError");
expect(err.code).toBe(NETWORK_INTRA_RELAY_CODES.NO_RELAY_CANDIDATE);
});
it("preserves the cause", () => {
const cause = new Error("inner");
const err = new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.INVALID_TRANSITION,
"intra_relay.invalid_transition",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,33 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
export const NETWORK_INTRA_RELAY_CODES = defineCodes("NET_INTRA_RELAY", [
"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",
] as const);
export type NetworkIntraRelayErrorCode =
(typeof NETWORK_INTRA_RELAY_CODES)[keyof typeof NETWORK_INTRA_RELAY_CODES];
export class NetworkIntraRelayError extends SuaveplanError<NetworkIntraRelayErrorCode> {
public constructor(
code: NetworkIntraRelayErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetworkIntraRelayError";
}
}

View File

@@ -0,0 +1,78 @@
// Side-effect: register error codes
import "./error-codes.js";
// Connection
export {
RELAY_TRANSITIONS,
RelayConnectionManager,
type RelayConnectionManagerConfig,
} from "./connection/relay-connection-manager.js";
// Discovery
export {
type PeerSnapshot,
RelayDiscovery,
type RelayDiscoveryConfig,
} from "./discovery/relay-discovery.js";
// Envelope
export {
advanceEnvelope,
DEFAULT_MAX_HOPS,
type InnerMessage,
nextHopFor,
unwrapEnvelope,
type WrapInput,
wrapEnvelope,
} from "./envelope/relay-envelope.js";
// Errors
export {
NETWORK_INTRA_RELAY_CODES,
NetworkIntraRelayError,
type NetworkIntraRelayErrorCode,
} from "./error-codes.js";
// Loop
export {
type LoopDropReason,
type LoopDropResult,
LoopPrevention,
type LoopPreventionBus,
type LoopPreventionConfig,
} from "./loop/loop-prevention.js";
// Monitor
export {
type PathQuality,
type PathQualityRecord,
type PerformanceMonitorBus,
RelayPerformanceMonitor,
type RelayPerformanceMonitorConfig,
} from "./monitor/relay-performance-monitor.js";
// Plugin
export {
type IntraRelayBus,
IntraRelayPlugin,
type IntraRelayPluginConfig,
} from "./plugin/intra-relay-plugin.js";
// Security
export {
type PeerKeyResolver,
RelaySecurityManager,
type RelaySecurityManagerConfig,
type SignatureVerifier,
} from "./security/relay-security-manager.js";
// Selector
export {
RelaySelector,
STRATEGY_LIST,
} from "./selector/relay-selector.js";
// Types
export type {
RelayCandidate,
RelayConnectionState,
RelayMessageEnvelope,
RelayPath,
RelayStrategy,
} from "./types/relay.types.js";
export type {
RelayAuthPolicy,
RelayRateLimit,
RelaySecurityCheckInput,
} from "./types/security.types.js";

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,85 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_INTRA_RELAY_CODES } from "../error-codes.js";
import type { RelayMessageEnvelope } from "../types/relay.types.js";
import { LoopPrevention } from "./loop-prevention.js";
const env = (
overrides: Partial<RelayMessageEnvelope> = {}
): RelayMessageEnvelope => ({
innerType: "x",
innerSenderId: "A",
innerTargetId: "B",
innerPayload: null,
relayPath: ["A", "R", "B"],
hopCount: 0,
maxHops: 8,
visitedNodes: ["A"],
...overrides,
});
describe("LoopPrevention.shouldDrop", () => {
it("does not drop a clean envelope", () => {
const guard = new LoopPrevention();
const result = guard.shouldDrop(env(), "R");
expect(result.drop).toBe(false);
expect(result.code).toBeNull();
expect(result.reason).toBeNull();
});
it("drops when hopCount >= maxHops with MAX_HOPS_EXCEEDED", () => {
const guard = new LoopPrevention();
const result = guard.shouldDrop(env({ hopCount: 8, maxHops: 8 }), "R");
expect(result.drop).toBe(true);
expect(result.reason).toBe("max-hops-exceeded");
expect(result.code).toBe(NETWORK_INTRA_RELAY_CODES.MAX_HOPS_EXCEEDED);
});
it("drops when current peer is in visitedNodes with RELAY_LOOP_DETECTED", () => {
const guard = new LoopPrevention();
const result = guard.shouldDrop(env({ visitedNodes: ["A", "R"] }), "R");
expect(result.drop).toBe(true);
expect(result.reason).toBe("loop-detected");
expect(result.code).toBe(NETWORK_INTRA_RELAY_CODES.RELAY_LOOP_DETECTED);
});
it("prefers max-hops-exceeded over loop-detected when both apply", () => {
const guard = new LoopPrevention();
const result = guard.shouldDrop(
env({ hopCount: 8, maxHops: 8, visitedNodes: ["A", "R"] }),
"R"
);
expect(result.reason).toBe("max-hops-exceeded");
});
it("emits relay:loop-detected on the optional bus for both drop reasons", () => {
const events: Array<{ topic: string; reason: string }> = [];
const guard = new LoopPrevention({
bus: {
emit: (topic, payload) =>
events.push({
topic,
reason: (payload as { reason: string }).reason,
}),
},
});
guard.shouldDrop(env({ visitedNodes: ["A", "R"] }), "R");
guard.shouldDrop(env({ hopCount: 8, maxHops: 8 }), "R");
expect(events.length).toBe(2);
expect(events.every((e) => e.topic === "relay:loop-detected")).toBe(
true
);
expect(events.map((e) => e.reason).sort()).toEqual([
"loop-detected",
"max-hops-exceeded",
]);
});
it("does not emit on the bus when not dropping", () => {
const events: unknown[] = [];
const guard = new LoopPrevention({
bus: { emit: (_t, p) => events.push(p) },
});
guard.shouldDrop(env(), "R");
expect(events.length).toBe(0);
});
});

View File

@@ -0,0 +1,85 @@
import {
NETWORK_INTRA_RELAY_CODES,
type NetworkIntraRelayErrorCode,
} from "../error-codes.js";
import type { RelayMessageEnvelope } from "../types/relay.types.js";
export type LoopDropReason = "max-hops-exceeded" | "loop-detected";
export interface LoopDropResult {
readonly drop: boolean;
readonly reason: LoopDropReason | null;
readonly code: NetworkIntraRelayErrorCode | null;
readonly currentPeerId: string;
readonly hopCount: number;
}
export interface LoopPreventionBus {
emit(
topic: "relay:loop-detected",
payload: {
reason: LoopDropReason;
code: NetworkIntraRelayErrorCode;
currentPeerId: string;
hopCount: number;
}
): void;
}
export interface LoopPreventionConfig {
readonly bus?: LoopPreventionBus;
}
export class LoopPrevention {
private readonly bus: LoopPreventionBus | undefined;
public constructor(config: LoopPreventionConfig = {}) {
this.bus = config.bus;
}
public shouldDrop<TPayload>(
envelope: RelayMessageEnvelope<TPayload>,
currentPeerId: string
): LoopDropResult {
if (envelope.hopCount >= envelope.maxHops) {
return this.report({
drop: true,
reason: "max-hops-exceeded",
code: NETWORK_INTRA_RELAY_CODES.MAX_HOPS_EXCEEDED,
currentPeerId,
hopCount: envelope.hopCount,
});
}
if (envelope.visitedNodes.includes(currentPeerId)) {
return this.report({
drop: true,
reason: "loop-detected",
code: NETWORK_INTRA_RELAY_CODES.RELAY_LOOP_DETECTED,
currentPeerId,
hopCount: envelope.hopCount,
});
}
return {
drop: false,
reason: null,
code: null,
currentPeerId,
hopCount: envelope.hopCount,
};
}
private report(
result: LoopDropResult & {
reason: LoopDropReason;
code: NetworkIntraRelayErrorCode;
}
): LoopDropResult {
this.bus?.emit("relay:loop-detected", {
reason: result.reason,
code: result.code,
currentPeerId: result.currentPeerId,
hopCount: result.hopCount,
});
return result;
}
}

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,123 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RelayPerformanceMonitor } from "./relay-performance-monitor.js";
describe("RelayPerformanceMonitor construction", () => {
it("rejects recoveredThresholdMs > degradedThresholdMs", () => {
expect(
() =>
new RelayPerformanceMonitor({
windowSize: 4,
degradedThresholdMs: 100,
recoveredThresholdMs: 200,
})
).toThrow();
});
it("clamps windowSize < 1 to 1", () => {
const m = new RelayPerformanceMonitor({
windowSize: 0,
degradedThresholdMs: 100,
recoveredThresholdMs: 50,
});
expect(m.windowSize).toBe(1);
});
});
describe("RelayPerformanceMonitor.record", () => {
const config = {
windowSize: 3,
degradedThresholdMs: 100,
recoveredThresholdMs: 50,
};
it("starts in healthy state and remains healthy below the degraded threshold", () => {
const m = new RelayPerformanceMonitor(config);
const r = m.record("A", "B", "R", 30);
expect(r.quality).toBe("healthy");
});
it("transitions to degraded when avg >= degradedThresholdMs", () => {
const events: Array<{ topic: string }> = [];
const m = new RelayPerformanceMonitor({
...config,
bus: { emit: (topic) => events.push({ topic }) },
});
m.record("A", "B", "R", 200);
const r = m.record("A", "B", "R", 250);
expect(r.quality).toBe("degraded");
expect(events.find((e) => e.topic === "relay:degraded")).toBeDefined();
});
it("transitions back to healthy when avg <= recoveredThresholdMs", () => {
const events: Array<{ topic: string }> = [];
const m = new RelayPerformanceMonitor({
...config,
bus: { emit: (topic) => events.push({ topic }) },
});
m.record("A", "B", "R", 200);
m.record("A", "B", "R", 250);
m.record("A", "B", "R", 30);
m.record("A", "B", "R", 30);
const r = m.record("A", "B", "R", 30);
expect(r.quality).toBe("healthy");
expect(events.find((e) => e.topic === "relay:recovered")).toBeDefined();
});
it("retains hysteresis — does not flap when avg is between thresholds", () => {
const events: Array<{ topic: string }> = [];
const m = new RelayPerformanceMonitor({
...config,
bus: { emit: (topic) => events.push({ topic }) },
});
m.record("A", "B", "R", 200);
m.record("A", "B", "R", 250);
// After degraded, an avg of 75 is between 50 and 100 — should stay degraded.
m.record("A", "B", "R", 75);
const r = m.record("A", "B", "R", 75);
expect(r.quality).toBe("degraded");
});
it("evicts older samples when the window is full", () => {
const m = new RelayPerformanceMonitor(config);
m.record("A", "B", "R", 10);
m.record("A", "B", "R", 20);
m.record("A", "B", "R", 30);
m.record("A", "B", "R", 40);
const r = m.get("A", "B");
expect(r?.samples).toEqual([20, 30, 40]);
});
it("captures the most recent relayId per pair", () => {
const m = new RelayPerformanceMonitor(config);
m.record("A", "B", "R1", 30);
m.record("A", "B", "R2", 30);
expect(m.get("A", "B")?.relayId).toBe("R2");
});
});
describe("RelayPerformanceMonitor inspectors", () => {
const config = {
windowSize: 3,
degradedThresholdMs: 100,
recoveredThresholdMs: 50,
};
it("get returns undefined for unknown pairs", () => {
const m = new RelayPerformanceMonitor(config);
expect(m.get("A", "B")).toBeUndefined();
});
it("reset drops the path's history", () => {
const m = new RelayPerformanceMonitor(config);
m.record("A", "B", "R", 30);
m.reset("A", "B");
expect(m.get("A", "B")).toBeUndefined();
});
it("list returns every tracked path", () => {
const m = new RelayPerformanceMonitor(config);
m.record("A", "B", "R", 30);
m.record("C", "D", "R2", 30);
expect(m.list().length).toBe(2);
});
});

View File

@@ -0,0 +1,144 @@
export type PathQuality = "healthy" | "degraded";
export interface PathQualityRecord {
readonly sourceId: string;
readonly targetId: string;
readonly relayId: string;
readonly samples: readonly number[];
readonly avgRttMs: number;
readonly quality: PathQuality;
}
export interface PerformanceMonitorBus {
emit(
topic: "relay:degraded" | "relay:recovered",
payload: {
sourceId: string;
targetId: string;
relayId: string;
avgRttMs: number;
}
): void;
}
export interface RelayPerformanceMonitorConfig {
readonly windowSize: number;
readonly degradedThresholdMs: number;
readonly recoveredThresholdMs: number;
readonly bus?: PerformanceMonitorBus;
}
interface MutableRecord {
sourceId: string;
targetId: string;
relayId: string;
samples: number[];
quality: PathQuality;
}
const pairKey = (sourceId: string, targetId: string): string =>
`${sourceId}|${targetId}`;
export class RelayPerformanceMonitor {
public readonly windowSize: number;
public readonly degradedThresholdMs: number;
public readonly recoveredThresholdMs: number;
private readonly bus: PerformanceMonitorBus | undefined;
private readonly records = new Map<string, MutableRecord>();
public constructor(config: RelayPerformanceMonitorConfig) {
if (config.recoveredThresholdMs > config.degradedThresholdMs) {
throw new Error(
"recoveredThresholdMs must be <= degradedThresholdMs (hysteresis)"
);
}
this.windowSize = Math.max(1, config.windowSize);
this.degradedThresholdMs = config.degradedThresholdMs;
this.recoveredThresholdMs = config.recoveredThresholdMs;
this.bus = config.bus;
}
public record(
sourceId: string,
targetId: string,
relayId: string,
rttMs: number
): PathQualityRecord {
const key = pairKey(sourceId, targetId);
let entry = this.records.get(key);
if (entry === undefined) {
entry = {
sourceId,
targetId,
relayId,
samples: [],
quality: "healthy",
};
this.records.set(key, entry);
}
entry.relayId = relayId;
entry.samples.push(rttMs);
if (entry.samples.length > this.windowSize) entry.samples.shift();
const avg = average(entry.samples);
const previousQuality = entry.quality;
if (entry.quality === "healthy" && avg >= this.degradedThresholdMs) {
entry.quality = "degraded";
} else if (
entry.quality === "degraded" &&
avg <= this.recoveredThresholdMs
) {
entry.quality = "healthy";
}
if (entry.quality !== previousQuality) {
const topic =
entry.quality === "degraded"
? "relay:degraded"
: "relay:recovered";
this.bus?.emit(topic, {
sourceId,
targetId,
relayId,
avgRttMs: avg,
});
}
return this.snapshot(entry, avg);
}
public get(
sourceId: string,
targetId: string
): PathQualityRecord | undefined {
const entry = this.records.get(pairKey(sourceId, targetId));
if (entry === undefined) return undefined;
return this.snapshot(entry, average(entry.samples));
}
public reset(sourceId: string, targetId: string): void {
this.records.delete(pairKey(sourceId, targetId));
}
public list(): readonly PathQualityRecord[] {
const out: PathQualityRecord[] = [];
for (const entry of this.records.values()) {
out.push(this.snapshot(entry, average(entry.samples)));
}
return out;
}
private snapshot(entry: MutableRecord, avg: number): PathQualityRecord {
return {
sourceId: entry.sourceId,
targetId: entry.targetId,
relayId: entry.relayId,
samples: [...entry.samples],
avgRttMs: avg,
quality: entry.quality,
};
}
}
function average(samples: readonly number[]): number {
let sum = 0;
for (const s of samples) sum += s;
return sum / samples.length;
}

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,137 @@
import type { NetworkNode } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { IntraRelayPlugin } from "./intra-relay-plugin.js";
const baseConfig = () => ({
localPeerId: "peer-A",
defaultStrategy: "lowest-latency" as const,
discovery: { excludeTier: 2, cacheTtlMs: 1_000 },
connection: { idleTimeoutMs: 60_000 },
monitor: {
windowSize: 8,
degradedThresholdMs: 200,
recoveredThresholdMs: 100,
},
});
describe("IntraRelayPlugin", () => {
it("exposes its name as 'intra-relay'", () => {
const p = new IntraRelayPlugin(baseConfig());
expect(p.name).toBe("intra-relay");
});
it("wires up every subsystem", () => {
const p = new IntraRelayPlugin(baseConfig());
expect(p.discovery).toBeDefined();
expect(p.selector).toBeDefined();
expect(p.connections).toBeDefined();
expect(p.monitor).toBeDefined();
expect(p.loop).toBeDefined();
});
it("registerPeer + chooseRelay returns the lowest-latency candidate", () => {
const p = new IntraRelayPlugin(baseConfig());
p.registerPeer({
peerId: "peer-2",
tier: 1,
latencyMs: 80,
reliability: 0.9,
hops: 1,
activePaths: 0,
directlyConnectedTo: ["peer-A", "peer-B"],
});
p.registerPeer({
peerId: "peer-3",
tier: 1,
latencyMs: 20,
reliability: 0.9,
hops: 1,
activePaths: 0,
directlyConnectedTo: ["peer-A", "peer-B"],
});
expect(p.chooseRelay("peer-B").peerId).toBe("peer-3");
});
it("unregisterPeer removes the peer from discovery", () => {
const p = new IntraRelayPlugin(baseConfig());
p.registerPeer({
peerId: "peer-2",
tier: 1,
latencyMs: 30,
reliability: 0.9,
hops: 1,
activePaths: 0,
directlyConnectedTo: ["peer-A", "peer-B"],
});
p.unregisterPeer("peer-2");
expect(p.discovery.knownPeers()).toEqual([]);
});
it("forwards an optional bus through to monitor and loop subsystems", () => {
const events: Array<{ topic: string }> = [];
const p = new IntraRelayPlugin({
...baseConfig(),
bus: { emit: (topic) => events.push({ topic }) },
});
p.monitor.record("peer-A", "peer-B", "R", 250);
p.monitor.record("peer-A", "peer-B", "R", 250);
p.loop.shouldDrop(
{
innerType: "x",
innerSenderId: "A",
innerTargetId: "B",
innerPayload: null,
relayPath: ["A", "R", "B"],
hopCount: 8,
maxHops: 8,
visitedNodes: ["A"],
},
"R"
);
expect(events.find((e) => e.topic === "relay:degraded")).toBeDefined();
expect(
events.find((e) => e.topic === "relay:loop-detected")
).toBeDefined();
});
it("security getter returns null when no security config supplied", () => {
const p = new IntraRelayPlugin(baseConfig());
expect(p.security).toBeNull();
});
it("security getter returns a wired RelaySecurityManager when configured", () => {
const p = new IntraRelayPlugin({
...baseConfig(),
security: {
policy: { requireSignature: false, trustedPeers: new Set() },
rateLimit: { maxMessagesPerSecond: 10, burstSize: 10 },
peerKeyResolver: () => undefined,
},
});
expect(p.security).not.toBeNull();
expect(p.security?.isRateLimited("peer-x")).toBe(false);
});
it("install / start / stop / dispose lifecycle", () => {
const p = new IntraRelayPlugin(baseConfig());
const fakeNode = { id: "peer-A" } as unknown as NetworkNode;
p.install(fakeNode);
expect(p.host()).toBe(fakeNode);
p.start();
expect(p.isStarted()).toBe(true);
p.stop();
expect(p.isStarted()).toBe(false);
p.registerPeer({
peerId: "peer-2",
tier: 1,
latencyMs: 30,
reliability: 0.9,
hops: 1,
activePaths: 0,
directlyConnectedTo: ["peer-A", "peer-B"],
});
p.discovery.discover("peer-A", "peer-B");
p.dispose();
expect(p.host()).toBeNull();
});
});

View File

@@ -0,0 +1,138 @@
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
import {
RelayConnectionManager,
type RelayConnectionManagerConfig,
} from "../connection/relay-connection-manager.js";
import {
type PeerSnapshot,
RelayDiscovery,
type RelayDiscoveryConfig,
} from "../discovery/relay-discovery.js";
import {
LoopPrevention,
type LoopPreventionBus,
} from "../loop/loop-prevention.js";
import {
type PerformanceMonitorBus,
RelayPerformanceMonitor,
type RelayPerformanceMonitorConfig,
} from "../monitor/relay-performance-monitor.js";
import {
RelaySecurityManager,
type RelaySecurityManagerConfig,
} from "../security/relay-security-manager.js";
import { RelaySelector } from "../selector/relay-selector.js";
import type { RelayCandidate, RelayStrategy } from "../types/relay.types.js";
export interface IntraRelayBus {
emit(
topic: "relay:loop-detected" | "relay:degraded" | "relay:recovered",
payload: unknown
): void;
}
export interface IntraRelayPluginConfig {
readonly localPeerId: string;
readonly defaultStrategy: RelayStrategy;
readonly discovery: RelayDiscoveryConfig;
readonly connection: RelayConnectionManagerConfig;
readonly monitor: RelayPerformanceMonitorConfig;
readonly bus?: IntraRelayBus;
/**
* Optional security configuration. Omit to disable peer
* authentication, signature validation, and rate limiting (the
* default for unit-test fixtures). Production deployments should
* supply this.
*/
readonly security?: RelaySecurityManagerConfig;
}
export class IntraRelayPlugin implements NetworkPlugin {
public readonly name = "intra-relay";
public readonly localPeerId: string;
public readonly defaultStrategy: RelayStrategy;
public readonly discovery: RelayDiscovery;
public readonly selector: RelaySelector;
public readonly connections: RelayConnectionManager;
public readonly monitor: RelayPerformanceMonitor;
public readonly loop: LoopPrevention;
private readonly _security: RelaySecurityManager | null;
private node: NetworkNode | null = null;
private started = false;
public constructor(config: IntraRelayPluginConfig) {
this.localPeerId = config.localPeerId;
this.defaultStrategy = config.defaultStrategy;
this.discovery = new RelayDiscovery(config.discovery);
this.selector = new RelaySelector();
this.connections = new RelayConnectionManager(config.connection);
const monitorBus: PerformanceMonitorBus | undefined =
config.bus === undefined
? undefined
: { emit: config.bus.emit.bind(config.bus) };
this.monitor = new RelayPerformanceMonitor({
windowSize: config.monitor.windowSize,
degradedThresholdMs: config.monitor.degradedThresholdMs,
recoveredThresholdMs: config.monitor.recoveredThresholdMs,
...(monitorBus === undefined ? {} : { bus: monitorBus }),
});
const loopBus: LoopPreventionBus | undefined =
config.bus === undefined
? undefined
: { emit: config.bus.emit.bind(config.bus) };
this.loop = new LoopPrevention(
loopBus === undefined ? {} : { bus: loopBus }
);
this._security =
config.security === undefined
? null
: new RelaySecurityManager(config.security);
}
/**
* Returns the wired `RelaySecurityManager` if one was supplied at
* construction, otherwise `null` (security disabled). Callers must
* null-check before invoking authorize.
*/
public get security(): RelaySecurityManager | null {
return this._security;
}
public registerPeer(snapshot: PeerSnapshot): void {
this.discovery.registerPeer(snapshot);
}
public unregisterPeer(peerId: string): void {
this.discovery.unregisterPeer(peerId);
}
public chooseRelay(targetId: string): RelayCandidate {
const candidates = this.discovery.discover(this.localPeerId, targetId);
return this.selector.select(this.defaultStrategy, candidates);
}
public install(node: NetworkNode): void {
this.node = node;
}
public start(): void {
this.started = true;
}
public stop(): void {
this.started = false;
}
public dispose(): void {
this.discovery.clearCache();
this.node = null;
}
public host(): NetworkNode | null {
return this.node;
}
public isStarted(): boolean {
return this.started;
}
}

View File

@@ -0,0 +1,7 @@
export {
type PeerKeyResolver,
RelaySecurityManager,
type RelaySecurityManagerConfig,
retryAfterMsOf,
type SignatureVerifier,
} from "./relay-security-manager.js";

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,380 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_INTRA_RELAY_CODES,
NetworkIntraRelayError,
} from "../error-codes.js";
import type {
RelayAuthPolicy,
RelayRateLimit,
} from "../types/security.types.js";
import {
type PeerKeyResolver,
RelaySecurityManager,
retryAfterMsOf,
type SignatureVerifier,
} from "./relay-security-manager.js";
const PEER_A = "peer-a";
const PEER_B = "peer-b";
const KEY_A = new Uint8Array(32).fill(0xaa);
const KEY_B = new Uint8Array(32).fill(0xbb);
const BYTES = new Uint8Array([1, 2, 3, 4]);
const GOOD_SIG = new Uint8Array(64).fill(0x11);
const BAD_SIG = new Uint8Array(64).fill(0x22);
function resolver(map: Map<string, Uint8Array>): PeerKeyResolver {
return (id) => map.get(id);
}
function fakeVerifier(accepted: Uint8Array): SignatureVerifier {
return (sig) =>
sig.length === accepted.length &&
sig.every((b, i) => b === accepted[i]);
}
const baseRate: RelayRateLimit = {
maxMessagesPerSecond: 1000,
burstSize: 1000,
};
function makeManager(overrides?: {
policy?: Partial<RelayAuthPolicy>;
rateLimit?: RelayRateLimit;
keys?: Map<string, Uint8Array>;
clock?: () => number;
verifier?: SignatureVerifier;
}): RelaySecurityManager {
const keys =
overrides?.keys ??
new Map([
[PEER_A, KEY_A],
[PEER_B, KEY_B],
]);
return new RelaySecurityManager({
policy: {
requireSignature: true,
trustedPeers: new Set(),
...overrides?.policy,
},
rateLimit: overrides?.rateLimit ?? baseRate,
peerKeyResolver: resolver(keys),
...(overrides?.clock !== undefined ? { clock: overrides.clock } : {}),
signatureVerifier: overrides?.verifier ?? fakeVerifier(GOOD_SIG),
});
}
describe("RelaySecurityManager — task 1.1: error codes registered", () => {
it("exposes the three new codes", () => {
expect(NETWORK_INTRA_RELAY_CODES.RELAY_SIGNATURE_INVALID).toBe(
"NET_INTRA_RELAY.RELAY_SIGNATURE_INVALID"
);
expect(NETWORK_INTRA_RELAY_CODES.RELAY_PEER_UNTRUSTED).toBe(
"NET_INTRA_RELAY.RELAY_PEER_UNTRUSTED"
);
expect(NETWORK_INTRA_RELAY_CODES.RELAY_RATE_LIMIT_EXCEEDED).toBe(
"NET_INTRA_RELAY.RELAY_RATE_LIMIT_EXCEEDED"
);
});
});
describe("RelaySecurityManager — task 1.2/1.3: peer authentication", () => {
it("admits a peer in the trusted-set allowlist", () => {
const mgr = makeManager({
policy: { trustedPeers: new Set([PEER_A]) },
});
expect(() =>
mgr.authorize({
senderPeerId: PEER_A,
canonicalBytes: BYTES,
signature: GOOD_SIG,
})
).not.toThrow();
});
it("rejects a peer NOT in a non-empty allowlist with RELAY_PEER_UNTRUSTED", () => {
const mgr = makeManager({
policy: { trustedPeers: new Set([PEER_A]) },
});
try {
mgr.authorize({
senderPeerId: PEER_B,
canonicalBytes: BYTES,
signature: GOOD_SIG,
});
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(NetworkIntraRelayError);
expect((err as NetworkIntraRelayError).code).toBe(
NETWORK_INTRA_RELAY_CODES.RELAY_PEER_UNTRUSTED
);
}
});
it("admits any peer when allowlist is empty (open mode)", () => {
const mgr = makeManager({ policy: { trustedPeers: new Set() } });
expect(() =>
mgr.authorize({
senderPeerId: PEER_B,
canonicalBytes: BYTES,
signature: GOOD_SIG,
})
).not.toThrow();
});
});
describe("RelaySecurityManager — task 1.4: signature validation", () => {
it("admits a message with a valid signature", () => {
const mgr = makeManager();
expect(() =>
mgr.authorize({
senderPeerId: PEER_A,
canonicalBytes: BYTES,
signature: GOOD_SIG,
})
).not.toThrow();
});
it("rejects a bad signature with RELAY_SIGNATURE_INVALID", () => {
const mgr = makeManager();
try {
mgr.authorize({
senderPeerId: PEER_A,
canonicalBytes: BYTES,
signature: BAD_SIG,
});
expect.fail("expected throw");
} catch (err) {
expect((err as NetworkIntraRelayError).code).toBe(
NETWORK_INTRA_RELAY_CODES.RELAY_SIGNATURE_INVALID
);
}
});
it("rejects a missing signature when policy requires one", () => {
const mgr = makeManager({ policy: { requireSignature: true } });
try {
mgr.authorize({ senderPeerId: PEER_A, canonicalBytes: BYTES });
expect.fail("expected throw");
} catch (err) {
expect((err as NetworkIntraRelayError).code).toBe(
NETWORK_INTRA_RELAY_CODES.RELAY_SIGNATURE_INVALID
);
}
});
it("admits a message without a signature when policy allows it", () => {
const mgr = makeManager({ policy: { requireSignature: false } });
expect(() =>
mgr.authorize({ senderPeerId: PEER_A, canonicalBytes: BYTES })
).not.toThrow();
});
it("rejects when no public key is on file for the peer (signature provided)", () => {
const mgr = makeManager({ keys: new Map() });
try {
mgr.authorize({
senderPeerId: PEER_A,
canonicalBytes: BYTES,
signature: GOOD_SIG,
});
expect.fail("expected throw");
} catch (err) {
expect((err as NetworkIntraRelayError).code).toBe(
NETWORK_INTRA_RELAY_CODES.RELAY_SIGNATURE_INVALID
);
}
});
it("uses the default ed25519.verify when no verifier is injected", () => {
// Using random fake bytes — real ed25519.verify will return false,
// exercising the default-verifier branch.
const mgr = new RelaySecurityManager({
policy: { requireSignature: true, trustedPeers: new Set() },
rateLimit: baseRate,
peerKeyResolver: resolver(new Map([[PEER_A, KEY_A]])),
});
try {
mgr.authorize({
senderPeerId: PEER_A,
canonicalBytes: BYTES,
signature: BAD_SIG,
});
expect.fail("expected throw");
} catch (err) {
expect((err as NetworkIntraRelayError).code).toBe(
NETWORK_INTRA_RELAY_CODES.RELAY_SIGNATURE_INVALID
);
}
});
});
describe("RelaySecurityManager — task 1.5: token-bucket rate limit", () => {
it("rejects past per-peer burstSize with RELAY_RATE_LIMIT_EXCEEDED", () => {
const now = 0;
const mgr = makeManager({
rateLimit: { maxMessagesPerSecond: 1, burstSize: 2 },
clock: () => now,
});
mgr.authorize({
senderPeerId: PEER_A,
canonicalBytes: BYTES,
signature: GOOD_SIG,
});
mgr.authorize({
senderPeerId: PEER_A,
canonicalBytes: BYTES,
signature: GOOD_SIG,
});
try {
mgr.authorize({
senderPeerId: PEER_A,
canonicalBytes: BYTES,
signature: GOOD_SIG,
});
expect.fail("expected throw");
} catch (err) {
expect((err as NetworkIntraRelayError).code).toBe(
NETWORK_INTRA_RELAY_CODES.RELAY_RATE_LIMIT_EXCEEDED
);
}
});
it("refills tokens as the clock advances", () => {
let now = 0;
const mgr = makeManager({
rateLimit: { maxMessagesPerSecond: 1000, burstSize: 1 },
clock: () => now,
});
mgr.authorize({
senderPeerId: PEER_A,
canonicalBytes: BYTES,
signature: GOOD_SIG,
});
// empty bucket, immediate retry should fail
expect(() =>
mgr.authorize({
senderPeerId: PEER_A,
canonicalBytes: BYTES,
signature: GOOD_SIG,
})
).toThrow();
now += 5;
expect(() =>
mgr.authorize({
senderPeerId: PEER_A,
canonicalBytes: BYTES,
signature: GOOD_SIG,
})
).not.toThrow();
});
it("enforces a global cap separate from per-peer buckets", () => {
const now = 0;
const mgr = makeManager({
rateLimit: { maxMessagesPerSecond: 1, burstSize: 2 },
clock: () => now,
});
mgr.authorize({
senderPeerId: PEER_A,
canonicalBytes: BYTES,
signature: GOOD_SIG,
});
mgr.authorize({
senderPeerId: PEER_B,
canonicalBytes: BYTES,
signature: GOOD_SIG,
});
// PEER_A still has 1 token in its per-peer bucket but global is empty
try {
mgr.authorize({
senderPeerId: PEER_A,
canonicalBytes: BYTES,
signature: GOOD_SIG,
});
expect.fail("expected throw");
} catch (err) {
const ne = err as NetworkIntraRelayError;
expect(ne.code).toBe(
NETWORK_INTRA_RELAY_CODES.RELAY_RATE_LIMIT_EXCEEDED
);
expect(ne.context).toMatchObject({ scope: "global" });
}
});
it("isRateLimited reports current pressure without consuming", () => {
const now = 0;
const mgr = makeManager({
rateLimit: { maxMessagesPerSecond: 1, burstSize: 1 },
clock: () => now,
});
expect(mgr.isRateLimited(PEER_A)).toBe(false);
mgr.authorize({
senderPeerId: PEER_A,
canonicalBytes: BYTES,
signature: GOOD_SIG,
});
expect(mgr.isRateLimited(PEER_A)).toBe(true);
});
it("reset() clears per-peer state and refills the global bucket", () => {
const now = 0;
const mgr = makeManager({
rateLimit: { maxMessagesPerSecond: 1, burstSize: 1 },
clock: () => now,
});
mgr.authorize({
senderPeerId: PEER_A,
canonicalBytes: BYTES,
signature: GOOD_SIG,
});
expect(mgr.isRateLimited(PEER_A)).toBe(true);
mgr.reset();
expect(mgr.isRateLimited(PEER_A)).toBe(false);
expect(() =>
mgr.authorize({
senderPeerId: PEER_A,
canonicalBytes: BYTES,
signature: GOOD_SIG,
})
).not.toThrow();
});
});
describe("retryAfterMsOf helper", () => {
it("returns the value when defined", () => {
expect(retryAfterMsOf(42)).toBe(42);
});
it("falls back to 0 when undefined (defensive branch)", () => {
expect(retryAfterMsOf(undefined)).toBe(0);
});
});
describe("RelaySecurityManager — constructor validation", () => {
it("rejects maxMessagesPerSecond <= 0", () => {
expect(
() =>
new RelaySecurityManager({
policy: {
requireSignature: false,
trustedPeers: new Set(),
},
rateLimit: { maxMessagesPerSecond: 0, burstSize: 1 },
peerKeyResolver: () => undefined,
})
).toThrow(RangeError);
});
it("rejects burstSize <= 0", () => {
expect(
() =>
new RelaySecurityManager({
policy: {
requireSignature: false,
trustedPeers: new Set(),
},
rateLimit: { maxMessagesPerSecond: 1, burstSize: 0 },
peerKeyResolver: () => undefined,
})
).toThrow(RangeError);
});
});

View File

@@ -0,0 +1,207 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { TokenBucket } from "@suaveplan/rate-limiter";
import {
NETWORK_INTRA_RELAY_CODES,
NetworkIntraRelayError,
} from "../error-codes.js";
import type {
RelayAuthPolicy,
RelayRateLimit,
RelaySecurityCheckInput,
} from "../types/security.types.js";
/**
* Function resolving a peer id to its `ed25519` public key. Returning
* `undefined` means the manager has no key on file for the peer; the
* authorize step then either rejects (when policy demands signatures)
* or admits the message (when signatures are optional).
*/
export type PeerKeyResolver = (peerId: string) => Uint8Array | undefined;
/**
* Verifier function with the same shape as `ed25519.verify`. Injectable
* for tests so a deterministic verifier can replace the real curve op.
*/
export type SignatureVerifier = (
signature: Uint8Array,
message: Uint8Array,
publicKey: Uint8Array
) => boolean;
export interface RelaySecurityManagerConfig {
readonly policy: RelayAuthPolicy;
readonly rateLimit: RelayRateLimit;
readonly peerKeyResolver: PeerKeyResolver;
readonly clock?: () => number;
readonly signatureVerifier?: SignatureVerifier;
}
/**
* Authorises inbound relay messages: enforces the trusted-peer allowlist,
* verifies `ed25519` signatures over canonical bytes when policy demands
* them, and applies a per-peer + global token-bucket rate limit. Throws
* a `NetworkIntraRelayError` with one of `RELAY_PEER_UNTRUSTED`,
* `RELAY_SIGNATURE_INVALID`, or `RELAY_RATE_LIMIT_EXCEEDED` on failure.
*/
export class RelaySecurityManager {
private readonly policy: RelayAuthPolicy;
private readonly rateLimit: RelayRateLimit;
private readonly peerKeyResolver: PeerKeyResolver;
private readonly clock: () => number;
private readonly signatureVerifier: SignatureVerifier;
private readonly perPeerBuckets = new Map<string, TokenBucket>();
private readonly globalBucket: TokenBucket;
public constructor(config: RelaySecurityManagerConfig) {
if (config.rateLimit.maxMessagesPerSecond <= 0) {
throw new RangeError(
"RelaySecurityManager: maxMessagesPerSecond must be > 0"
);
}
if (config.rateLimit.burstSize <= 0) {
throw new RangeError("RelaySecurityManager: burstSize must be > 0");
}
this.policy = config.policy;
this.rateLimit = config.rateLimit;
this.peerKeyResolver = config.peerKeyResolver;
this.clock = config.clock ?? (() => Date.now());
this.signatureVerifier = config.signatureVerifier ?? ed25519.verify;
this.globalBucket = this.makeBucket();
}
/**
* Run the full authorize pipeline against an inbound relay message.
*
* @throws `NetworkIntraRelayError` with `RELAY_PEER_UNTRUSTED`,
* `RELAY_SIGNATURE_INVALID`, or `RELAY_RATE_LIMIT_EXCEEDED`.
*/
public authorize(input: RelaySecurityCheckInput): void {
this.assertTrusted(input.senderPeerId);
this.assertSignature(input);
this.assertRateLimit(input.senderPeerId);
}
/**
* Inspect (without consuming) whether `peerId` currently has at least
* one rate-limit token available in both its per-peer bucket and the
* global bucket. Useful for observability dashboards.
*/
public isRateLimited(peerId: string): boolean {
const bucket = this.perPeerBuckets.get(peerId);
const perPeerOk = bucket === undefined || bucket.available >= 1;
return !(perPeerOk && this.globalBucket.available >= 1);
}
/** Drop all per-peer rate-limit state. The global bucket is reset too. */
public reset(): void {
this.perPeerBuckets.clear();
this.globalBucket.refill();
}
private assertTrusted(peerId: string): void {
if (
this.policy.trustedPeers.size > 0 &&
!this.policy.trustedPeers.has(peerId)
) {
throw new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.RELAY_PEER_UNTRUSTED,
"intra_relay.peer_untrusted",
{ context: { peerId } }
);
}
}
private assertSignature(input: RelaySecurityCheckInput): void {
const publicKey = this.peerKeyResolver(input.senderPeerId);
if (input.signature === undefined) {
if (this.policy.requireSignature) {
throw new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.RELAY_SIGNATURE_INVALID,
"intra_relay.signature_missing",
{ context: { peerId: input.senderPeerId } }
);
}
return;
}
if (publicKey === undefined) {
throw new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.RELAY_SIGNATURE_INVALID,
"intra_relay.peer_key_missing",
{ context: { peerId: input.senderPeerId } }
);
}
const ok = this.signatureVerifier(
input.signature,
input.canonicalBytes,
publicKey
);
if (!ok) {
throw new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.RELAY_SIGNATURE_INVALID,
"intra_relay.signature_invalid",
{ context: { peerId: input.senderPeerId } }
);
}
}
private assertRateLimit(peerId: string): void {
const bucket = this.bucketFor(peerId);
const peerResult = bucket.consume(1);
if (!peerResult.allowed) {
throw new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.RELAY_RATE_LIMIT_EXCEEDED,
"intra_relay.rate_limit_per_peer",
{
context: {
peerId,
scope: "per-peer",
retryAfterMs: retryAfterMsOf(peerResult.retryAfter),
},
}
);
}
const globalResult = this.globalBucket.consume(1);
if (!globalResult.allowed) {
throw new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.RELAY_RATE_LIMIT_EXCEEDED,
"intra_relay.rate_limit_global",
{
context: {
peerId,
scope: "global",
retryAfterMs: retryAfterMsOf(globalResult.retryAfter),
},
}
);
}
}
private bucketFor(peerId: string): TokenBucket {
const existing = this.perPeerBuckets.get(peerId);
if (existing !== undefined) {
return existing;
}
const fresh = this.makeBucket();
this.perPeerBuckets.set(peerId, fresh);
return fresh;
}
private makeBucket(): TokenBucket {
return new TokenBucket({
capacity: this.rateLimit.burstSize,
refillRate: this.rateLimit.maxMessagesPerSecond / 1000,
clock: this.clock,
});
}
}
/**
* Coerces the optional `retryAfter` from `RateLimitResult` to a numeric
* `retryAfterMs`. Exported for direct unit testing of the fallback branch
* (`@suaveplan/rate-limiter`'s `TokenBucket` always sets `retryAfter` when
* the request is denied, but the schema-level type is optional, so the
* defensive `?? 0` branch must remain).
*/
export function retryAfterMsOf(retryAfter: number | undefined): number {
return retryAfter ?? 0;
}

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,156 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_INTRA_RELAY_CODES,
NetworkIntraRelayError,
} from "../error-codes.js";
import type { RelayCandidate, RelayStrategy } from "../types/relay.types.js";
import { RelaySelector, STRATEGY_LIST } from "./relay-selector.js";
const c = (overrides: Partial<RelayCandidate> = {}): RelayCandidate => ({
peerId: "peer-x",
tier: 1,
latencyMs: 50,
reliability: 0.9,
hops: 2,
activePaths: 5,
...overrides,
});
describe("RelaySelector.select strategies", () => {
const s = new RelaySelector();
it("lowest-latency picks the smallest latencyMs", () => {
const winner = s.select("lowest-latency", [
c({ peerId: "p-1", latencyMs: 80 }),
c({ peerId: "p-2", latencyMs: 20 }),
c({ peerId: "p-3", latencyMs: 50 }),
]);
expect(winner.peerId).toBe("p-2");
});
it("highest-reliability picks the largest reliability", () => {
const winner = s.select("highest-reliability", [
c({ peerId: "p-1", reliability: 0.8 }),
c({ peerId: "p-2", reliability: 0.99 }),
c({ peerId: "p-3", reliability: 0.85 }),
]);
expect(winner.peerId).toBe("p-2");
});
it("fewest-hops picks the smallest hops", () => {
const winner = s.select("fewest-hops", [
c({ peerId: "p-1", hops: 3 }),
c({ peerId: "p-2", hops: 1 }),
c({ peerId: "p-3", hops: 2 }),
]);
expect(winner.peerId).toBe("p-2");
});
it("tier-optimized picks the lowest tier", () => {
const winner = s.select("tier-optimized", [
c({ peerId: "p-1", tier: 1 }),
c({ peerId: "p-2", tier: 0 }),
c({ peerId: "p-3", tier: 1 }),
]);
expect(winner.peerId).toBe("p-2");
});
it("load-balanced picks the smallest activePaths", () => {
const winner = s.select("load-balanced", [
c({ peerId: "p-1", activePaths: 8 }),
c({ peerId: "p-2", activePaths: 1 }),
c({ peerId: "p-3", activePaths: 4 }),
]);
expect(winner.peerId).toBe("p-2");
});
});
describe("RelaySelector tie-breaking", () => {
const s = new RelaySelector();
it("breaks ties by lex hostId", () => {
const winner = s.select("lowest-latency", [
c({ peerId: "p-z", latencyMs: 50 }),
c({ peerId: "p-a", latencyMs: 50 }),
c({ peerId: "p-m", latencyMs: 50 }),
]);
expect(winner.peerId).toBe("p-a");
});
});
describe("RelaySelector.rank", () => {
const s = new RelaySelector();
it("returns the full ranked list best-first", () => {
const ranked = s.rank("lowest-latency", [
c({ peerId: "p-1", latencyMs: 80 }),
c({ peerId: "p-2", latencyMs: 20 }),
c({ peerId: "p-3", latencyMs: 50 }),
]);
expect(ranked.map((r) => r.peerId)).toEqual(["p-2", "p-3", "p-1"]);
});
it("does not mutate the input", () => {
const input = [
c({ peerId: "p-1", latencyMs: 80 }),
c({ peerId: "p-2", latencyMs: 20 }),
];
const before = input.map((p) => p.peerId);
s.rank("lowest-latency", input);
expect(input.map((p) => p.peerId)).toEqual(before);
});
it("returns an empty array when given no candidates", () => {
expect(s.rank("lowest-latency", [])).toEqual([]);
});
});
describe("RelaySelector error paths", () => {
const s = new RelaySelector();
it("raises NO_RELAY_CANDIDATE on empty candidate list", () => {
try {
s.select("lowest-latency", []);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(NetworkIntraRelayError);
expect((err as NetworkIntraRelayError).code).toBe(
NETWORK_INTRA_RELAY_CODES.NO_RELAY_CANDIDATE
);
}
});
it("raises STRATEGY_UNKNOWN for invalid strategy in select", () => {
try {
s.select("nonsense" as RelayStrategy, [c()]);
throw new Error("expected throw");
} catch (err) {
expect((err as NetworkIntraRelayError).code).toBe(
NETWORK_INTRA_RELAY_CODES.STRATEGY_UNKNOWN
);
}
});
it("raises STRATEGY_UNKNOWN for invalid strategy in rank", () => {
try {
s.rank("nonsense" as RelayStrategy, [c()]);
throw new Error("expected throw");
} catch (err) {
expect((err as NetworkIntraRelayError).code).toBe(
NETWORK_INTRA_RELAY_CODES.STRATEGY_UNKNOWN
);
}
});
});
describe("STRATEGY_LIST", () => {
it("enumerates all five strategies", () => {
expect(STRATEGY_LIST).toEqual([
"lowest-latency",
"highest-reliability",
"fewest-hops",
"tier-optimized",
"load-balanced",
]);
});
});

View File

@@ -0,0 +1,78 @@
import {
NETWORK_INTRA_RELAY_CODES,
NetworkIntraRelayError,
} from "../error-codes.js";
import type { RelayCandidate, RelayStrategy } from "../types/relay.types.js";
const STRATEGIES: ReadonlySet<RelayStrategy> = new Set([
"lowest-latency",
"highest-reliability",
"fewest-hops",
"tier-optimized",
"load-balanced",
]);
export class RelaySelector {
public select(
strategy: RelayStrategy,
candidates: readonly RelayCandidate[]
): RelayCandidate {
if (!STRATEGIES.has(strategy)) {
throw new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.STRATEGY_UNKNOWN,
"intra_relay.strategy_unknown",
{ context: { strategy } }
);
}
if (candidates.length === 0) {
throw new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.NO_RELAY_CANDIDATE,
"intra_relay.no_candidate",
{ context: { strategy } }
);
}
const ranked = this.rank(strategy, candidates);
return ranked[0]!;
}
public rank(
strategy: RelayStrategy,
candidates: readonly RelayCandidate[]
): RelayCandidate[] {
if (!STRATEGIES.has(strategy)) {
throw new NetworkIntraRelayError(
NETWORK_INTRA_RELAY_CODES.STRATEGY_UNKNOWN,
"intra_relay.strategy_unknown",
{ context: { strategy } }
);
}
const copy = [...candidates];
copy.sort((a, b) => {
const primary = this.compare(strategy, a, b);
if (primary !== 0) return primary;
return a.peerId.localeCompare(b.peerId);
});
return copy;
}
private compare(
strategy: RelayStrategy,
a: RelayCandidate,
b: RelayCandidate
): number {
switch (strategy) {
case "lowest-latency":
return a.latencyMs - b.latencyMs;
case "highest-reliability":
return b.reliability - a.reliability;
case "fewest-hops":
return a.hops - b.hops;
case "tier-optimized":
return a.tier - b.tier;
case "load-balanced":
return a.activePaths - b.activePaths;
}
}
}
export const STRATEGY_LIST: readonly RelayStrategy[] = [...STRATEGIES];

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,37 @@
export type RelayConnectionState = "pending" | "active" | "degraded" | "closed";
export type RelayStrategy =
| "lowest-latency"
| "highest-reliability"
| "fewest-hops"
| "tier-optimized"
| "load-balanced";
export interface RelayCandidate {
readonly peerId: string;
readonly tier: number;
readonly latencyMs: number;
readonly reliability: number;
readonly hops: number;
readonly activePaths: number;
}
export interface RelayPath {
readonly sourceId: string;
readonly targetId: string;
readonly relayId: string;
readonly establishedAt: number;
readonly state: RelayConnectionState;
readonly lastActivityAt: number;
}
export interface RelayMessageEnvelope<TPayload = unknown> {
readonly innerType: string;
readonly innerSenderId: string;
readonly innerTargetId: string;
readonly innerPayload: TPayload;
readonly relayPath: readonly string[];
readonly hopCount: number;
readonly maxHops: number;
readonly visitedNodes: readonly string[];
}

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.

View File

@@ -0,0 +1,47 @@
/**
* Configuration controlling whether and how relay-message signatures are
* validated, and which peers are trusted to act as relays.
*/
export interface RelayAuthPolicy {
/**
* When `true`, every inbound relay message MUST carry an `ed25519`
* signature over its canonical bytes; unsigned messages are rejected
* with `RELAY_SIGNATURE_INVALID`.
*/
readonly requireSignature: boolean;
/**
* Allowlist of peer ids permitted to act as relays. When the set is
* empty, every peer with a registered public key is allowed; when the
* set is non-empty, only peers in the set may relay (others are
* rejected with `RELAY_PEER_UNTRUSTED`).
*/
readonly trustedPeers: ReadonlySet<string>;
}
/**
* Token-bucket rate-limit configuration applied per peer (and as a global
* cap across all peers).
*/
export interface RelayRateLimit {
/** Steady-state messages-per-second refill rate. */
readonly maxMessagesPerSecond: number;
/** Maximum burst tokens the bucket may hold. */
readonly burstSize: number;
}
/**
* Inbound payload shape consumed by `RelaySecurityManager.authorize` —
* a relay message envelope plus the metadata needed to verify signature
* provenance.
*/
export interface RelaySecurityCheckInput {
/** Peer id of the immediate sender (the relay node). */
readonly senderPeerId: string;
/** Canonical bytes the signature was produced over. */
readonly canonicalBytes: Uint8Array;
/**
* Detached `ed25519` signature. Optional when
* `RelayAuthPolicy.requireSignature === false`.
*/
readonly signature?: Uint8Array;
}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as relay from "../src/index.js";
describe("@suaveplan/network-plugin-intra-relay public surface", () => {
it("exports every documented class", () => {
expect(relay.RelayDiscovery).toBeDefined();
expect(relay.RelaySelector).toBeDefined();
expect(relay.RelayConnectionManager).toBeDefined();
expect(relay.LoopPrevention).toBeDefined();
expect(relay.RelayPerformanceMonitor).toBeDefined();
expect(relay.IntraRelayPlugin).toBeDefined();
});
it("exports the error registry", () => {
expect(relay.NETWORK_INTRA_RELAY_CODES).toBeDefined();
expect(relay.NetworkIntraRelayError).toBeDefined();
});
it("exports envelope helpers + constants", () => {
expect(typeof relay.wrapEnvelope).toBe("function");
expect(typeof relay.advanceEnvelope).toBe("function");
expect(typeof relay.nextHopFor).toBe("function");
expect(typeof relay.unwrapEnvelope).toBe("function");
expect(relay.DEFAULT_MAX_HOPS).toBe(8);
expect(Array.isArray(relay.STRATEGY_LIST)).toBe(true);
expect(relay.RELAY_TRANSITIONS).toBeDefined();
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"],
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,40 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/network-plugin-intra-relay",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/crypto-curve",
"@suaveplan/error",
"@suaveplan/network",
"@suaveplan/rate-limiter",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});