Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
import { createEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MiddlewareManager } from "../manager/middleware-manager.js";
const env = (overrides: Partial<Parameters<typeof createEnvelope>[0]> = {}) =>
createEnvelope({
type: "chain:test",
senderId: "peer-1",
payload: {},
...overrides,
});
describe("Chain semantics: null-drop, error-policy, direction isolation", () => {
it("null return at any step short-circuits and reports droppedBy", async () => {
const manager = new MiddlewareManager();
const seen: string[] = [];
manager.register({
id: "first",
priority: 10,
apply: (e) => {
seen.push("first");
return e;
},
});
manager.register({
id: "drop-here",
priority: 20,
apply: () => {
seen.push("drop-here");
return null;
},
});
manager.register({
id: "after",
priority: 30,
apply: (e) => {
seen.push("after");
return e;
},
});
const result = await manager.dispatch(env(), "outgoing");
expect(result.dropped).toBe(true);
expect(result.droppedBy).toBe("drop-here");
expect(result.executed).toEqual(["first", "drop-here"]);
expect(seen).toEqual(["first", "drop-here"]);
});
it("incoming and outgoing chains are isolated", async () => {
const manager = new MiddlewareManager();
const calls: string[] = [];
manager.register({
id: "in-only",
priority: 50,
direction: "incoming",
apply: (e) => {
calls.push("in-only");
return e;
},
});
manager.register({
id: "out-only",
priority: 50,
direction: "outgoing",
apply: (e) => {
calls.push("out-only");
return e;
},
});
await manager.dispatch(env(), "outgoing");
await manager.dispatch(env(), "incoming");
expect(calls).toEqual(["out-only", "in-only"]);
});
it("envelope flows through every middleware in order", async () => {
const manager = new MiddlewareManager();
manager.register({
id: "tag-a",
priority: 100,
apply: (e) => ({
...e,
headers: { ...e.headers, "x-a": "1" },
}),
});
manager.register({
id: "tag-b",
priority: 200,
apply: (e) => ({
...e,
headers: { ...e.headers, "x-b": "1" },
}),
});
const result = await manager.dispatch(env(), "outgoing");
expect(result.envelope?.headers["x-a"]).toBe("1");
expect(result.envelope?.headers["x-b"]).toBe("1");
});
});

View File

