Initial Commit
This commit is contained in:
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type PeerKeyResolver,
|
||||
RelaySecurityManager,
|
||||
type RelaySecurityManagerConfig,
|
||||
retryAfterMsOf,
|
||||
type SignatureVerifier,
|
||||
} from "./relay-security-manager.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
@@ -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.
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user