@@ -0,0 +1,56 @@
# Error codes — `@suaveplan/network-plugin-middleware`
## Purpose
Frozen registry of every error code raised by the middleware chain. Each code maps to one failure class so consumers can branch on `error.code` without parsing message text. Codes are namespaced under `NET_MW` and registered into the global error registry on first import via the side-effect import in `src/index.ts`.
## Features
- Six codes covering registration, validation, throttling, security, and generic chain errors.
- `NetworkMwError` extends `SuaveplanError<NetworkMwErrorCode>` so cause-chain traversal, redaction, and OTel adapters work without per-package wiring.
- Typed union `NetworkMwErrorCode` exhaustively covers the closed registry so callers using `switch` get exhaustiveness checking.
- Frozen registry — runtime mutation throws.
## Usage: Basic Example
```ts
import {
NETWORK_MW_CODES,
NetworkMwError,
} from "@suaveplan/network-plugin-middleware";
throw new NetworkMwError(
NETWORK_MW_CODES.VALIDATION_FAILED,
"validation.bad-payload",
{ context: { messageType: "registry:add", reason: "missing peerId" } }
);
```
## Usage: Advanced Example
```ts
try {
await throttler.apply(envelope, context);
} catch (err) {
if (
err instanceof NetworkMwError &&
err.code === NETWORK_MW_CODES.THROTTLED
) {
return { dropped: true, reason: "rate-limit", retryAfter: 1_000 };
}
throw err;
}
```
## API Reference
- `NETWORK_MW_CODES` — frozen object of `KEY -> "NET_MW.KEY"`. Throws on mutation attempts.
- `NetworkMwErrorCode` — TypeScript union of every code value.
- `NetworkMwError` — `SuaveplanError<NetworkMwErrorCode>` subclass; `name === "NetworkMwError"`.
## Implementation Notes
- The registry is constructed via `defineCodes("NET_MW", […] as const)` so the type system enumerates the value union. Adding a code requires bumping the `as const` tuple — TypeScript will then propagate the new key everywhere `NetworkMwErrorCode` is referenced.
- Codes are deliberately coarse (one per failure class) rather than per-call-site so consumers can build stable retry / observability policies without churn when implementations change.
- `MIDDLEWARE_ERROR` is the catch-all the manager raises when a middleware throws under the `error-policy: "stop"` strategy. Specific middlewares (validation, throttling, security) prefer their own dedicated codes for richer downstream logic.
- The side-effect import in `src/index.ts` registers the codes even when consumers tree-shake the named exports.

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_MW_CODES, NetworkMwError } from "./error-codes.js";
describe("NETWORK_MW_CODES", () => {
it("registers under NET_MW", () => {
for (const value of Object.values(NETWORK_MW_CODES)) {
expect(value).toMatch(/^NET_MW\./);
}
});
it("is frozen", () => {
expect(Object.isFrozen(NETWORK_MW_CODES)).toBe(true);
});
it("contains the documented codes", () => {
for (const key of [
"MIDDLEWARE_ALREADY_REGISTERED",
"MIDDLEWARE_NOT_REGISTERED",
"VALIDATION_FAILED",
"THROTTLED",
"SECURITY_REJECTED",
"MIDDLEWARE_ERROR",
] as const) {
expect(NETWORK_MW_CODES).toHaveProperty(key);
}
});
});
describe("NetworkMwError", () => {
it("extends SuaveplanError", () => {
const err = new NetworkMwError(
NETWORK_MW_CODES.VALIDATION_FAILED,
"rejected"
);
expect(err.name).toBe("NetworkMwError");
expect(err.code).toBe(NETWORK_MW_CODES.VALIDATION_FAILED);
});
it("preserves cause", () => {
const cause = new Error("inner");
const err = new NetworkMwError(
NETWORK_MW_CODES.MIDDLEWARE_ERROR,
"with-cause",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,32 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Frozen registry of every error code thrown by
* `@suaveplan/network-plugin-middleware`.
*/
export const NETWORK_MW_CODES = defineCodes("NET_MW", [
"MIDDLEWARE_ALREADY_REGISTERED",
"MIDDLEWARE_NOT_REGISTERED",
"VALIDATION_FAILED",
"THROTTLED",
"SECURITY_REJECTED",
"MIDDLEWARE_ERROR",
] as const);
export type NetworkMwErrorCode =
(typeof NETWORK_MW_CODES)[keyof typeof NETWORK_MW_CODES];
export class NetworkMwError extends SuaveplanError<NetworkMwErrorCode> {
public constructor(
code: NetworkMwErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetworkMwError";
}
}

View File

@@ -0,0 +1,59 @@
# `I18nMiddleware`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Normalises locale-related metadata across the chain. Injects an `x-locale` header, sets `x-direction: rtl` for right-to-left scripts, and (when configured with a clock) rewrites the envelope's timestamp to a single reference frame so peers in different timezones do not produce comparison-resistant timestamps.
## Features
- BCP-47 locale tag carried in the `x-locale` header so receivers can switch translations / formatters without inspecting the payload.
- RTL detection via configurable locale-prefix list (defaults to `ar`, `he`, `fa`, `ur` — covers the bulk of right-to-left scripts).
- Optional UTC normalisation: callers pass a `clock` (typically `Date.now`) and the middleware rewrites `envelope.timestamp` so cross-host comparisons stay reliable.
- Lives in the NORMAL band by default (`priority: 280`) so locale headers are present before transformation / metrics observe them.
## Usage: Basic Example
```ts
import {
I18nMiddleware,
MiddlewareManager,
} from "@suaveplan/network-plugin-middleware";
const manager = new MiddlewareManager();
manager.register(new I18nMiddleware({ locale: "ar-EG" }));
const result = await manager.dispatch(envelope, "outgoing");
result.envelope?.headers["x-locale"]; // "ar-EG"
result.envelope?.headers["x-direction"]; // "rtl"
```
## Usage: Advanced Example
```ts
import { I18nMiddleware } from "@suaveplan/network-plugin-middleware";
let now = 1_700_000_000_000;
const i18n = new I18nMiddleware({
id: "i18n-utc",
priority: 290,
locale: "en-US",
rtlLocales: ["ar", "he", "fa", "ur", "yi"],
clock: () => now,
});
```
## API Reference
- `class I18nMiddleware implements Middleware`
- `constructor(config?: I18nMiddlewareConfig)`.
- `apply(envelope, context): MessageEnvelope` — never drops.
- `interface I18nMiddlewareConfig` — `{ id?, priority?, direction?, locale?, rtlLocales?, clock? }`.
## Implementation Notes
- RTL detection compares the lower-cased locale tag against each prefix. A locale matches the prefix exactly (`"ar"`) or hyphen-bounded (`"ar-EG"`); `"argonaut"` does NOT match the `ar` prefix because the comparator only allows the empty suffix or a `-` separator.
- The middleware always returns a new envelope object; even when it leaves the headers untouched, callers can rely on identity-equality being a useful "did i18n run?" signal.
- The clock rewrite is opt-in: applications that already enforce monotonic timestamps elsewhere (e.g. via `network-plugin-protocol`) should leave it unset to avoid double-normalisation.
- `x-locale` is the conventional WebSocket extension header, deliberately reused so subscribers (logger, metrics) do not need to learn a new key per protocol surface.

View File

@@ -0,0 +1,71 @@
import type { MessageEnvelope } from "@suaveplan/network";
import type {
Middleware,
MiddlewareContext,
} from "../types/middleware.types.js";
export interface I18nMiddlewareConfig {
readonly id?: string;
readonly priority?: number;
readonly direction?: MiddlewareContext["direction"];
/** BCP-47 locale tag (default `"en"`). */
readonly locale?: string;
/** Set of locale prefixes considered RTL (default `["ar", "he", "fa", "ur"]`). */
readonly rtlLocales?: ReadonlyArray<string>;
/** Optional clock — when set, the middleware rewrites `envelope.timestamp` to `clock()`. */
readonly clock?: () => number;
}
const DEFAULT_RTL = Object.freeze(["ar", "he", "fa", "ur"]);
/**
* Locale-aware envelope normalisation: injects `x-locale` and (when
* applicable) `x-direction: rtl` headers, and optionally normalises
* `envelope.timestamp` to a caller-supplied clock so timestamps from
* peers in other timezones land in a single reference frame.
*/
export class I18nMiddleware implements Middleware {
public readonly id: string;
public readonly priority: number;
public readonly direction: MiddlewareContext["direction"] | undefined;
public enabled = true;
private readonly locale: string;
private readonly rtlPrefixes: ReadonlyArray<string>;
private readonly clock: (() => number) | undefined;
public constructor(config: I18nMiddlewareConfig = {}) {
this.id = config.id ?? "i18n";
this.priority = config.priority ?? 280;
this.direction = config.direction;
this.locale = config.locale ?? "en";
this.rtlPrefixes = config.rtlLocales ?? DEFAULT_RTL;
this.clock = config.clock;
}
public apply(
envelope: MessageEnvelope,
_context: MiddlewareContext
): MessageEnvelope {
const headers = { ...envelope.headers };
headers["x-locale"] = this.locale;
if (this.isRtl(this.locale)) {
headers["x-direction"] = "rtl";
}
const next: MessageEnvelope = {
...envelope,
headers,
};
if (this.clock !== undefined) {
return { ...next, timestamp: this.clock() };
}
return next;
}
private isRtl(locale: string): boolean {
const lower = locale.toLowerCase();
for (const prefix of this.rtlPrefixes) {
if (lower === prefix || lower.startsWith(`${prefix}-`)) return true;
}
return false;
}
}

View File

@@ -0,0 +1,56 @@
import { createEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { I18nMiddleware } from "./i18n-middleware.js";
const env = () =>
createEnvelope({ type: "i18n:test", senderId: "peer-1", payload: {} });
const ctx = {
direction: "outgoing" as const,
state: {} as Record<string, unknown>,
};
describe("I18nMiddleware: locale headers, RTL, UTC normalisation", () => {
it("injects x-locale header (default en, no x-direction)", () => {
const i = new I18nMiddleware();
const result = i.apply(env(), ctx);
expect(result.headers["x-locale"]).toBe("en");
expect(result.headers["x-direction"]).toBeUndefined();
});
it("emits x-direction: rtl for RTL locales", () => {
const i = new I18nMiddleware({ locale: "ar-EG" });
const result = i.apply(env(), ctx);
expect(result.headers["x-direction"]).toBe("rtl");
});
it("matches RTL prefix exactly (no false positives)", () => {
const i = new I18nMiddleware({ locale: "argonaut" });
const result = i.apply(env(), ctx);
expect(result.headers["x-direction"]).toBeUndefined();
});
it("matches when locale equals an RTL prefix exactly", () => {
const i = new I18nMiddleware({ locale: "he" });
const result = i.apply(env(), ctx);
expect(result.headers["x-direction"]).toBe("rtl");
});
it("rewrites timestamp when clock is provided", () => {
const now = 1_700_000_000_000;
const i = new I18nMiddleware({ clock: () => now });
const e = env();
const before = e.timestamp;
const result = i.apply(e, ctx);
expect(result.timestamp).toBe(now);
expect(result.timestamp).not.toBe(before);
});
it("custom rtlLocales overrides defaults", () => {
const i = new I18nMiddleware({
locale: "yi",
rtlLocales: ["yi"],
});
expect(i.apply(env(), ctx).headers["x-direction"]).toBe("rtl");
});
});

View File

@@ -0,0 +1,81 @@
// Error codes (side-effect registration)
import "./error-codes.js";
// Errors
export {
NETWORK_MW_CODES,
NetworkMwError,
type NetworkMwErrorCode,
} from "./error-codes.js";
// I18n
export {
I18nMiddleware,
type I18nMiddlewareConfig,
} from "./i18n/i18n-middleware.js";
// Logging
export {
type LoggingBus,
type LoggingFormatter,
LoggingMiddleware,
type LoggingMiddlewareConfig,
} from "./logging/logging-middleware.js";
// Manager
export {
MiddlewareManager,
priorityForBand,
} from "./manager/middleware-manager.js";
// Metrics
export {
type MessageMetrics,
type MetricsBus,
MetricsMiddleware,
type MetricsMiddlewareConfig,
} from "./metrics/metrics-middleware.js";
// Plugin
export {
MiddlewarePlugin,
type MiddlewarePluginConfig,
} from "./plugin/middleware-plugin.js";
// Security
export {
type EncryptFn,
SecurityMiddleware,
type SecurityMiddlewareConfig,
type SecurityPolicy,
type SecurityPolicyEntry,
type SignFn,
type TierName,
type VerifyFn,
} from "./security/security-middleware.js";
// Throttling
export {
type ThrottleLimit,
ThrottlingMiddleware,
type ThrottlingMiddlewareConfig,
} from "./throttling/throttling-middleware.js";
// Transformation
export {
TransformationMiddleware,
type TransformationMiddlewareConfig,
type TransformFn,
} from "./transformation/transformation-middleware.js";
// Types
export type {
ChainResult,
ErrorPolicy,
ErrorPolicyConfig,
} from "./types/chain.types.js";
export type {
ChainDirection,
Middleware,
MiddlewareContext,
MiddlewarePriority,
} from "./types/middleware.types.js";
// Validation
export {
type ValidationLogger,
ValidationMiddleware,
type ValidationMiddlewareConfig,
type ValidationMode,
type ValidationWarning,
} from "./validation/validation-middleware.js";

View File

@@ -0,0 +1,77 @@
# `LoggingMiddleware`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Records every envelope that traverses the chain as a structured log entry. Pluggable formatter, optional payload preview with size cap, and optional bus publish (`middleware:logged`) so a single registered logger feeds whatever observability stack the deployment runs (console, JSON sink, OTel adapter).
## Features
- Configurable id and priority (default `"logger"`, `300` so it lives in the LOW band — observability runs after the substantive middleware).
- `includeBody` toggle: off by default so logs do not accidentally leak sensitive payloads; when on, payloads are clipped to `maxBodyChars` (default 1024) and JSON-serialised when possible.
- `formatter` knob lets callers shape the entry (newline-delimited JSON, OTel `LogRecord`, …) without subclassing.
- `bus` injection is optional — leave it unset if the consumer wants to call the formatter manually or attach via OTel.
## Usage: Basic Example
```ts
import {
LoggingMiddleware,
MiddlewareManager,
} from "@suaveplan/network-plugin-middleware";
const manager = new MiddlewareManager();
manager.register(new LoggingMiddleware({ includeBody: true }));
await manager.dispatch(envelope, "outgoing");
```
## Usage: Advanced Example
```ts
import {
LoggingMiddleware,
MiddlewareManager,
type LoggingBus,
} from "@suaveplan/network-plugin-middleware";
const events: unknown[] = [];
const bus: LoggingBus = {
emit: (_event, payload) => events.push(payload.entry),
};
const manager = new MiddlewareManager();
manager.register(
new LoggingMiddleware({
id: "wire-tap",
priority: 410, // BACKGROUND
includeBody: true,
maxBodyChars: 256,
bus,
formatter: ({ envelope, context, bodyPreview }) => ({
ts: envelope.timestamp,
t: envelope.type,
d: context.direction,
preview: bodyPreview,
}),
})
);
await manager.dispatch(envelope, "incoming");
```
## API Reference
- `class LoggingMiddleware implements Middleware`
- `constructor(config?: LoggingMiddlewareConfig)`.
- `apply(envelope, context): MessageEnvelope` — never drops.
- `interface LoggingMiddlewareConfig` — `{ id?, priority?, direction?, includeBody?, maxBodyChars?, bus?, formatter? }`.
- `interface LoggingBus` — `{ emit("middleware:logged", payload): void }`.
- `type LoggingFormatter` — `(input) => unknown`.
## Implementation Notes
- The default formatter returns a frozen object so consumers cannot accidentally mutate the entry between emit and observation.
- Body serialisation uses `JSON.stringify` with a `try`/`catch` fallback to `String(...)` so circular references or `BigInt` payloads do not blow up the chain. Callers that care about exact serialisation should pass a custom formatter and skip the built-in preview.
- The body cap is character-based, not byte-based, because the entry travels through structured logs that consume characters; any byte-budget conversion belongs in the sink.
- Payload preview is suppressed when `includeBody === false` even if a custom formatter requests it — the formatter receives `bodyPreview: null` so callers cannot accidentally bypass the toggle through formatter composition.

View File

@@ -0,0 +1,118 @@
import type { MessageEnvelope } from "@suaveplan/network";
import type {
Middleware,
MiddlewareContext,
} from "../types/middleware.types.js";
export type LoggingFormatter = (input: {
envelope: MessageEnvelope;
context: MiddlewareContext;
bodyPreview: string | null;
}) => unknown;
export interface LoggingBus {
emit(
event: "middleware:logged",
payload: {
middlewareId: string;
direction: MiddlewareContext["direction"];
messageId: string;
messageType: string;
entry: unknown;
}
): void;
}
export interface LoggingMiddlewareConfig {
/** Defaults to `"logger"`. */
readonly id?: string;
/** Defaults to `300` (LOW band). */
readonly priority?: number;
/** Optional direction filter. */
readonly direction?: MiddlewareContext["direction"];
/** Whether to include the payload preview. Defaults to `false`. */
readonly includeBody?: boolean;
/** Maximum payload preview length (characters). Defaults to 1024. */
readonly maxBodyChars?: number;
/** Optional bus to publish each entry to. */
readonly bus?: LoggingBus;
/** Optional custom formatter. Defaults to a structured object. */
readonly formatter?: LoggingFormatter;
}
const DEFAULT_FORMATTER: LoggingFormatter = ({
envelope,
context,
bodyPreview,
}) =>
Object.freeze({
id: envelope.id,
type: envelope.type,
senderId: envelope.senderId,
direction: context.direction,
timestamp: envelope.timestamp,
bodyPreview,
});
/**
* Records every envelope that flows through the chain as a structured
* log entry. Optionally publishes to a bus topic
* (`middleware:logged`) for aggregation by `network-plugin-monitor`
* or any custom subscriber.
*/
export class LoggingMiddleware implements Middleware {
public readonly id: string;
public readonly priority: number;
public readonly direction: MiddlewareContext["direction"] | undefined;
public enabled = true;
private readonly includeBody: boolean;
private readonly maxBodyChars: number;
private readonly bus: LoggingBus | undefined;
private readonly formatter: LoggingFormatter;
public constructor(config: LoggingMiddlewareConfig = {}) {
this.id = config.id ?? "logger";
this.priority = config.priority ?? 300;
this.direction = config.direction;
this.includeBody = config.includeBody ?? false;
this.maxBodyChars = config.maxBodyChars ?? 1024;
this.bus = config.bus;
this.formatter = config.formatter ?? DEFAULT_FORMATTER;
}
public apply(
envelope: MessageEnvelope,
context: MiddlewareContext
): MessageEnvelope {
const entry = this.formatter({
envelope,
context,
bodyPreview: this.bodyPreview(envelope),
});
if (this.bus !== undefined) {
this.bus.emit("middleware:logged", {
middlewareId: this.id,
direction: context.direction,
messageId: envelope.id,
messageType: envelope.type,
entry,
});
}
return envelope;
}
private bodyPreview(envelope: MessageEnvelope): string | null {
if (!this.includeBody) return null;
let serialized: string;
try {
// `JSON.stringify` returns `undefined` for `function` /
// `symbol` / standalone `undefined` payloads — coerce to an
// empty string so the size check below has a real value.
serialized = JSON.stringify(envelope.payload) ?? "";
} catch {
serialized = String(envelope.payload);
}
if (serialized.length <= this.maxBodyChars) return serialized;
return `${serialized.slice(0, this.maxBodyChars)}…`;
}
}

View File

@@ -0,0 +1,105 @@
import { createEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LoggingMiddleware } from "./logging-middleware.js";
const env = (payload: unknown) =>
createEnvelope({
type: "log:test",
senderId: "peer-1",
payload,
});
const ctx = (
direction: "incoming" | "outgoing"
): {
direction: "incoming" | "outgoing";
state: Record<string, unknown>;
} => ({ direction, state: {} });
describe("LoggingMiddleware: body inclusion, formatting, max size", () => {
it("does not modify the envelope and never drops", () => {
const logger = new LoggingMiddleware();
const e = env({ ok: true });
expect(logger.apply(e, ctx("outgoing"))).toBe(e);
});
it("uses default formatter and emits to bus when configured", () => {
const events: unknown[] = [];
const logger = new LoggingMiddleware({
includeBody: true,
bus: {
emit: (_event, payload) => events.push(payload),
},
});
logger.apply(env({ hello: "world" }), ctx("outgoing"));
expect(events.length).toBe(1);
});
it("body inclusion off → bodyPreview is null", () => {
let captured: { bodyPreview: string | null } | null = null;
const logger = new LoggingMiddleware({
includeBody: false,
formatter: ({ bodyPreview }) => {
captured = { bodyPreview };
return bodyPreview;
},
});
logger.apply(env({ secret: "x" }), ctx("outgoing"));
expect(captured?.bodyPreview).toBeNull();
});
it("clips body past maxBodyChars with an ellipsis", () => {
let captured: string | null = null;
const logger = new LoggingMiddleware({
includeBody: true,
maxBodyChars: 10,
formatter: ({ bodyPreview }) => {
captured = bodyPreview;
return bodyPreview;
},
});
const long = "x".repeat(200);
logger.apply(env(long), ctx("outgoing"));
expect(captured).not.toBeNull();
expect((captured as unknown as string).endsWith("…")).toBe(true);
expect((captured as unknown as string).length).toBe(11); // 10 chars + ellipsis
});
it("falls back to String(payload) when JSON.stringify throws", () => {
let captured: string | null = null;
const logger = new LoggingMiddleware({
includeBody: true,
formatter: ({ bodyPreview }) => {
captured = bodyPreview;
return bodyPreview;
},
});
const cyclic: Record<string, unknown> = {};
cyclic["self"] = cyclic;
logger.apply(env(cyclic), ctx("outgoing"));
expect(captured).toContain("[object");
});
it("does not throw when no bus is configured", () => {
const logger = new LoggingMiddleware({ includeBody: true });
expect(() =>
logger.apply(env({ hello: "world" }), ctx("incoming"))
).not.toThrow();
});
it("coerces undefined-stringify payloads (functions / symbols) to ''", () => {
let captured: string | null = "sentinel";
const logger = new LoggingMiddleware({
includeBody: true,
formatter: ({ bodyPreview }) => {
captured = bodyPreview;
return bodyPreview;
},
});
// `JSON.stringify(() => 0)` returns `undefined` — the middleware
// coerces to an empty string so the size check still has a value.
const tampered = { ...env("x"), payload: () => 0 };
logger.apply(tampered, ctx("outgoing"));
expect(captured).toBe("");
});
});

View File

@@ -0,0 +1,322 @@
import { createEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_MW_CODES, type NetworkMwError } from "../error-codes.js";
import type { Middleware } from "../types/middleware.types.js";
import { MiddlewareManager, priorityForBand } from "./middleware-manager.js";
const env = (overrides: Partial<Parameters<typeof createEnvelope>[0]> = {}) =>
createEnvelope({
type: "test",
senderId: "peer-1",
payload: {},
...overrides,
});
describe("NET-41 — MiddlewareManager priority bands", () => {
it("priorityForBand maps numeric priorities to bands", () => {
expect(priorityForBand(0)).toBe("CRITICAL");
expect(priorityForBand(99)).toBe("CRITICAL");
expect(priorityForBand(150)).toBe("HIGH");
expect(priorityForBand(250)).toBe("NORMAL");
expect(priorityForBand(350)).toBe("LOW");
expect(priorityForBand(1_000)).toBe("BACKGROUND");
});
it("runs middlewares in priority order", async () => {
const manager = new MiddlewareManager();
const order: string[] = [];
const make = (id: string, priority: number): Middleware => ({
id,
priority,
apply: (e) => {
order.push(id);
return e;
},
});
manager.register(make("background", 410));
manager.register(make("critical", 50));
manager.register(make("normal", 250));
manager.register(make("high", 150));
await manager.dispatch(env(), "outgoing");
expect(order).toEqual(["critical", "high", "normal", "background"]);
});
});
describe("NET-41 — register / unregister / enable / disable", () => {
it("rejects duplicate registration", () => {
const manager = new MiddlewareManager();
const m: Middleware = { id: "x", priority: 100, apply: (e) => e };
manager.register(m);
try {
manager.register(m);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.MIDDLEWARE_ALREADY_REGISTERED
);
}
});
it("unregister missing throws MIDDLEWARE_NOT_REGISTERED", () => {
const manager = new MiddlewareManager();
try {
manager.unregister("nope");
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.MIDDLEWARE_NOT_REGISTERED
);
}
});
it("enable / disable on missing id throws", () => {
const manager = new MiddlewareManager();
expect(() => manager.enable("nope")).toThrow();
expect(() => manager.disable("nope")).toThrow();
});
it("disabled middleware is skipped from the chain", async () => {
const manager = new MiddlewareManager();
const seen: string[] = [];
manager.register({
id: "a",
priority: 100,
apply: (e) => {
seen.push("a");
return e;
},
});
manager.register({
id: "b",
priority: 200,
apply: (e) => {
seen.push("b");
return e;
},
});
manager.disable("a");
await manager.dispatch(env(), "outgoing");
expect(seen).toEqual(["b"]);
manager.enable("a");
await manager.dispatch(env(), "outgoing");
expect(seen).toEqual(["b", "a", "b"]);
});
it("respects per-direction filter", async () => {
const manager = new MiddlewareManager();
const seen: string[] = [];
manager.register({
id: "in-only",
priority: 100,
direction: "incoming",
apply: (e) => {
seen.push("in-only");
return e;
},
});
manager.register({
id: "out-only",
priority: 100,
direction: "outgoing",
apply: (e) => {
seen.push("out-only");
return e;
},
});
manager.register({
id: "both",
priority: 200,
apply: (e) => {
seen.push("both");
return e;
},
});
await manager.dispatch(env(), "outgoing");
expect(seen).toEqual(["out-only", "both"]);
seen.length = 0;
await manager.dispatch(env(), "incoming");
expect(seen).toEqual(["in-only", "both"]);
});
});
describe("NET-41 — chain-result + error policies", () => {
it("returns dropped: true when a middleware returns null", async () => {
const manager = new MiddlewareManager();
manager.register({ id: "a", priority: 100, apply: (e) => e });
manager.register({
id: "drop",
priority: 200,
apply: () => null,
});
manager.register({ id: "z", priority: 300, apply: (e) => e });
const result = await manager.dispatch(env(), "outgoing");
expect(result.dropped).toBe(true);
expect(result.envelope).toBeNull();
expect(result.droppedBy).toBe("drop");
expect(result.executed).toEqual(["a", "drop"]);
});
it("error-policy: stop wraps as MIDDLEWARE_ERROR", async () => {
const manager = new MiddlewareManager();
manager.register({
id: "boom",
priority: 100,
apply: () => {
throw new Error("kaboom");
},
});
try {
await manager.dispatch(env(), "outgoing");
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.MIDDLEWARE_ERROR
);
}
});
it("error-policy: continue swallows and forwards the envelope", async () => {
const manager = new MiddlewareManager({
errorPolicy: { policy: "continue" },
});
const seen: string[] = [];
manager.register({
id: "boom",
priority: 100,
apply: () => {
throw new Error("kaboom");
},
});
manager.register({
id: "after",
priority: 200,
apply: (e) => {
seen.push("after");
return e;
},
});
const result = await manager.dispatch(env(), "outgoing");
expect(result.dropped).toBe(false);
expect(seen).toEqual(["after"]);
});
it("error-policy: retry recovers when the next attempt succeeds", async () => {
const manager = new MiddlewareManager({
errorPolicy: { policy: "retry", maxRetries: 3 },
});
let calls = 0;
manager.register({
id: "flaky",
priority: 100,
apply: (e) => {
calls += 1;
if (calls < 3) throw new Error("transient");
return e;
},
});
const result = await manager.dispatch(env(), "outgoing");
expect(result.dropped).toBe(false);
expect(calls).toBe(3);
});
it("error-policy: retry exhausted bubbles MIDDLEWARE_ERROR", async () => {
const manager = new MiddlewareManager({
errorPolicy: { policy: "retry", maxRetries: 2 },
});
manager.register({
id: "always-fails",
priority: 100,
apply: () => {
throw new Error("nope");
},
});
try {
await manager.dispatch(env(), "outgoing");
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.MIDDLEWARE_ERROR
);
}
});
it("retry honours minimum 1 attempt when maxRetries is omitted", async () => {
const manager = new MiddlewareManager({
errorPolicy: { policy: "retry" },
});
let calls = 0;
manager.register({
id: "fails-once",
priority: 100,
apply: () => {
calls += 1;
throw new Error("nope");
},
});
try {
await manager.dispatch(env(), "outgoing");
expect.fail("should have thrown");
} catch {
// expected
}
expect(calls).toBe(1);
});
it("preserves non-Error throws (no cause attached) under stop policy", async () => {
const manager = new MiddlewareManager();
manager.register({
id: "weird",
priority: 100,
apply: () => {
const literal: unknown = "string-error";
throw literal;
},
});
try {
await manager.dispatch(env(), "outgoing");
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.MIDDLEWARE_ERROR
);
expect((err as NetworkMwError).cause).toBeUndefined();
}
});
it("preserves non-Error throws under retry exhaustion", async () => {
const manager = new MiddlewareManager({
errorPolicy: { policy: "retry", maxRetries: 2 },
});
manager.register({
id: "weird-retry",
priority: 100,
apply: () => {
const literal: unknown = "string-error";
throw literal;
},
});
try {
await manager.dispatch(env(), "outgoing");
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.MIDDLEWARE_ERROR
);
expect((err as NetworkMwError).cause).toBeUndefined();
}
});
it("list / has / chain reflect the registered set", () => {
const manager = new MiddlewareManager();
manager.register({
id: "in",
priority: 100,
direction: "incoming",
apply: (e) => e,
});
manager.register({ id: "any", priority: 200, apply: (e) => e });
expect(manager.has("any")).toBe(true);
expect(manager.list().map((m) => m.id)).toEqual(["in", "any"]);
expect(manager.chain("outgoing").map((m) => m.id)).toEqual(["any"]);
});
});

View File

@@ -0,0 +1,94 @@
# `MiddlewareManager`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the registered middleware list and runs both inbound and outbound chains in priority order for every dispatch. Pins the priority bands defined in `NET-41` (CRITICAL `[0,99]`, HIGH `[100,199]`, NORMAL `[200,299]`, LOW `[300,399]`, BACKGROUND `[400,∞)`) and handles the three error policies (`continue`, `stop`, `retry`) so individual middlewares stay narrow.
## Features
- `register` / `unregister` / `enable` / `disable` for runtime mutation without restart.
- Per-direction chain: `direction: undefined` means "run on both"; otherwise the middleware only runs for the matching direction.
- `chain(direction)` returns the ordered, enabled list a dispatch would run; useful for debugging and tests.
- `dispatch(envelope, direction)` returns a `ChainResult` so callers branch on `dropped: true` instead of inspecting envelope state.
- `priorityForBand(priority)` exported as a free function so callers asking "what band does this priority belong to?" do not need a manager instance.
## Usage: Basic Example
```ts
import {
MiddlewareManager,
type Middleware,
} from "@suaveplan/network-plugin-middleware";
const manager = new MiddlewareManager();
manager.register({
id: "tag",
priority: 250,
apply: (env) => ({
...env,
headers: { ...env.headers, "x-tagged": "yes" },
}),
} satisfies Middleware);
const result = await manager.dispatch(envelope, "outgoing");
result.dropped; // false
result.envelope?.headers["x-tagged"]; // "yes"
```
## Usage: Advanced Example
```ts
import {
MiddlewareManager,
priorityForBand,
} from "@suaveplan/network-plugin-middleware";
const manager = new MiddlewareManager({
errorPolicy: { policy: "retry", maxRetries: 3 },
});
manager.register({
id: "validate",
priority: 50, // CRITICAL band
direction: "incoming",
apply: async (env) => (env.payload === undefined ? null : env),
});
manager.register({
id: "metrics",
priority: 410, // BACKGROUND band
apply: async (env) => env,
});
priorityForBand(50); // "CRITICAL"
priorityForBand(410); // "BACKGROUND"
manager.disable("metrics");
const result = await manager.dispatch(envelope, "incoming");
if (result.dropped) {
console.warn("dropped by", result.droppedBy);
}
```
## API Reference
- `class MiddlewareManager(config?)`
- `register(middleware: Middleware): void` — throws `MIDDLEWARE_ALREADY_REGISTERED` on duplicate id.
- `unregister(id: string): void` — throws `MIDDLEWARE_NOT_REGISTERED` if missing.
- `enable(id) / disable(id): void` — throws `MIDDLEWARE_NOT_REGISTERED` if missing.
- `has(id): boolean`.
- `list(): ReadonlyArray<Middleware>`.
- `chain(direction): ReadonlyArray<Middleware>` — ordered, enabled, direction-filtered list.
- `dispatch(envelope, direction): Promise<ChainResult>`.
- `function priorityForBand(priority: number): MiddlewarePriority`.
## Implementation Notes
- Middlewares are stored in a `Map` keyed by id. Re-`register` is rejected so callers cannot accidentally clobber an existing middleware silently — `unregister` first if you mean to replace.
- The chain runs synchronously in priority order. Async middlewares add one micro-task per call but preserve ordering across awaits.
- Error handling goes through `runOne`: `continue` swallows and forwards the un-mutated envelope, `stop` rethrows as `MIDDLEWARE_ERROR` with the original as `cause`, `retry` re-invokes up to `maxRetries` before falling back to `stop`. The chain itself never silently drops because of an exception — callers always learn what happened.
- `ChainResult.executed` is frozen so consumers cannot mutate the audit trail. `envelope` is `null` exactly when `dropped: true`, so the type forces explicit handling.
- Outbound and inbound chains share state (the `state` bag is per-dispatch, not per-direction) but execute independently so a slow `incoming` dispatch never blocks an `outgoing` send.

View File

@@ -0,0 +1,190 @@
import type { MessageEnvelope } from "@suaveplan/network";
import { NETWORK_MW_CODES, NetworkMwError } from "../error-codes.js";
import type { ChainResult, ErrorPolicyConfig } from "../types/chain.types.js";
import type {
ChainDirection,
Middleware,
MiddlewareContext,
MiddlewarePriority,
} from "../types/middleware.types.js";
/**
* Numeric range a band claims. The manager rejects priorities outside
* `[0, ∞)` and slots middleware into bands by simple comparison.
*/
// Band boundaries are encoded directly in `priorityForBand` to avoid a
// declarative table that would force a defensive fallback branch the
// coverage gate cannot reach.
/**
* Resolve the band a numeric priority belongs to.
*/
export function priorityForBand(priority: number): MiddlewarePriority {
if (priority < 100) return "CRITICAL";
if (priority < 200) return "HIGH";
if (priority < 300) return "NORMAL";
if (priority < 400) return "LOW";
return "BACKGROUND";
}
/**
* The manager owns the registered middleware list, runs the chain in
* priority order for both directions, applies the configured error
* policy, and returns a `ChainResult` per dispatch so callers can act
* on drops without inspecting envelope state.
*/
export class MiddlewareManager {
private readonly middlewares = new Map<string, Middleware>();
private readonly enabledOverrides = new Map<string, boolean>();
private readonly errorPolicy: ErrorPolicyConfig;
public constructor(config: { errorPolicy?: ErrorPolicyConfig } = {}) {
this.errorPolicy = config.errorPolicy ?? { policy: "stop" };
}
public register(middleware: Middleware): void {
if (this.middlewares.has(middleware.id)) {
throw new NetworkMwError(
NETWORK_MW_CODES.MIDDLEWARE_ALREADY_REGISTERED,
"manager.duplicate",
{ context: { id: middleware.id } }
);
}
this.middlewares.set(middleware.id, middleware);
}
public unregister(id: string): void {
if (!this.middlewares.delete(id)) {
throw new NetworkMwError(
NETWORK_MW_CODES.MIDDLEWARE_NOT_REGISTERED,
"manager.missing",
{ context: { id } }
);
}
this.enabledOverrides.delete(id);
}
public enable(id: string): void {
this.requireMiddleware(id);
this.enabledOverrides.set(id, true);
}
public disable(id: string): void {
this.requireMiddleware(id);
this.enabledOverrides.set(id, false);
}
public has(id: string): boolean {
return this.middlewares.has(id);
}
public list(): ReadonlyArray<Middleware> {
return Array.from(this.middlewares.values());
}
/**
* Ordered list of middlewares the manager will run for the given
* direction. Sorted by ascending priority (CRITICAL first).
*/
public chain(direction: ChainDirection): ReadonlyArray<Middleware> {
return this.list()
.filter((m) => this.isEnabled(m))
.filter(
(m) => m.direction === undefined || m.direction === direction
)
.sort((a, b) => a.priority - b.priority);
}
private isEnabled(middleware: Middleware): boolean {
const override = this.enabledOverrides.get(middleware.id);
if (override !== undefined) return override;
return middleware.enabled !== false;
}
public async dispatch(
envelope: MessageEnvelope,
direction: ChainDirection
): Promise<ChainResult> {
const ordered = this.chain(direction);
const context: MiddlewareContext = {
direction,
state: {},
};
const executed: string[] = [];
let current: MessageEnvelope = envelope;
for (const middleware of ordered) {
const next = await this.runOne(middleware, current, context);
executed.push(middleware.id);
if (next === null) {
return Object.freeze({
dropped: true,
envelope: null,
executed: Object.freeze([...executed]),
droppedBy: middleware.id,
});
}
current = next;
}
return Object.freeze({
dropped: false,
envelope: current,
executed: Object.freeze([...executed]),
});
}
private async runOne(
middleware: Middleware,
envelope: MessageEnvelope,
context: MiddlewareContext
): Promise<MessageEnvelope | null> {
const maxRetries =
this.errorPolicy.policy === "retry"
? Math.max(1, this.errorPolicy.maxRetries ?? 1)
: 1;
let lastError: unknown = null;
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
try {
return await middleware.apply(envelope, context);
} catch (err) {
lastError = err;
if (this.errorPolicy.policy === "continue") {
return envelope;
}
if (this.errorPolicy.policy === "stop") {
throw new NetworkMwError(
NETWORK_MW_CODES.MIDDLEWARE_ERROR,
"manager.middleware-threw",
{
context: { id: middleware.id },
cause: err instanceof Error ? err : undefined,
}
);
}
}
}
// Retry exhausted — surface the last error per the stop strategy.
throw new NetworkMwError(
NETWORK_MW_CODES.MIDDLEWARE_ERROR,
"manager.middleware-retries-exhausted",
{
context: {
id: middleware.id,
attempts: maxRetries,
},
cause: lastError instanceof Error ? lastError : undefined,
}
);
}
private requireMiddleware(id: string): Middleware {
const m = this.middlewares.get(id);
if (m === undefined) {
throw new NetworkMwError(
NETWORK_MW_CODES.MIDDLEWARE_NOT_REGISTERED,
"manager.missing",
{ context: { id } }
);
}
return m;
}
}

View File

@@ -0,0 +1,78 @@
# `MetricsMiddleware`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Records per-message metrics — size, processing time, and chain queue wait — and publishes them to a `middleware:metrics` bus topic for `network-plugin-monitor` to aggregate. Sits in the BACKGROUND band by default (`priority: 410`) so it observes the chain after every other middleware has run.
## Features
- Approximate UTF-8 size derived from a JSON serialisation of the envelope; falls back to a `String(payload)` form for non-serialisable payloads.
- Processing-time / queue-wait deltas derived from a per-dispatch state key (`metrics.enqueuedAt`) so registering the middleware twice — once early, once late — yields `enter` and `exit` measurements that share a baseline.
- Optional bus injection so the middleware can be used purely for its side-effects (custom subscriber attached elsewhere).
- Injectable clock for deterministic tests.
## Usage: Basic Example
```ts
import {
MetricsMiddleware,
MiddlewareManager,
} from "@suaveplan/network-plugin-middleware";
const samples: unknown[] = [];
const manager = new MiddlewareManager();
manager.register(
new MetricsMiddleware({
bus: { emit: (_event, m) => samples.push(m) },
})
);
await manager.dispatch(envelope, "outgoing");
```
## Usage: Advanced Example
```ts
import {
MetricsMiddleware,
type MessageMetrics,
} from "@suaveplan/network-plugin-middleware";
let now = 0;
const events: MessageMetrics[] = [];
const bus = { emit: (_event: string, m: MessageMetrics) => events.push(m) };
// Two instances: one at the start of the chain (enter), one at the
// end (exit). The late instance reports a non-zero `processingTimeMs`
// because the early instance seeded `metrics.enqueuedAt`.
const enter = new MetricsMiddleware({
id: "metrics-enter",
priority: 1,
bus,
clock: () => now,
});
const exit = new MetricsMiddleware({
id: "metrics-exit",
priority: 999,
bus,
clock: () => now,
});
```
## API Reference
- `class MetricsMiddleware implements Middleware`
- `constructor(config?: MetricsMiddlewareConfig)`.
- `apply(envelope, context): MessageEnvelope` — never drops, always emits one metrics event when `bus` is set.
- `interface MetricsMiddlewareConfig` — `{ id?, priority?, direction?, bus?, clock? }`.
- `interface MessageMetrics` — `{ direction, messageId, messageType, sizeBytes, processingTimeMs, queueWaitMs }`.
- `interface MetricsBus` — `{ emit("middleware:metrics", metrics): void }`.
## Implementation Notes
- Size estimation uses a hand-rolled UTF-8 byte counter rather than `TextEncoder` so the middleware works on constrained runtimes (Bun browser, edge workers) without an extra allocation per message. The estimate is exact for the BMP and within a small percentage for surrogate-paired scripts.
- The state key `metrics.enqueuedAt` is intentionally namespaced so it does not collide with caller state. Stage-specific keys (e.g. `metrics.enqueuedAt.encryption`) can be layered by configuring `id` accordingly and reading state in a custom subscriber.
- The middleware never drops — telemetry should never be the cause of a message vanishing. Failures inside the bus emit propagate as exceptions and are governed by the manager's `error-policy`.
- `processingTimeMs` and `queueWaitMs` are equal in the single-instance case because the chain treats the first sample as both the enqueue and the snapshot. Two-instance setups (early enter / late exit) populate them separately.

View File

@@ -0,0 +1,104 @@
import type { MessageEnvelope } from "@suaveplan/network";
import type {
Middleware,
MiddlewareContext,
} from "../types/middleware.types.js";
export interface MessageMetrics {
readonly direction: MiddlewareContext["direction"];
readonly messageId: string;
readonly messageType: string;
/** Approximate envelope size in bytes (UTF-8 of JSON serialization). */
readonly sizeBytes: number;
/** Wall-clock duration the chain has spent on this message so far. */
readonly processingTimeMs: number;
/** Time between enqueue (set by metrics on entry) and now. */
readonly queueWaitMs: number;
}
export interface MetricsBus {
emit(event: "middleware:metrics", metrics: MessageMetrics): void;
}
export interface MetricsMiddlewareConfig {
readonly id?: string;
readonly priority?: number;
readonly direction?: MiddlewareContext["direction"];
readonly bus?: MetricsBus;
/** Injectable clock (defaults to `Date.now`). */
readonly clock?: () => number;
}
const ENQUEUED_STATE_KEY = "metrics.enqueuedAt";
/**
* Records per-message metrics — size, processing time so far, and
* queue wait between enqueue and current chain step. Publishes a
* `middleware:metrics` event on the bus for `network-plugin-monitor`
* to aggregate.
*
* Register this middleware twice if you want to capture both enter
* and exit timings: an early instance writes `metrics.enqueuedAt`
* into the shared chain state, a late instance computes the deltas
* and publishes.
*/
export class MetricsMiddleware implements Middleware {
public readonly id: string;
public readonly priority: number;
public readonly direction: MiddlewareContext["direction"] | undefined;
public enabled = true;
private readonly bus: MetricsBus | undefined;
private readonly clock: () => number;
public constructor(config: MetricsMiddlewareConfig = {}) {
this.id = config.id ?? "metrics";
this.priority = config.priority ?? 410;
this.direction = config.direction;
this.bus = config.bus;
this.clock = config.clock ?? (() => Date.now());
}
public apply(
envelope: MessageEnvelope,
context: MiddlewareContext
): MessageEnvelope {
const now = this.clock();
const enqueuedAt =
(context.state[ENQUEUED_STATE_KEY] as number | undefined) ?? now;
if (context.state[ENQUEUED_STATE_KEY] === undefined) {
context.state[ENQUEUED_STATE_KEY] = now;
}
const sizeBytes = approximateSize(envelope);
const metrics: MessageMetrics = Object.freeze({
direction: context.direction,
messageId: envelope.id,
messageType: envelope.type,
sizeBytes,
processingTimeMs: now - enqueuedAt,
queueWaitMs: now - enqueuedAt,
});
this.bus?.emit("middleware:metrics", metrics);
return envelope;
}
}
function approximateSize(envelope: MessageEnvelope): number {
let serialized: string;
try {
// `envelope` is always an object so `JSON.stringify` returns a
// string, never `undefined`.
serialized = JSON.stringify(envelope);
} catch {
serialized = String(envelope.payload);
}
// Cheap UTF-8 byte estimate that avoids allocating a TextEncoder
// when the runtime is constrained.
let bytes = 0;
for (let i = 0; i < serialized.length; i += 1) {
const code = serialized.charCodeAt(i);
if (code < 0x80) bytes += 1;
else if (code < 0x800) bytes += 2;
else bytes += 3;
}
return bytes;
}

View File

@@ -0,0 +1,102 @@
import { createEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type MessageMetrics,
MetricsMiddleware,
} from "./metrics-middleware.js";
const env = (payload: unknown) =>
createEnvelope({
type: "metric:test",
senderId: "peer-1",
payload,
});
describe("MetricsMiddleware: size, processing time, queue wait", () => {
it("emits a single metrics event per apply", () => {
const events: MessageMetrics[] = [];
const m = new MetricsMiddleware({
bus: { emit: (_event, metrics) => events.push(metrics) },
});
m.apply(env({ hello: "world" }), {
direction: "outgoing",
state: {},
});
expect(events.length).toBe(1);
expect(events[0]?.messageType).toBe("metric:test");
expect(events[0]?.sizeBytes).toBeGreaterThan(0);
});
it("two-instance pipeline measures non-zero processing time", async () => {
const events: MessageMetrics[] = [];
const bus = {
emit: (_event: string, metrics: MessageMetrics) =>
events.push(metrics),
};
let now = 100;
const enter = new MetricsMiddleware({
id: "enter",
priority: 1,
bus,
clock: () => now,
});
const exit = new MetricsMiddleware({
id: "exit",
priority: 999,
bus,
clock: () => now,
});
const state: Record<string, unknown> = {};
enter.apply(env("x"), { direction: "outgoing", state });
now += 25;
exit.apply(env("x"), { direction: "outgoing", state });
expect(events[0]?.processingTimeMs).toBe(0);
expect(events[1]?.processingTimeMs).toBe(25);
expect(events[1]?.queueWaitMs).toBe(25);
});
it("does not throw when no bus is configured", () => {
const m = new MetricsMiddleware();
expect(() =>
m.apply(env("x"), { direction: "incoming", state: {} })
).not.toThrow();
});
it("size estimator handles unicode characters", () => {
const events: MessageMetrics[] = [];
const m = new MetricsMiddleware({
bus: { emit: (_event, metrics) => events.push(metrics) },
});
m.apply(env("😀漢字"), { direction: "outgoing", state: {} });
// multi-byte chars should push the size above the ASCII baseline
expect(events[0]?.sizeBytes).toBeGreaterThan(20);
});
it("size estimator counts 2-byte (Latin Supplement) chars", () => {
const events: MessageMetrics[] = [];
const m = new MetricsMiddleware({
bus: { emit: (_event, metrics) => events.push(metrics) },
});
// "café" — `é` (U+00E9) lives in the [0x80, 0x800) range and
// contributes 2 bytes each.
m.apply(env("café"), { direction: "outgoing", state: {} });
expect(events[0]?.sizeBytes).toBeGreaterThan(0);
});
it("falls back to String(payload) when JSON.stringify throws", () => {
const events: MessageMetrics[] = [];
const m = new MetricsMiddleware({
bus: { emit: (_event, metrics) => events.push(metrics) },
});
// Build an envelope whose payload is unstringifiable. We bypass
// the schema by mutating the field after creation — production
// code should not do this; the test exercises the defensive
// fallback path.
const e = env("x");
const cyclic: Record<string, unknown> = {};
cyclic["self"] = cyclic;
const tampered = { ...e, payload: cyclic };
m.apply(tampered, { direction: "outgoing", state: {} });
expect(events[0]?.sizeBytes).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,85 @@
# `MiddlewarePlugin`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Single `NetworkPlugin` that owns a `MiddlewareManager` and exposes it as a public field so consumers register, dispatch, and inspect middleware via the manager directly. Keeps the `NetworkPlugin` facade narrow — the manager is the substantive surface; the plugin just gives it a lifecycle hook into a `NetworkNode`.
## Features
- `install(node)` records the host node and registers any middlewares passed at construction time (idempotent: pre-existing ids are skipped).
- `start` / `stop` / `dispose` lifecycle that satisfies `NetworkPlugin`.
- `dispose` unregisters every middleware so a re-installed plugin starts clean.
- Public `manager` field gives consumers full access to runtime register / unregister / enable / disable / dispatch.
## Usage: Basic Example
```ts
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import {
LoggingMiddleware,
MiddlewarePlugin,
} from "@suaveplan/network-plugin-middleware";
const bus = new NetworkBus();
const node = new NetworkNode({ id: "node-1", networkBus: bus });
const plugin = new MiddlewarePlugin({
middlewares: [new LoggingMiddleware({ includeBody: true })],
});
node.use(plugin);
await node.start();
await plugin.manager.dispatch(envelope, "outgoing");
```
## Usage: Advanced Example
```ts
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import {
MiddlewarePlugin,
ValidationMiddleware,
SecurityMiddleware,
ThrottlingMiddleware,
} from "@suaveplan/network-plugin-middleware";
const plugin = new MiddlewarePlugin({
errorPolicy: { policy: "stop" },
middlewares: [
new ValidationMiddleware({ schemas: { /* … */ } }),
new SecurityMiddleware({ /* … */ }),
new ThrottlingMiddleware({
global: { capacity: 100, refillRate: 50 },
}),
],
});
const node = new NetworkNode({
id: "node-1",
networkBus: new NetworkBus(),
});
node.use(plugin);
await node.start();
```
## API Reference
- `class MiddlewarePlugin implements NetworkPlugin`
- `name: "middleware"`.
- `manager: MiddlewareManager` — public field; primary surface.
- `constructor(config?: MiddlewarePluginConfig)`.
- `install(node: NetworkNode): void`.
- `start(): void` / `stop(): void` — idempotent.
- `dispose(): void` — unregisters every middleware and detaches the node.
- `host(): NetworkNode | null` — node currently bound (or `null` after dispose).
- `isStarted(): boolean`.
- `interface MiddlewarePluginConfig` — `{ errorPolicy?, middlewares? }`.
## Implementation Notes
- The plugin holds a _reference_ to the manager rather than re-exporting its methods so the public surface stays explicit. Callers that prefer a thin facade can write one over the public field — keeping the plugin minimal makes refactors cheaper.
- Middlewares registered at construction time are only applied on `install` so a plugin built before its host node still satisfies the same contract once attached.
- `dispose` walks `manager.list()` and unregisters each middleware. The `has(id)` guard absorbs the (rare) case of an id being removed mid-iteration by an external listener.
- `start` / `stop` only flip a boolean: the manager is fully usable without the plugin being started — `start` exists to satisfy `NetworkPlugin` and to give consumers a single hook for "OK, now you may begin processing".

View File

@@ -0,0 +1,72 @@
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
import { MiddlewareManager } from "../manager/middleware-manager.js";
import type { ErrorPolicyConfig } from "../types/chain.types.js";
import type { Middleware } from "../types/middleware.types.js";
export interface MiddlewarePluginConfig {
/** Optional error policy applied to every middleware run. */
readonly errorPolicy?: ErrorPolicyConfig;
/** Middlewares to register at construction time. */
readonly middlewares?: ReadonlyArray<Middleware>;
}
/**
* `NetworkPlugin` that owns a `MiddlewareManager` and wires its
* lifecycle into the host `NetworkNode`. Consumers register
* middlewares via the plugin's public `manager` field — keeping the
* facade narrow lets the manager evolve without breaking the plugin
* contract.
*/
export class MiddlewarePlugin implements NetworkPlugin {
public readonly name = "middleware";
public readonly manager: MiddlewareManager;
private readonly initialMiddlewares: ReadonlyArray<Middleware>;
private node: NetworkNode | null = null;
private started = false;
public constructor(config: MiddlewarePluginConfig = {}) {
this.manager = new MiddlewareManager({
...(config.errorPolicy === undefined
? {}
: { errorPolicy: config.errorPolicy }),
});
this.initialMiddlewares = Object.freeze([
...(config.middlewares ?? []),
]);
}
public install(node: NetworkNode): void {
this.node = node;
for (const middleware of this.initialMiddlewares) {
if (!this.manager.has(middleware.id)) {
this.manager.register(middleware);
}
}
}
public start(): void {
this.started = true;
}
public stop(): void {
this.started = false;
}
public dispose(): void {
// Snapshot the ids first so the unregister loop does not race
// against the underlying iterator.
const ids = this.manager.list().map((m) => m.id);
for (const id of ids) {
this.manager.unregister(id);
}
this.node = null;
}
public host(): NetworkNode | null {
return this.node;
}
public isStarted(): boolean {
return this.started;
}
}

View File

@@ -0,0 +1,121 @@
import { createEnvelope, NetworkBus, NetworkNode } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LoggingMiddleware } from "../logging/logging-middleware.js";
import { MiddlewarePlugin } from "./middleware-plugin.js";
const env = () =>
createEnvelope({ type: "plugin:test", senderId: "peer-1", payload: {} });
const newNode = () => {
const bus = new NetworkBus();
return new NetworkNode({ id: "node-1", networkBus: bus });
};
describe("MiddlewarePlugin: NetworkPlugin lifecycle", () => {
it("constructs with no middlewares", () => {
const plugin = new MiddlewarePlugin();
expect(plugin.name).toBe("middleware");
expect(plugin.manager.list()).toEqual([]);
expect(plugin.host()).toBeNull();
expect(plugin.isStarted()).toBe(false);
});
it("registers initial middlewares on install (idempotent)", async () => {
const logger = new LoggingMiddleware({ id: "logger-init" });
const plugin = new MiddlewarePlugin({ middlewares: [logger] });
// Re-installing must not throw on the duplicate id.
const node = newNode();
node.use(plugin);
await node.start();
expect(plugin.manager.has("logger-init")).toBe(true);
expect(plugin.host()).not.toBeNull();
await node.stop();
});
it("start / stop toggle isStarted", async () => {
const plugin = new MiddlewarePlugin();
const node = newNode();
node.use(plugin);
await node.start();
expect(plugin.isStarted()).toBe(true);
await node.stop();
expect(plugin.isStarted()).toBe(false);
});
it("dispose unregisters every middleware and detaches the node", async () => {
const plugin = new MiddlewarePlugin({
middlewares: [new LoggingMiddleware({ id: "to-clean" })],
});
const node = newNode();
node.use(plugin);
await node.start();
expect(plugin.manager.has("to-clean")).toBe(true);
await node.stop();
plugin.dispose();
expect(plugin.manager.list().length).toBe(0);
expect(plugin.host()).toBeNull();
});
it("manager.dispatch runs registered middlewares end-to-end", async () => {
const events: unknown[] = [];
const plugin = new MiddlewarePlugin({
middlewares: [
new LoggingMiddleware({
id: "wired-logger",
bus: { emit: (_event, payload) => events.push(payload) },
}),
],
});
const node = newNode();
node.use(plugin);
await node.start();
await plugin.manager.dispatch(env(), "outgoing");
await node.stop();
expect(events.length).toBe(1);
});
it("custom error policy is honoured", async () => {
const plugin = new MiddlewarePlugin({
errorPolicy: { policy: "continue" },
middlewares: [
{
id: "boom",
priority: 10,
apply: () => {
throw new Error("noisy");
},
},
{
id: "after",
priority: 20,
apply: (e) => e,
},
],
});
const node = newNode();
node.use(plugin);
await node.start();
const result = await plugin.manager.dispatch(env(), "outgoing");
await node.stop();
expect(result.dropped).toBe(false);
});
it("install skips initial middlewares whose id is already registered", async () => {
const dup = new LoggingMiddleware({ id: "dup" });
const plugin = new MiddlewarePlugin({ middlewares: [dup] });
// Pre-register manually so the install loop hits the
// already-registered branch without throwing.
plugin.manager.register(new LoggingMiddleware({ id: "dup" }));
const node = newNode();
node.use(plugin);
await node.start();
expect(plugin.manager.has("dup")).toBe(true);
await node.stop();
});
it("dispose is a no-op when no middlewares are registered", () => {
const plugin = new MiddlewarePlugin();
expect(() => plugin.dispose()).not.toThrow();
expect(plugin.host()).toBeNull();
});
});

View File

@@ -0,0 +1,86 @@
# `SecurityMiddleware`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Applies sign / encrypt on outbound messages and verifies signatures on inbound messages, gated by a per-`MessageType` or per-tier policy. Crypto-implementation-agnostic: callers supply `sign`, `encrypt`, and `verify` callbacks so the middleware works with any crypto module today (and will plug into `@suaveplan/network-plugin-crypto-messages` once that package ships).
## Features
- Two-key lookup: policy is checked first by `MessageType`, then by tier name resolved from a caller-supplied `tierFor(peerId)` lookup. The first match wins.
- `requireSignature: true` rejects unsigned/malformed inbound messages with `SECURITY_REJECTED`.
- Outbound `sign` runs before `encrypt` so the verifier sees the signature inside the encrypted envelope.
- Misconfiguration (policy demands signing without supplying a `sign` callback) is detected at dispatch time, not runtime, so deployments fail loudly.
- Lives in CRITICAL band by default (`priority: 80`) so security runs immediately after validation.
## Usage: Basic Example
```ts
import {
SecurityMiddleware,
MiddlewareManager,
} from "@suaveplan/network-plugin-middleware";
const manager = new MiddlewareManager();
manager.register(
new SecurityMiddleware({
policy: {
"registry:add": { sign: true, requireSignature: true },
},
sign: async (env) => ({
...env,
headers: { ...env.headers, "x-signature": "sig-stub" },
}),
verify: async (env) => env.headers["x-signature"] === "sig-stub",
})
);
```
## Usage: Advanced Example
```ts
import {
SecurityMiddleware,
type SecurityPolicy,
type TierName,
} from "@suaveplan/network-plugin-middleware";
const policy: SecurityPolicy = {
"tier-0": { sign: true, encrypt: true, requireSignature: true },
"tier-1": { sign: true, requireSignature: true },
"tier-2": { sign: true },
"registry:add": { sign: true, requireSignature: true },
};
const security = new SecurityMiddleware({
id: "tier-aware-security",
priority: 90,
policy,
tierFor: (peerId): TierName | undefined =>
peerId.startsWith("hub-") ? "tier-0" : "tier-2",
sign: signWithEd25519,
encrypt: encryptWithChacha,
verify: verifyEd25519,
});
```
## API Reference
- `class SecurityMiddleware implements Middleware`
- `constructor(config?: SecurityMiddlewareConfig)`.
- `apply(envelope, context): Promise<MessageEnvelope | null>`.
- `interface SecurityMiddlewareConfig` — `{ id?, priority?, direction?, tierFor?, policy?, sign?, encrypt?, verify? }`.
- `interface SecurityPolicyEntry` — `{ sign?, encrypt?, requireSignature? }`.
- `type SecurityPolicy` — `Readonly<Record<string, SecurityPolicyEntry | undefined>>` keyed by `MessageType` or `TierName`.
- `type TierName` — `"tier-0" | "tier-1" | "tier-2" | "tier-3"`.
- `type SignFn` / `EncryptFn` / `VerifyFn` — callback signatures.
## Implementation Notes
- The `MessageType` lookup wins over the tier lookup so deployments can opt specific message types out of (or into) more restrictive policies without rewriting the tier table.
- `tierFor` resolves against `envelope.targetId ?? envelope.senderId` so outbound messages key off the recipient and inbound messages key off the originator. Callers that need different lookup semantics can compose two SecurityMiddleware instances under different ids.
- Crypto callbacks MAY be sync or async; the middleware always awaits, so adding async-only flows (e.g. WebCrypto) does not require recompiling sync paths.
- The error code is deliberately generic (`SECURITY_REJECTED`) — distinguishing missing-callback vs invalid-signature is left to the `messageKey` so callers can branch on the key string when richer feedback is required.
- Policy entries default to "no action" — `sign: false` and `requireSignature: false` are equivalent to omitting the keys, matching the proposal's "additive only" stance.

View File

@@ -0,0 +1,164 @@
import type { MessageEnvelope } from "@suaveplan/network";
import { NETWORK_MW_CODES, NetworkMwError } from "../error-codes.js";
import type {
Middleware,
MiddlewareContext,
} from "../types/middleware.types.js";
export type TierName = "tier-0" | "tier-1" | "tier-2" | "tier-3";
export interface SecurityPolicyEntry {
/** Whether outbound messages of this type/tier should be signed. */
readonly sign?: boolean;
/** Whether outbound messages of this type/tier should be encrypted. */
readonly encrypt?: boolean;
/** Whether inbound messages MUST be signed; reject otherwise. */
readonly requireSignature?: boolean;
}
export type SecurityPolicy = Readonly<
Record<string, SecurityPolicyEntry | undefined>
>;
export type SignFn = (
envelope: MessageEnvelope,
context: MiddlewareContext
) => MessageEnvelope | Promise<MessageEnvelope>;
export type EncryptFn = SignFn;
export type VerifyFn = (
envelope: MessageEnvelope,
context: MiddlewareContext
) => boolean | Promise<boolean>;
export interface SecurityMiddlewareConfig {
readonly id?: string;
readonly priority?: number;
readonly direction?: MiddlewareContext["direction"];
/**
* Lookup helper that maps a `peerId` to the tier name used in
* `policy`. Returning `undefined` means "no tier-specific entry".
*/
readonly tierFor?: (peerId: string) => TierName | undefined;
/**
* Policy keyed by either `MessageType` or `TierName` — the
* middleware checks both, message type taking precedence.
*/
readonly policy?: SecurityPolicy;
/** Required when any policy entry sets `sign: true`. */
readonly sign?: SignFn;
/** Required when any policy entry sets `encrypt: true`. */
readonly encrypt?: EncryptFn;
/** Required when any policy entry sets `requireSignature: true`. */
readonly verify?: VerifyFn;
}
/**
* Applies sign / encrypt / verify per policy. The middleware is
* crypto-implementation-agnostic: callers supply `sign`, `encrypt`,
* and `verify` callbacks (typically backed by
* `@suaveplan/network-plugin-crypto-messages` once shipped, or by an
* in-house crypto module today).
*/
export class SecurityMiddleware implements Middleware {
public readonly id: string;
public readonly priority: number;
public readonly direction: MiddlewareContext["direction"] | undefined;
public enabled = true;
private readonly policy: SecurityPolicy;
private readonly tierFor:
| ((peerId: string) => TierName | undefined)
| undefined;
private readonly signFn: SignFn | undefined;
private readonly encryptFn: EncryptFn | undefined;
private readonly verifyFn: VerifyFn | undefined;
public constructor(config: SecurityMiddlewareConfig = {}) {
this.id = config.id ?? "security";
this.priority = config.priority ?? 80;
this.direction = config.direction;
this.policy = config.policy ?? {};
this.tierFor = config.tierFor;
this.signFn = config.sign;
this.encryptFn = config.encrypt;
this.verifyFn = config.verify;
}
public async apply(
envelope: MessageEnvelope,
context: MiddlewareContext
): Promise<MessageEnvelope | null> {
const entry = this.entryFor(envelope);
if (entry === undefined) return envelope;
if (context.direction === "outgoing") {
return await this.handleOutgoing(envelope, context, entry);
}
return await this.handleIncoming(envelope, context, entry);
}
private async handleOutgoing(
envelope: MessageEnvelope,
context: MiddlewareContext,
entry: SecurityPolicyEntry
): Promise<MessageEnvelope> {
let current = envelope;
if (entry.sign === true) {
if (this.signFn === undefined) {
throw this.misconfigured("security.sign-fn-missing");
}
current = await this.signFn(current, context);
}
if (entry.encrypt === true) {
if (this.encryptFn === undefined) {
throw this.misconfigured("security.encrypt-fn-missing");
}
current = await this.encryptFn(current, context);
}
return current;
}
private async handleIncoming(
envelope: MessageEnvelope,
context: MiddlewareContext,
entry: SecurityPolicyEntry
): Promise<MessageEnvelope> {
if (entry.requireSignature !== true) return envelope;
if (this.verifyFn === undefined) {
throw this.misconfigured("security.verify-fn-missing");
}
const ok = await this.verifyFn(envelope, context);
if (!ok) {
throw new NetworkMwError(
NETWORK_MW_CODES.SECURITY_REJECTED,
"security.signature-invalid",
{
context: {
messageId: envelope.id,
messageType: envelope.type,
senderId: envelope.senderId,
},
}
);
}
return envelope;
}
private entryFor(
envelope: MessageEnvelope
): SecurityPolicyEntry | undefined {
const byType = this.policy[envelope.type];
if (byType !== undefined) return byType;
const peerId = envelope.targetId ?? envelope.senderId;
const tier = this.tierFor?.(peerId);
if (tier === undefined) return undefined;
return this.policy[tier];
}
private misconfigured(messageKey: string): NetworkMwError {
return new NetworkMwError(
NETWORK_MW_CODES.SECURITY_REJECTED,
messageKey
);
}
}

View File

@@ -0,0 +1,181 @@
import { createEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_MW_CODES, type NetworkMwError } from "../error-codes.js";
import { SecurityMiddleware } from "./security-middleware.js";
const env = (overrides: Partial<Parameters<typeof createEnvelope>[0]> = {}) =>
createEnvelope({
type: "secure:test",
senderId: "peer-1",
payload: {},
...overrides,
});
describe("SecurityMiddleware: per-policy sign / encrypt / verify", () => {
it("signs outbound when policy says so", async () => {
const s = new SecurityMiddleware({
policy: { "secure:test": { sign: true } },
sign: async (e) => ({
...e,
headers: { ...e.headers, "x-signature": "sig" },
}),
});
const result = await s.apply(env(), {
direction: "outgoing",
state: {},
});
expect(result?.headers["x-signature"]).toBe("sig");
});
it("throws SECURITY_REJECTED when sign callback is missing", async () => {
const s = new SecurityMiddleware({
policy: { "secure:test": { sign: true } },
});
try {
await s.apply(env(), { direction: "outgoing", state: {} });
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.SECURITY_REJECTED
);
}
});
it("encrypts after signing on outbound", async () => {
const order: string[] = [];
const s = new SecurityMiddleware({
policy: { "secure:test": { sign: true, encrypt: true } },
sign: async (e) => {
order.push("sign");
return e;
},
encrypt: async (e) => {
order.push("encrypt");
return e;
},
});
await s.apply(env(), { direction: "outgoing", state: {} });
expect(order).toEqual(["sign", "encrypt"]);
});
it("rejects unsigned inbound when requireSignature", async () => {
const s = new SecurityMiddleware({
policy: { "secure:test": { requireSignature: true } },
verify: async () => false,
});
try {
await s.apply(env(), { direction: "incoming", state: {} });
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.SECURITY_REJECTED
);
}
});
it("requires verify callback when requireSignature is set", async () => {
const s = new SecurityMiddleware({
policy: { "secure:test": { requireSignature: true } },
});
try {
await s.apply(env(), { direction: "incoming", state: {} });
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.SECURITY_REJECTED
);
}
});
it("forwards inbound when verify returns true", async () => {
const s = new SecurityMiddleware({
policy: { "secure:test": { requireSignature: true } },
verify: async () => true,
});
const e = env();
expect(await s.apply(e, { direction: "incoming", state: {} })).toBe(e);
});
it("encrypt callback missing throws SECURITY_REJECTED", async () => {
const s = new SecurityMiddleware({
policy: { "secure:test": { sign: true, encrypt: true } },
sign: async (e) => e,
});
try {
await s.apply(env(), { direction: "outgoing", state: {} });
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.SECURITY_REJECTED
);
}
});
it("falls back to tier policy when no per-type entry matches", async () => {
const s = new SecurityMiddleware({
policy: { "tier-0": { sign: true } },
tierFor: (peerId) => (peerId === "hub-A" ? "tier-0" : undefined),
sign: async (e) => ({
...e,
headers: { ...e.headers, "x-tier-signed": "yes" },
}),
});
const result = await s.apply(
env({ targetId: "hub-A", type: "anything" }),
{ direction: "outgoing", state: {} }
);
expect(result?.headers["x-tier-signed"]).toBe("yes");
});
it("returns the envelope when no policy entry matches", async () => {
const s = new SecurityMiddleware({});
const e = env();
expect(await s.apply(e, { direction: "outgoing", state: {} })).toBe(e);
});
it("inbound without requireSignature is forwarded", async () => {
const s = new SecurityMiddleware({
policy: { "secure:test": { sign: true } },
sign: async (e) => e,
});
const e = env();
expect(await s.apply(e, { direction: "incoming", state: {} })).toBe(e);
});
it("falls back to senderId when targetId is absent for tier lookup", async () => {
const s = new SecurityMiddleware({
policy: { "tier-1": { sign: true } },
tierFor: (peerId) => (peerId === "peer-X" ? "tier-1" : undefined),
sign: async (e) => ({
...e,
headers: { ...e.headers, "x-from-sender": "yes" },
}),
});
const result = await s.apply(
env({ senderId: "peer-X", type: "anything" }),
{ direction: "outgoing", state: {} }
);
expect(result?.headers["x-from-sender"]).toBe("yes");
});
it("default-config constructor (no args) is usable", async () => {
const s = new SecurityMiddleware();
const e = env();
expect(await s.apply(e, { direction: "outgoing", state: {} })).toBe(e);
});
it("encrypt-only outbound (no sign) still encrypts", async () => {
const s = new SecurityMiddleware({
policy: { "secure:test": { encrypt: true } },
encrypt: async (e) => ({
...e,
headers: { ...e.headers, "x-encrypted": "yes" },
}),
});
const result = await s.apply(env(), {
direction: "outgoing",
state: {},
});
expect(result?.headers["x-encrypted"]).toBe("yes");
});
});

View File

@@ -0,0 +1,68 @@
# `ThrottlingMiddleware`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Token-bucket rate limiting backed by `@suaveplan/rate-limiter` for the network chain. Three independent layers — global, per-`senderId`, per-`MessageType` — combine conservatively: a message is allowed only when every applicable bucket has tokens. When any bucket refuses, the middleware either throws `THROTTLED` (default) so the manager's error policy decides, or returns `null` for a clean chain drop.
## Features
- Three configurable layers, each with its own `capacity` (burst) and `refillRate` (sustained tokens / sec).
- Per-peer buckets are created lazily on first sight so the cost is `O(active peers)` rather than `O(known peers)`.
- `onExceed: "throw"` / `"drop"` lets callers choose between loud-failure semantics (informational headers, retry policy) and silent suppression (audit-only deployments).
- Injectable clock — every internal `TokenBucket` shares the manager's clock so deterministic tests can pin the schedule exactly.
- Lives in HIGH band by default (`priority: 150`) so throttling runs after security/validation but before application-level transformation.
## Usage: Basic Example
```ts
import {
ThrottlingMiddleware,
MiddlewareManager,
} from "@suaveplan/network-plugin-middleware";
const manager = new MiddlewareManager();
manager.register(
new ThrottlingMiddleware({
global: { capacity: 10, refillRate: 5 },
})
);
await manager.dispatch(envelope, "outgoing");
```
## Usage: Advanced Example
```ts
import { ThrottlingMiddleware } from "@suaveplan/network-plugin-middleware";
let now = 0;
const throttler = new ThrottlingMiddleware({
id: "tiered-throttle",
priority: 160,
onExceed: "drop",
clock: () => now,
global: { capacity: 1000, refillRate: 500 },
perPeer: { capacity: 100, refillRate: 50 },
perType: {
"chunk:upload": { capacity: 20, refillRate: 5 },
"registry:add": { capacity: 5, refillRate: 1 },
},
});
```
## API Reference
- `class ThrottlingMiddleware implements Middleware`
- `constructor(config?: ThrottlingMiddlewareConfig)`.
- `apply(envelope, context): MessageEnvelope | null` — `null` only when `onExceed: "drop"`.
- `interface ThrottleLimit` — `{ refillRate, capacity }`.
- `interface ThrottlingMiddlewareConfig` — `{ id?, priority?, direction?, global?, perPeer?, perType?, onExceed?, clock? }`.
## Implementation Notes
- All three layers must allow the message — this is intentional: the most-restrictive bucket wins. Combining limits any other way (e.g. "any bucket allows") would let a misbehaving peer chew through the global budget unchecked.
- Per-peer buckets are not garbage-collected. For long-running processes with high peer churn, attach an external `peer:disconnected` listener and call `forget(senderId)` from a custom subclass that exposes the peer-bucket map; the default behaviour optimises for the common case where the peer set is bounded.
- `THROTTLED` carries `retryAfterSec` so callers (the dispatcher, a custom error responder) can communicate `Retry-After` semantics back to the sender.
- `onExceed: "drop"` returns `null` from `apply`, which the manager surfaces as `ChainResult.dropped: true` with `droppedBy: "throttler"` — observability gets the throttle event even when the failure is intentionally silent.

View File

@@ -0,0 +1,121 @@
import type { MessageEnvelope } from "@suaveplan/network";
import { TokenBucket } from "@suaveplan/rate-limiter";
import { NETWORK_MW_CODES, NetworkMwError } from "../error-codes.js";
import type {
Middleware,
MiddlewareContext,
} from "../types/middleware.types.js";
export interface ThrottleLimit {
/** Sustained tokens per second. */
readonly refillRate: number;
/** Bucket size — burst allowance. */
readonly capacity: number;
}
export interface ThrottlingMiddlewareConfig {
readonly id?: string;
readonly priority?: number;
readonly direction?: MiddlewareContext["direction"];
/** Limit applied to every message regardless of peer / type. */
readonly global?: ThrottleLimit;
/** Per-`senderId` limit; the same limit applies to each peer independently. */
readonly perPeer?: ThrottleLimit;
/** Per-`MessageType` limit map. */
readonly perType?: Readonly<Record<string, ThrottleLimit>>;
/** Action when any bucket refuses a token. Defaults to `"throw"`. */
readonly onExceed?: "throw" | "drop";
/** Injectable clock — wired into every TokenBucket for determinism. */
readonly clock?: () => number;
}
/**
* Token-bucket rate limiting via `@suaveplan/rate-limiter`. Three
* independent layers — global, per-peer, per-type — combine
* conservatively: a message is allowed only if every applicable
* bucket has tokens. When any bucket refuses, the middleware either
* throws `THROTTLED` (default) or returns `null` (chain drop).
*/
export class ThrottlingMiddleware implements Middleware {
public readonly id: string;
public readonly priority: number;
public readonly direction: MiddlewareContext["direction"] | undefined;
public enabled = true;
private readonly globalBucket: TokenBucket | null;
private readonly perPeerLimit: ThrottleLimit | undefined;
private readonly perPeerBuckets = new Map<string, TokenBucket>();
private readonly perTypeBuckets: Map<string, TokenBucket>;
private readonly onExceed: "throw" | "drop";
private readonly clock: () => number;
public constructor(config: ThrottlingMiddlewareConfig = {}) {
this.id = config.id ?? "throttler";
this.priority = config.priority ?? 150;
this.direction = config.direction;
this.onExceed = config.onExceed ?? "throw";
this.clock = config.clock ?? (() => Date.now());
this.globalBucket =
config.global !== undefined ? this.makeBucket(config.global) : null;
this.perPeerLimit = config.perPeer;
this.perTypeBuckets = new Map();
for (const [type, limit] of Object.entries(config.perType ?? {})) {
this.perTypeBuckets.set(type, this.makeBucket(limit));
}
}
public apply(
envelope: MessageEnvelope,
_context: MiddlewareContext
): MessageEnvelope | null {
const buckets = this.bucketsFor(envelope);
for (const bucket of buckets) {
const result = bucket.consume(1);
if (!result.allowed) {
// `retryAfter` is always set on `allowed: false` results
// by `@suaveplan/rate-limiter`'s `TokenBucket.consume`.
return this.refuse(envelope, result.retryAfter as number);
}
}
return envelope;
}
private bucketsFor(envelope: MessageEnvelope): TokenBucket[] {
const out: TokenBucket[] = [];
if (this.globalBucket !== null) out.push(this.globalBucket);
if (this.perPeerLimit !== undefined) {
let bucket = this.perPeerBuckets.get(envelope.senderId);
if (bucket === undefined) {
bucket = this.makeBucket(this.perPeerLimit);
this.perPeerBuckets.set(envelope.senderId, bucket);
}
out.push(bucket);
}
const typeBucket = this.perTypeBuckets.get(envelope.type);
if (typeBucket !== undefined) out.push(typeBucket);
return out;
}
private refuse(envelope: MessageEnvelope, retryAfterSec: number): null {
if (this.onExceed === "drop") return null;
throw new NetworkMwError(
NETWORK_MW_CODES.THROTTLED,
"throttling.exceeded",
{
context: {
messageId: envelope.id,
messageType: envelope.type,
senderId: envelope.senderId,
retryAfterSec,
},
}
);
}
private makeBucket(limit: ThrottleLimit): TokenBucket {
return new TokenBucket({
capacity: limit.capacity,
refillRate: limit.refillRate,
clock: this.clock,
});
}
}

View File

@@ -0,0 +1,117 @@
import { createEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_MW_CODES, type NetworkMwError } from "../error-codes.js";
import { ThrottlingMiddleware } from "./throttling-middleware.js";
const env = (overrides: Partial<Parameters<typeof createEnvelope>[0]> = {}) =>
createEnvelope({
type: "test",
senderId: "peer-1",
payload: {},
...overrides,
});
const ctx = {
direction: "outgoing" as const,
state: {} as Record<string, unknown>,
};
describe("ThrottlingMiddleware: global / per-peer / per-type / burst", () => {
it("throws THROTTLED past the global capacity", () => {
const now = 0;
const t = new ThrottlingMiddleware({
global: { capacity: 2, refillRate: 1 },
clock: () => now,
});
t.apply(env(), ctx);
t.apply(env(), ctx);
try {
t.apply(env(), ctx);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.THROTTLED
);
}
});
it("onExceed: drop returns null instead of throwing", () => {
const now = 0;
const t = new ThrottlingMiddleware({
global: { capacity: 1, refillRate: 1 },
clock: () => now,
onExceed: "drop",
});
t.apply(env(), ctx);
expect(t.apply(env(), ctx)).toBeNull();
});
it("per-peer buckets are independent across senderIds", () => {
const now = 0;
const t = new ThrottlingMiddleware({
perPeer: { capacity: 1, refillRate: 1 },
clock: () => now,
});
t.apply(env({ senderId: "peer-A" }), ctx);
t.apply(env({ senderId: "peer-B" }), ctx);
try {
t.apply(env({ senderId: "peer-A" }), ctx);
expect.fail("should have thrown");
} catch {
// expected
}
});
it("per-type bucket only triggers on matching messages", () => {
const now = 0;
const t = new ThrottlingMiddleware({
perType: { "rare:type": { capacity: 1, refillRate: 1 } },
clock: () => now,
});
t.apply(env({ type: "common", senderId: "peer-1" }), ctx);
t.apply(env({ type: "common", senderId: "peer-1" }), ctx);
t.apply(env({ type: "common", senderId: "peer-1" }), ctx);
t.apply(env({ type: "rare:type", senderId: "peer-1" }), ctx);
try {
t.apply(env({ type: "rare:type", senderId: "peer-1" }), ctx);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.THROTTLED
);
}
});
it("burst capacity is honoured up to the bucket size", () => {
const now = 0;
const t = new ThrottlingMiddleware({
global: { capacity: 5, refillRate: 1 },
clock: () => now,
});
for (let i = 0; i < 5; i += 1) {
expect(t.apply(env(), ctx)).not.toBeNull();
}
try {
t.apply(env(), ctx);
expect.fail("should have thrown");
} catch {
// expected
}
});
it("no-op when no limits configured", () => {
const t = new ThrottlingMiddleware();
for (let i = 0; i < 100; i += 1) {
expect(t.apply(env(), ctx)).not.toBeNull();
}
});
it("uses Date.now as the default clock", () => {
// Construct without an injected clock so the default arrow is
// wired into every TokenBucket and exercised on apply.
const t = new ThrottlingMiddleware({
global: { capacity: 5, refillRate: 1 },
});
expect(t.apply(env(), ctx)).not.toBeNull();
});
});

View File

@@ -0,0 +1,83 @@
# `TransformationMiddleware`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Applies per-`MessageType` transforms to the envelope. Use it for the routine, predictable mutations the chain owns: compressing large payloads, stripping debug fields in production, injecting OTel trace context, redacting fields by policy. Sits in the NORMAL band by default (`priority: 220`) so it runs after security/validation but before metrics and logging.
## Features
- Per-`MessageType` transform map, plus optional `defaultTransform` for catch-all behaviour.
- Transforms MAY be async; the middleware always awaits.
- A transform may return `null` to drop the message (the manager surfaces this via `ChainResult.dropped`), `undefined` to leave the envelope unchanged, or a new envelope.
- Direction filter respected — pair with `direction: "outgoing"` for a "compress on send" rule.
## Usage: Basic Example
```ts
import {
TransformationMiddleware,
MiddlewareManager,
} from "@suaveplan/network-plugin-middleware";
const manager = new MiddlewareManager();
manager.register(
new TransformationMiddleware({
transforms: {
"telemetry:report": (env) => ({
...env,
payload: redactDebug(env.payload),
}),
},
})
);
```
## Usage: Advanced Example
```ts
import {
TransformationMiddleware,
type TransformFn,
} from "@suaveplan/network-plugin-middleware";
const compressIfLarge: TransformFn = async (env) => {
if (typeof env.payload !== "string") return env;
if (env.payload.length < 4096) return env;
return {
...env,
headers: { ...env.headers, "x-encoding": "gzip" },
payload: await gzip(env.payload),
};
};
const dropDebug: TransformFn = (env) =>
env.type.startsWith("debug:") && process.env.NODE_ENV === "production"
? null
: undefined;
const transformer = new TransformationMiddleware({
id: "shape-and-drop",
priority: 230,
direction: "outgoing",
transforms: { "chunk:upload": compressIfLarge },
defaultTransform: dropDebug,
});
```
## API Reference
- `class TransformationMiddleware implements Middleware`
- `constructor(config?: TransformationMiddlewareConfig)`.
- `apply(envelope, context): Promise<MessageEnvelope | null>`.
- `interface TransformationMiddlewareConfig` — `{ id?, priority?, direction?, transforms?, defaultTransform? }`.
- `type TransformFn` — `(envelope, context) => MessageEnvelope | null | undefined | Promise<…>`.
## Implementation Notes
- Returning `undefined` from a transform is treated as "no change". This keeps trivial pass-throughs short (`(env) => env.x === ok ? undefined : edited(env)`) without forcing callers to spread the envelope on every match.
- The middleware always awaits the transform so async transforms (compression, asynchronous redaction, OTel context injection) preserve chain order.
- Transforms run for messages whose type matches; messages without an explicit entry fall through unless `defaultTransform` is set. The split lets callers narrow expensive transforms to specific types without hand-coding type checks inside the transform body.
- The middleware does NOT validate the result of a transform — that is `ValidationMiddleware`'s job. Compose them in priority order (validate first → transform → re-validate later if needed).

View File

@@ -0,0 +1,59 @@
import type { MessageEnvelope } from "@suaveplan/network";
import type {
Middleware,
MiddlewareContext,
} from "../types/middleware.types.js";
export type TransformFn = (
envelope: MessageEnvelope,
context: MiddlewareContext
) =>
| MessageEnvelope
| null
| undefined
| Promise<MessageEnvelope | null | undefined>;
export interface TransformationMiddlewareConfig {
readonly id?: string;
readonly priority?: number;
readonly direction?: MiddlewareContext["direction"];
/** Per-MessageType transforms applied in registration order. */
readonly transforms?: Readonly<Record<string, TransformFn>>;
/** Transform applied to messages whose type has no specific entry. */
readonly defaultTransform?: TransformFn;
}
/**
* Modifies the envelope per `MessageType`. Common uses: compress
* large payloads, strip debug fields in production, inject trace
* context for distributed tracing. Returning `null` from a transform
* propagates as a chain-drop (the manager surfaces it via
* `ChainResult.dropped`).
*/
export class TransformationMiddleware implements Middleware {
public readonly id: string;
public readonly priority: number;
public readonly direction: MiddlewareContext["direction"] | undefined;
public enabled = true;
private readonly transforms: Readonly<Record<string, TransformFn>>;
private readonly defaultTransform: TransformFn | undefined;
public constructor(config: TransformationMiddlewareConfig = {}) {
this.id = config.id ?? "transformer";
this.priority = config.priority ?? 220;
this.direction = config.direction;
this.transforms = config.transforms ?? {};
this.defaultTransform = config.defaultTransform;
}
public async apply(
envelope: MessageEnvelope,
context: MiddlewareContext
): Promise<MessageEnvelope | null> {
const fn = this.transforms[envelope.type] ?? this.defaultTransform;
if (fn === undefined) return envelope;
const next = await fn(envelope, context);
if (next === null) return null;
return next ?? envelope;
}
}

View File

@@ -0,0 +1,75 @@
import { createEnvelope, type MessageEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TransformationMiddleware } from "./transformation-middleware.js";
const env = (type: string, payload: unknown) =>
createEnvelope({ type, senderId: "peer-1", payload });
const ctx = {
direction: "outgoing" as const,
state: {} as Record<string, unknown>,
};
describe("TransformationMiddleware: type-specific transforms", () => {
it("passes through when no transform is registered", async () => {
const t = new TransformationMiddleware();
const e = env("any", "x");
expect(await t.apply(e, ctx)).toBe(e);
});
it("applies the type-specific transform", async () => {
const t = new TransformationMiddleware({
transforms: {
stringify: (e) => ({
...e,
payload: String(e.payload).toUpperCase(),
}),
},
});
const result = await t.apply(env("stringify", "hi"), ctx);
expect(result?.payload).toBe("HI");
});
it("returning undefined leaves the envelope unchanged", async () => {
const t = new TransformationMiddleware({
transforms: {
noop: () => undefined,
},
});
const e = env("noop", "x");
expect(await t.apply(e, ctx)).toBe(e);
});
it("returning null drops the message", async () => {
const t = new TransformationMiddleware({
transforms: {
drop: () => null,
},
});
expect(await t.apply(env("drop", "x"), ctx)).toBeNull();
});
it("falls back to defaultTransform when no per-type entry matches", async () => {
const t = new TransformationMiddleware({
defaultTransform: (e: MessageEnvelope) => ({
...e,
headers: { ...e.headers, "x-default": "yes" },
}),
});
const result = await t.apply(env("any", "x"), ctx);
expect(result?.headers["x-default"]).toBe("yes");
});
it("supports async transforms", async () => {
const t = new TransformationMiddleware({
transforms: {
async: async (e) => ({
...e,
payload: `async:${String(e.payload)}`,
}),
},
});
const result = await t.apply(env("async", "x"), ctx);
expect(result?.payload).toBe("async:x");
});
});

View File

@@ -0,0 +1,56 @@
# Chain types — `@suaveplan/network-plugin-middleware`
## Purpose
Shapes the manager uses to report what happened during a chain run and to configure how it reacts when a middleware throws. Both shapes live here (rather than in the manager module) so consumers — bus subscribers, telemetry adapters, custom dispatchers — can import the types without pulling in the manager runtime.
## Features
- `ErrorPolicy` closed union (`continue`, `stop`, `retry`) covering the three meaningful strategies; `ErrorPolicyConfig` carries the optional `maxRetries` knob.
- `ChainResult` includes `dropped`, the resulting envelope (or `null`), the ordered list of middleware ids that ran, and an optional `droppedBy` id so callers can report which middleware refused the message.
## Usage: Basic Example
```ts
import type {
ChainResult,
ErrorPolicyConfig,
} from "@suaveplan/network-plugin-middleware";
const policy: ErrorPolicyConfig = { policy: "retry", maxRetries: 3 };
declare const result: ChainResult;
if (result.dropped) {
console.warn("message dropped by", result.droppedBy);
}
```
## Usage: Advanced Example
```ts
import type {
ChainResult,
ErrorPolicy,
} from "@suaveplan/network-plugin-middleware";
const fallback: ErrorPolicy = "continue";
declare const result: ChainResult;
console.table({
dropped: result.dropped,
droppedBy: result.droppedBy ?? "—",
executed: result.executed.join(" → "),
});
```
## API Reference
- `type ErrorPolicy` — `"continue" | "stop" | "retry"`.
- `interface ErrorPolicyConfig` — `{ policy, maxRetries? }`.
- `interface ChainResult` — `{ dropped, envelope, executed, droppedBy? }`.
## Implementation Notes
- `ChainResult.envelope` is typed `MessageEnvelope | null` rather than `MessageEnvelope?` so consumers cannot accidentally dispatch a dropped envelope by treating `undefined` as "no change".
- `executed` is `ReadonlyArray<string>` so consumers cannot mutate the audit trail. The manager builds the array per dispatch and freezes the result before returning.
- `ErrorPolicyConfig.maxRetries` is only honoured under `policy: "retry"` so the policy / knobs stay grouped — callers that misconfigure another policy with a `maxRetries` value get no surprises.
- These types do not include retry attempt counts: the goal of `ChainResult` is *what happened*, not *how it got there*. Adding finer-grained instrumentation belongs in `metrics-middleware` so callers do not pay the cost when they do not opt in.

View File

@@ -0,0 +1,36 @@
import type { MessageEnvelope } from "@suaveplan/network";
/**
* Strategy for handling middleware errors. Default is `"stop"`.
*
* - `continue`: log the error and pass the un-mutated envelope to the
* next middleware. Use for non-critical observability middleware.
* - `stop`: drop the message. Use for any middleware whose failure
* means the message MUST NOT proceed (validation, security).
* - `retry`: re-invoke the throwing middleware up to `maxRetries`
* times before falling back to `stop`. Use for flaky external
* dependencies.
*/
export type ErrorPolicy = "continue" | "stop" | "retry";
export interface ErrorPolicyConfig {
readonly policy: ErrorPolicy;
/** Only honoured when `policy === "retry"`. Defaults to 1. */
readonly maxRetries?: number;
}
/**
* Outcome of a single middleware-chain run. The manager returns this
* so callers can branch on `dropped: true` (skip downstream send) and
* inspect which middlewares modified the envelope.
*/
export interface ChainResult {
/** True when a middleware returned `null`. */
readonly dropped: boolean;
/** The envelope after the chain ran (`null` when dropped). */
readonly envelope: MessageEnvelope | null;
/** Ids of middlewares that ran (in execution order). */
readonly executed: ReadonlyArray<string>;
/** Optional id of the middleware that dropped the message. */
readonly droppedBy?: string;
}

View File

@@ -0,0 +1,69 @@
# Middleware types — `@suaveplan/network-plugin-middleware`
## Purpose
Shared structural types for the middleware chain (`NET-41`). The contract a middleware author satisfies is a single function (`apply(envelope, context)`) returning the next envelope, `null` to drop, or a Promise of either; all of the bookkeeping (priority bands, direction filtering, enable/disable) lives in the manager so middleware classes stay narrow.
## Features
- `Middleware` — minimal callable interface every concrete middleware satisfies.
- `MiddlewareContext` — carries direction + per-dispatch `state` scratch so middlewares can pass derived values downstream without mutating envelope headers.
- `ChainDirection` — closed `"incoming" | "outgoing"` union so dual-direction logic stays exhaustively type-checked.
- `MiddlewarePriority` — closed band union (`CRITICAL`, `HIGH`, `NORMAL`, `LOW`, `BACKGROUND`) for callers that prefer the enum to a raw number.
## Usage: Basic Example
```ts
import type {
Middleware,
MiddlewareContext,
} from "@suaveplan/network-plugin-middleware";
import type { MessageEnvelope } from "@suaveplan/network";
const tagger: Middleware = {
id: "header-tagger",
priority: 250,
apply(envelope: MessageEnvelope, _ctx: MiddlewareContext) {
return {
...envelope,
headers: { ...envelope.headers, "x-tagged": "yes" },
};
},
};
```
## Usage: Advanced Example
```ts
import type {
Middleware,
MiddlewareContext,
} from "@suaveplan/network-plugin-middleware";
import type { MessageEnvelope } from "@suaveplan/network";
const inboundOnlyDropper: Middleware = {
id: "drop-debug",
priority: 350,
direction: "incoming",
enabled: process.env.NODE_ENV === "production",
async apply(env: MessageEnvelope, ctx: MiddlewareContext) {
ctx.state["sawDebug"] = env.type.startsWith("debug:");
return ctx.state["sawDebug"] === true ? null : env;
},
};
```
## API Reference
- `interface Middleware` — `{ id, priority, direction?, enabled?, apply(envelope, context) }` — `apply` MAY be sync or async.
- `interface MiddlewareContext` — `{ direction, state }`.
- `type ChainDirection` — `"incoming" | "outgoing"`.
- `type MiddlewarePriority` — `"CRITICAL" | "HIGH" | "NORMAL" | "LOW" | "BACKGROUND"`.
## Implementation Notes
- `priority` is a raw `number` rather than the band enum so callers can position middleware *within* a band (e.g. `120` vs `180` both inside HIGH). The manager exposes `priorityForBand` to convert when callers prefer the enum.
- `direction` is optional: middleware that sets neither runs in both directions. Filtering happens once at registration time so the per-dispatch loop stays branchless.
- `enabled` defaults to `true`. The manager respects this on every dispatch so middlewares can flip on/off at runtime without re-registering.
- `apply` MAY return a Promise; the manager always awaits, even for sync middlewares, so the chain order is preserved across async boundaries. Synchronous middlewares pay one micro-task per call, which is negligible compared to network RTT.
- `state` is a plain object rather than a `Map` so JSON-friendly observability adapters can serialise it for tracing.

View File

@@ -0,0 +1,47 @@
import type { MessageEnvelope } from "@suaveplan/network";
/**
* Direction the chain is currently processing. Each middleware MAY
* elect to act on only one direction.
*/
export type ChainDirection = "incoming" | "outgoing";
/**
* Five priority bands the manager honours when ordering middleware.
* Numeric ranges follow the spec: CRITICAL 0–99, HIGH 100–199,
* NORMAL 200–299, LOW 300–399, BACKGROUND 400+.
*/
export type MiddlewarePriority =
| "CRITICAL"
| "HIGH"
| "NORMAL"
| "LOW"
| "BACKGROUND";
/**
* Context passed to every middleware. Carries the live envelope plus
* direction and an arbitrary `state` bag the chain creates per
* dispatch so middlewares can pass scratch data downstream without
* mutating the envelope.
*/
export interface MiddlewareContext {
readonly direction: ChainDirection;
readonly state: Record<string, unknown>;
}
/**
* Pure middleware contract. The manager calls `apply(envelope,
* context)` for every registered middleware that is enabled and
* matches the current direction. Returning `null` drops the message
* for the rest of the chain.
*/
export interface Middleware {
readonly id: string;
readonly priority: number;
readonly direction?: ChainDirection | undefined;
readonly enabled?: boolean | undefined;
apply(
envelope: MessageEnvelope,
context: MiddlewareContext
): MessageEnvelope | null | Promise<MessageEnvelope | null>;
}

View File

@@ -0,0 +1,81 @@
# `ValidationMiddleware`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Schema-based payload validation for inbound and outbound messages. Per-`MessageType` Zod schemas describe the expected shape; messages whose type has no entry pass through (or hit the optional `defaultSchema`). Two modes — `strict` (default) raises `VALIDATION_FAILED` so the manager's `error-policy: "stop"` rejects the envelope; `lenient` logs a structured warning and forwards the envelope unchanged.
## Features
- Per-`MessageType` schema map, plus optional `defaultSchema` for catch-all enforcement.
- Strict / lenient modes covering both "block bad messages" and "observe-only deployment" stories.
- Lenient mode emits structured warnings (`{ messageId, messageType, issues[] }`) to a pluggable logger so deployments wire whatever sink they prefer.
- Lives in CRITICAL band by default (`priority: 50`) so validation runs before any transformation, security, or metrics middleware.
## Usage: Basic Example
```ts
import z from "zod";
import {
ValidationMiddleware,
MiddlewareManager,
} from "@suaveplan/network-plugin-middleware";
const manager = new MiddlewareManager();
manager.register(
new ValidationMiddleware({
schemas: {
"registry:add": z.object({ peerId: z.string().min(1) }),
},
})
);
await manager.dispatch(envelope, "incoming");
```
## Usage: Advanced Example
```ts
import z from "zod";
import {
ValidationMiddleware,
type ValidationLogger,
} from "@suaveplan/network-plugin-middleware";
const warnings: unknown[] = [];
const logger: ValidationLogger = {
warn: (w) => warnings.push(w),
};
const validator = new ValidationMiddleware({
id: "lenient-validator",
priority: 60,
mode: "lenient",
logger,
schemas: {
"registry:add": z.object({ peerId: z.string().uuid() }),
},
defaultSchema: z
.object({})
.passthrough()
.refine((p) => Object.keys(p).length > 0, "non-empty payload required"),
});
```
## API Reference
- `class ValidationMiddleware implements Middleware`
- `constructor(config?: ValidationMiddlewareConfig)`.
- `apply(envelope, context): MessageEnvelope` — throws `VALIDATION_FAILED` (strict) or warns (lenient).
- `interface ValidationMiddlewareConfig` — `{ id?, priority?, direction?, mode?, logger?, schemas?, defaultSchema? }`.
- `type ValidationMode` — `"strict" | "lenient"`.
- `interface ValidationWarning` — `{ messageId, messageType, issues }`.
- `interface ValidationLogger` — `{ warn(warning): void }`.
## Implementation Notes
- Validation uses `safeParse` so a thrown Zod error never escapes the middleware. Strict mode wraps the issues into `NetworkMwError(VALIDATION_FAILED)` so the manager's error policy controls what happens next; lenient mode forwards unchanged so callers see _all_ validation issues over time, not just the first.
- Messages without a schema pass through silently. The `defaultSchema` knob lets callers reverse this default for "deny by default" deployments without registering a schema per message type.
- The middleware does NOT modify the envelope. Schemas that _transform_ payloads should compose with `TransformationMiddleware` downstream — splitting concerns keeps the validator's behaviour predictable for audit reviews.
- The default priority (`50`) places the validator inside the CRITICAL band so it runs before any other middleware. Override via the `priority` knob when validation is informational rather than gating.

View File

@@ -0,0 +1,93 @@
import type { MessageEnvelope } from "@suaveplan/network";
import type { ZodType } from "zod";
import { NETWORK_MW_CODES, NetworkMwError } from "../error-codes.js";
import type {
Middleware,
MiddlewareContext,
} from "../types/middleware.types.js";
export type ValidationMode = "strict" | "lenient";
export interface ValidationWarning {
readonly messageId: string;
readonly messageType: string;
readonly issues: ReadonlyArray<string>;
}
export interface ValidationLogger {
warn(warning: ValidationWarning): void;
}
export interface ValidationMiddlewareConfig {
readonly id?: string;
readonly priority?: number;
readonly direction?: MiddlewareContext["direction"];
readonly mode?: ValidationMode;
/** Optional logger for lenient warnings. */
readonly logger?: ValidationLogger;
/** Per-MessageType Zod schemas that the payload must satisfy. */
readonly schemas?: Readonly<Record<string, ZodType>>;
/** Schema applied to messages whose type has no entry in `schemas`. */
readonly defaultSchema?: ZodType;
}
/**
* Validates `envelope.payload` against a per-`MessageType` Zod schema.
*
* - `strict` (default): throw `VALIDATION_FAILED` on the first invalid
* payload. Combined with the manager's `error-policy: "stop"`, the
* chain rejects the envelope.
* - `lenient`: log a warning via the supplied `logger` and forward
* the un-mutated envelope so observability sees the issue without
* blocking traffic.
*/
export class ValidationMiddleware implements Middleware {
public readonly id: string;
public readonly priority: number;
public readonly direction: MiddlewareContext["direction"] | undefined;
public enabled = true;
private readonly mode: ValidationMode;
private readonly logger: ValidationLogger | undefined;
private readonly schemas: Readonly<Record<string, ZodType>>;
private readonly defaultSchema: ZodType | undefined;
public constructor(config: ValidationMiddlewareConfig = {}) {
this.id = config.id ?? "validator";
this.priority = config.priority ?? 50;
this.direction = config.direction;
this.mode = config.mode ?? "strict";
this.logger = config.logger;
this.schemas = config.schemas ?? {};
this.defaultSchema = config.defaultSchema;
}
public apply(
envelope: MessageEnvelope,
_context: MiddlewareContext
): MessageEnvelope {
const schema = this.schemas[envelope.type] ?? this.defaultSchema;
if (schema === undefined) return envelope;
const result = schema.safeParse(envelope.payload);
if (result.success) return envelope;
const issues = result.error.issues.map((issue) => issue.message);
if (this.mode === "lenient") {
this.logger?.warn({
messageId: envelope.id,
messageType: envelope.type,
issues,
});
return envelope;
}
throw new NetworkMwError(
NETWORK_MW_CODES.VALIDATION_FAILED,
"validation.payload-rejected",
{
context: {
messageId: envelope.id,
messageType: envelope.type,
issues,
},
}
);
}
}

View File

@@ -0,0 +1,97 @@
import { createEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import z from "zod";
import { NETWORK_MW_CODES, type NetworkMwError } from "../error-codes.js";
import {
type ValidationLogger,
ValidationMiddleware,
} from "./validation-middleware.js";
const env = (type: string, payload: unknown) =>
createEnvelope({ type, senderId: "peer-1", payload });
const ctx = {
direction: "incoming" as const,
state: {} as Record<string, unknown>,
};
describe("ValidationMiddleware: schema validation, strict / lenient", () => {
it("passes through when no schema is registered for the type", () => {
const v = new ValidationMiddleware({});
const e = env("unknown:type", { any: "thing" });
expect(v.apply(e, ctx)).toBe(e);
});
it("strict mode throws VALIDATION_FAILED on bad payload", () => {
const v = new ValidationMiddleware({
schemas: {
"registry:add": z.object({ peerId: z.string().min(1) }),
},
});
try {
v.apply(env("registry:add", { peerId: "" }), ctx);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.VALIDATION_FAILED
);
}
});
it("strict mode passes on a valid payload", () => {
const v = new ValidationMiddleware({
schemas: {
"registry:add": z.object({ peerId: z.string().min(1) }),
},
});
const e = env("registry:add", { peerId: "abc" });
expect(v.apply(e, ctx)).toBe(e);
});
it("lenient mode warns and forwards on bad payload", () => {
const warnings: unknown[] = [];
const logger: ValidationLogger = {
warn: (w) => warnings.push(w),
};
const v = new ValidationMiddleware({
mode: "lenient",
logger,
schemas: {
"registry:add": z.object({ peerId: z.string().min(1) }),
},
});
const e = env("registry:add", { peerId: "" });
expect(v.apply(e, ctx)).toBe(e);
expect(warnings.length).toBe(1);
});
it("defaultSchema applies when no per-type schema matches", () => {
const v = new ValidationMiddleware({
defaultSchema: z.object({ ok: z.literal(true) }),
});
try {
v.apply(env("any:type", { ok: false }), ctx);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.VALIDATION_FAILED
);
}
});
it("lenient mode does not require a logger (warning is swallowed)", () => {
const v = new ValidationMiddleware({
mode: "lenient",
schemas: {
"registry:add": z.object({ peerId: z.string().min(1) }),
},
});
expect(() => v.apply(env("registry:add", {}), ctx)).not.toThrow();
});
it("default-config constructor (no args) passes through any payload", () => {
const v = new ValidationMiddleware();
const e = env("any:type", { x: 1 });
expect(v.apply(e, ctx)).toBe(e);
});
});