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,125 @@
# @suaveplan/network-plugin-server-bridge - Tier-0 host ↔ central-server bridge for the Suaveplan mesh: ServerGateway (transport-agnostic, 2× heartbeat disconnect detection), GracefulDegradationFsm (online → degrading → offline → recovering → online), OfflineModeCoordinator (autonomous mode + token-grace extension), LogBuffer (circular signed-log replay queue), ReconciliationProtocol (server-watermark-driven differential with LWW conflict resolution), CommandDispatcher (Ed25519 signature + nonce-replay protection), HeartbeatMultiplexer (Tier-0 batch forwarding), four hosting-model coordinators (PURE_CLIENT, SERVER_BASED, SERVER_TO_CLIENT_FAILOVER, CLIENT_WITH_SERVER_BACKUP), and ServerBridgePlugin facade with tier gate.
## Summary
Tier-0 host ↔ central-server bridge for the Suaveplan mesh.
`ServerBridgePlugin` ties together every subsystem the bridge needs:
- **`ServerGateway`** — transport-agnostic byte channel with a heartbeat loop and 2× heartbeat disconnect detection.
- **`GracefulDegradationFsm`** — five-state FSM (`online → degrading → offline → recovering → online`) with strict transition validation.
- **`OfflineModeCoordinator`** — switches mesh to autonomous mode on disconnect, extends token grace, blocks new sessions, emits `server:offline` / `server:online`.
- **`LogBuffer`** — circular replay queue for signed activity logs with `entryHash` dedup, `seq`-ordered drain, partial-progress cursor.
- **`ReconciliationProtocol`** — server-watermark-driven differential with last-writer-wins conflict resolution (host wins ties).
- **`CommandDispatcher`** — Ed25519 signature validation (verifier injected), nonce + maxAge replay protection, dispatch to per-type handlers and `command:<type>` bus topics.
- **`HeartbeatMultiplexer`** — Tier-0 batches per-peer heartbeats and forwards aggregated batches upstream so peers don't each open a server WebSocket.
- **Hosting models** — four coordinators (`PureClientCoordinator`, `ServerBasedCoordinator`, `FailoverCoordinator`, `BackupCoordinator`) implementing `PURE_CLIENT`, `SERVER_BASED`, `SERVER_TO_CLIENT_FAILOVER`, `CLIENT_WITH_SERVER_BACKUP` per `NET-56`.
<!-- begin:auto-docs -->
## Documentation tree
- [buffer](./src/buffer/log-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [log-buffer](./src/buffer/log-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [command](./src/command/command-dispatcher.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [command-dispatcher](./src/command/command-dispatcher.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes/error-codes.md) — Frozen `NET_SB` error registry for `@suaveplan/network-plugin-server-bridge`. Every failure path the server bridge can…
- [error-codes](./src/error-codes/error-codes.md) — Frozen `NET_SB` error registry for `@suaveplan/network-plugin-server-bridge`. Every failure path the server bridge can…
- [fsm](./src/fsm/graceful-degradation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [graceful-degradation-fsm](./src/fsm/graceful-degradation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [gateway](./src/gateway/server-gateway.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [server-gateway](./src/gateway/server-gateway.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [backup-coordinator](./src/hosting/backup-coordinator.md) — `BackupCoordinator` implements the `CLIENT_WITH_SERVER_BACKUP` model: clients host normally and the server is a backup…
- [failover-coordinator](./src/hosting/failover-coordinator.md) — `FailoverCoordinator` implements the `SERVER_TO_CLIENT_FAILOVER` model — the proposal's current default. The server is…
- [hosting-model-config](./src/hosting/hosting-model-config.md) — `hostingModelConfig(model)` is the single accessor for the four hosting-model presets the proposal documents (`PURE_CLI…
- [pure-client-coordinator](./src/hosting/pure-client-coordinator.md) — `PureClientCoordinator` implements the `PURE_CLIENT` hosting model: no central server is in the picture, and token issu…
- [server-based-coordinator](./src/hosting/server-based-coordinator.md) — `ServerBasedCoordinator` implements the `SERVER_BASED` hosting model: the server is always the top-tier host, clients n…
- [multiplexer](./src/multiplexer/heartbeat-multiplexer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [heartbeat-multiplexer](./src/multiplexer/heartbeat-multiplexer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [offline](./src/offline/offline-mode-coordinator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [offline-mode-coordinator](./src/offline/offline-mode-coordinator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/server-bridge-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [server-bridge-plugin](./src/plugin/server-bridge-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [reconciliation](./src/reconciliation/reconciliation-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [reconciliation-protocol](./src/reconciliation/reconciliation-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [log-buffer](./src/buffer/log-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [command](./src/command/command-dispatcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [command-dispatcher](./src/command/command-dispatcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [fsm](./src/fsm/graceful-degradation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_- [graceful-degradation-fsm](./src/fsm/graceful-degradation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_- [gateway](./src/gateway/server-gateway.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-gateway](./src/gateway/server-gateway.md) — <!-- begin:auto-docs --> _(no submodules)_- [backup-coordinator](./src/hosting/backup-coordinator.md) — `BackupCoordinator` implements the `CLIENT_WITH_SERVER_BACKUP` model: clients host normally and the server is a backup…
- [failover-coordinator](./src/hosting/failover-coordinator.md) — `FailoverCoordinator` implements the `SERVER_TO_CLIENT_FAILOVER` model — the proposal's current default. The server is…
- [hosting-model-config](./src/hosting/hosting-model-config.md) — `hostingModelConfig(model)` is the single accessor for the four hosting-model presets the proposal documents (`PURE_CLI…
- [pure-client-coordinator](./src/hosting/pure-client-coordinator.md) — `PureClientCoordinator` implements the `PURE_CLIENT` hosting model: no central server is in the picture, and token issu…
- [server-based-coordinator](./src/hosting/server-based-coordinator.md) — `ServerBasedCoordinator` implements the `SERVER_BASED` hosting model: the server is always the top-tier host, clients n…
- [multiplexer](./src/multiplexer/heartbeat-multiplexer.md) — <!-- begin:auto-docs --> _(no submodules)_- [heartbeat-multiplexer](./src/multiplexer/heartbeat-multiplexer.md) — <!-- begin:auto-docs --> _(no submodules)_- [offline](./src/offline/offline-mode-coordinator.md) — <!-- begin:auto-docs --> _(no submodules)_- [offline-mode-coordinator](./src/offline/offline-mode-coordinator.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/server-bridge-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-bridge-plugin](./src/plugin/server-bridge-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciliation](./src/reconciliation/reconciliation-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciliation-protocol](./src/reconciliation/reconciliation-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_SB` error registry for `@suaveplan/network-plugin-server-bridge`. Every failure path the server bridge can…
- [log-buffer](./src/buffer/log-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [command](./src/command/command-dispatcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [command-dispatcher](./src/command/command-dispatcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [fsm](./src/fsm/graceful-degradation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_- [graceful-degradation-fsm](./src/fsm/graceful-degradation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_- [gateway](./src/gateway/server-gateway.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-gateway](./src/gateway/server-gateway.md) — <!-- begin:auto-docs --> _(no submodules)_- [backup-coordinator](./src/hosting/backup-coordinator.md) — `BackupCoordinator` implements the `CLIENT_WITH_SERVER_BACKUP` model: clients host normally and the server is a backup…
- [failover-coordinator](./src/hosting/failover-coordinator.md) — `FailoverCoordinator` implements the `SERVER_TO_CLIENT_FAILOVER` model — the proposal's current default. The server is…
- [hosting-model-config](./src/hosting/hosting-model-config.md) — `hostingModelConfig(model)` is the single accessor for the four hosting-model presets the proposal documents (`PURE_CLI…
- [pure-client-coordinator](./src/hosting/pure-client-coordinator.md) — `PureClientCoordinator` implements the `PURE_CLIENT` hosting model: no central server is in the picture, and token issu…
- [server-based-coordinator](./src/hosting/server-based-coordinator.md) — `ServerBasedCoordinator` implements the `SERVER_BASED` hosting model: the server is always the top-tier host, clients n…
- [multiplexer](./src/multiplexer/heartbeat-multiplexer.md) — <!-- begin:auto-docs --> _(no submodules)_- [heartbeat-multiplexer](./src/multiplexer/heartbeat-multiplexer.md) — <!-- begin:auto-docs --> _(no submodules)_- [offline](./src/offline/offline-mode-coordinator.md) — <!-- begin:auto-docs --> _(no submodules)_- [offline-mode-coordinator](./src/offline/offline-mode-coordinator.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/server-bridge-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-bridge-plugin](./src/plugin/server-bridge-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciliation](./src/reconciliation/reconciliation-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciliation-protocol](./src/reconciliation/reconciliation-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_SB` error registry for `@suaveplan/network-plugin-server-bridge`. Every failure path the server bridge can…
- [log-buffer](./src/buffer/log-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [command](./src/command/command-dispatcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [command-dispatcher](./src/command/command-dispatcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [fsm](./src/fsm/graceful-degradation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_- [graceful-degradation-fsm](./src/fsm/graceful-degradation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_- [gateway](./src/gateway/server-gateway.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-gateway](./src/gateway/server-gateway.md) — <!-- begin:auto-docs --> _(no submodules)_- [backup-coordinator](./src/hosting/backup-coordinator.md) — `BackupCoordinator` implements the `CLIENT_WITH_SERVER_BACKUP` model: clients host normally and the server is a backup…
- [failover-coordinator](./src/hosting/failover-coordinator.md) — `FailoverCoordinator` implements the `SERVER_TO_CLIENT_FAILOVER` model — the proposal's current default. The server is…
- [hosting-model-config](./src/hosting/hosting-model-config.md) — `hostingModelConfig(model)` is the single accessor for the four hosting-model presets the proposal documents (`PURE_CLI…
- [pure-client-coordinator](./src/hosting/pure-client-coordinator.md) — `PureClientCoordinator` implements the `PURE_CLIENT` hosting model: no central server is in the picture, and token issu…
- [server-based-coordinator](./src/hosting/server-based-coordinator.md) — `ServerBasedCoordinator` implements the `SERVER_BASED` hosting model: the server is always the top-tier host, clients n…
- [multiplexer](./src/multiplexer/heartbeat-multiplexer.md) — <!-- begin:auto-docs --> _(no submodules)_- [heartbeat-multiplexer](./src/multiplexer/heartbeat-multiplexer.md) — <!-- begin:auto-docs --> _(no submodules)_- [offline](./src/offline/offline-mode-coordinator.md) — <!-- begin:auto-docs --> _(no submodules)_- [offline-mode-coordinator](./src/offline/offline-mode-coordinator.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/server-bridge-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-bridge-plugin](./src/plugin/server-bridge-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciliation](./src/reconciliation/reconciliation-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciliation-protocol](./src/reconciliation/reconciliation-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_SB` error registry for `@suaveplan/network-plugin-server-bridge`. Every failure path the server bridge can…
- [log-buffer](./src/buffer/log-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [command](./src/command/command-dispatcher.md) — <!-- begin:auto-docs --> _(no submodules)_ - [command-dispatcher](./src/command/command-dispatcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [fsm](./src/fsm/graceful-degradation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_ - [graceful-degradation-fsm](./src/fsm/graceful-degradation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_- [gateway](./src/gateway/server-gateway.md) — <!-- begin:auto-docs --> _(no submodules)_ - [server-gateway](./src/gateway/server-gateway.md) — <!-- begin:auto-docs --> _(no submodules)_- [backup-coordinator](./src/hosting/backup-coordinator.md) — `BackupCoordinator` implements the `CLIENT_WITH_SERVER_BACKUP` model: clients host normally and the server is a backup…
- [failover-coordinator](./src/hosting/failover-coordinator.md) — `FailoverCoordinator` implements the `SERVER_TO_CLIENT_FAILOVER` model — the proposal's current default. The server is…
- [hosting-model-config](./src/hosting/hosting-model-config.md) — `hostingModelConfig(model)` is the single accessor for the four hosting-model presets the proposal documents (`PURE_CLI…
- [pure-client-coordinator](./src/hosting/pure-client-coordinator.md) — `PureClientCoordinator` implements the `PURE_CLIENT` hosting model: no central server is in the picture, and token issu…
- [server-based-coordinator](./src/hosting/server-based-coordinator.md) — `ServerBasedCoordinator` implements the `SERVER_BASED` hosting model: the server is always the top-tier host, clients n…
- [multiplexer](./src/multiplexer/heartbeat-multiplexer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [heartbeat-multiplexer](./src/multiplexer/heartbeat-multiplexer.md) — <!-- begin:auto-docs --> _(no submodules)_- [offline](./src/offline/offline-mode-coordinator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [offline-mode-coordinator](./src/offline/offline-mode-coordinator.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/server-bridge-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [server-bridge-plugin](./src/plugin/server-bridge-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciliation](./src/reconciliation/reconciliation-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_ - [reconciliation-protocol](./src/reconciliation/reconciliation-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_SB` error registry for `@suaveplan/network-plugin-server-bridge`. Every failure path the server bridge can…
## Install
```bash
bun add @suaveplan/network-plugin-server-bridge
```
## Quick start
```ts
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import {
FailoverCoordinator,
ServerBridgePlugin,
} from "@suaveplan/network-plugin-server-bridge";
const node = new NetworkNode({ id: "host-1", networkBus: new NetworkBus() });
const plugin = new ServerBridgePlugin({
tierGate: () => topology.localTier() === 0,
transportFactory: () => openWebSocket(serverUrl),
heartbeatIntervalMs: 1_000,
bufferCapacity: 10_000,
tokenGraceMs: 30_000,
extendedTokenGraceMs: 5 * 60_000,
verifyCommand: (canon, sig) => ed25519Verify(sig, canon, serverPubKey),
currentMeshEpoch: () => meshEpoch.current(),
sessionAttributor: (entry) => extractSession(entry),
hostingCoordinator: new FailoverCoordinator({
serverAvailable: () => gateway.connectionState() === "open",
mintToken: (req) => sign(req),
}),
heartbeatBatchWindowMs: 500,
heartbeatSink: async (batch) => gateway.send(encode(batch)),
});
node.use(plugin);
await node.start();
```
## Modules
- [`error-codes`](./src/error-codes.md) — `NETWORK_SB_CODES` registry + `NetworkSbError`.
- [`fsm/graceful-degradation-fsm`](./src/fsm/graceful-degradation-fsm.md) — five-state bridge FSM.
- [`gateway/server-gateway`](./src/gateway/server-gateway.md) — heartbeat loop + 2× disconnect detection.
- [`gateway/server-gateway.types`](./src/gateway/server-gateway.types.md) — gateway shapes.
- [`offline/offline-mode-coordinator`](./src/offline/offline-mode-coordinator.md) — autonomous mode + grace extension.
- [`buffer/log-buffer`](./src/buffer/log-buffer.md) — circular replay queue.
- [`reconciliation/reconciliation-protocol`](./src/reconciliation/reconciliation-protocol.md) — diff + LWW conflict.
- [`reconciliation/reconciliation-protocol.types`](./src/reconciliation/reconciliation-protocol.types.md) — reconciliation shapes.
- [`command/command-dispatcher`](./src/command/command-dispatcher.md) — signed-command pipeline.
- [`command/command-dispatcher.types`](./src/command/command-dispatcher.types.md) — dispatcher shapes.
- [`multiplexer/heartbeat-multiplexer`](./src/multiplexer/heartbeat-multiplexer.md) — Tier-0 batch forwarding.
- [`hosting/hosting-model-config`](./src/hosting/hosting-model-config.md) — four-model preset accessor.
- [`hosting/pure-client-coordinator`](./src/hosting/pure-client-coordinator.md) — Byzantine quorum.
- [`hosting/server-based-coordinator`](./src/hosting/server-based-coordinator.md) — server-only authority.
- [`hosting/failover-coordinator`](./src/hosting/failover-coordinator.md) — current default.
- [`hosting/backup-coordinator`](./src/hosting/backup-coordinator.md) — client-primary, server-backup.
- [`plugin/server-bridge-plugin`](./src/plugin/server-bridge-plugin.md) — facade satisfying `NetworkPlugin`.

View File

@@ -0,0 +1,91 @@
{
"name": "@suaveplan/network-plugin-server-bridge",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Tier-0 host ↔ central-server bridge for the Suaveplan mesh: ServerGateway (transport-agnostic, 2× heartbeat disconnect detection), GracefulDegradationFsm (online → degrading → offline → recovering → online), OfflineModeCoordinator (autonomous mode + token-grace extension), LogBuffer (circular signed-log replay queue), ReconciliationProtocol (server-watermark-driven differential with LWW conflict resolution), CommandDispatcher (Ed25519 signature + nonce-replay protection), HeartbeatMultiplexer (Tier-0 batch forwarding), four hosting-model coordinators (PURE_CLIENT, SERVER_BASED, SERVER_TO_CLIENT_FAILOVER, CLIENT_WITH_SERVER_BACKUP), and ServerBridgePlugin facade with tier gate.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./error-codes": {
"import": { "types": "./dist/error-codes/index.d.ts", "default": "./dist/error-codes/index.js" }
},
"./buffer/log-buffer": {
"import": { "types": "./dist/buffer/log-buffer.d.ts", "default": "./dist/buffer/log-buffer.js" }
},
"./command/command-dispatcher": {
"import": { "types": "./dist/command/command-dispatcher.d.ts", "default": "./dist/command/command-dispatcher.js" }
},
"./fsm/graceful-degradation-fsm": {
"import": { "types": "./dist/fsm/graceful-degradation-fsm.d.ts", "default": "./dist/fsm/graceful-degradation-fsm.js" }
},
"./gateway/server-gateway": {
"import": { "types": "./dist/gateway/server-gateway.d.ts", "default": "./dist/gateway/server-gateway.js" }
},
"./hosting/hosting-model-config": {
"import": { "types": "./dist/hosting/hosting-model-config.d.ts", "default": "./dist/hosting/hosting-model-config.js" }
},
"./hosting/pure-client-coordinator": {
"import": { "types": "./dist/hosting/pure-client-coordinator.d.ts", "default": "./dist/hosting/pure-client-coordinator.js" }
},
"./hosting/server-based-coordinator": {
"import": { "types": "./dist/hosting/server-based-coordinator.d.ts", "default": "./dist/hosting/server-based-coordinator.js" }
},
"./hosting/failover-coordinator": {
"import": { "types": "./dist/hosting/failover-coordinator.d.ts", "default": "./dist/hosting/failover-coordinator.js" }
},
"./hosting/backup-coordinator": {
"import": { "types": "./dist/hosting/backup-coordinator.d.ts", "default": "./dist/hosting/backup-coordinator.js" }
},
"./multiplexer/heartbeat-multiplexer": {
"import": { "types": "./dist/multiplexer/heartbeat-multiplexer.d.ts", "default": "./dist/multiplexer/heartbeat-multiplexer.js" }
},
"./offline/offline-mode-coordinator": {
"import": { "types": "./dist/offline/offline-mode-coordinator.d.ts", "default": "./dist/offline/offline-mode-coordinator.js" }
},
"./plugin/server-bridge-plugin": {
"import": { "types": "./dist/plugin/server-bridge-plugin.d.ts", "default": "./dist/plugin/server-bridge-plugin.js" }
},
"./reconciliation/reconciliation-protocol": {
"import": { "types": "./dist/reconciliation/reconciliation-protocol.d.ts", "default": "./dist/reconciliation/reconciliation-protocol.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/network": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -0,0 +1,54 @@
/**
* NET-32 / NET-33 — log buffering, replay ordering, and dedup.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LogBuffer } from "./log-buffer.js";
describe("LogBuffer (spec conformance)", () => {
it("buffers signed activity logs", () => {
const b = new LogBuffer({ capacity: 100 });
b.append({
seq: 1,
entryHash: "abc",
bytes: new Uint8Array([1, 2, 3]),
});
expect(b.size()).toBe(1);
});
it("replays in monotonic seq order", async () => {
const b = new LogBuffer({ capacity: 100 });
b.append({ seq: 3, entryHash: "c", bytes: new Uint8Array([3]) });
b.append({ seq: 1, entryHash: "a", bytes: new Uint8Array([1]) });
b.append({ seq: 2, entryHash: "b", bytes: new Uint8Array([2]) });
const order: number[] = [];
await b.drain((e) => order.push(e.seq));
expect(order).toEqual([1, 2, 3]);
});
it("deduplicates by entryHash", () => {
const b = new LogBuffer({ capacity: 4 });
const dupHash = "same-hash";
const first = b.append({
seq: 1,
entryHash: dupHash,
bytes: new Uint8Array([1]),
});
const second = b.append({
seq: 1,
entryHash: dupHash,
bytes: new Uint8Array([1]),
});
expect(first).toBe(true);
expect(second).toBe(false);
expect(b.size()).toBe(1);
});
it("evicts oldest on overflow (circular semantics)", () => {
const b = new LogBuffer({ capacity: 2 });
b.append({ seq: 1, entryHash: "a", bytes: new Uint8Array([1]) });
b.append({ seq: 2, entryHash: "b", bytes: new Uint8Array([2]) });
b.append({ seq: 3, entryHash: "c", bytes: new Uint8Array([3]) });
expect(b.size()).toBe(2);
expect(b.has("a")).toBe(false);
});
});

View File

@@ -0,0 +1,5 @@
export {
LogBuffer,
type LogBufferAppend,
type ReplayHandler,
} from "./log-buffer.js";

View File

@@ -0,0 +1,80 @@
# log-buffer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`LogBuffer` is the in-memory circular replay queue that holds signed activity logs while the server is offline. On reconnect the gateway hands the buffer to the reconciliation protocol, which calls `drain(handler)` to replay the entries in monotonic `seq` order. The buffer is the local-only side of the offline-replay contract — durable cross-host replication of the same logs is the responsibility of `@suaveplan/network-plugin-audit-mesh`.
The two invariants the buffer enforces are **monotonic replay** and **content dedup**. Replay is by `seq` (so out-of-order appends from concurrent producers still surface in order), and dedup is by `entryHash` (so a redrive that re-appends an already-present entry is a no-op rather than a duplicate emission to the server).
## Features
- Configurable hard capacity; oldest entries evict on overflow (FIFO with hash-dedup).
- `entryHash`-keyed dedup (`Set<string>`); identical re-appends return `false` without mutation.
- Out-of-order appends are inserted via binary search so `peekAll()` is always seq-sorted.
- `drain(handler)` advances a partial-progress cursor; if the handler throws on entry N, the buffer reports `BUFFER_REPLAY_FAILED` carrying the failing seq, and entries `< N` are still in the queue (no mid-drain data loss).
- `ackThrough(seq)` lets a server that confirms asynchronously trim the head without going through `drain`.
- Injectable clock (`config.clock`) so `bufferedAt` is deterministic in tests.
## Usage: Basic Example
```ts
import { LogBuffer } from "@suaveplan/network-plugin-server-bridge";
const buffer = new LogBuffer({ capacity: 10_000 });
buffer.append({
seq: 1,
entryHash: "0xabc...",
bytes: signedLogBytes,
});
await buffer.drain(async (entry) => {
await gateway.send(entry.bytes);
});
```
## Usage: Advanced Example
```ts
import { LogBuffer } from "@suaveplan/network-plugin-server-bridge";
const buffer = new LogBuffer({
capacity: 50_000,
clock: () => fakeClock.now(),
});
// Server confirms receipt asynchronously
gateway.onAck((seq) => {
const trimmed = buffer.ackThrough(seq);
metrics.acked.add(trimmed);
});
// Partial drain — if handler fails, replay can resume later
try {
await buffer.drain((entry) => sender.send(entry));
} catch (err) {
if (err.code === "NET_SB.BUFFER_REPLAY_FAILED") {
// entries from err.context.seq onward remain pending
scheduleRetry();
}
}
```
## API Reference
- `new LogBuffer(config)` — `capacity` MUST be a positive integer; throws `NetworkSbError(BUFFER_CAPACITY_INVALID)` otherwise.
- `append(entry): boolean` — returns `true` for new entries, `false` for dedup hits. Throws `NetworkSbError(BUFFER_ENTRY_INVALID)` for negative/NaN seq, empty hash, or empty bytes.
- `drain(handler): Promise<number>` — replays pending entries in seq order, awaits each handler. Returns the count of successfully drained entries. Wraps handler failures in `NetworkSbError(BUFFER_REPLAY_FAILED)` with the failing seq in `context`.
- `ackThrough(seq): number` — drops entries with seq ≤ `seq`; returns the count removed.
- `pending(): readonly BufferedEntry[]` — entries past the current replay cursor.
- `peekAll(): readonly BufferedEntry[]` — every entry currently in the buffer.
- `size()` / `isEmpty()` / `has(entryHash)` / `clear()` — observability + reset.
- `LogBufferAppend`, `ReplayHandler` — companion types.
## Implementation Notes
- The dedup set is rebuilt on every eviction (delete-then-insert), so memory stays bounded at `O(capacity)` regardless of how many inserts have run.
- Eviction during a partial drain decrements the cursor when needed so the cursor stays valid relative to the surviving entries.
- The buffer is intentionally in-memory only. Crash safety requires `@suaveplan/network-plugin-audit-mesh`'s cross-host replication; pairing the two yields the durability the proposal calls for without coupling the server-bridge to a specific persistence backend.

View File

@@ -0,0 +1,210 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_SB_CODES,
NetworkSbError,
} from "../error-codes/error-codes.js";
import { LogBuffer } from "./log-buffer.js";
const ent = (seq: number, hash = `h${seq}`, byte = seq) => ({
seq,
entryHash: hash,
bytes: new Uint8Array([byte || 1]),
});
describe("LogBuffer", () => {
it("rejects non-positive capacity", () => {
expect(() => new LogBuffer({ capacity: 0 })).toThrow(NetworkSbError);
expect(() => new LogBuffer({ capacity: -1 })).toThrow(NetworkSbError);
expect(() => new LogBuffer({ capacity: 1.5 })).toThrow(NetworkSbError);
});
it("appends in order and reports size", () => {
const b = new LogBuffer({ capacity: 4 });
expect(b.isEmpty()).toBe(true);
b.append(ent(1));
b.append(ent(2));
expect(b.size()).toBe(2);
expect(b.isEmpty()).toBe(false);
});
it("dedups by entryHash", () => {
const b = new LogBuffer({ capacity: 4 });
expect(b.append(ent(1, "x"))).toBe(true);
expect(b.append(ent(1, "x"))).toBe(false);
expect(b.size()).toBe(1);
expect(b.has("x")).toBe(true);
});
it("rejects invalid entries", () => {
const b = new LogBuffer({ capacity: 4 });
expect(() =>
b.append({ seq: -1, entryHash: "x", bytes: new Uint8Array([1]) })
).toThrow(NetworkSbError);
expect(() =>
b.append({ seq: 1, entryHash: "", bytes: new Uint8Array([1]) })
).toThrow(NetworkSbError);
expect(() =>
b.append({ seq: 1, entryHash: "x", bytes: new Uint8Array() })
).toThrow(NetworkSbError);
expect(() =>
b.append({
seq: Number.NaN,
entryHash: "x",
bytes: new Uint8Array([1]),
})
).toThrow(NetworkSbError);
});
it("evicts oldest entry when at capacity", () => {
const b = new LogBuffer({ capacity: 3 });
b.append(ent(1));
b.append(ent(2));
b.append(ent(3));
b.append(ent(4));
expect(b.size()).toBe(3);
expect(b.peekAll().map((e) => e.seq)).toEqual([2, 3, 4]);
expect(b.has("h1")).toBe(false);
});
it("inserts out-of-order entries in seq order", () => {
const b = new LogBuffer({ capacity: 8 });
b.append(ent(1));
b.append(ent(3));
b.append(ent(2));
expect(b.peekAll().map((e) => e.seq)).toEqual([1, 2, 3]);
});
it("uses injected clock for bufferedAt", () => {
let t = 100;
const b = new LogBuffer({ capacity: 2, clock: () => t });
b.append(ent(1));
t = 200;
b.append(ent(2));
const all = b.peekAll();
expect(all[0]?.bufferedAt).toBe(100);
expect(all[1]?.bufferedAt).toBe(200);
});
it("drains entries in order through the handler", async () => {
const b = new LogBuffer({ capacity: 4 });
b.append(ent(1));
b.append(ent(2));
b.append(ent(3));
const seen: number[] = [];
const count = await b.drain((e) => {
seen.push(e.seq);
});
expect(count).toBe(3);
expect(seen).toEqual([1, 2, 3]);
expect(b.isEmpty()).toBe(true);
});
it("drain on empty buffer is a no-op", async () => {
const b = new LogBuffer({ capacity: 4 });
const count = await b.drain(() => undefined);
expect(count).toBe(0);
});
it("drain wraps handler failure in BUFFER_REPLAY_FAILED", async () => {
const b = new LogBuffer({ capacity: 4 });
b.append(ent(1));
b.append(ent(2));
await expect(
b.drain((e) => {
if (e.seq === 2) throw new Error("boom");
})
).rejects.toMatchObject({
code: NETWORK_SB_CODES.BUFFER_REPLAY_FAILED,
});
// The first entry was processed; the second still pending
expect(b.size()).toBe(2); // nothing cleared on failure
expect(b.pending().map((e) => e.seq)).toEqual([2]);
});
it("drain wraps non-Error throwables", async () => {
const b = new LogBuffer({ capacity: 4 });
b.append(ent(1));
const literal: unknown = "boom-literal";
await expect(
b.drain(() => {
throw literal;
})
).rejects.toMatchObject({
code: NETWORK_SB_CODES.BUFFER_REPLAY_FAILED,
});
});
it("clear empties the buffer and resets the cursor", () => {
const b = new LogBuffer({ capacity: 4 });
b.append(ent(1));
b.append(ent(2));
b.clear();
expect(b.size()).toBe(0);
expect(b.has("h1")).toBe(false);
});
it("ackThrough removes acked entries", () => {
const b = new LogBuffer({ capacity: 4 });
b.append(ent(1));
b.append(ent(2));
b.append(ent(3));
const acked = b.ackThrough(2);
expect(acked).toBe(2);
expect(b.peekAll().map((e) => e.seq)).toEqual([3]);
expect(b.has("h1")).toBe(false);
});
it("ackThrough is a no-op when seq is below the head", () => {
const b = new LogBuffer({ capacity: 4 });
b.append(ent(5));
const acked = b.ackThrough(2);
expect(acked).toBe(0);
expect(b.size()).toBe(1);
});
it("ackThrough decrements replayCursor", async () => {
const b = new LogBuffer({ capacity: 4 });
b.append(ent(1));
b.append(ent(2));
b.append(ent(3));
// partial drain: stop after seq=1
await expect(
b.drain((e) => {
if (e.seq === 2) throw new Error("stop");
})
).rejects.toBeInstanceOf(NetworkSbError);
expect(b.pending().map((e) => e.seq)).toEqual([2, 3]);
b.ackThrough(2);
expect(b.pending().map((e) => e.seq)).toEqual([3]);
});
it("ackThrough on empty buffer is a no-op", () => {
const b = new LogBuffer({ capacity: 4 });
const acked = b.ackThrough(99);
expect(acked).toBe(0);
});
it("drain on a buffer that completes leaves it empty", async () => {
const b = new LogBuffer({ capacity: 2 });
b.append(ent(1));
const count = await b.drain(() => undefined);
expect(count).toBe(1);
expect(b.size()).toBe(0);
});
it("eviction shifts replay cursor when needed", async () => {
const b = new LogBuffer({ capacity: 2 });
b.append(ent(1));
b.append(ent(2));
// partial drain
await expect(
b.drain((e) => {
if (e.seq === 2) throw new Error("x");
})
).rejects.toBeInstanceOf(NetworkSbError);
// cursor sits at index 1 (entry seq=2 still pending)
b.append(ent(3));
// The evict path runs and we still have valid pending state
expect(b.size()).toBe(2);
});
});

View File

@@ -0,0 +1,185 @@
import {
NETWORK_SB_CODES,
NetworkSbError,
} from "../error-codes/error-codes.js";
import type { BufferedEntry, LogBufferConfig } from "../types/buffer.types.js";
/**
* Append-shape used at insert time. `bufferedAt` is filled by the buffer
* from its injected clock so callers don't need to thread the clock
* through their own data path.
*/
export interface LogBufferAppend {
readonly seq: number;
readonly entryHash: string;
readonly bytes: Uint8Array;
}
export type ReplayHandler = (entry: BufferedEntry) => void | Promise<void>;
/**
* In-memory circular buffer of signed activity logs. Replay is in
* monotonic `seq` order; appending the same `entryHash` twice is a no-op
* (dedup key). When the capacity is exhausted, the oldest entry is
* evicted to make room.
*/
export class LogBuffer {
private readonly capacity: number;
private readonly clock: () => number;
private readonly entries: BufferedEntry[] = [];
private readonly hashes = new Set<string>();
private replayCursor = 0;
public constructor(config: LogBufferConfig) {
if (!Number.isInteger(config.capacity) || config.capacity <= 0) {
throw new NetworkSbError(
NETWORK_SB_CODES.BUFFER_CAPACITY_INVALID,
"sb.buffer.capacity_invalid",
{ context: { capacity: config.capacity } }
);
}
this.capacity = config.capacity;
this.clock = config.clock ?? (() => Date.now());
}
public size(): number {
return this.entries.length;
}
public isEmpty(): boolean {
return this.entries.length === 0;
}
public has(entryHash: string): boolean {
return this.hashes.has(entryHash);
}
public peekAll(): readonly BufferedEntry[] {
return [...this.entries];
}
public append(entry: LogBufferAppend): boolean {
if (
!Number.isFinite(entry.seq) ||
entry.seq < 0 ||
entry.entryHash.length === 0 ||
entry.bytes.length === 0
) {
throw new NetworkSbError(
NETWORK_SB_CODES.BUFFER_ENTRY_INVALID,
"sb.buffer.entry_invalid",
{
context: {
seq: entry.seq,
hashLen: entry.entryHash.length,
bytesLen: entry.bytes.length,
},
}
);
}
if (this.hashes.has(entry.entryHash)) return false;
if (this.entries.length >= this.capacity) {
// Length-guarded shift — capacity is always ≥ 1 so the head
// exists. Cast keeps the typed-narrowing without an extra
// unreachable branch the coverage tool would flag.
const evicted = this.entries.shift() as BufferedEntry;
this.hashes.delete(evicted.entryHash);
if (this.replayCursor > 0) this.replayCursor--;
}
const stored: BufferedEntry = {
seq: entry.seq,
entryHash: entry.entryHash,
bytes: entry.bytes,
bufferedAt: this.clock(),
};
this.insertSorted(stored);
this.hashes.add(entry.entryHash);
return true;
}
public clear(): void {
this.entries.length = 0;
this.hashes.clear();
this.replayCursor = 0;
}
/**
* Snapshot of entries that have not yet been acknowledged.
*/
public pending(): readonly BufferedEntry[] {
return this.entries.slice(this.replayCursor);
}
/**
* Replay every pending entry through the handler in seq order. After
* a successful drain the buffer is empty; if the handler throws on
* any entry the cursor stops at that entry and the rest stay
* pending. The wrapping `NetworkSbError(BUFFER_REPLAY_FAILED)` carries
* the failing seq.
*/
public async drain(handler: ReplayHandler): Promise<number> {
let processed = 0;
while (this.replayCursor < this.entries.length) {
// Loop condition guarantees this index is in-bounds.
const entry = this.entries[this.replayCursor] as BufferedEntry;
try {
await handler(entry);
} catch (cause) {
throw new NetworkSbError(
NETWORK_SB_CODES.BUFFER_REPLAY_FAILED,
"sb.buffer.replay_failed",
{
cause:
cause instanceof Error
? cause
: new Error(String(cause)),
context: { seq: entry.seq, processed },
}
);
}
this.replayCursor++;
processed++;
}
// Loop exits only when cursor catches up — release memory.
this.clear();
return processed;
}
/**
* Acknowledge entries up to and including `seq`. Used when the server
* confirms receipt asynchronously rather than via the handler return.
*/
public ackThrough(seq: number): number {
let acked = 0;
while (this.entries.length > 0) {
// Length guard above ensures the head exists.
const head = this.entries[0] as BufferedEntry;
if (head.seq > seq) break;
this.entries.shift();
this.hashes.delete(head.entryHash);
if (this.replayCursor > 0) this.replayCursor--;
acked++;
}
return acked;
}
private insertSorted(entry: BufferedEntry): void {
// Fast path: monotonic appends at the tail
const last = this.entries[this.entries.length - 1];
if (last === undefined || entry.seq >= last.seq) {
this.entries.push(entry);
return;
}
// Out-of-order insert (rare but possible during replay re-buffer)
let lo = 0;
let hi = this.entries.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
// Bounds guaranteed: lo ≤ mid < hi ≤ entries.length.
const probe = this.entries[mid] as BufferedEntry;
if (probe.seq <= entry.seq) lo = mid + 1;
else hi = mid;
}
this.entries.splice(lo, 0, entry);
}
}

View File

@@ -0,0 +1,79 @@
# command-dispatcher
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CommandDispatcher` is the security-critical entry point for every server-issued command that arrives at a Tier-0 mesh host. It is the only piece of the server-bridge that runs cryptographic verification on the inbound side: every command is canonicalized, fed through an injected `SignatureVerifier`, then either invoked on a registered handler or emitted as `command:<type>` on the bus. Between verification and dispatch it enforces the two replay defences the proposal calls for — a `maxAgeMs` cutoff and a FIFO `nonce` cache.
The `SignatureVerifier` is injected (rather than depending on `@suaveplan/crypto-curve` directly) so the package stays tree-shakeable: consumers that only need the bus pattern can plug in `() => true`, consumers that need real Ed25519 verification can wire `ed25519.verify` from `@suaveplan/crypto-curve` in one line.
## Features
- Deterministic canonicalization (`canonicalizeCommand`) — fixed big-endian length-prefixed envelope, sorted JSON payload per discriminant. Both ends of the channel produce byte-identical material to sign / verify.
- Replay protection: `maxAgeMs` cutoff (default 5 minutes) plus FIFO nonce cache (default 1024 entries; oldest evicted on overflow).
- Dispatch is dual-rail: registered per-type handlers AND a bus emit. At least one of the two MUST be configured — otherwise dispatch fails with `COMMAND_HANDLER_MISSING`, surfacing silent-drop bugs at startup rather than in production.
- Discriminated `CommandHandler<TCommand>` types so handlers receive a fully narrowed payload.
- All four documented failure modes carry their own `NETWORK_SB_CODES.*`: `COMMAND_UNKNOWN_TYPE`, `COMMAND_REPLAY_DETECTED`, `COMMAND_SIGNATURE_INVALID`, `COMMAND_HANDLER_MISSING`.
## Usage: Basic Example
```ts
import {
CommandDispatcher,
type SignatureVerifier,
} from "@suaveplan/network-plugin-server-bridge";
import { ed25519 } from "@suaveplan/crypto-curve";
const verify: SignatureVerifier = (canonical, sig) =>
ed25519.verify(sig, canonical, serverPublicKey);
const dispatcher = new CommandDispatcher({
verify,
handlers: {
"kick-peer": (cmd) => peerManager.kick(cmd.targetPeerId, cmd.reason),
},
});
await dispatcher.dispatch(signedCommand);
```
## Usage: Advanced Example
```ts
import { CommandDispatcher } from "@suaveplan/network-plugin-server-bridge";
const dispatcher = new CommandDispatcher({
verify: ed25519Verify,
nonceCacheSize: 4096,
maxAgeMs: 30_000,
busTopicPrefix: "server",
bus: networkBus,
handlers: {
broadcast: async (cmd, meta) => {
await audit.record({
type: "command-broadcast",
issuedAt: meta.issuedAt,
payload: cmd,
});
},
},
});
// Fan out: handler runs first, then the bus topic fires for any
// other plugin that wants to observe the command. Both run for every
// successfully-validated dispatch.
```
## API Reference
- `canonicalizeCommand(signed): CanonicalCommand` — fixed-shape, big-endian, length-prefixed encoder. Sorts `split-mesh.peerIds` for stability.
- `new CommandDispatcher(config)` — `verify` is required. Other knobs: `nonceCacheSize`, `maxAgeMs`, `clock`, `bus`, `busTopicPrefix`, `handlers`.
- `dispatch(signed): Promise<void>` — runs the full pipeline. Throws one of `COMMAND_UNKNOWN_TYPE`, `COMMAND_REPLAY_DETECTED`, `COMMAND_SIGNATURE_INVALID`, `COMMAND_HANDLER_MISSING` on rejection.
## Implementation Notes
- The nonce cache is FIFO rather than LRU because nonces are random — recency carries no information, so an LRU policy would only add overhead.
- We intentionally `await handler(...)` before emitting on the bus so a failing handler aborts the whole dispatch (and the bus subscribers never see a half-applied command). This costs handler-latency on the dispatch path but matches the proposal's "no half-applied state" invariant.
- The `as` cast inside `dispatch` to bridge the discriminated handler map to the open `ServerCommand` runtime check is the only widening we accept here — the surrounding `COMMAND_TYPE_SET.has(...)` guard ensures the runtime type matches the static type.

View File

@@ -0,0 +1,251 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_SB_CODES } from "../error-codes/error-codes.js";
import type { ServerCommand, SignedCommand } from "../types/command.types.js";
import {
CommandDispatcher,
canonicalizeCommand,
} from "./command-dispatcher.js";
const ALWAYS_OK = () => true;
const ALWAYS_BAD = () => false;
const SIG = new Uint8Array([0xde, 0xad]);
const baseSigned = (
command: ServerCommand,
issuedAt = Date.now(),
nonce = "n1"
): SignedCommand => ({
command,
issuedAt,
nonce,
signature: SIG,
});
describe("canonicalizeCommand", () => {
it("encodes each variant deterministically", () => {
for (const cmd of [
{ type: "kick-peer", targetPeerId: "p", reason: "r" } as const,
{
type: "merge-sessions",
fromSessionId: "a",
toSessionId: "b",
} as const,
{
type: "split-mesh",
meshId: "m",
newMeshId: "n",
peerIds: ["b", "a"],
} as const,
{
type: "issue-token",
tokenType: "session" as const,
tokenBytes: new Uint8Array([1, 2, 3]),
} as const,
{
type: "broadcast",
message: "hi",
scope: "mesh" as const,
} as const,
{
type: "shutdown-mesh",
meshId: "m",
deadlineMs: 100,
} as const,
]) {
const out = canonicalizeCommand(baseSigned(cmd));
expect(out.bytes.length).toBeGreaterThan(0);
}
});
it("split-mesh peerIds are sorted for stability", () => {
const issuedAt = 1_700_000_000_000;
const a = canonicalizeCommand(
baseSigned(
{
type: "split-mesh",
meshId: "m",
newMeshId: "n",
peerIds: ["a", "b", "c"],
},
issuedAt
)
);
const b = canonicalizeCommand(
baseSigned(
{
type: "split-mesh",
meshId: "m",
newMeshId: "n",
peerIds: ["c", "b", "a"],
},
issuedAt
)
);
expect(a.bytes).toEqual(b.bytes);
});
});
describe("CommandDispatcher", () => {
it("rejects unknown command type", async () => {
const d = new CommandDispatcher({ verify: ALWAYS_OK });
const bogus = {
command: { type: "no-such-thing" },
issuedAt: 1,
nonce: "n",
signature: SIG,
} as unknown as SignedCommand;
await expect(d.dispatch(bogus)).rejects.toMatchObject({
code: NETWORK_SB_CODES.COMMAND_UNKNOWN_TYPE,
});
});
it("rejects too-old commands as replay", async () => {
const now = 10_000;
const d = new CommandDispatcher({
verify: ALWAYS_OK,
clock: () => now,
maxAgeMs: 1000,
});
await expect(
d.dispatch(
baseSigned(
{ type: "broadcast", message: "x", scope: "mesh" },
1000
)
)
).rejects.toMatchObject({
code: NETWORK_SB_CODES.COMMAND_REPLAY_DETECTED,
});
});
it("rejects duplicate nonces as replay", async () => {
const events: { topic: string; payload: unknown }[] = [];
const d = new CommandDispatcher({
verify: ALWAYS_OK,
bus: {
emit: (topic, payload) => events.push({ topic, payload }),
},
});
const cmd = baseSigned({
type: "broadcast",
message: "x",
scope: "mesh",
});
await d.dispatch(cmd);
await expect(d.dispatch(cmd)).rejects.toMatchObject({
code: NETWORK_SB_CODES.COMMAND_REPLAY_DETECTED,
});
});
it("rejects bad signatures", async () => {
const d = new CommandDispatcher({
verify: ALWAYS_BAD,
bus: { emit: () => undefined },
});
await expect(
d.dispatch(
baseSigned({ type: "broadcast", message: "x", scope: "mesh" })
)
).rejects.toMatchObject({
code: NETWORK_SB_CODES.COMMAND_SIGNATURE_INVALID,
});
});
it("requires either a handler or a bus", async () => {
const d = new CommandDispatcher({ verify: ALWAYS_OK });
await expect(
d.dispatch(
baseSigned({ type: "broadcast", message: "x", scope: "mesh" })
)
).rejects.toMatchObject({
code: NETWORK_SB_CODES.COMMAND_HANDLER_MISSING,
});
});
it("invokes the registered handler and emits on the bus", async () => {
const handlerCalls: ServerCommand[] = [];
const events: { topic: string; payload: unknown }[] = [];
const d = new CommandDispatcher({
verify: ALWAYS_OK,
bus: {
emit: (topic, payload) => events.push({ topic, payload }),
},
handlers: {
"kick-peer": (cmd) => {
handlerCalls.push(cmd);
},
},
});
await d.dispatch(
baseSigned({
type: "kick-peer",
targetPeerId: "p",
reason: "r",
})
);
expect(handlerCalls).toHaveLength(1);
expect(events[0]?.topic).toBe("command:kick-peer");
});
it("custom topic prefix", async () => {
const events: { topic: string }[] = [];
const d = new CommandDispatcher({
verify: ALWAYS_OK,
busTopicPrefix: "srv",
bus: { emit: (topic) => events.push({ topic }) },
});
await d.dispatch(
baseSigned({ type: "broadcast", message: "hi", scope: "all" })
);
expect(events[0]?.topic).toBe("srv:broadcast");
});
it("nonce cache evicts oldest beyond capacity", async () => {
const d = new CommandDispatcher({
verify: ALWAYS_OK,
nonceCacheSize: 2,
bus: { emit: () => undefined },
});
const now = Date.now();
for (let i = 0; i < 4; i++) {
await d.dispatch(
baseSigned(
{ type: "broadcast", message: "x", scope: "mesh" },
now,
`n${i}`
)
);
}
// n0 should now be evicted; replaying n0 should NOT be flagged
await expect(
d.dispatch(
baseSigned(
{ type: "broadcast", message: "x", scope: "mesh" },
now,
"n0"
)
)
).resolves.toBeUndefined();
});
it("await handler completion before emitting on bus", async () => {
const events: { topic: string }[] = [];
let handlerDone = false;
const d = new CommandDispatcher({
verify: ALWAYS_OK,
bus: { emit: (topic) => events.push({ topic }) },
handlers: {
broadcast: async () => {
await Promise.resolve();
handlerDone = true;
},
},
});
await d.dispatch(
baseSigned({ type: "broadcast", message: "x", scope: "mesh" })
);
expect(handlerDone).toBe(true);
expect(events).toHaveLength(1);
});
});

View File

@@ -0,0 +1,213 @@
import {
NETWORK_SB_CODES,
NetworkSbError,
} from "../error-codes/error-codes.js";
import {
COMMAND_TYPES,
type CommandType,
type ServerCommand,
type SignedCommand,
} from "../types/command.types.js";
import type {
CanonicalCommand,
CommandDispatchBus,
CommandDispatcherConfig,
CommandHandlerMap,
SignatureVerifier,
} from "./command-dispatcher.types.js";
const DEFAULT_NONCE_CACHE = 1024;
const DEFAULT_MAX_AGE_MS = 5 * 60 * 1000;
const COMMAND_TYPE_SET = new Set<CommandType>(COMMAND_TYPES);
/**
* Deterministic canonical encoding of a signed command. Used for
* signature verification — both sides must agree byte-for-byte on the
* material they sign. JSON would work but doesn't pin key order; this
* encoder produces a fixed-shape big-endian byte string instead.
*/
export function canonicalizeCommand(signed: SignedCommand): CanonicalCommand {
// Layout: 4-byte type-tag length || type-tag || 8-byte issuedAt ||
// 4-byte nonce length || nonce-utf8 || JSON(command)
const enc = new TextEncoder();
const typeBytes = enc.encode(signed.command.type);
const nonceBytes = enc.encode(signed.nonce);
const cmdBytes = enc.encode(canonicalCommandPayload(signed.command));
const total =
4 + typeBytes.length + 8 + 4 + nonceBytes.length + cmdBytes.length;
const out = new Uint8Array(total);
const view = new DataView(out.buffer);
let off = 0;
view.setUint32(off, typeBytes.length, false);
off += 4;
out.set(typeBytes, off);
off += typeBytes.length;
// 64-bit big-endian
const hi = Math.floor(signed.issuedAt / 0x1_0000_0000);
const lo = signed.issuedAt >>> 0;
view.setUint32(off, hi, false);
view.setUint32(off + 4, lo, false);
off += 8;
view.setUint32(off, nonceBytes.length, false);
off += 4;
out.set(nonceBytes, off);
off += nonceBytes.length;
out.set(cmdBytes, off);
return { bytes: out };
}
function canonicalCommandPayload(command: ServerCommand): string {
// Sort fields deterministically. We hand-write per discriminant so
// the encoder doesn't need a runtime stable-stringify.
switch (command.type) {
case "kick-peer":
return JSON.stringify({
reason: command.reason,
targetPeerId: command.targetPeerId,
type: "kick-peer",
});
case "merge-sessions":
return JSON.stringify({
fromSessionId: command.fromSessionId,
toSessionId: command.toSessionId,
type: "merge-sessions",
});
case "split-mesh":
return JSON.stringify({
meshId: command.meshId,
newMeshId: command.newMeshId,
peerIds: [...command.peerIds].sort(),
type: "split-mesh",
});
case "issue-token":
return JSON.stringify({
tokenBytes: Array.from(command.tokenBytes),
tokenType: command.tokenType,
type: "issue-token",
});
case "broadcast":
return JSON.stringify({
message: command.message,
scope: command.scope,
type: "broadcast",
});
case "shutdown-mesh":
return JSON.stringify({
deadlineMs: command.deadlineMs,
meshId: command.meshId,
type: "shutdown-mesh",
});
}
}
interface NonceCache {
has(nonce: string): boolean;
add(nonce: string): void;
}
class FifoNonceCache implements NonceCache {
private readonly seen = new Set<string>();
private readonly order: string[] = [];
public constructor(private readonly capacity: number) {}
public has(nonce: string): boolean {
return this.seen.has(nonce);
}
public add(nonce: string): void {
this.seen.add(nonce);
this.order.push(nonce);
while (this.order.length > this.capacity) {
// Length-guarded shift — order is non-empty in this branch.
const oldest = this.order.shift() as string;
this.seen.delete(oldest);
}
}
}
/**
* Validates Ed25519-signed server commands and dispatches them to
* per-command handlers + the bus. Replay protection combines a nonce
* cache and a maxAge cutoff.
*/
export class CommandDispatcher {
private readonly verify: SignatureVerifier;
private readonly bus: CommandDispatchBus | undefined;
private readonly topicPrefix: string;
private readonly handlers: CommandHandlerMap;
private readonly nonces: NonceCache;
private readonly maxAgeMs: number;
private readonly clock: () => number;
public constructor(config: CommandDispatcherConfig) {
this.verify = config.verify;
this.bus = config.bus;
this.topicPrefix = config.busTopicPrefix ?? "command";
this.handlers = config.handlers ?? {};
this.nonces = new FifoNonceCache(
config.nonceCacheSize ?? DEFAULT_NONCE_CACHE
);
this.maxAgeMs = config.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
this.clock = config.clock ?? (() => Date.now());
}
public async dispatch(signed: SignedCommand): Promise<void> {
if (!COMMAND_TYPE_SET.has(signed.command.type)) {
throw new NetworkSbError(
NETWORK_SB_CODES.COMMAND_UNKNOWN_TYPE,
"sb.command.unknown_type",
{ context: { type: signed.command.type } }
);
}
const now = this.clock();
if (now - signed.issuedAt > this.maxAgeMs) {
throw new NetworkSbError(
NETWORK_SB_CODES.COMMAND_REPLAY_DETECTED,
"sb.command.replay_too_old",
{ context: { issuedAt: signed.issuedAt, now } }
);
}
if (this.nonces.has(signed.nonce)) {
throw new NetworkSbError(
NETWORK_SB_CODES.COMMAND_REPLAY_DETECTED,
"sb.command.replay_nonce_seen",
{ context: { nonce: signed.nonce } }
);
}
const canonical = canonicalizeCommand(signed);
if (!this.verify(canonical.bytes, signed.signature)) {
throw new NetworkSbError(
NETWORK_SB_CODES.COMMAND_SIGNATURE_INVALID,
"sb.command.signature_invalid",
{ context: { type: signed.command.type } }
);
}
this.nonces.add(signed.nonce);
const handler = this.handlers[signed.command.type] as
| ((
cmd: ServerCommand,
meta: { issuedAt: number; nonce: string }
) => void | Promise<void>)
| undefined;
if (handler === undefined && this.bus === undefined) {
throw new NetworkSbError(
NETWORK_SB_CODES.COMMAND_HANDLER_MISSING,
"sb.command.handler_missing",
{ context: { type: signed.command.type } }
);
}
if (handler !== undefined) {
await handler(signed.command, {
issuedAt: signed.issuedAt,
nonce: signed.nonce,
});
}
this.bus?.emit(`${this.topicPrefix}:${signed.command.type}`, {
command: signed.command,
issuedAt: signed.issuedAt,
nonce: signed.nonce,
});
}
}

View File

@@ -0,0 +1,68 @@
# command-dispatcher.types
## Purpose
Companion type definitions for `CommandDispatcher`. Defines `SignatureVerifier` (the injected sync verification function), `CommandDispatchBus` (the structural bus shape), `CommandHandler<TCommand>` (the per-type handler signature), `CommandHandlerMap` (the `{ [type]: handler }` registry shape), `CommandHandlerMeta` (the meta object passed to handlers), `CommandDispatcherConfig` (the constructor options bundle), and `CanonicalCommand` (the byte-encoded form returned by `canonicalizeCommand`).
Splitting the types out keeps the implementation file under the 300-line cap and lets type-only consumers import the contracts without pulling in the runtime class. Every type is structural so consumers can substitute their own bus / verifier / clock without inheritance.
## Features
- `SignatureVerifier` is intentionally synchronous — every supported runtime verifies Ed25519 signatures in sub-millisecond time, and an async surface here would force every call site through `await` for no real-world benefit.
- `CommandHandler<TCommand>` is generic so handlers receive a fully narrowed payload (e.g. a `kick-peer` handler sees `{ targetPeerId; reason }` directly, with no discriminated-union switch in the body).
- `CommandHandlerMap` is partial — every type is optional. Combined with the dispatcher's "handler OR bus required" invariant, this lets callers register handlers for security-sensitive commands and rely on the bus for the rest.
- `CommandDispatcherConfig` exposes `nonceCacheSize` and `maxAgeMs` so callers can tune replay protection per deployment without forking the dispatcher.
## Usage: Basic Example
```ts
import type {
CommandDispatcherConfig,
SignatureVerifier,
} from "@suaveplan/network-plugin-server-bridge";
const verify: SignatureVerifier = (canonical, sig) =>
nacl.sign.detached.verify(canonical, sig, serverPublicKey);
const config: CommandDispatcherConfig = { verify };
```
## Usage: Advanced Example
```ts
import type {
CommandDispatchBus,
CommandHandlerMap,
CommandHandlerMeta,
} from "@suaveplan/network-plugin-server-bridge";
const handlers: CommandHandlerMap = {
"kick-peer": (cmd, meta: CommandHandlerMeta) => {
peers.kick(cmd.targetPeerId, cmd.reason);
audit.record({
type: "kick",
issuedAt: meta.issuedAt,
nonce: meta.nonce,
});
},
};
const bus: CommandDispatchBus = {
emit: (topic, payload) => networkBus.emit(topic, payload),
};
```
## API Reference
- `SignatureVerifier` — `(canonical: Uint8Array, signature: Uint8Array) => boolean`.
- `CommandDispatchBus` — `{ emit(topic: string, payload: unknown): void }`.
- `CommandHandler<TCommand>` — `(command: TCommand, meta: CommandHandlerMeta) => void | Promise<void>`.
- `CommandHandlerMap` — partial map keyed by `CommandType`, narrowed per discriminant.
- `CommandHandlerMeta` — `{ issuedAt: number; nonce: string }`.
- `CommandDispatcherConfig` — `{ verify; nonceCacheSize?; maxAgeMs?; clock?; bus?; busTopicPrefix?; handlers? }`.
- `CanonicalCommand` — `{ bytes: Uint8Array }`.
## Implementation Notes
- `CommandHandlerMap` uses TypeScript's discriminated narrowing (`Extract<ServerCommand, { type: K }>`) so handlers receive a strictly typed command payload at compile time.
- The bus shape is intentionally narrower than `NetworkBus` from `@suaveplan/network` — only `emit` is needed. Any structural impl satisfies it, including `NetworkBus` itself.

View File

@@ -0,0 +1,70 @@
import type { CommandType, ServerCommand } from "../types/command.types.js";
/**
* Server-command signature verifier. Receives the canonical bytes of the
* command envelope and the signature; returns `true` only when the
* signature is valid AND was produced by the server's expected key.
*
* The shape is intentionally synchronous — Ed25519 verification is
* sub-millisecond on every supported runtime, and an async surface here
* would force every dispatch site through `await`.
*/
export type SignatureVerifier = (
canonical: Uint8Array,
signature: Uint8Array
) => boolean;
/**
* Bus shape the dispatcher needs. Compatible with `NetworkBus` from
* `@suaveplan/network` (and any structural impl).
*/
export interface CommandDispatchBus {
emit(topic: string, payload: unknown): void;
}
/**
* Per-command handler. Returning a Promise is supported but the
* dispatcher does not block on completion — handlers must own their own
* back-pressure.
*/
export type CommandHandler<TCommand extends ServerCommand = ServerCommand> = (
command: TCommand,
meta: CommandHandlerMeta
) => void | Promise<void>;
export interface CommandHandlerMeta {
readonly issuedAt: number;
readonly nonce: string;
}
export type CommandHandlerMap = {
readonly [K in CommandType]?: CommandHandler<
Extract<ServerCommand, { type: K }>
>;
};
export interface CommandDispatcherConfig {
readonly verify: SignatureVerifier;
/**
* Maximum number of nonces remembered for replay protection. Older
* nonces are evicted FIFO once the cap is exceeded. Default 1024.
*/
readonly nonceCacheSize?: number;
/**
* Maximum age of a command in milliseconds. Defaults to
* 5 minutes — beyond this the dispatcher rejects with
* `COMMAND_REPLAY_DETECTED`.
*/
readonly maxAgeMs?: number;
readonly clock?: () => number;
/** Optional bus to emit `command:<type>` topics on. */
readonly bus?: CommandDispatchBus;
/** Bus topic prefix; defaults to `"command"`. */
readonly busTopicPrefix?: string;
/** Per-command-type handler map. */
readonly handlers?: CommandHandlerMap;
}
export interface CanonicalCommand {
readonly bytes: Uint8Array;
}

View File

@@ -0,0 +1,50 @@
/**
* NET-34 — server command dispatch.
*
* Pins: signature validation, nonce-replay rejection, dispatch through
* registered handlers and the bus.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_SB_CODES } from "../error-codes/error-codes.js";
import type { SignedCommand } from "../types/command.types.js";
import { CommandDispatcher } from "./command-dispatcher.js";
const cmd = (nonce: string): SignedCommand => ({
command: { type: "broadcast", message: "x", scope: "mesh" },
issuedAt: Date.now(),
nonce,
signature: new Uint8Array([1]),
});
describe("NET-34 — command dispatch", () => {
it("validates Ed25519 signatures via the injected verifier", async () => {
const d = new CommandDispatcher({
verify: () => false,
bus: { emit: () => undefined },
});
await expect(d.dispatch(cmd("a"))).rejects.toMatchObject({
code: NETWORK_SB_CODES.COMMAND_SIGNATURE_INVALID,
});
});
it("dispatches accepted commands to the bus", async () => {
const events: string[] = [];
const d = new CommandDispatcher({
verify: () => true,
bus: { emit: (t) => events.push(t) },
});
await d.dispatch(cmd("a"));
expect(events).toEqual(["command:broadcast"]);
});
it("rejects replayed commands", async () => {
const d = new CommandDispatcher({
verify: () => true,
bus: { emit: () => undefined },
});
await d.dispatch(cmd("dup"));
await expect(d.dispatch(cmd("dup"))).rejects.toMatchObject({
code: NETWORK_SB_CODES.COMMAND_REPLAY_DETECTED,
});
});
});

View File

@@ -0,0 +1,13 @@
export {
CommandDispatcher,
canonicalizeCommand,
} from "./command-dispatcher.js";
export type {
CanonicalCommand,
CommandDispatchBus,
CommandDispatcherConfig,
CommandHandler,
CommandHandlerMap,
CommandHandlerMeta,
SignatureVerifier,
} from "./command-dispatcher.types.js";

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_SB_CODES, NetworkSbError } from "./error-codes/error-codes.js";
describe("NETWORK_SB_CODES (spec conformance)", () => {
it("registers under NET_SB", () => {
for (const value of Object.values(NETWORK_SB_CODES)) {
expect(value).toMatch(/^NET_SB\./);
}
});
it("is frozen", () => {
expect(Object.isFrozen(NETWORK_SB_CODES)).toBe(true);
});
it("contains every documented code", () => {
for (const key of [
"GATEWAY_NOT_CONNECTED",
"GATEWAY_CLOSED",
"GATEWAY_HEARTBEAT_TIMEOUT",
"GATEWAY_SEND_FAILED",
"INVALID_TRANSITION",
"COMMAND_SIGNATURE_INVALID",
"COMMAND_REPLAY_DETECTED",
"COMMAND_UNKNOWN_TYPE",
"COMMAND_HANDLER_MISSING",
"BUFFER_CAPACITY_INVALID",
"BUFFER_ENTRY_INVALID",
"BUFFER_REPLAY_FAILED",
"RECONCILIATION_EPOCH_INVALID",
"RECONCILIATION_CONFLICT",
"MULTIPLEXER_BATCH_OVERFLOW",
"PLUGIN_TIER_GATE_REJECTED",
"HOSTING_MODEL_INVALID",
"HOSTING_QUORUM_NOT_MET",
] as const) {
expect(NETWORK_SB_CODES).toHaveProperty(key);
}
});
});
describe("NetworkSbError (spec conformance)", () => {
it("extends SuaveplanError", () => {
const err = new NetworkSbError(
NETWORK_SB_CODES.GATEWAY_NOT_CONNECTED,
"x"
);
expect(err.name).toBe("NetworkSbError");
expect(err.code).toBe(NETWORK_SB_CODES.GATEWAY_NOT_CONNECTED);
});
it("preserves cause", () => {
const cause = new Error("inner");
const err = new NetworkSbError(
NETWORK_SB_CODES.GATEWAY_SEND_FAILED,
"with-cause",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,56 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Frozen `NET_SB` error registry for `@suaveplan/network-plugin-server-bridge`. Every failure path the server bridge can surface — gateway not connected, gateway closed, invalid FSM transition, command signature mismatch, replay attempt, unknown command type, invalid buffer capacity, malformed buffer entry — is mapped onto one of these stable codes.
The registry is locked at module load time via `defineCodes("NET_SB", [...] as const)` so consumers downstream (logger redaction maps, i18n catalogs, the OpenSpec conformance checker) can rely on the set never silently growing without a code change. Per `AGENTS.md §2c`, every code in this registry is referenced from `src/` somewhere — typically as the argument to `new NetworkSbError(...)` for thrown failures.
## Features
- Eight codes covering gateway lifecycle (`GATEWAY_NOT_CONNECTED`, `GATEWAY_CLOSED`), FSM violations (`INVALID_TRANSITION`), command security (`COMMAND_SIGNATURE_INVALID`, `COMMAND_REPLAY_DETECTED`, `COMMAND_UNKNOWN_TYPE`), and buffer validation (`BUFFER_CAPACITY_INVALID`, `BUFFER_ENTRY_INVALID`).
- Sibling `NetworkSbError` extends `SuaveplanError<NetworkSbErrorCode>` so consumers can write `instanceof NetworkSbError` and access `error.code` with full type narrowing.
- The two security codes (`COMMAND_SIGNATURE_INVALID` / `COMMAND_REPLAY_DETECTED`) are deliberately distinct so observability can distinguish "bad signature" (potential man-in-the-middle) from "old signature" (replay attempt) at the dashboard level.
## Usage: Basic Example
```ts
import {
NETWORK_SB_CODES,
NetworkSbError,
} from "@suaveplan/network-plugin-server-bridge";
throw new NetworkSbError(
NETWORK_SB_CODES.COMMAND_SIGNATURE_INVALID,
"sb.command.signature_invalid",
{ context: { commandType: "kick-peer" } }
);
```
## Usage: Advanced Example
```ts
import {
NetworkSbError,
type NetworkSbErrorCode,
} from "@suaveplan/network-plugin-server-bridge";
function classify(err: unknown): NetworkSbErrorCode | null {
return err instanceof NetworkSbError ? err.code : null;
}
```
## API Reference
- `NETWORK_SB_CODES` — frozen object with eight keys: `GATEWAY_NOT_CONNECTED`, `GATEWAY_CLOSED`, `INVALID_TRANSITION`, `COMMAND_SIGNATURE_INVALID`, `COMMAND_REPLAY_DETECTED`, `COMMAND_UNKNOWN_TYPE`, `BUFFER_CAPACITY_INVALID`, `BUFFER_ENTRY_INVALID`. Values are `"NET_SB.<KEY>"` strings.
- `NetworkSbErrorCode` — string-literal union of the registry values.
- `NetworkSbError` — error class extending `SuaveplanError<NetworkSbErrorCode>`. Constructor: `(code, messageKey, options?)`.
## Implementation Notes
- The list grows only by adding to the array literal; there is no programmatic `register()` API by design — the `as const` plus `defineCodes` arrangement is the contract.
- Code messages use dotted i18n keys (e.g. `sb.gateway.not_connected`) so the logger's i18n adapter can resolve them at format time.

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_SB_CODES, NetworkSbError } from "./error-codes.js";
describe("NETWORK_SB_CODES", () => {
it("contains expected codes", () => {
expect(NETWORK_SB_CODES.GATEWAY_NOT_CONNECTED).toBe(
"NET_SB.GATEWAY_NOT_CONNECTED"
);
expect(NETWORK_SB_CODES.INVALID_TRANSITION).toBe(
"NET_SB.INVALID_TRANSITION"
);
expect(NETWORK_SB_CODES.HOSTING_MODEL_INVALID).toBe(
"NET_SB.HOSTING_MODEL_INVALID"
);
});
});
describe("NetworkSbError", () => {
it("constructs with correct name and code", () => {
const err = new NetworkSbError(
NETWORK_SB_CODES.GATEWAY_NOT_CONNECTED,
"gateway disconnected"
);
expect(err.name).toBe("NetworkSbError");
expect(err.code).toBe("NET_SB.GATEWAY_NOT_CONNECTED");
expect(err.message).toContain("gateway disconnected");
});
it("preserves options context", () => {
const err = new NetworkSbError(
NETWORK_SB_CODES.COMMAND_SIGNATURE_INVALID,
"bad sig",
{ context: { foo: "bar" } }
);
expect(err.context).toEqual({ foo: "bar" });
});
});

View File

@@ -0,0 +1,40 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
export const NETWORK_SB_CODES = defineCodes("NET_SB", [
"GATEWAY_NOT_CONNECTED",
"GATEWAY_CLOSED",
"GATEWAY_HEARTBEAT_TIMEOUT",
"GATEWAY_SEND_FAILED",
"INVALID_TRANSITION",
"COMMAND_SIGNATURE_INVALID",
"COMMAND_REPLAY_DETECTED",
"COMMAND_UNKNOWN_TYPE",
"COMMAND_HANDLER_MISSING",
"BUFFER_CAPACITY_INVALID",
"BUFFER_ENTRY_INVALID",
"BUFFER_REPLAY_FAILED",
"RECONCILIATION_EPOCH_INVALID",
"RECONCILIATION_CONFLICT",
"MULTIPLEXER_BATCH_OVERFLOW",
"PLUGIN_TIER_GATE_REJECTED",
"HOSTING_MODEL_INVALID",
"HOSTING_QUORUM_NOT_MET",
] as const);
export type NetworkSbErrorCode =
(typeof NETWORK_SB_CODES)[keyof typeof NETWORK_SB_CODES];
export class NetworkSbError extends SuaveplanError<NetworkSbErrorCode> {
public constructor(
code: NetworkSbErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetworkSbError";
}
}

View File

@@ -0,0 +1,5 @@
export type { NetworkSbErrorCode } from "./error-codes.js";
export {
NETWORK_SB_CODES,
NetworkSbError,
} from "./error-codes.js";

View File

@@ -0,0 +1,49 @@
/**
* Pins the canonical 5-state graceful-degradation FSM transitions.
* Online → degrading → offline → recovering → online.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_SB_CODES,
NetworkSbError,
} from "../error-codes/error-codes.js";
import {
BRIDGE_TRANSITIONS,
GracefulDegradationFsm,
} from "./graceful-degradation-fsm.js";
describe("GracefulDegradationFsm (spec conformance)", () => {
it("starts in online", () => {
const fsm = new GracefulDegradationFsm();
expect(fsm.state()).toBe("online");
});
it("walks the canonical online → degrading → offline → recovering → online cycle", () => {
const fsm = new GracefulDegradationFsm();
fsm.transition("degrading", "lat");
fsm.transition("offline", "lost");
fsm.transition("recovering", "retry");
fsm.transition("online", "ack");
expect(fsm.state()).toBe("online");
});
it("rejects illegal jumps", () => {
const fsm = new GracefulDegradationFsm();
try {
fsm.transition("offline", "skip-degrading");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(NetworkSbError);
expect((err as NetworkSbError).code).toBe(
NETWORK_SB_CODES.INVALID_TRANSITION
);
}
});
it("BRIDGE_TRANSITIONS exposes the legal moves per state", () => {
expect(BRIDGE_TRANSITIONS.online).toEqual(["degrading"]);
expect(BRIDGE_TRANSITIONS.degrading).toEqual(["online", "offline"]);
expect(BRIDGE_TRANSITIONS.offline).toEqual(["recovering"]);
expect(BRIDGE_TRANSITIONS.recovering).toEqual(["online", "offline"]);
});
});

View File

@@ -0,0 +1,70 @@
# GracefulDegradationFsm
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Five-state finite-state machine modelling the bridge lifecycle: `online` (server reachable, all systems normal) → `degrading` (heartbeat missed, retrying with backoff) → `offline` (gave up, mesh in autonomous mode) → `recovering` (server detected again, replaying buffered logs and reconciling) → `online`. The FSM owns transition validation, history, and listener fan-out — it does NOT own the timing logic that decides _when_ to transition (that's `ServerGateway` and `OfflineModeCoordinator`).
The transitions are deliberately one-directional except for the two recovery edges: `degrading` can go back to `online` if the heartbeat recovers before the retry budget exhausts, and `recovering` can fall back to `offline` if the reconciliation flow fails. Forbidden transitions (e.g. `online → offline` directly) raise `NET_SB.INVALID_TRANSITION` so misuse is loud.
## Features
- Strict transition validation: `online → degrading`, `degrading → online | offline`, `offline → recovering`, `recovering → online | offline`. Anything else throws `NET_SB.INVALID_TRANSITION`.
- `canTransition(next)` is the non-throwing predicate; `transition(next, reason)` is the throwing mutator.
- Listener fan-out via `onTransition(listener)` — the listener receives the full `BridgeStateChange`. Returns an unsubscribe function.
- Per-transition history (`history_()`) for diagnostics; resets to empty on `reset()`.
- Injectable clock for deterministic tests.
- `BRIDGE_TRANSITIONS` exported for documentation / dashboard rendering of the legal graph.
## Usage: Basic Example
```ts
import { GracefulDegradationFsm } from "@suaveplan/network-plugin-server-bridge";
const fsm = new GracefulDegradationFsm();
fsm.transition("degrading", "heartbeat-timeout");
fsm.transition("offline", "max-retries-exceeded");
fsm.transition("recovering", "heartbeat-detected");
fsm.transition("online", "reconciliation-complete");
```
## Usage: Advanced Example
```ts
import { GracefulDegradationFsm } from "@suaveplan/network-plugin-server-bridge";
const fsm = new GracefulDegradationFsm({
clock: () => 1_700_000_000_000,
initialState: "online",
});
const off = fsm.onTransition((change) => {
metrics.increment(`sb.fsm.${change.from}_to_${change.to}`);
if (change.to === "offline") offlineCoordinator.activate();
});
bus.on("server:heartbeat-timeout", () =>
fsm.transition("degrading", "heartbeat-timeout")
);
off(); // remove listener
```
## API Reference
- `GracefulDegradationFsm(config?)` — `config: { clock?; initialState? }`. Defaults to `online`.
- `state()` — current state.
- `canTransition(next)` — `boolean`.
- `transition(next, reason)` — returns `BridgeStateChange`. Raises `NET_SB.INVALID_TRANSITION`.
- `onTransition(listener)` — returns unsubscribe function.
- `history_()` — `readonly BridgeStateChange[]` (trailing underscore disambiguates from prototype `history`).
- `reset(state)` — sets state and clears history (for tests / forced recovery).
- `BRIDGE_TRANSITIONS` — frozen `Record<BridgeState, readonly BridgeState[]>`.
## Implementation Notes
- The transition map is frozen at module load; mutating it is a runtime contract violation.
- Listeners are notified synchronously after the state mutation, so they observe the new state via `fsm.state()` if they query it.
- `reset` is intentionally a separate method rather than a `transition` variant — resetting state is an operator-driven action, not a normal lifecycle transition.

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BRIDGE_TRANSITIONS,
GracefulDegradationFsm,
} from "./graceful-degradation-fsm.js";
describe("GracefulDegradationFsm", () => {
it("starts online by default", () => {
const fsm = new GracefulDegradationFsm();
expect(fsm.state()).toBe("online");
});
it("accepts custom initial state", () => {
const fsm = new GracefulDegradationFsm({ initialState: "offline" });
expect(fsm.state()).toBe("offline");
});
it("uses custom clock", () => {
const t = 42;
const fsm = new GracefulDegradationFsm({ clock: () => t });
const change = fsm.transition("degrading", "slow");
expect(change.at).toBe(42);
});
it("canTransition returns true for valid moves", () => {
const fsm = new GracefulDegradationFsm();
expect(fsm.canTransition("degrading")).toBe(true);
expect(fsm.canTransition("offline")).toBe(false);
});
it("transitions and records history", () => {
const fsm = new GracefulDegradationFsm();
fsm.transition("degrading", "latency");
expect(fsm.state()).toBe("degrading");
expect(fsm.history_()).toHaveLength(1);
expect(fsm.history_()[0].reason).toBe("latency");
});
it("notifies listeners", () => {
const fsm = new GracefulDegradationFsm();
const changes: Parameters<Parameters<typeof fsm.onTransition>[0]>[0][] =
[];
const off = fsm.onTransition((c) => changes.push(c));
fsm.transition("degrading", "x");
expect(changes).toHaveLength(1);
off();
fsm.transition("offline", "y");
expect(changes).toHaveLength(1);
});
it("throws on invalid transition", () => {
const fsm = new GracefulDegradationFsm();
expect(() => fsm.transition("offline", "nope")).toThrow(
"invalid_transition"
);
});
it("reset clears history and sets state", () => {
const fsm = new GracefulDegradationFsm();
fsm.transition("degrading", "x");
fsm.reset("online");
expect(fsm.state()).toBe("online");
expect(fsm.history_()).toHaveLength(0);
});
it("exposes BRIDGE_TRANSITIONS", () => {
expect(BRIDGE_TRANSITIONS.online).toContain("degrading");
});
});

View File

@@ -0,0 +1,78 @@
import {
NETWORK_SB_CODES,
NetworkSbError,
} from "../error-codes/error-codes.js";
import type { BridgeState, BridgeStateChange } from "../types/state.types.js";
const TRANSITIONS: Readonly<Record<BridgeState, readonly BridgeState[]>> =
Object.freeze({
online: ["degrading"],
degrading: ["online", "offline"],
offline: ["recovering"],
recovering: ["online", "offline"],
});
export type FsmListener = (change: BridgeStateChange) => void;
export interface GracefulDegradationFsmConfig {
readonly clock?: () => number;
readonly initialState?: BridgeState;
}
export class GracefulDegradationFsm {
private current: BridgeState;
private readonly clock: () => number;
private readonly listeners = new Set<FsmListener>();
private readonly history: BridgeStateChange[] = [];
public constructor(config: GracefulDegradationFsmConfig = {}) {
this.current = config.initialState ?? "online";
this.clock = config.clock ?? (() => Date.now());
}
public state(): BridgeState {
return this.current;
}
public canTransition(next: BridgeState): boolean {
return TRANSITIONS[this.current].includes(next);
}
public transition(next: BridgeState, reason: string): BridgeStateChange {
if (!this.canTransition(next)) {
throw new NetworkSbError(
NETWORK_SB_CODES.INVALID_TRANSITION,
"sb.fsm.invalid_transition",
{
context: { from: this.current, to: next, reason },
}
);
}
const change: BridgeStateChange = {
from: this.current,
to: next,
at: this.clock(),
reason,
};
this.current = next;
this.history.push(change);
for (const listener of this.listeners) listener(change);
return change;
}
public onTransition(listener: FsmListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
public history_(): readonly BridgeStateChange[] {
return [...this.history];
}
public reset(state: BridgeState): void {
this.current = state;
this.history.length = 0;
}
}
export const BRIDGE_TRANSITIONS = TRANSITIONS;

View File

@@ -0,0 +1,106 @@
/**
* NET-31 — Server bridge heartbeat and detection.
*
* Pins: connect, heartbeat emission, disconnect detection within
* 2× heartbeat interval, and explicit close. The transport is injected
* (`BridgeTransportFactory`) so this test never touches the network.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { BridgeTransport, Scheduler, TimerHandle } from "./index.js";
import { ServerGateway } from "./server-gateway.js";
class StepScheduler implements Scheduler {
public intervals: { fn: () => void; ms: number }[] = [];
public timeouts: { fn: () => void; ms: number }[] = [];
public setInterval(fn: () => void, ms: number): TimerHandle {
const entry = { fn, ms };
this.intervals.push(entry);
return {
cancel: () => {
this.intervals = this.intervals.filter((e) => e !== entry);
},
};
}
public setTimeout(fn: () => void, ms: number): TimerHandle {
const entry = { fn, ms };
this.timeouts.push(entry);
return {
cancel: () => {
this.timeouts = this.timeouts.filter((e) => e !== entry);
},
};
}
}
function fakeTransport(): {
transport: BridgeTransport;
sent: Uint8Array[];
} {
const sent: Uint8Array[] = [];
let onMsg: ((b: Uint8Array) => void) | null = null;
let onClose: ((r: string) => void) | null = null;
return {
sent,
transport: {
send(b) {
sent.push(b);
},
onMessage(h) {
onMsg = h;
return () => (onMsg = null);
},
onClose(h) {
onClose = h;
return () => (onClose = null);
},
close() {
onClose?.("transport-close");
},
},
};
}
describe("NET-31 — server bridge heartbeat", () => {
it("opens and emits heartbeat on schedule", async () => {
const t = fakeTransport();
const sched = new StepScheduler();
const gw = new ServerGateway(
{ transportFactory: () => t.transport, heartbeatIntervalMs: 200 },
{},
sched
);
await gw.connect();
expect(sched.intervals[0]?.ms).toBe(200);
sched.intervals[0]?.fn();
expect(t.sent).toHaveLength(1);
gw.close();
});
it("disconnect deadline defaults to 2× heartbeat", async () => {
const t = fakeTransport();
const sched = new StepScheduler();
const gw = new ServerGateway(
{ transportFactory: () => t.transport, heartbeatIntervalMs: 250 },
{},
sched
);
await gw.connect();
expect(sched.timeouts[0]?.ms).toBe(500);
gw.close();
});
it("fires onDisconnect when the deadline expires", async () => {
const t = fakeTransport();
const sched = new StepScheduler();
const reasons: string[] = [];
const gw = new ServerGateway(
{ transportFactory: () => t.transport, heartbeatIntervalMs: 100 },
{ onDisconnect: (r) => reasons.push(r) },
sched
);
await gw.connect();
sched.timeouts[0]?.fn();
expect(reasons).toEqual(["heartbeat-timeout"]);
expect(gw.connectionState()).toBe("closed");
});
});

View File

@@ -0,0 +1,14 @@
export {
type Scheduler,
ServerGateway,
type TimerHandle,
} from "./server-gateway.js";
export type {
BridgeTransport,
BridgeTransportFactory,
GatewayConnectionState,
GatewayInbound,
GatewayInboundKind,
ServerGatewayConfig,
ServerGatewayHandlers,
} from "./server-gateway.types.js";

View File

@@ -0,0 +1,83 @@
# server-gateway
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ServerGateway` is the persistent, transport-agnostic byte channel between a Tier-0 mesh host and the central server. It owns the heartbeat loop, the disconnect-detection deadline, and the demux between heartbeat-acks and ordinary inbound frames. The transport itself is injected via `BridgeTransportFactory`, so the same gateway runs unchanged over WebSocket in production, fetch-poll under restricted networks, and an in-memory pipe under test — never opening a real socket from this package.
The gateway is the lowest-level subsystem that satisfies `NET-31` ("disconnect MUST be detected within 2× heartbeat interval"). The default `disconnectTimeoutMs = 2 × heartbeatIntervalMs` is enforced at construction time and overridable when an asymmetric ratio (e.g. for satellite or high-jitter links) is required.
## Features
- `BridgeTransport` is a structural `{ send, onMessage, onClose, close }` shape. Any duplex byte channel can satisfy it.
- Heartbeat marker bytes (`heartbeatBytes`, `heartbeatAckBytes`) are configurable to match the framing the server already speaks. Defaults are the two-byte `"hb"` / `"ak"` markers.
- `Scheduler` is injectable so manual-clock tests can step intervals and timeouts without `vi.useFakeTimers`.
- Heartbeat send-failures and server-initiated closes converge on a single `handleClose(reason)` so the disconnect callback fires exactly once per session.
- `connect()` is idempotent on already-open gateways; `close()` is idempotent and swallows transport `close()` failures so teardown never throws.
- `send` failures are wrapped in `NetworkSbError(GATEWAY_SEND_FAILED)` with the original cause attached, satisfying the `**Verified by:**` contract for OpenSpec audit.
## Usage: Basic Example
```ts
import {
ServerGateway,
type BridgeTransport,
} from "@suaveplan/network-plugin-server-bridge";
const transport: BridgeTransport = openWebSocket();
const gateway = new ServerGateway({
transportFactory: () => transport,
heartbeatIntervalMs: 1_000,
});
await gateway.connect();
await gateway.send(new Uint8Array([1, 2, 3]));
gateway.close();
```
## Usage: Advanced Example
```ts
import {
ServerGateway,
type Scheduler,
} from "@suaveplan/network-plugin-server-bridge";
const scheduler: Scheduler = {
setInterval: (fn, ms) => ({ cancel: () => undefined }),
setTimeout: (fn, ms) => ({ cancel: () => undefined }),
};
const gateway = new ServerGateway(
{
transportFactory: () => openTransport(),
heartbeatIntervalMs: 500,
disconnectTimeoutMs: 750, // tighter than 2×
clock: () => Date.now(),
},
{
onConnect: () => console.error("server-bridge: online"),
onDisconnect: (reason) =>
console.error(`server-bridge: offline (${reason})`),
onMessage: ({ bytes }) => router.dispatch(bytes),
onHeartbeatAck: (atMs) => metrics.recordAck(atMs),
},
scheduler
);
await gateway.connect();
```
## API Reference
- `new ServerGateway(config, handlers?, scheduler?)` — `config.heartbeatIntervalMs` MUST be positive; `disconnectTimeoutMs` defaults to `2 × heartbeatIntervalMs`. Throws `NetworkSbError(GATEWAY_HEARTBEAT_TIMEOUT)` if the interval is non-positive.
- `connect(): Promise<void>` — open the transport, wire handlers, start the heartbeat loop, arm the deadline, and emit `onConnect`.
- `send(bytes): Promise<void>` — forward bytes to the transport. Throws `NetworkSbError(GATEWAY_NOT_CONNECTED)` when not open and `NetworkSbError(GATEWAY_SEND_FAILED)` when the transport itself fails.
- `close(reason?): void` — idempotent teardown. Cancels timers, unsubscribes handlers, calls `transport.close(reason)` (errors swallowed), emits `onDisconnect`.
- `connectionState()` / `lastAckTimestamp()` — observability accessors.
- `BridgeTransport`, `BridgeTransportFactory`, `ServerGatewayConfig`, `ServerGatewayHandlers`, `Scheduler`, `TimerHandle`, `GatewayConnectionState`, `GatewayInbound`, `GatewayInboundKind` — types.
## Implementation Notes
- The deadline is rearmed whenever a heartbeat-ack is received. This is the entire mechanism behind the 2× rule: if the upstream peer disappears, no ack arrives, the timer fires, and the disconnect handler runs.
- Heartbeat sends that throw or reject collapse into `handleClose("heartbeat-send-failed:<message>")`. We never silently swallow the failure — a dead send is treated as a closed channel.
- Frame demux is a single byte-equality check against `heartbeatAckBytes`. This is intentionally simple; richer envelopes belong in the protocol plugin upstream of the gateway.

View File

@@ -0,0 +1,514 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_SB_CODES,
NetworkSbError,
} from "../error-codes/error-codes.js";
import type { BridgeTransport, Scheduler, TimerHandle } from "./index.js";
import { ServerGateway } from "./server-gateway.js";
interface PendingTimer {
fn: () => void;
ms: number;
repeating: boolean;
cancelled: boolean;
}
class ManualScheduler implements Scheduler {
public readonly timers: PendingTimer[] = [];
public setInterval(fn: () => void, ms: number): TimerHandle {
const t: PendingTimer = { fn, ms, repeating: true, cancelled: false };
this.timers.push(t);
return { cancel: () => (t.cancelled = true) };
}
public setTimeout(fn: () => void, ms: number): TimerHandle {
const t: PendingTimer = { fn, ms, repeating: false, cancelled: false };
this.timers.push(t);
return { cancel: () => (t.cancelled = true) };
}
public fireAllIntervals(): void {
for (const t of this.timers) {
if (t.repeating && !t.cancelled) t.fn();
}
}
public fireAllTimeouts(): void {
for (const t of this.timers) {
if (!t.repeating && !t.cancelled) {
t.cancelled = true;
t.fn();
}
}
}
}
interface FakeTransportControls {
transport: BridgeTransport;
sentFrames: Uint8Array[];
receive(frame: Uint8Array): void;
serverClose(reason: string): void;
closed: { value: boolean; reason: string | null };
}
function makeFakeTransport(
opts: {
sendThrows?: boolean;
sendRejects?: boolean;
closeThrows?: boolean;
} = {}
): FakeTransportControls {
let messageHandler: ((f: Uint8Array) => void) | null = null;
let closeHandler: ((r: string) => void) | null = null;
const sentFrames: Uint8Array[] = [];
const closed = { value: false, reason: null as string | null };
const transport: BridgeTransport = {
send(bytes) {
if (opts.sendThrows) throw new Error("send-sync-failed");
if (opts.sendRejects)
return Promise.reject(new Error("send-async-failed"));
sentFrames.push(bytes);
},
onMessage(h) {
messageHandler = h;
return () => {
messageHandler = null;
};
},
onClose(h) {
closeHandler = h;
return () => {
closeHandler = null;
};
},
close(reason) {
if (opts.closeThrows) throw new Error("close-failed");
closed.value = true;
closed.reason = reason ?? null;
},
};
return {
transport,
sentFrames,
receive(frame) {
messageHandler?.(frame);
},
serverClose(reason) {
closeHandler?.(reason);
},
closed,
};
}
describe("ServerGateway", () => {
it("rejects non-positive heartbeat interval", () => {
const fake = makeFakeTransport();
expect(
() =>
new ServerGateway({
transportFactory: () => fake.transport,
heartbeatIntervalMs: 0,
})
).toThrow(NetworkSbError);
});
it("connects, opens heartbeat loop, and exposes connection state", async () => {
const fake = makeFakeTransport();
const sched = new ManualScheduler();
const events: string[] = [];
const gateway = new ServerGateway(
{
transportFactory: () => fake.transport,
heartbeatIntervalMs: 100,
},
{ onConnect: () => events.push("connect") },
sched
);
expect(gateway.connectionState()).toBe("idle");
await gateway.connect();
expect(gateway.connectionState()).toBe("open");
expect(events).toEqual(["connect"]);
sched.fireAllIntervals();
expect(fake.sentFrames).toHaveLength(1);
expect(fake.sentFrames[0]).toEqual(new Uint8Array([0x68, 0x62]));
gateway.close();
});
it("connect is idempotent on already-open gateway", async () => {
const fake = makeFakeTransport();
const gateway = new ServerGateway({
transportFactory: () => fake.transport,
heartbeatIntervalMs: 100,
});
await gateway.connect();
await gateway.connect();
expect(gateway.connectionState()).toBe("open");
gateway.close();
});
it("throws GATEWAY_CLOSED when sending after close", async () => {
const fake = makeFakeTransport();
const gateway = new ServerGateway({
transportFactory: () => fake.transport,
heartbeatIntervalMs: 100,
});
await gateway.connect();
gateway.close();
await expect(gateway.send(new Uint8Array([1]))).rejects.toMatchObject({
code: NETWORK_SB_CODES.GATEWAY_CLOSED,
});
});
it("throws when sending while not connected", async () => {
const fake = makeFakeTransport();
const gateway = new ServerGateway({
transportFactory: () => fake.transport,
heartbeatIntervalMs: 100,
});
await expect(gateway.send(new Uint8Array([1]))).rejects.toThrow(
"sb.gateway.not_connected"
);
});
it("send forwards bytes to the transport", async () => {
const fake = makeFakeTransport();
const gateway = new ServerGateway({
transportFactory: () => fake.transport,
heartbeatIntervalMs: 100,
});
await gateway.connect();
await gateway.send(new Uint8Array([7, 8, 9]));
expect(fake.sentFrames).toEqual([new Uint8Array([7, 8, 9])]);
gateway.close();
});
it("send wraps transport failure in NetworkSbError(GATEWAY_SEND_FAILED)", async () => {
const fake = makeFakeTransport({ sendThrows: true });
const gateway = new ServerGateway({
transportFactory: () => fake.transport,
heartbeatIntervalMs: 100,
});
await gateway.connect();
await expect(gateway.send(new Uint8Array([1]))).rejects.toMatchObject({
code: NETWORK_SB_CODES.GATEWAY_SEND_FAILED,
});
gateway.close();
});
it("send wraps non-Error throwables", async () => {
let messageHandler: ((f: Uint8Array) => void) | null = null;
const fakeTransport: BridgeTransport = {
send() {
const literal: unknown = "literal-failure";
throw literal;
},
onMessage(h) {
messageHandler = h;
return () => {
messageHandler = null;
};
},
onClose() {
return () => undefined;
},
close() {
/* noop */
},
};
const gateway = new ServerGateway({
transportFactory: () => fakeTransport,
heartbeatIntervalMs: 100,
});
await gateway.connect();
await expect(gateway.send(new Uint8Array([1]))).rejects.toMatchObject({
code: NETWORK_SB_CODES.GATEWAY_SEND_FAILED,
});
expect(messageHandler).not.toBeNull();
gateway.close();
});
it("treats same-length non-ack frames as messages", async () => {
const fake = makeFakeTransport();
const messages: Uint8Array[] = [];
const gateway = new ServerGateway(
{
transportFactory: () => fake.transport,
heartbeatIntervalMs: 100,
},
{ onMessage: (m) => messages.push(m.bytes) }
);
await gateway.connect();
// same length 2 as heartbeatAckBytes (`ak` = [0x61, 0x6b]) but different
fake.receive(new Uint8Array([0x61, 0x6c]));
expect(messages).toEqual([new Uint8Array([0x61, 0x6c])]);
gateway.close();
});
it("dispatches non-heartbeat frames to onMessage", async () => {
const fake = makeFakeTransport();
const messages: Uint8Array[] = [];
const gateway = new ServerGateway(
{
transportFactory: () => fake.transport,
heartbeatIntervalMs: 100,
},
{
onMessage: (m) => messages.push(m.bytes),
}
);
await gateway.connect();
fake.receive(new Uint8Array([10, 20, 30]));
expect(messages).toEqual([new Uint8Array([10, 20, 30])]);
gateway.close();
});
it("recognises heartbeat-ack and rearms deadline", async () => {
const fake = makeFakeTransport();
const acks: number[] = [];
let now = 0;
const gateway = new ServerGateway(
{
transportFactory: () => fake.transport,
heartbeatIntervalMs: 100,
clock: () => now,
},
{ onHeartbeatAck: (t) => acks.push(t) }
);
await gateway.connect();
now = 50;
fake.receive(new Uint8Array([0x61, 0x6b]));
expect(acks).toEqual([50]);
expect(gateway.lastAckTimestamp()).toBe(50);
gateway.close();
});
it("disconnects when 2× heartbeat deadline expires", async () => {
const fake = makeFakeTransport();
const sched = new ManualScheduler();
const dis: string[] = [];
const gateway = new ServerGateway(
{
transportFactory: () => fake.transport,
heartbeatIntervalMs: 100,
},
{ onDisconnect: (r) => dis.push(r) },
sched
);
await gateway.connect();
sched.fireAllTimeouts(); // simulate deadline expiry
expect(dis).toEqual(["heartbeat-timeout"]);
expect(gateway.connectionState()).toBe("closed");
});
it("respects custom disconnectTimeoutMs", async () => {
const fake = makeFakeTransport();
const sched = new ManualScheduler();
const gateway = new ServerGateway(
{
transportFactory: () => fake.transport,
heartbeatIntervalMs: 100,
disconnectTimeoutMs: 250,
},
{},
sched
);
await gateway.connect();
const deadlineTimer = sched.timers.find((t) => !t.repeating);
expect(deadlineTimer?.ms).toBe(250);
gateway.close();
});
it("close is idempotent", async () => {
const fake = makeFakeTransport();
const gateway = new ServerGateway({
transportFactory: () => fake.transport,
heartbeatIntervalMs: 100,
});
await gateway.connect();
gateway.close();
gateway.close(); // no-op
expect(gateway.connectionState()).toBe("closed");
});
it("close swallows transport.close failures", async () => {
const fake = makeFakeTransport({ closeThrows: true });
const gateway = new ServerGateway({
transportFactory: () => fake.transport,
heartbeatIntervalMs: 100,
});
await gateway.connect();
expect(() => gateway.close()).not.toThrow();
});
it("transport-side close routes through onDisconnect", async () => {
const fake = makeFakeTransport();
const dis: string[] = [];
const gateway = new ServerGateway(
{
transportFactory: () => fake.transport,
heartbeatIntervalMs: 100,
},
{ onDisconnect: (r) => dis.push(r) }
);
await gateway.connect();
fake.serverClose("server-bye");
expect(dis).toEqual(["server-bye"]);
expect(gateway.connectionState()).toBe("closed");
// a second server-close is a no-op
fake.serverClose("again");
expect(dis).toEqual(["server-bye"]);
});
it("heartbeat send failure triggers handleClose", async () => {
const fake = makeFakeTransport({ sendRejects: true });
const sched = new ManualScheduler();
const dis: string[] = [];
const gateway = new ServerGateway(
{
transportFactory: () => fake.transport,
heartbeatIntervalMs: 100,
},
{ onDisconnect: (r) => dis.push(r) },
sched
);
await gateway.connect();
sched.fireAllIntervals();
// wait microtask
await Promise.resolve();
await Promise.resolve();
expect(dis[0]).toMatch(/heartbeat-send-failed/);
});
it("interval after close is a no-op", async () => {
const fake = makeFakeTransport();
const sched = new ManualScheduler();
const gateway = new ServerGateway(
{
transportFactory: () => fake.transport,
heartbeatIntervalMs: 100,
},
{},
sched
);
await gateway.connect();
const intervalTimer = sched.timers.find((t) => t.repeating);
gateway.close();
// Manually invoke the interval fn even though cancelled flag is set:
// exercises the state guard inside the loop.
intervalTimer?.fn();
expect(fake.sentFrames).toHaveLength(0);
});
it("deadline timer ignored after close", async () => {
const fake = makeFakeTransport();
const sched = new ManualScheduler();
const gateway = new ServerGateway(
{
transportFactory: () => fake.transport,
heartbeatIntervalMs: 100,
},
{ onDisconnect: () => undefined },
sched
);
await gateway.connect();
const deadlineTimer = sched.timers.find((t) => !t.repeating);
gateway.close();
// invoking the cancelled deadline must not double-fire the close path
deadlineTimer?.fn();
expect(gateway.connectionState()).toBe("closed");
});
it("heartbeat send rejection with non-Error closes with stringified cause", async () => {
let messageHandler: ((f: Uint8Array) => void) | null = null;
let closeHandler: ((r: string) => void) | null = null;
const fakeT: BridgeTransport = {
send() {
const literal: unknown = "non-error-literal";
return Promise.reject(literal);
},
onMessage(h) {
messageHandler = h;
return () => (messageHandler = null);
},
onClose(h) {
closeHandler = h;
return () => (closeHandler = null);
},
close() {
/* noop */
},
};
const sched = new ManualScheduler();
const dis: string[] = [];
const gateway = new ServerGateway(
{ transportFactory: () => fakeT, heartbeatIntervalMs: 100 },
{ onDisconnect: (r) => dis.push(r) },
sched
);
await gateway.connect();
sched.fireAllIntervals();
await Promise.resolve();
await Promise.resolve();
expect(dis[0]).toContain("non-error-literal");
expect(messageHandler).toBeNull();
expect(closeHandler).toBeNull();
});
it("handleClose short-circuits on duplicate fire after state is closed", async () => {
// A bug-tolerant transport that ignores its own unsubscribe and
// fires onClose twice. The gateway must short-circuit on the
// second hit so onDisconnect runs exactly once.
let onClose: ((r: string) => void) | null = null;
const fakeT: BridgeTransport = {
send: () => undefined,
onMessage: () => () => undefined,
onClose(h) {
onClose = h;
return () => undefined; // intentionally do NOT clear
},
close: () => undefined,
};
const dis: string[] = [];
const gateway = new ServerGateway(
{ transportFactory: () => fakeT, heartbeatIntervalMs: 100 },
{ onDisconnect: (r) => dis.push(r) }
);
await gateway.connect();
onClose?.("first");
onClose?.("second-ignored");
expect(dis).toEqual(["first"]);
expect(gateway.connectionState()).toBe("closed");
});
it("uses custom heartbeat / ack markers when configured", async () => {
const fake = makeFakeTransport();
const sched = new ManualScheduler();
const acks: number[] = [];
const customHb = new Uint8Array([0x01, 0x02, 0x03]);
const customAck = new Uint8Array([0xff]);
const gateway = new ServerGateway(
{
transportFactory: () => fake.transport,
heartbeatIntervalMs: 100,
heartbeatBytes: customHb,
heartbeatAckBytes: customAck,
clock: () => 7,
},
{ onHeartbeatAck: (t) => acks.push(t) },
sched
);
await gateway.connect();
sched.fireAllIntervals();
expect(fake.sentFrames[0]).toEqual(customHb);
fake.receive(customAck);
expect(acks).toEqual([7]);
gateway.close();
});
it("default scheduler runs (smoke test against real timers)", async () => {
const fake = makeFakeTransport();
const gateway = new ServerGateway({
transportFactory: () => fake.transport,
heartbeatIntervalMs: 1_000_000, // never fires during the test
});
await gateway.connect();
gateway.close();
});
});

View File

@@ -0,0 +1,231 @@
import {
NETWORK_SB_CODES,
NetworkSbError,
} from "../error-codes/error-codes.js";
import type {
BridgeTransport,
GatewayConnectionState,
GatewayInbound,
ServerGatewayConfig,
ServerGatewayHandlers,
} from "./server-gateway.types.js";
const DEFAULT_HEARTBEAT = new Uint8Array([0x68, 0x62]); // "hb"
const DEFAULT_HEARTBEAT_ACK = new Uint8Array([0x61, 0x6b]); // "ak"
const equalBytes = (a: Uint8Array, b: Uint8Array): boolean => {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
};
interface TimerHandle {
cancel(): void;
}
interface Scheduler {
setInterval(fn: () => void, ms: number): TimerHandle;
setTimeout(fn: () => void, ms: number): TimerHandle;
}
const defaultScheduler: Scheduler = {
setInterval(fn, ms) {
const id = setInterval(fn, ms);
return { cancel: () => clearInterval(id) };
},
setTimeout(fn, ms) {
const id = setTimeout(fn, ms);
return { cancel: () => clearTimeout(id) };
},
};
/**
* Persistent Tier-0 ↔ central-server gateway.
*
* Owns the heartbeat loop and the disconnect-detection deadline. The
* underlying transport is injected via `BridgeTransportFactory` so tests
* never open real sockets and consumers can substitute WebSocket,
* fetch-poll, or any other duplex byte channel.
*/
export class ServerGateway {
private readonly config: ServerGatewayConfig;
private readonly handlers: ServerGatewayHandlers;
private readonly clock: () => number;
private readonly heartbeatBytes: Uint8Array;
private readonly heartbeatAckBytes: Uint8Array;
private readonly disconnectTimeoutMs: number;
private readonly scheduler: Scheduler;
private state: GatewayConnectionState = "idle";
private transport: BridgeTransport | null = null;
private offMessage: (() => void) | null = null;
private offClose: (() => void) | null = null;
private heartbeatTimer: TimerHandle | null = null;
private deadlineTimer: TimerHandle | null = null;
private lastAckAt = 0;
public constructor(
config: ServerGatewayConfig,
handlers: ServerGatewayHandlers = {},
scheduler: Scheduler = defaultScheduler
) {
if (config.heartbeatIntervalMs <= 0) {
throw new NetworkSbError(
NETWORK_SB_CODES.GATEWAY_HEARTBEAT_TIMEOUT,
"sb.gateway.heartbeat_invalid",
{ context: { value: config.heartbeatIntervalMs } }
);
}
this.config = config;
this.handlers = handlers;
this.clock = config.clock ?? (() => Date.now());
this.heartbeatBytes = config.heartbeatBytes ?? DEFAULT_HEARTBEAT;
this.heartbeatAckBytes =
config.heartbeatAckBytes ?? DEFAULT_HEARTBEAT_ACK;
this.disconnectTimeoutMs =
config.disconnectTimeoutMs ?? 2 * config.heartbeatIntervalMs;
this.scheduler = scheduler;
}
public connectionState(): GatewayConnectionState {
return this.state;
}
public async connect(): Promise<void> {
if (this.state === "open" || this.state === "connecting") return;
this.state = "connecting";
const transport = await this.config.transportFactory();
this.transport = transport;
this.offMessage = transport.onMessage((frame) =>
this.handleFrame(frame)
);
this.offClose = transport.onClose((reason) => this.handleClose(reason));
this.state = "open";
this.lastAckAt = this.clock();
this.startHeartbeatLoop();
this.armDeadline();
this.handlers.onConnect?.();
}
public async send(bytes: Uint8Array): Promise<void> {
if (this.state === "closed" || this.state === "closing") {
throw new NetworkSbError(
NETWORK_SB_CODES.GATEWAY_CLOSED,
"sb.gateway.closed",
{ context: { state: this.state } }
);
}
if (this.state !== "open" || this.transport === null) {
throw new NetworkSbError(
NETWORK_SB_CODES.GATEWAY_NOT_CONNECTED,
"sb.gateway.not_connected",
{ context: { state: this.state } }
);
}
try {
await this.transport.send(bytes);
} catch (cause) {
throw new NetworkSbError(
NETWORK_SB_CODES.GATEWAY_SEND_FAILED,
"sb.gateway.send_failed",
{
cause:
cause instanceof Error
? cause
: new Error(String(cause)),
}
);
}
}
public lastAckTimestamp(): number {
return this.lastAckAt;
}
public close(reason = "explicit-close"): void {
if (this.state === "closed" || this.state === "closing") return;
this.state = "closing";
this.teardownTimers();
this.offMessage?.();
this.offClose?.();
this.offMessage = null;
this.offClose = null;
const transport = this.transport;
this.transport = null;
try {
transport?.close(reason);
} catch {
// transport.close MUST be idempotent; swallow to keep teardown
// deterministic. The disconnect handler is still invoked below.
}
this.state = "closed";
this.handlers.onDisconnect?.(reason);
}
private handleFrame(frame: Uint8Array): void {
if (equalBytes(frame, this.heartbeatAckBytes)) {
this.lastAckAt = this.clock();
this.armDeadline();
this.handlers.onHeartbeatAck?.(this.lastAckAt);
return;
}
const inbound: GatewayInbound = { kind: "message", bytes: frame };
this.handlers.onMessage?.(inbound);
}
private handleClose(reason: string): void {
// After explicit close() the onClose handler is unsubscribed
// synchronously, so handleClose is only re-entered from
// server-side close events while state is still "open". A
// duplicate server-side fire reaches us with state === "closed"
// and short-circuits.
if (this.state === "closed") return;
this.state = "closed";
this.teardownTimers();
this.offMessage?.();
this.offClose?.();
this.offMessage = null;
this.offClose = null;
this.transport = null;
this.handlers.onDisconnect?.(reason);
}
private startHeartbeatLoop(): void {
this.heartbeatTimer = this.scheduler.setInterval(() => {
if (this.state !== "open" || this.transport === null) return;
void Promise.resolve(
this.transport.send(this.heartbeatBytes)
).catch((cause: unknown) => {
this.handleClose(
`heartbeat-send-failed:${cause instanceof Error ? cause.message : String(cause)}`
);
});
}, this.config.heartbeatIntervalMs);
}
private armDeadline(): void {
this.deadlineTimer?.cancel();
this.deadlineTimer = this.scheduler.setTimeout(() => {
if (this.state !== "open") return;
this.handleClose("heartbeat-timeout");
}, this.disconnectTimeoutMs);
}
private teardownTimers(): void {
this.heartbeatTimer?.cancel();
this.deadlineTimer?.cancel();
this.heartbeatTimer = null;
this.deadlineTimer = null;
}
}
export type {
BridgeTransport,
BridgeTransportFactory,
GatewayConnectionState,
GatewayInbound,
GatewayInboundKind,
ServerGatewayConfig,
ServerGatewayHandlers,
} from "./server-gateway.types.js";
export type { Scheduler, TimerHandle };

View File

@@ -0,0 +1,81 @@
# server-gateway.types
## Purpose
Shape definitions for the `ServerGateway` byte channel. Defines `BridgeTransport` (the structural shape any duplex transport must satisfy), `BridgeTransportFactory` (the factory the gateway calls during `connect()`), the inbound-frame discriminated union (`GatewayInbound` × `GatewayInboundKind`), the connection state enum (`GatewayConnectionState`), the gateway construction config (`ServerGatewayConfig`), and the handler bundle (`ServerGatewayHandlers`).
The split between the `*.types.ts` file and the `*.ts` implementation lets us co-locate handler / config docs without forcing the implementation file over the 300-line cap, and lets type-only consumers import from `gateway/server-gateway.types.js` without pulling in the runtime class.
## Features
- `BridgeTransport` is a structural interface — no class hierarchy required. Any object with `send`, `onMessage`, `onClose`, `close` qualifies.
- `BridgeTransportFactory` may be sync or async so the gateway can `await` a handshake (TLS, JWT exchange) before the channel is "open".
- `GatewayInbound.kind` is a discriminated union so handlers can statically narrow on `"heartbeat-ack"` vs `"message"` even though the gateway swallows the heartbeat-ack form before dispatch (it is exposed for completeness — future server protocols may surface other meta frames).
- `ServerGatewayConfig.heartbeatBytes` / `.heartbeatAckBytes` make the wire markers configurable so the gateway interoperates with whatever the server already speaks.
## Usage: Basic Example
```ts
import type {
BridgeTransport,
ServerGatewayConfig,
} from "@suaveplan/network-plugin-server-bridge";
const transport: BridgeTransport = {
send: (b) => socket.send(b),
onMessage: (h) => {
socket.on("message", h);
return () => socket.off("message", h);
},
onClose: (h) => {
socket.on("close", h);
return () => socket.off("close", h);
},
close: () => socket.close(),
};
const config: ServerGatewayConfig = {
transportFactory: () => transport,
heartbeatIntervalMs: 1_000,
};
```
## Usage: Advanced Example
```ts
import type {
BridgeTransport,
GatewayInbound,
ServerGatewayHandlers,
} from "@suaveplan/network-plugin-server-bridge";
const handlers: ServerGatewayHandlers = {
onConnect: () => bus.emit("server:connected", { at: Date.now() }),
onDisconnect: (reason) =>
bus.emit("server:disconnected", { reason, at: Date.now() }),
onMessage: (frame: GatewayInbound) => {
if (frame.kind === "message") router.dispatch(frame.bytes);
},
onHeartbeatAck: (atMs) => latencyHistogram.observe(atMs - lastSent),
};
const factory: () => Promise<BridgeTransport> = async () => {
const tok = await acquireServerJwt();
return openWebSocketWithToken(tok);
};
```
## API Reference
- `BridgeTransport` — `{ send(bytes); onMessage(handler) → unsubscribe; onClose(handler) → unsubscribe; close(reason?) }`.
- `BridgeTransportFactory` — `() => BridgeTransport | Promise<BridgeTransport>`.
- `GatewayConnectionState` — `"idle" | "connecting" | "open" | "closing" | "closed"`.
- `GatewayInbound` — `{ kind: GatewayInboundKind; bytes: Uint8Array }`.
- `GatewayInboundKind` — `"heartbeat-ack" | "message"`.
- `ServerGatewayConfig` — `{ transportFactory; heartbeatIntervalMs; disconnectTimeoutMs?; clock?; heartbeatBytes?; heartbeatAckBytes? }`.
- `ServerGatewayHandlers` — optional `{ onConnect; onDisconnect; onMessage; onHeartbeatAck }`.
## Implementation Notes
- The factory is invoked exactly once per `connect()` call; if the gateway is later closed and reconnected, the factory runs again. This lets the consumer perform fresh authentication on every reconnect without bookkeeping inside the gateway.
- All handler signatures are `void`-returning — the gateway never awaits handler completion, so handlers must not block the loop.

View File

@@ -0,0 +1,66 @@
/**
* Connection state of the underlying transport.
*/
export type GatewayConnectionState =
| "idle"
| "connecting"
| "open"
| "closing"
| "closed";
/**
* Inbound payload kind from the server. Heartbeat acks are recognised by
* the gateway itself and never surface to consumers; every other frame is
* dispatched verbatim.
*/
export type GatewayInboundKind = "heartbeat-ack" | "message";
export interface GatewayInbound {
readonly kind: GatewayInboundKind;
readonly bytes: Uint8Array;
}
export interface BridgeTransport {
/** Send raw bytes upstream. Throws / rejects on failure. */
send(bytes: Uint8Array): void | Promise<void>;
/**
* Register the inbound handler. Returns an unsubscribe function. The
* gateway invokes the handler for every incoming frame including the
* server's heartbeat acknowledgement.
*/
onMessage(handler: (frame: Uint8Array) => void): () => void;
/**
* Register the close-event handler. Returns an unsubscribe. Triggered
* when the upstream peer closes the channel for any reason.
*/
onClose(handler: (reason: string) => void): () => void;
/** Close the channel. Idempotent. */
close(reason?: string): void;
}
export type BridgeTransportFactory = () =>
| BridgeTransport
| Promise<BridgeTransport>;
export interface ServerGatewayConfig {
readonly transportFactory: BridgeTransportFactory;
/** Heartbeat interval in milliseconds. */
readonly heartbeatIntervalMs: number;
/**
* Optional disconnect-detection timeout. Defaults to
* `2 × heartbeatIntervalMs` per `NET-31`.
*/
readonly disconnectTimeoutMs?: number;
readonly clock?: () => number;
/** Bytes to use as the heartbeat marker on the wire. */
readonly heartbeatBytes?: Uint8Array;
/** Bytes the server uses for the heartbeat acknowledgement. */
readonly heartbeatAckBytes?: Uint8Array;
}
export interface ServerGatewayHandlers {
onConnect?: () => void;
onDisconnect?: (reason: string) => void;
onMessage?: (frame: GatewayInbound) => void;
onHeartbeatAck?: (atMs: number) => void;
}

View File

@@ -0,0 +1,61 @@
# backup-coordinator
## Purpose
`BackupCoordinator` implements the `CLIENT_WITH_SERVER_BACKUP` model: clients host normally and the server is a backup authority that overrides client decisions whenever the gateway is online. Token issuance always succeeds; the attribution flips between `"server"` (when online — server signature is canonical) and `"client"` (when offline). Reconciliation flips the same way.
This is the inverse of the `SERVER_TO_CLIENT_FAILOVER` model: the steady state is client-driven and the server is an auditor / re-authoriser rather than the primary issuer. It suits deployments where the mesh is largely self-sufficient and the server exists for compliance, audit, and emergency override.
## Features
- `canClientPromote()` is unconditionally `true` — the model assumes clients are always primary.
- Token issuance always succeeds; attribution is `"server"` when online and `"client"` when offline.
- `resolveConflict()` flips with availability — server-wins when online, host-wins when offline.
- Server's role is to re-sign / override client tokens during reconciliation, not to gate issuance in real time.
## Usage: Basic Example
```ts
import { BackupCoordinator } from "@suaveplan/network-plugin-server-bridge";
const coord = new BackupCoordinator({
serverAvailable: () => gateway.connectionState() === "open",
mintToken: (req) => sign(req),
});
const result = coord.issueToken({
subjectId: "user-1",
tokenType: "mesh",
issuedAt: Date.now(),
});
```
## Usage: Advanced Example
```ts
import { BackupCoordinator } from "@suaveplan/network-plugin-server-bridge";
const coord = new BackupCoordinator({
serverAvailable: () => bridgeFsm.state() === "online",
mintToken: (req) => signWithRotatingKey(req),
});
bus.on("hosting:reconcile", (payload) => {
if (coord.resolveConflict() === "server" && payload.serverDiff) {
applyServerOverrides(payload.serverDiff);
}
});
```
## API Reference
- `new BackupCoordinator({ serverAvailable, mintToken })` — both required.
- `canClientPromote(): boolean` — always `true`.
- `issueToken(request): TokenIssueResult` — always `{ approved: true }`; attribution flips.
- `resolveConflict(): "host" | "server"` — flips with availability.
- `model: "CLIENT_WITH_SERVER_BACKUP"` — discriminant.
## Implementation Notes
- The behavioural difference between `BackupCoordinator` and `FailoverCoordinator` is solely `canClientPromote` (always true vs. only-when-offline). Both share the "always approve, attribute to whichever side is alive" issuance pattern and the same flipping reconciliation direction.
- We deliberately did not fold the two coordinators into one configurable class — they encode different operational intents, and the `model` discriminant gives downstream consumers a single source of truth without sniffing config flags.

View File

@@ -0,0 +1,45 @@
import type { HostingCoordinator, TokenMinter } from "./coordinator.types.js";
import type {
TokenIssueRequest,
TokenIssueResult,
} from "./hosting-model.types.js";
export interface BackupCoordinatorConfig {
readonly serverAvailable: () => boolean;
readonly mintToken: TokenMinter;
}
/**
* `CLIENT_WITH_SERVER_BACKUP` model — clients host normally; the server
* is a backup authority that overrides client decisions when online.
* Client may always promote; token issuance always succeeds, attributed
* to the server when online (so the server's signature is canonical) and
* to the client otherwise. Reconciliation is server-wins when online,
* host-wins when offline.
*/
export class BackupCoordinator implements HostingCoordinator {
public readonly model = "CLIENT_WITH_SERVER_BACKUP" as const;
private readonly available: () => boolean;
private readonly mint: TokenMinter;
public constructor(config: BackupCoordinatorConfig) {
this.available = config.serverAvailable;
this.mint = config.mintToken;
}
public canClientPromote(): boolean {
return true;
}
public issueToken(request: TokenIssueRequest): TokenIssueResult {
return {
approved: true,
approvedBy: this.available() ? "server" : "client",
tokenBytes: this.mint(request),
};
}
public resolveConflict(): "host" | "server" {
return this.available() ? "server" : "host";
}
}

View File

@@ -0,0 +1,34 @@
import type {
QuorumVote,
TokenIssueRequest,
TokenIssueResult,
} from "./hosting-model.types.js";
/**
* Common surface every hosting-model coordinator implements. The four
* concrete coordinators differ in HOW they answer; the surface is the
* same so the plugin façade can swap implementations without consumer
* impact.
*/
export interface HostingCoordinator {
readonly model:
| "PURE_CLIENT"
| "SERVER_BASED"
| "SERVER_TO_CLIENT_FAILOVER"
| "CLIENT_WITH_SERVER_BACKUP";
/** Whether a client may attempt self-promotion right now. */
canClientPromote(): boolean;
/** Issue a token under the model's authority rules. */
issueToken(
request: TokenIssueRequest,
votes?: readonly QuorumVote[]
): TokenIssueResult;
/** Resolution direction for a reconciliation conflict under this model. */
resolveConflict(): "host" | "server";
}
/**
* Function the coordinators call to mint the actual token bytes once
* authority is established.
*/
export type TokenMinter = (request: TokenIssueRequest) => Uint8Array;

View File

@@ -0,0 +1,220 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_SB_CODES,
NetworkSbError,
} from "../error-codes/error-codes.js";
import { BackupCoordinator } from "./backup-coordinator.js";
import { FailoverCoordinator } from "./failover-coordinator.js";
import { PureClientCoordinator } from "./pure-client-coordinator.js";
import { ServerBasedCoordinator } from "./server-based-coordinator.js";
const TOKEN = new Uint8Array([0xa5]);
const mint = () => TOKEN;
const REQ = {
subjectId: "subject-1",
tokenType: "session" as const,
issuedAt: 1,
};
describe("PureClientCoordinator", () => {
it("rejects invalid totalPeers", () => {
expect(
() => new PureClientCoordinator({ totalPeers: 0, mintToken: mint })
).toThrow(NetworkSbError);
expect(
() =>
new PureClientCoordinator({ totalPeers: 1.5, mintToken: mint })
).toThrow(NetworkSbError);
});
it("approves with sufficient quorum (2f+1)", () => {
// f = floor((4-1)/3) = 1; required = 3
const coord = new PureClientCoordinator({
totalPeers: 4,
mintToken: mint,
});
const result = coord.issueToken(REQ, [
{ peerId: "a", accept: true },
{ peerId: "b", accept: true },
{ peerId: "c", accept: true },
{ peerId: "d", accept: false },
]);
expect(result.approved).toBe(true);
expect(result.approvedBy).toBe("quorum");
});
it("ignores duplicate peer votes", () => {
const coord = new PureClientCoordinator({
totalPeers: 4,
mintToken: mint,
});
// 3 yeas if dedup OK; 5 yeas if not
expect(() =>
coord.issueToken(REQ, [
{ peerId: "a", accept: true },
{ peerId: "a", accept: true },
{ peerId: "a", accept: true },
{ peerId: "b", accept: true },
{ peerId: "c", accept: true },
])
).not.toThrow();
});
it("rejects when quorum not met", () => {
const coord = new PureClientCoordinator({
totalPeers: 4,
mintToken: mint,
});
try {
coord.issueToken(REQ, [{ peerId: "a", accept: true }]);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(NetworkSbError);
expect((err as NetworkSbError).code).toBe(
NETWORK_SB_CODES.HOSTING_QUORUM_NOT_MET
);
}
});
it("client may always promote", () => {
const coord = new PureClientCoordinator({
totalPeers: 4,
mintToken: mint,
});
expect(coord.canClientPromote()).toBe(true);
});
it("rejects when no votes are supplied", () => {
const coord = new PureClientCoordinator({
totalPeers: 4,
mintToken: mint,
});
try {
coord.issueToken(REQ);
throw new Error("expected throw");
} catch (err) {
expect((err as NetworkSbError).code).toBe(
NETWORK_SB_CODES.HOSTING_QUORUM_NOT_MET
);
}
});
it("conflict resolution favours host", () => {
const coord = new PureClientCoordinator({
totalPeers: 4,
mintToken: mint,
});
expect(coord.resolveConflict()).toBe("host");
});
});
describe("ServerBasedCoordinator", () => {
it("forbids promotion", () => {
const coord = new ServerBasedCoordinator({
serverAvailable: () => true,
mintToken: mint,
});
expect(coord.canClientPromote()).toBe(false);
});
it("approves when server is online", () => {
const coord = new ServerBasedCoordinator({
serverAvailable: () => true,
mintToken: mint,
});
expect(coord.issueToken(REQ).approved).toBe(true);
});
it("rejects when server is offline", () => {
const coord = new ServerBasedCoordinator({
serverAvailable: () => false,
mintToken: mint,
});
const result = coord.issueToken(REQ);
expect(result.approved).toBe(false);
expect(result.tokenBytes).toBeNull();
});
it("conflict resolution always server-wins", () => {
const coord = new ServerBasedCoordinator({
serverAvailable: () => true,
mintToken: mint,
});
expect(coord.resolveConflict()).toBe("server");
});
});
describe("FailoverCoordinator", () => {
it("client may promote only when server is offline", () => {
let online = true;
const coord = new FailoverCoordinator({
serverAvailable: () => online,
mintToken: mint,
});
expect(coord.canClientPromote()).toBe(false);
online = false;
expect(coord.canClientPromote()).toBe(true);
});
it("attributes token to server when online, client when offline", () => {
let online = true;
const coord = new FailoverCoordinator({
serverAvailable: () => online,
mintToken: mint,
});
expect(coord.issueToken(REQ).approvedBy).toBe("server");
online = false;
expect(coord.issueToken(REQ).approvedBy).toBe("client");
});
it("conflict resolution flips with availability", () => {
let online = true;
const coord = new FailoverCoordinator({
serverAvailable: () => online,
mintToken: mint,
});
expect(coord.resolveConflict()).toBe("server");
online = false;
expect(coord.resolveConflict()).toBe("host");
});
});
describe("BackupCoordinator", () => {
it("client may always promote", () => {
const coord = new BackupCoordinator({
serverAvailable: () => true,
mintToken: mint,
});
expect(coord.canClientPromote()).toBe(true);
});
it("server overrides client when online", () => {
const coord = new BackupCoordinator({
serverAvailable: () => true,
mintToken: mint,
});
const result = coord.issueToken(REQ);
expect(result.approved).toBe(true);
expect(result.approvedBy).toBe("server");
});
it("client owns issuance when server offline", () => {
const coord = new BackupCoordinator({
serverAvailable: () => false,
mintToken: mint,
});
const result = coord.issueToken(REQ);
expect(result.approvedBy).toBe("client");
});
it("conflict resolution flips with availability", () => {
let online = true;
const coord = new BackupCoordinator({
serverAvailable: () => online,
mintToken: mint,
});
expect(coord.resolveConflict()).toBe("server");
online = false;
expect(coord.resolveConflict()).toBe("host");
});
});

View File

@@ -0,0 +1,67 @@
# failover-coordinator
## Purpose
`FailoverCoordinator` implements the `SERVER_TO_CLIENT_FAILOVER` model — the proposal's current default. The server is the primary host and clients only promote when the gateway is offline. Token issuance is always approved (the cluster cannot afford to wedge while the server is unreachable), but the attribution flips between `"server"` and `"client"` based on availability so the audit trail clearly shows who minted each token.
This is the most common production model: server consistency in the steady state, client autonomy during outages, with reconciliation flipping back to server-wins as soon as the gateway recovers.
## Features
- `canClientPromote()` is gated on `!serverAvailable()` — clients only step up when the server is genuinely gone.
- Token issuance always succeeds; attribution is `"server"` when online and `"client"` when offline.
- `resolveConflict()` returns `"server"` when online and `"host"` when offline — matches the proposal's "server is authority but the host owns local activity until reconnect" framing.
- No quorum machinery; the failover model assumes the local host is trusted to act unilaterally during outages (the audit-mesh layer is the integrity backstop).
## Usage: Basic Example
```ts
import { FailoverCoordinator } from "@suaveplan/network-plugin-server-bridge";
const coord = new FailoverCoordinator({
serverAvailable: () => gateway.connectionState() === "open",
mintToken: (req) => signLocal(req),
});
if (coord.canClientPromote()) electionEngine.allowSelfPromote();
const result = coord.issueToken({
subjectId: "session-1",
tokenType: "session",
issuedAt: Date.now(),
});
```
## Usage: Advanced Example
```ts
import { FailoverCoordinator } from "@suaveplan/network-plugin-server-bridge";
const coord = new FailoverCoordinator({
serverAvailable: () => bridgeFsm.state() === "online",
mintToken: (req) => mintWithAvailableKey(req),
});
bridgeFsm.onTransition((change) => {
if (change.to === "offline") {
bus.emit("hosting:client-takeover", { reason: change.reason });
} else if (change.to === "online") {
bus.emit("hosting:server-resumed", { reason: change.reason });
}
});
const decision = coord.resolveConflict(); // "server" online, "host" offline
```
## API Reference
- `new FailoverCoordinator({ serverAvailable, mintToken })` — both required.
- `canClientPromote(): boolean` — `true` iff `serverAvailable() === false`.
- `issueToken(request): TokenIssueResult` — always `{ approved: true }`; `approvedBy` is `"server"` or `"client"` depending on availability.
- `resolveConflict(): "host" | "server"` — flips with availability.
- `model: "SERVER_TO_CLIENT_FAILOVER"` — discriminant.
## Implementation Notes
- `serverAvailable` is read on every method call, not cached. The cost is one virtual function call per decision; the benefit is no race window between an availability change and the coordinator's next decision.
- The model commits to "always approve" because the alternative — wedging during a server outage — defeats the purpose of having a failover model in the first place.
- The host-wins reconciliation direction during outages means buffered logs survive reconnection unless the server's clock is strictly later. The default LWW resolver in `ReconciliationProtocol` picks this up automatically.

View File

@@ -0,0 +1,43 @@
import type { HostingCoordinator, TokenMinter } from "./coordinator.types.js";
import type {
TokenIssueRequest,
TokenIssueResult,
} from "./hosting-model.types.js";
export interface FailoverCoordinatorConfig {
readonly serverAvailable: () => boolean;
readonly mintToken: TokenMinter;
}
/**
* `SERVER_TO_CLIENT_FAILOVER` model — current default. Server primary,
* client takeover when the gateway goes offline. Client takeover always
* approves token issuance (the cluster cannot afford to wedge while the
* server is unreachable).
*/
export class FailoverCoordinator implements HostingCoordinator {
public readonly model = "SERVER_TO_CLIENT_FAILOVER" as const;
private readonly available: () => boolean;
private readonly mint: TokenMinter;
public constructor(config: FailoverCoordinatorConfig) {
this.available = config.serverAvailable;
this.mint = config.mintToken;
}
public canClientPromote(): boolean {
return !this.available();
}
public issueToken(request: TokenIssueRequest): TokenIssueResult {
return {
approved: true,
approvedBy: this.available() ? "server" : "client",
tokenBytes: this.mint(request),
};
}
public resolveConflict(): "host" | "server" {
return this.available() ? "server" : "host";
}
}

View File

@@ -0,0 +1,61 @@
# hosting-model-config
## Purpose
`hostingModelConfig(model)` is the single accessor for the four hosting-model presets the proposal documents (`PURE_CLIENT`, `SERVER_BASED`, `SERVER_TO_CLIENT_FAILOVER`, `CLIENT_WITH_SERVER_BACKUP`). Each preset is a frozen `HostingModelConfig` describing the model's election behaviour (`clientPromotionAllowed`), token-authority kind (`tokenAuthority`), and reconciliation direction (`conflictDirection`). The accessor throws `HOSTING_MODEL_INVALID` for unknown values rather than silently degrading, satisfying `NET-56`'s "must be selectable at mesh creation" clause via fail-loud configuration validation.
The presets are colocated with the four concrete coordinators (`PureClientCoordinator`, `ServerBasedCoordinator`, `FailoverCoordinator`, `BackupCoordinator`) so the runtime authority decisions and the static configuration descriptors live in the same directory. This keeps the proposal's three behaviour axes — election, authority, reconciliation — visible in one place and ensures every coordinator can be cross-referenced against its config.
## Features
- Frozen presets keyed by `HostingModel` enum.
- `isHostingModel(value)` predicate for runtime validation of caller-supplied strings.
- `hostingModelConfig(model)` throws `NetworkSbError(HOSTING_MODEL_INVALID)` when the caller passes a string outside the documented enum.
- `HOSTING_MODELS` is exported as a `readonly HostingModel[]` so consumers can enumerate the supported set without importing the type.
## Usage: Basic Example
```ts
import {
hostingModelConfig,
} from "@suaveplan/network-plugin-server-bridge";
const cfg = hostingModelConfig("SERVER_TO_CLIENT_FAILOVER");
console.log(cfg.tokenAuthority); // "either"
console.log(cfg.clientPromotionAllowed); // true
```
## Usage: Advanced Example
```ts
import {
hostingModelConfig,
isHostingModel,
} from "@suaveplan/network-plugin-server-bridge";
function selectModel(input: string) {
if (!isHostingModel(input)) {
throw new Error(`unknown hosting model: ${input}`);
}
const cfg = hostingModelConfig(input);
return {
cfg,
elections: cfg.clientPromotionAllowed ? "open" : "server-only",
reconciliation: cfg.conflictDirection,
};
}
const decision = selectModel(process.env.HOSTING_MODEL ?? "SERVER_BASED");
```
## API Reference
- `HOSTING_MODELS: readonly HostingModel[]` — the four documented model names.
- `hostingModelConfig(model: HostingModel): HostingModelConfig` — preset accessor; throws `NetworkSbError(HOSTING_MODEL_INVALID)` for unknown values.
- `isHostingModel(value: string): value is HostingModel` — runtime guard.
## Implementation Notes
- The presets are `Object.freeze`d at module load so consumers can never mutate them by accident.
- The runtime guard short-circuits via a `Set<HostingModel>` for `O(1)` lookup, which matters when the validator runs inside hot reload paths.
- The accessor never returns a copy — it returns the frozen preset reference directly, so consumers receive identity-stable values across calls.

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_SB_CODES,
NetworkSbError,
} from "../error-codes/error-codes.js";
import {
HOSTING_MODELS,
hostingModelConfig,
isHostingModel,
} from "./hosting-model-config.js";
describe("hostingModelConfig", () => {
it("exposes all four documented models", () => {
expect(HOSTING_MODELS).toEqual([
"PURE_CLIENT",
"SERVER_BASED",
"SERVER_TO_CLIENT_FAILOVER",
"CLIENT_WITH_SERVER_BACKUP",
]);
});
it("returns the preset for each model", () => {
for (const m of HOSTING_MODELS) {
const cfg = hostingModelConfig(m);
expect(cfg.model).toBe(m);
}
});
it("PURE_CLIENT requires quorum and allows promotion", () => {
const cfg = hostingModelConfig("PURE_CLIENT");
expect(cfg.tokenAuthority).toBe("quorum");
expect(cfg.clientPromotionAllowed).toBe(true);
expect(cfg.conflictDirection).toBe("client-wins");
});
it("SERVER_BASED forbids promotion and is server-only", () => {
const cfg = hostingModelConfig("SERVER_BASED");
expect(cfg.clientPromotionAllowed).toBe(false);
expect(cfg.tokenAuthority).toBe("server");
expect(cfg.conflictDirection).toBe("server-wins");
});
it("rejects unknown models with HOSTING_MODEL_INVALID", () => {
try {
hostingModelConfig("BOGUS" as never);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(NetworkSbError);
expect((err as NetworkSbError).code).toBe(
NETWORK_SB_CODES.HOSTING_MODEL_INVALID
);
}
});
it("isHostingModel predicate", () => {
expect(isHostingModel("PURE_CLIENT")).toBe(true);
expect(isHostingModel("not-a-model")).toBe(false);
});
});

View File

@@ -0,0 +1,57 @@
import {
NETWORK_SB_CODES,
NetworkSbError,
} from "../error-codes/error-codes.js";
import {
HOSTING_MODELS,
type HostingModel,
type HostingModelConfig,
} from "./hosting-model.types.js";
const PRESETS: Readonly<Record<HostingModel, HostingModelConfig>> =
Object.freeze({
PURE_CLIENT: {
model: "PURE_CLIENT",
clientPromotionAllowed: true,
tokenAuthority: "quorum",
conflictDirection: "client-wins",
},
SERVER_BASED: {
model: "SERVER_BASED",
clientPromotionAllowed: false,
tokenAuthority: "server",
conflictDirection: "server-wins",
},
SERVER_TO_CLIENT_FAILOVER: {
model: "SERVER_TO_CLIENT_FAILOVER",
clientPromotionAllowed: true,
tokenAuthority: "either",
conflictDirection: "server-wins",
},
CLIENT_WITH_SERVER_BACKUP: {
model: "CLIENT_WITH_SERVER_BACKUP",
clientPromotionAllowed: true,
tokenAuthority: "either",
conflictDirection: "server-wins",
},
});
const HOSTING_MODEL_SET = new Set<HostingModel>(HOSTING_MODELS);
export function hostingModelConfig(model: HostingModel): HostingModelConfig {
if (!HOSTING_MODEL_SET.has(model)) {
throw new NetworkSbError(
NETWORK_SB_CODES.HOSTING_MODEL_INVALID,
"sb.hosting.model_invalid",
{ context: { model } }
);
}
return PRESETS[model];
}
export function isHostingModel(value: string): value is HostingModel {
return HOSTING_MODEL_SET.has(value as HostingModel);
}
export type { HostingModel, HostingModelConfig };
export { HOSTING_MODELS };

View File

@@ -0,0 +1,47 @@
export type HostingModel =
| "PURE_CLIENT"
| "SERVER_BASED"
| "SERVER_TO_CLIENT_FAILOVER"
| "CLIENT_WITH_SERVER_BACKUP";
export const HOSTING_MODELS: readonly HostingModel[] = [
"PURE_CLIENT",
"SERVER_BASED",
"SERVER_TO_CLIENT_FAILOVER",
"CLIENT_WITH_SERVER_BACKUP",
] as const;
export type ConflictDirection = "server-wins" | "client-wins";
export type TokenAuthorityKind = "server" | "quorum" | "either";
export interface HostingModelConfig {
readonly model: HostingModel;
/** Whether clients are allowed to self-promote during election. */
readonly clientPromotionAllowed: boolean;
/** Where token-issuance authority lives. */
readonly tokenAuthority: TokenAuthorityKind;
/** Reconciliation tiebreak direction. */
readonly conflictDirection: ConflictDirection;
}
export interface TokenIssueRequest {
readonly subjectId: string;
readonly tokenType: "session" | "mesh";
readonly issuedAt: number;
}
export interface TokenIssueResult {
readonly approved: boolean;
readonly approvedBy: "server" | "quorum" | "client" | "rejected";
readonly tokenBytes: Uint8Array | null;
}
/**
* Vote cast by a peer during a Byzantine quorum decision. The voter is
* identified by `peerId`; the `accept` flag is the signed yes/no.
*/
export interface QuorumVote {
readonly peerId: string;
readonly accept: boolean;
}

View File

@@ -0,0 +1,66 @@
/**
* NET-56 — Configurable hosting model.
*
* Pins: each of the four documented models exists, exposes the expected
* election-behaviour / token-authority / reconciliation-direction
* combination, and rejects unknown values.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BackupCoordinator } from "./backup-coordinator.js";
import { FailoverCoordinator } from "./failover-coordinator.js";
import { HOSTING_MODELS, hostingModelConfig } from "./hosting-model-config.js";
import { PureClientCoordinator } from "./pure-client-coordinator.js";
import { ServerBasedCoordinator } from "./server-based-coordinator.js";
describe("NET-56 — hosting models", () => {
it("exposes exactly the four documented models", () => {
expect(new Set(HOSTING_MODELS)).toEqual(
new Set([
"PURE_CLIENT",
"SERVER_BASED",
"SERVER_TO_CLIENT_FAILOVER",
"CLIENT_WITH_SERVER_BACKUP",
])
);
});
it("PURE_CLIENT — Byzantine quorum, client-wins", () => {
const cfg = hostingModelConfig("PURE_CLIENT");
expect(cfg.tokenAuthority).toBe("quorum");
expect(cfg.conflictDirection).toBe("client-wins");
const coord = new PureClientCoordinator({
totalPeers: 4,
mintToken: () => new Uint8Array([1]),
});
expect(coord.canClientPromote()).toBe(true);
});
it("SERVER_BASED — server-only authority, no client promotion", () => {
const cfg = hostingModelConfig("SERVER_BASED");
expect(cfg.clientPromotionAllowed).toBe(false);
const coord = new ServerBasedCoordinator({
serverAvailable: () => true,
mintToken: () => new Uint8Array([1]),
});
expect(coord.canClientPromote()).toBe(false);
});
it("SERVER_TO_CLIENT_FAILOVER — promotion gated on server outage", () => {
let online = true;
const coord = new FailoverCoordinator({
serverAvailable: () => online,
mintToken: () => new Uint8Array([1]),
});
expect(coord.canClientPromote()).toBe(false);
online = false;
expect(coord.canClientPromote()).toBe(true);
});
it("CLIENT_WITH_SERVER_BACKUP — server overrides when online", () => {
const coord = new BackupCoordinator({
serverAvailable: () => true,
mintToken: () => new Uint8Array([1]),
});
expect(coord.resolveConflict()).toBe("server");
});
});

View File

@@ -0,0 +1,34 @@
export {
BackupCoordinator,
type BackupCoordinatorConfig,
} from "./backup-coordinator.js";
export type {
HostingCoordinator,
TokenMinter,
} from "./coordinator.types.js";
export {
FailoverCoordinator,
type FailoverCoordinatorConfig,
} from "./failover-coordinator.js";
export type {
ConflictDirection,
QuorumVote,
TokenAuthorityKind,
TokenIssueRequest,
TokenIssueResult,
} from "./hosting-model.types.js";
export {
HOSTING_MODELS,
type HostingModel,
type HostingModelConfig,
hostingModelConfig,
isHostingModel,
} from "./hosting-model-config.js";
export {
PureClientCoordinator,
type PureClientCoordinatorConfig,
} from "./pure-client-coordinator.js";
export {
ServerBasedCoordinator,
type ServerBasedCoordinatorConfig,
} from "./server-based-coordinator.js";

View File

@@ -0,0 +1,78 @@
# pure-client-coordinator
## Purpose
`PureClientCoordinator` implements the `PURE_CLIENT` hosting model: no central server is in the picture, and token issuance requires a Byzantine `2f + 1` quorum of accept votes from the registered peer set. The coordinator is the source of truth for whether a token request meets the quorum threshold; it does not issue blank approvals. Every token mint goes through the injected `TokenMinter` so the cryptographic material itself stays out of this layer's concern.
This coordinator is the strictest of the four — it allows client promotion unconditionally (because there is no server to demote it) but requires a Byzantine majority for every authority decision. Failure to meet the quorum surfaces as `NetworkSbError(HOSTING_QUORUM_NOT_MET)` so the caller can observe the gap explicitly rather than silently degrading to an unsigned token.
## Features
- Byzantine quorum: `f = floor((totalPeers - 1) / 3)`; required = `2f + 1` accept votes.
- Vote dedup: duplicate `peerId`s are counted once so a single peer cannot inflate the tally.
- Validates `totalPeers` at construction (positive integer); throws `HOSTING_MODEL_INVALID` otherwise.
- `canClientPromote()` is unconditionally `true` — the model has no server to defer to.
- `resolveConflict()` always returns `"host"` — local activity is canonical.
## Usage: Basic Example
```ts
import { PureClientCoordinator } from "@suaveplan/network-plugin-server-bridge";
const coord = new PureClientCoordinator({
totalPeers: 7,
mintToken: (req) => signToken(req),
});
const result = coord.issueToken(
{ subjectId: "user-1", tokenType: "session", issuedAt: Date.now() },
[
{ peerId: "p1", accept: true },
{ peerId: "p2", accept: true },
{ peerId: "p3", accept: true },
{ peerId: "p4", accept: true },
{ peerId: "p5", accept: true },
]
);
```
## Usage: Advanced Example
```ts
import {
NETWORK_SB_CODES,
NetworkSbError,
PureClientCoordinator,
} from "@suaveplan/network-plugin-server-bridge";
const coord = new PureClientCoordinator({
totalPeers: meshSize,
mintToken: (req) => signed(req),
});
try {
const result = coord.issueToken(req, votes);
deliver(result.tokenBytes);
} catch (err) {
if (
err instanceof NetworkSbError &&
err.code === NETWORK_SB_CODES.HOSTING_QUORUM_NOT_MET
) {
retryAfterReSync();
} else throw err;
}
```
## API Reference
- `new PureClientCoordinator({ totalPeers, mintToken })` — `totalPeers` MUST be a positive integer.
- `canClientPromote(): boolean` — always `true`.
- `issueToken(request, votes?): TokenIssueResult` — throws `HOSTING_QUORUM_NOT_MET` if the dedup'd accept count is below `2f + 1`.
- `resolveConflict(): "host"` — always `host`.
- `model: "PURE_CLIENT"` — discriminant for the `HostingCoordinator` union.
## Implementation Notes
- Vote dedup uses a `Set<peerId>`; each peer's first ballot is the canonical one. This costs `O(votes)` memory per call and is the only realistic defence against a malicious peer flooding the tally.
- The Byzantine threshold formula assumes `totalPeers ≥ 1`; the guard throws on `totalPeers ≤ 0` so the formula's denominator is always safe.
- `resolveConflict` is intentionally model-static rather than situation-dependent — `PURE_CLIENT` has no server to weigh against, so the resolution direction never changes.

View File

@@ -0,0 +1,83 @@
import {
NETWORK_SB_CODES,
NetworkSbError,
} from "../error-codes/error-codes.js";
import type { HostingCoordinator, TokenMinter } from "./coordinator.types.js";
import type {
QuorumVote,
TokenIssueRequest,
TokenIssueResult,
} from "./hosting-model.types.js";
export interface PureClientCoordinatorConfig {
/**
* Total number of voting peers in the mesh. Byzantine quorum requires
* at least `2f + 1` accept votes where `f = floor((totalPeers - 1) / 3)`.
*/
readonly totalPeers: number;
readonly mintToken: TokenMinter;
}
/**
* `PURE_CLIENT` model — no central server. Token issuance requires a
* Byzantine 2f+1 quorum of accept votes from registered peers.
*/
export class PureClientCoordinator implements HostingCoordinator {
public readonly model = "PURE_CLIENT" as const;
private readonly totalPeers: number;
private readonly mint: TokenMinter;
public constructor(config: PureClientCoordinatorConfig) {
if (!Number.isInteger(config.totalPeers) || config.totalPeers <= 0) {
throw new NetworkSbError(
NETWORK_SB_CODES.HOSTING_MODEL_INVALID,
"sb.hosting.pure_client.peers_invalid",
{ context: { totalPeers: config.totalPeers } }
);
}
this.totalPeers = config.totalPeers;
this.mint = config.mintToken;
}
public canClientPromote(): boolean {
return true;
}
public issueToken(
request: TokenIssueRequest,
votes?: readonly QuorumVote[]
): TokenIssueResult {
const ballots = votes ?? [];
const f = Math.floor((this.totalPeers - 1) / 3);
const required = 2 * f + 1;
const seen = new Set<string>();
let yeas = 0;
for (const v of ballots) {
if (seen.has(v.peerId)) continue;
seen.add(v.peerId);
if (v.accept) yeas++;
}
if (yeas < required) {
throw new NetworkSbError(
NETWORK_SB_CODES.HOSTING_QUORUM_NOT_MET,
"sb.hosting.quorum_not_met",
{
context: {
yeas,
required,
totalPeers: this.totalPeers,
},
}
);
}
return {
approved: true,
approvedBy: "quorum",
tokenBytes: this.mint(request),
};
}
public resolveConflict(): "host" | "server" {
return "host";
}
}

View File

@@ -0,0 +1,67 @@
# server-based-coordinator
## Purpose
`ServerBasedCoordinator` implements the `SERVER_BASED` hosting model: the server is always the top-tier host, clients never promote, and every token issuance defers to the server. When the server is offline (`serverAvailable() === false`), token issuance is rejected — there is no local fallback by design. This is the strictest consistency model of the four; trade-off is a single point of failure (the server).
The coordinator is a pure decision module: it observes server availability via the injected `serverAvailable` callback, and routes the token mint through the injected `TokenMinter`. The server's signature itself is the property of the minter.
## Features
- `canClientPromote()` always returns `false`.
- Token issuance returns `{ approved: true, approvedBy: "server" }` when online; `{ approved: false, approvedBy: "rejected", tokenBytes: null }` when offline.
- `resolveConflict()` always returns `"server"` — the server is canonical regardless of online state, because the proposal's `SERVER_BASED` model says so.
- No retry / no quorum fallback — by design, this is the model that opts in to single-point-of-failure semantics in exchange for fastest consistency.
## Usage: Basic Example
```ts
import { ServerBasedCoordinator } from "@suaveplan/network-plugin-server-bridge";
const coord = new ServerBasedCoordinator({
serverAvailable: () => gateway.connectionState() === "open",
mintToken: (req) => serverSign(req),
});
const result = coord.issueToken({
subjectId: "user-1",
tokenType: "mesh",
issuedAt: Date.now(),
});
```
## Usage: Advanced Example
```ts
import { ServerBasedCoordinator } from "@suaveplan/network-plugin-server-bridge";
const coord = new ServerBasedCoordinator({
serverAvailable: () => fsm.state() === "online",
mintToken: (req) => signedBy(serverPrivateKey, req),
});
function tryIssue(req) {
const result = coord.issueToken(req);
if (!result.approved) {
// SERVER_BASED model rejects offline issuance — surface to caller
// so they can either wait for reconnect or fall back to a different
// mesh-wide strategy.
throw new Error("server offline; token issuance unavailable");
}
return result.tokenBytes;
}
```
## API Reference
- `new ServerBasedCoordinator({ serverAvailable, mintToken })` — both are required.
- `canClientPromote(): boolean` — always `false`.
- `issueToken(request): TokenIssueResult` — `{ approved, approvedBy, tokenBytes }`. `tokenBytes` is `null` only when `approved === false`.
- `resolveConflict(): "server"` — always `server`.
- `model: "SERVER_BASED"` — discriminant.
## Implementation Notes
- The `serverAvailable` callback is read on every request, not cached, so the coordinator immediately reflects gateway state changes.
- The model intentionally has no quorum or peer-vote machinery — wiring one would defeat the model's purpose.
- `resolveConflict` returns `"server"` even when the server is offline because the proposal commits to server-canonical reconciliation under this model. Offline reconciliation in `SERVER_BASED` is impossible by definition, so the value only matters once the gateway returns.

View File

@@ -0,0 +1,50 @@
import type { HostingCoordinator, TokenMinter } from "./coordinator.types.js";
import type {
TokenIssueRequest,
TokenIssueResult,
} from "./hosting-model.types.js";
export interface ServerBasedCoordinatorConfig {
/** Whether the gateway currently has an open link to the server. */
readonly serverAvailable: () => boolean;
readonly mintToken: TokenMinter;
}
/**
* `SERVER_BASED` model — server is always top-tier host. Clients never
* promote; token issuance defers entirely to the server. When the server
* is offline, every issuance request is rejected (no local fallback).
*/
export class ServerBasedCoordinator implements HostingCoordinator {
public readonly model = "SERVER_BASED" as const;
private readonly available: () => boolean;
private readonly mint: TokenMinter;
public constructor(config: ServerBasedCoordinatorConfig) {
this.available = config.serverAvailable;
this.mint = config.mintToken;
}
public canClientPromote(): boolean {
return false;
}
public issueToken(request: TokenIssueRequest): TokenIssueResult {
if (!this.available()) {
return {
approved: false,
approvedBy: "rejected",
tokenBytes: null,
};
}
return {
approved: true,
approvedBy: "server",
tokenBytes: this.mint(request),
};
}
public resolveConflict(): "host" | "server" {
return "server";
}
}

View File

@@ -0,0 +1,122 @@
// Side-effect import — registers NET_SB codes at module load so subpath
// consumers automatically inherit the registry.
import "./error-codes/index.js";
export {
LogBuffer,
type LogBufferAppend,
type ReplayHandler,
} from "./buffer/log-buffer.js";
export {
CommandDispatcher,
canonicalizeCommand,
} from "./command/command-dispatcher.js";
export type {
CanonicalCommand,
CommandDispatchBus,
CommandDispatcherConfig,
CommandHandler,
CommandHandlerMap,
CommandHandlerMeta,
SignatureVerifier,
} from "./command/command-dispatcher.types.js";
export {
NETWORK_SB_CODES,
NetworkSbError,
type NetworkSbErrorCode,
} from "./error-codes/index.js";
export {
BRIDGE_TRANSITIONS,
type FsmListener,
GracefulDegradationFsm,
type GracefulDegradationFsmConfig,
} from "./fsm/graceful-degradation-fsm.js";
export {
type BridgeTransport,
type BridgeTransportFactory,
type GatewayConnectionState,
type GatewayInbound,
type GatewayInboundKind,
type Scheduler,
ServerGateway,
type ServerGatewayConfig,
type ServerGatewayHandlers,
type TimerHandle,
} from "./gateway/server-gateway.js";
export {
BackupCoordinator,
type BackupCoordinatorConfig,
} from "./hosting/backup-coordinator.js";
export type {
HostingCoordinator,
TokenMinter,
} from "./hosting/coordinator.types.js";
export {
FailoverCoordinator,
type FailoverCoordinatorConfig,
} from "./hosting/failover-coordinator.js";
export type {
ConflictDirection,
QuorumVote,
TokenAuthorityKind,
TokenIssueRequest,
TokenIssueResult,
} from "./hosting/hosting-model.types.js";
export {
HOSTING_MODELS,
type HostingModel,
type HostingModelConfig,
hostingModelConfig,
isHostingModel,
} from "./hosting/hosting-model-config.js";
export {
PureClientCoordinator,
type PureClientCoordinatorConfig,
} from "./hosting/pure-client-coordinator.js";
export {
ServerBasedCoordinator,
type ServerBasedCoordinatorConfig,
} from "./hosting/server-based-coordinator.js";
export {
type BatchSink,
type HeartbeatBatch,
HeartbeatMultiplexer,
type HeartbeatMultiplexerConfig,
type PeerHeartbeat,
} from "./multiplexer/heartbeat-multiplexer.js";
export {
type OfflineCoordinatorBus,
OfflineModeCoordinator,
type OfflineModeCoordinatorConfig,
} from "./offline/offline-mode-coordinator.js";
export {
ServerBridgePlugin,
type ServerBridgePluginConfig,
type TierGate,
} from "./plugin/server-bridge-plugin.js";
export {
ReconciliationProtocol,
type ReconciliationProtocolConfig,
} from "./reconciliation/reconciliation-protocol.js";
export type {
ConflictReport,
MeshSequence,
ReconciliationOutcome,
ServerWatermark,
SessionAttributor,
} from "./reconciliation/reconciliation-protocol.types.js";
export type {
BufferedEntry,
LogBufferConfig,
} from "./types/buffer.types.js";
export {
COMMAND_TYPES,
type CommandType,
type ServerCommand,
type SignedCommand,
} from "./types/command.types.js";
export type {
BridgeState,
BridgeStateChange,
OfflineMode,
} from "./types/state.types.js";

View File

@@ -0,0 +1,80 @@
# heartbeat-multiplexer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`HeartbeatMultiplexer` aggregates per-peer heartbeats observed by a Tier-0 host and forwards them to the central server in fixed-window batches. It exists to satisfy the proposal's bandwidth invariant: instead of every peer in the mesh opening its own WebSocket to the server, only the Tier-0 host connects, and per-peer heartbeats are coalesced into a single periodic message upstream.
The multiplexer is timer-free by design — it tracks the window-open instant and exposes `shouldFlush()` so the host's outer scheduler decides when to call `flushNow()`. This keeps the multiplexer pure (no side effects until flush) and lets the host pin a single deterministic clock for heartbeat alignment instead of fighting two competing intervals.
## Features
- Per-peer coalescing: only the latest seq per peer in the open window is kept; older seqs are dropped silently to prevent retransmit storms.
- Hard cap on batch size (`maxBatchSize`, default 1024) — exceeding it raises `MULTIPLEXER_BATCH_OVERFLOW` rather than allowing unbounded memory growth on a misbehaving mesh.
- Sink is async-aware: `flushNow()` awaits the sink so the multiplexer never overlaps two outbound batches.
- Stable serialization: `flushNow()` emits heartbeats sorted by `peerId` so the wire frame is deterministic across runs.
- Optional `emitEmptyBatches` for keepalive-style channels that need a tick even when no peers are alive (off by default — silence is the right wire behaviour for "nothing happened").
## Usage: Basic Example
```ts
import { HeartbeatMultiplexer } from "@suaveplan/network-plugin-server-bridge";
const mux = new HeartbeatMultiplexer({
batchWindowMs: 1_000,
sink: async (batch) => {
await gateway.send(serializeBatch(batch));
},
});
mux.ingest({ peerId: "p1", seq: 1, receivedAt: Date.now() });
if (mux.shouldFlush()) await mux.flushNow();
```
## Usage: Advanced Example
```ts
import { HeartbeatMultiplexer } from "@suaveplan/network-plugin-server-bridge";
const mux = new HeartbeatMultiplexer({
batchWindowMs: 500,
maxBatchSize: 4096,
clock: () => fakeClock.now(),
emitEmptyBatches: true,
sink: async (batch) => {
const bytes = encodeBatch(batch);
try {
await gateway.send(bytes);
metrics.batchSize.observe(batch.heartbeats.length);
} catch (err) {
// Gateway send failure → buffer for replay
buffer.append({
seq: batch.windowEnd,
entryHash: hash(bytes),
bytes,
});
}
},
});
setInterval(() => {
if (mux.shouldFlush()) void mux.flushNow();
}, 100);
```
## API Reference
- `new HeartbeatMultiplexer(config)` — `batchWindowMs` MUST be positive; otherwise throws `MULTIPLEXER_BATCH_OVERFLOW`.
- `ingest(heartbeat): boolean` — returns `true` if accepted, `false` if stale (older seq) or disposed. Throws `MULTIPLEXER_BATCH_OVERFLOW` if the batch is full.
- `flushNow(): Promise<HeartbeatBatch | null>` — emits the open batch (sorted by peerId) and clears state. Returns `null` if the batch is empty and `emitEmptyBatches` is `false` (the default).
- `shouldFlush()` / `windowAge()` / `windowOpenedAt()` / `size()` — observability accessors.
- `dispose()` — disables further ingests / flushes.
- `PeerHeartbeat`, `HeartbeatBatch`, `HeartbeatMultiplexerConfig`, `BatchSink` — types.
## Implementation Notes
- The peer-keyed coalescing is `O(1)` amortized via a `Map<peerId, PeerHeartbeat>` shadowing the `current[]` array; keeping both lets us drop a stale entry without an `O(n)` scan.
- Sorting at flush is `O(n log n)` per batch; since `n ≤ maxBatchSize`, this is bounded at construction time and never grows with mesh size.
- The `dispose` flag short-circuits both ingest and flush so a multiplexer that has been torn down by the plugin can never produce a final ghost batch.

View File

@@ -0,0 +1,156 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_SB_CODES,
NetworkSbError,
} from "../error-codes/error-codes.js";
import {
type HeartbeatBatch,
HeartbeatMultiplexer,
} from "./heartbeat-multiplexer.js";
describe("HeartbeatMultiplexer", () => {
it("rejects non-positive window", () => {
expect(
() =>
new HeartbeatMultiplexer({
batchWindowMs: 0,
sink: () => undefined,
})
).toThrow(NetworkSbError);
});
it("ingests heartbeats and tracks size + window", () => {
let now = 100;
const batches: HeartbeatBatch[] = [];
const m = new HeartbeatMultiplexer({
batchWindowMs: 50,
clock: () => now,
sink: (b) => {
batches.push(b);
},
});
expect(m.windowOpenedAt()).toBe(0);
m.ingest({ peerId: "p1", seq: 1, receivedAt: 100 });
expect(m.size()).toBe(1);
expect(m.windowOpenedAt()).toBe(100);
now = 110;
expect(m.windowAge()).toBe(10);
});
it("coalesces by peer keeping the latest seq", () => {
const m = new HeartbeatMultiplexer({
batchWindowMs: 50,
sink: () => undefined,
});
m.ingest({ peerId: "p1", seq: 1, receivedAt: 1 });
m.ingest({ peerId: "p1", seq: 2, receivedAt: 2 });
expect(m.size()).toBe(1);
});
it("ignores stale (older-seq) heartbeats", () => {
const m = new HeartbeatMultiplexer({
batchWindowMs: 50,
sink: () => undefined,
});
m.ingest({ peerId: "p1", seq: 5, receivedAt: 1 });
const accepted = m.ingest({ peerId: "p1", seq: 4, receivedAt: 2 });
expect(accepted).toBe(false);
});
it("shouldFlush honours window", () => {
let now = 100;
const m = new HeartbeatMultiplexer({
batchWindowMs: 50,
clock: () => now,
sink: () => undefined,
});
expect(m.shouldFlush()).toBe(false);
m.ingest({ peerId: "p1", seq: 1, receivedAt: now });
expect(m.shouldFlush()).toBe(false);
now = 200;
expect(m.shouldFlush()).toBe(true);
});
it("flushNow emits a sorted batch and clears state", async () => {
let now = 100;
const batches: HeartbeatBatch[] = [];
const m = new HeartbeatMultiplexer({
batchWindowMs: 50,
clock: () => now,
sink: (b) => {
batches.push(b);
},
});
m.ingest({ peerId: "z", seq: 1, receivedAt: now });
m.ingest({ peerId: "a", seq: 2, receivedAt: now });
now = 200;
const batch = await m.flushNow();
expect(batch).not.toBeNull();
expect(batch?.heartbeats.map((h) => h.peerId)).toEqual(["a", "z"]);
expect(batch?.windowStart).toBe(100);
expect(batch?.windowEnd).toBe(200);
expect(m.size()).toBe(0);
expect(batches).toHaveLength(1);
});
it("flushNow returns null when empty by default", async () => {
const m = new HeartbeatMultiplexer({
batchWindowMs: 50,
sink: () => undefined,
});
const res = await m.flushNow();
expect(res).toBeNull();
});
it("flushNow emits empty batch when configured", async () => {
const now = 100;
const m = new HeartbeatMultiplexer({
batchWindowMs: 50,
clock: () => now,
emitEmptyBatches: true,
sink: () => undefined,
});
const res = await m.flushNow();
expect(res).not.toBeNull();
expect(res?.heartbeats).toHaveLength(0);
});
it("rejects ingest beyond maxBatchSize with overflow error", () => {
const m = new HeartbeatMultiplexer({
batchWindowMs: 50,
maxBatchSize: 2,
sink: () => undefined,
});
m.ingest({ peerId: "a", seq: 1, receivedAt: 1 });
m.ingest({ peerId: "b", seq: 1, receivedAt: 1 });
try {
m.ingest({ peerId: "c", seq: 1, receivedAt: 1 });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(NetworkSbError);
expect((err as NetworkSbError).code).toBe(
NETWORK_SB_CODES.MULTIPLEXER_BATCH_OVERFLOW
);
}
});
it("dispose ignores subsequent ingests + flushes", async () => {
const m = new HeartbeatMultiplexer({
batchWindowMs: 50,
sink: () => undefined,
});
m.dispose();
const accepted = m.ingest({ peerId: "p", seq: 1, receivedAt: 1 });
expect(accepted).toBe(false);
const res = await m.flushNow();
expect(res).toBeNull();
});
it("windowAge is zero when empty", () => {
const m = new HeartbeatMultiplexer({
batchWindowMs: 50,
sink: () => undefined,
});
expect(m.windowAge()).toBe(0);
});
});

View File

@@ -0,0 +1,140 @@
import {
NETWORK_SB_CODES,
NetworkSbError,
} from "../error-codes/error-codes.js";
export interface PeerHeartbeat {
readonly peerId: string;
readonly seq: number;
readonly receivedAt: number;
readonly meta?: Readonly<Record<string, string | number | boolean>>;
}
export interface HeartbeatBatch {
readonly windowStart: number;
readonly windowEnd: number;
readonly heartbeats: readonly PeerHeartbeat[];
}
export type BatchSink = (batch: HeartbeatBatch) => void | Promise<void>;
export interface HeartbeatMultiplexerConfig {
readonly batchWindowMs: number;
/** Hard cap on heartbeats per batch. Default 1024. */
readonly maxBatchSize?: number;
readonly clock?: () => number;
readonly sink: BatchSink;
/**
* If `true`, calling `flushNow` while no heartbeats are pending emits
* an empty batch. Defaults to `false` so the multiplexer never sends
* keepalive noise upstream.
*/
readonly emitEmptyBatches?: boolean;
}
const DEFAULT_MAX = 1024;
/**
* Aggregates per-peer heartbeats from the Tier-0 host and forwards them
* to the central server in fixed-window batches. The window is started
* lazily on the first heartbeat after a flush; intervals are explicit
* via `flushNow()` so the multiplexer carries no internal timer.
*/
export class HeartbeatMultiplexer {
private readonly config: HeartbeatMultiplexerConfig;
private readonly clock: () => number;
private readonly maxBatchSize: number;
private current: PeerHeartbeat[] = [];
private windowStart = 0;
private latestPerPeer = new Map<string, PeerHeartbeat>();
private disposed = false;
public constructor(config: HeartbeatMultiplexerConfig) {
if (config.batchWindowMs <= 0) {
throw new NetworkSbError(
NETWORK_SB_CODES.MULTIPLEXER_BATCH_OVERFLOW,
"sb.multiplexer.window_invalid",
{ context: { batchWindowMs: config.batchWindowMs } }
);
}
this.config = config;
this.clock = config.clock ?? (() => Date.now());
this.maxBatchSize = config.maxBatchSize ?? DEFAULT_MAX;
}
public ingest(heartbeat: PeerHeartbeat): boolean {
if (this.disposed) return false;
if (this.current.length === 0) this.windowStart = this.clock();
// Coalesce: keep only the latest seq per peer in the window.
const existing = this.latestPerPeer.get(heartbeat.peerId);
if (existing !== undefined && existing.seq >= heartbeat.seq) {
return false;
}
if (existing !== undefined) {
// Invariant: every entry in `latestPerPeer` is also in
// `current[]` until the next flush, so indexOf is always ≥ 0.
const idx = this.current.indexOf(existing);
this.current.splice(idx, 1);
}
if (this.current.length >= this.maxBatchSize) {
throw new NetworkSbError(
NETWORK_SB_CODES.MULTIPLEXER_BATCH_OVERFLOW,
"sb.multiplexer.batch_overflow",
{
context: {
size: this.current.length,
max: this.maxBatchSize,
},
}
);
}
this.current.push(heartbeat);
this.latestPerPeer.set(heartbeat.peerId, heartbeat);
return true;
}
public size(): number {
return this.current.length;
}
public windowOpenedAt(): number {
return this.current.length === 0 ? 0 : this.windowStart;
}
public windowAge(): number {
if (this.current.length === 0) return 0;
return this.clock() - this.windowStart;
}
public shouldFlush(): boolean {
if (this.current.length === 0) return false;
return this.windowAge() >= this.config.batchWindowMs;
}
public async flushNow(): Promise<HeartbeatBatch | null> {
if (this.disposed) return null;
if (
this.current.length === 0 &&
this.config.emitEmptyBatches !== true
) {
return null;
}
const batch: HeartbeatBatch = {
windowStart: this.windowStart,
windowEnd: this.clock(),
heartbeats: [...this.current].sort((a, b) =>
a.peerId.localeCompare(b.peerId)
),
};
this.current = [];
this.latestPerPeer = new Map();
await this.config.sink(batch);
return batch;
}
public dispose(): void {
this.disposed = true;
this.current = [];
this.latestPerPeer.clear();
}
}

View File

@@ -0,0 +1,7 @@
export {
type BatchSink,
type HeartbeatBatch,
HeartbeatMultiplexer,
type HeartbeatMultiplexerConfig,
type PeerHeartbeat,
} from "./heartbeat-multiplexer.js";

View File

@@ -0,0 +1,78 @@
# offline-mode-coordinator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`OfflineModeCoordinator` is the policy bridge between the bridge FSM and the rest of the mesh. It listens for `online → offline` and `offline → online` transitions on the injected `GracefulDegradationFsm` and: switches the coordinator's mode flag to `"active"` / `"inactive"`, extends the token grace window so already-issued tokens keep working through the outage, blocks new session creation while offline (`canCreateNewSession()` returns `false`), and emits `server:offline` / `server:online` on the optional bus so other plugins (audit-mesh, governance, partition) can react.
The coordinator is intentionally a passive listener: it never drives the FSM. Whatever component owns the gateway's heartbeat result (the plugin facade) drives transitions; this module just translates them into mesh-wide policy.
## Features
- Edge-triggered emit: each transition into `offline` or back into `online` emits exactly once. Subsequent flapping into the same state does not re-emit, so a flaky link doesn't spam the bus.
- `effectiveTokenGraceMs()` returns the extended grace while offline, the regular grace while online — the consumer (typically `@suaveplan/network-plugin-crypto-messages`) reads it on every token validation.
- `canCreateNewSession()` is the explicit "should we let new sessions form?" decision, separated from the token-grace logic because the two policies move on different timelines.
- `dispose()` removes the FSM listener so the coordinator can be torn down without leaking subscriptions.
## Usage: Basic Example
```ts
import {
GracefulDegradationFsm,
OfflineModeCoordinator,
} from "@suaveplan/network-plugin-server-bridge";
const fsm = new GracefulDegradationFsm();
const coord = new OfflineModeCoordinator({
fsm,
tokenGraceMs: 30_000,
extendedTokenGraceMs: 5 * 60_000,
});
fsm.transition("degrading", "latency");
fsm.transition("offline", "lost");
console.log(coord.effectiveTokenGraceMs()); // 300_000
console.log(coord.canCreateNewSession()); // false
```
## Usage: Advanced Example
```ts
import {
GracefulDegradationFsm,
OfflineModeCoordinator,
type OfflineCoordinatorBus,
} from "@suaveplan/network-plugin-server-bridge";
const bus: OfflineCoordinatorBus = {
emit: (topic, payload) => networkBus.emit(topic, payload),
};
const fsm = new GracefulDegradationFsm();
const coord = new OfflineModeCoordinator({
fsm,
tokenGraceMs: 30_000,
extendedTokenGraceMs: 5 * 60_000,
bus,
});
// Other plugins react to the bus topics
networkBus.on("server:offline", ({ reason }) =>
audit.record({ kind: "server-outage", reason })
);
```
## API Reference
- `new OfflineModeCoordinator({ fsm, tokenGraceMs, extendedTokenGraceMs, bus? })` — constructs the coordinator and immediately subscribes to the FSM.
- `currentMode(): "active" | "inactive"` — current offline-mode state.
- `effectiveTokenGraceMs(): number` — the grace window callers should apply right now.
- `canCreateNewSession(): boolean` — whether new sessions are permitted.
- `dispose(): void` — removes the FSM subscription.
## Implementation Notes
- The coordinator stores the FSM unsubscribe in `off` so `dispose` is `O(1)`. Re-subscribing after dispose requires a fresh coordinator instance — that is a deliberate pattern to prevent zombie subscriptions.
- Bus emission is gated on the mode actually flipping (`mode === "inactive"` → `"active"` and vice versa). This is the proposal's "no duplicate events" requirement — multiple consecutive `offline` transitions only emit once.
- The two grace windows are caller-supplied because the right values depend on the mesh's session-rotation policy; this module does not opine.

View File

@@ -0,0 +1,122 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GracefulDegradationFsm } from "../fsm/graceful-degradation-fsm.js";
import { OfflineModeCoordinator } from "./offline-mode-coordinator.js";
describe("OfflineModeCoordinator", () => {
it("starts inactive", () => {
const fsm = new GracefulDegradationFsm();
const coord = new OfflineModeCoordinator({
fsm,
tokenGraceMs: 1000,
extendedTokenGraceMs: 5000,
});
expect(coord.currentMode()).toBe("inactive");
expect(coord.effectiveTokenGraceMs()).toBe(1000);
expect(coord.canCreateNewSession()).toBe(true);
coord.dispose();
});
it("switches to active on offline transition and emits", () => {
const fsm = new GracefulDegradationFsm();
const events: Array<{
topic: string;
payload: { at: number; reason: string };
}> = [];
const bus = {
emit(
topic: "server:offline" | "server:online",
payload: { at: number; reason: string }
) {
events.push({ topic, payload });
},
};
const coord = new OfflineModeCoordinator({
fsm,
tokenGraceMs: 1000,
extendedTokenGraceMs: 5000,
bus,
});
fsm.transition("degrading", "latency");
fsm.transition("offline", "lost");
expect(coord.currentMode()).toBe("active");
expect(coord.effectiveTokenGraceMs()).toBe(5000);
expect(coord.canCreateNewSession()).toBe(false);
expect(events).toHaveLength(1);
expect(events[0].topic).toBe("server:offline");
expect(events[0].payload.reason).toBe("lost");
coord.dispose();
});
it("returns to inactive on online transition and emits", () => {
const fsm = new GracefulDegradationFsm();
const events: Array<{
topic: string;
payload: { at: number; reason: string };
}> = [];
const bus = {
emit(
topic: "server:offline" | "server:online",
payload: { at: number; reason: string }
) {
events.push({ topic, payload });
},
};
const coord = new OfflineModeCoordinator({
fsm,
tokenGraceMs: 1000,
extendedTokenGraceMs: 5000,
bus,
});
fsm.transition("degrading", "latency");
fsm.transition("offline", "lost");
fsm.transition("recovering", "retry");
fsm.transition("online", "restored");
expect(coord.currentMode()).toBe("inactive");
expect(events).toHaveLength(2);
expect(events[1].topic).toBe("server:online");
coord.dispose();
});
it("does not emit duplicate events", () => {
const fsm = new GracefulDegradationFsm();
const events: Array<{
topic: string;
payload: { at: number; reason: string };
}> = [];
const bus = {
emit(
topic: "server:offline" | "server:online",
payload: { at: number; reason: string }
) {
events.push({ topic, payload });
},
};
const coord = new OfflineModeCoordinator({
fsm,
tokenGraceMs: 1000,
extendedTokenGraceMs: 5000,
bus,
});
fsm.transition("degrading", "x");
fsm.transition("offline", "a");
fsm.transition("recovering", "b");
fsm.transition("offline", "c");
expect(events.filter((e) => e.topic === "server:offline")).toHaveLength(
1
);
coord.dispose();
});
it("dispose removes listener", () => {
const fsm = new GracefulDegradationFsm();
const coord = new OfflineModeCoordinator({
fsm,
tokenGraceMs: 1000,
extendedTokenGraceMs: 5000,
});
coord.dispose();
fsm.transition("degrading", "x");
fsm.transition("offline", "y");
expect(coord.currentMode()).toBe("inactive");
});
});

View File

@@ -0,0 +1,65 @@
import type { GracefulDegradationFsm } from "../fsm/graceful-degradation-fsm.js";
import type { OfflineMode } from "../types/state.types.js";
export interface OfflineCoordinatorBus {
emit(
topic: "server:offline" | "server:online",
payload: { at: number; reason: string }
): void;
}
export interface OfflineModeCoordinatorConfig {
readonly fsm: GracefulDegradationFsm;
readonly tokenGraceMs: number;
readonly extendedTokenGraceMs: number;
readonly bus?: OfflineCoordinatorBus;
}
export class OfflineModeCoordinator {
public readonly tokenGraceMs: number;
public readonly extendedTokenGraceMs: number;
private readonly fsm: GracefulDegradationFsm;
private readonly bus: OfflineCoordinatorBus | undefined;
private mode: OfflineMode = "inactive";
private readonly off: () => void;
public constructor(config: OfflineModeCoordinatorConfig) {
this.fsm = config.fsm;
this.tokenGraceMs = config.tokenGraceMs;
this.extendedTokenGraceMs = config.extendedTokenGraceMs;
this.bus = config.bus;
this.off = this.fsm.onTransition((change) => {
if (change.to === "offline" && this.mode === "inactive") {
this.mode = "active";
this.bus?.emit("server:offline", {
at: change.at,
reason: change.reason,
});
} else if (change.to === "online" && this.mode === "active") {
this.mode = "inactive";
this.bus?.emit("server:online", {
at: change.at,
reason: change.reason,
});
}
});
}
public currentMode(): OfflineMode {
return this.mode;
}
public effectiveTokenGraceMs(): number {
return this.mode === "active"
? this.extendedTokenGraceMs
: this.tokenGraceMs;
}
public canCreateNewSession(): boolean {
return this.mode === "inactive";
}
public dispose(): void {
this.off();
}
}

View File

@@ -0,0 +1,52 @@
/**
* NET-32 — autonomous mode + token-grace extension.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GracefulDegradationFsm } from "../fsm/graceful-degradation-fsm.js";
import { OfflineModeCoordinator } from "./offline-mode-coordinator.js";
describe("NET-32 — offline coordinator", () => {
it("extends token grace when offline", () => {
const fsm = new GracefulDegradationFsm();
const coord = new OfflineModeCoordinator({
fsm,
tokenGraceMs: 1_000,
extendedTokenGraceMs: 10_000,
});
expect(coord.effectiveTokenGraceMs()).toBe(1_000);
fsm.transition("degrading", "x");
fsm.transition("offline", "y");
expect(coord.effectiveTokenGraceMs()).toBe(10_000);
coord.dispose();
});
it("blocks new session creation while offline", () => {
const fsm = new GracefulDegradationFsm();
const coord = new OfflineModeCoordinator({
fsm,
tokenGraceMs: 1_000,
extendedTokenGraceMs: 10_000,
});
fsm.transition("degrading", "x");
fsm.transition("offline", "y");
expect(coord.canCreateNewSession()).toBe(false);
coord.dispose();
});
it("emits server:offline / server:online on edge transitions", () => {
const fsm = new GracefulDegradationFsm();
const events: string[] = [];
const coord = new OfflineModeCoordinator({
fsm,
tokenGraceMs: 1_000,
extendedTokenGraceMs: 10_000,
bus: { emit: (t) => events.push(t) },
});
fsm.transition("degrading", "x");
fsm.transition("offline", "y");
fsm.transition("recovering", "z");
fsm.transition("online", "back");
expect(events).toEqual(["server:offline", "server:online"]);
coord.dispose();
});
});

View File

@@ -0,0 +1,5 @@
export {
ServerBridgePlugin,
type ServerBridgePluginConfig,
type TierGate,
} from "./server-bridge-plugin.js";

View File

@@ -0,0 +1,95 @@
# server-bridge-plugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ServerBridgePlugin` is the single facade that satisfies `NetworkPlugin` and ties every server-bridge subsystem together: gateway, FSM, offline coordinator, log buffer, command dispatcher, heartbeat multiplexer, reconciliation protocol, and the chosen hosting coordinator. It owns the lifecycle hooks the `NetworkNode` calls (`install`, `start`, `stop`, `dispose`) and gates startup on the `tierGate` callback so the plugin is a no-op on non-Tier-0 hosts.
The plugin never reaches into `network-plugin-topology` to learn its tier — that would create a circular dep and force every consumer to ship the topology plugin. Instead, the gate is a function the consumer wires (typically a one-line `topology.localTier() === 0`).
## Features
- Single constructor wiring six injectable subsystems with sane defaults via the existing config types.
- Tier-gate: `tierGate()` evaluated at `install` time; non-Tier-0 hosts skip the gateway entirely. The rejection reason is exposed via `tierRejection()` so observability can flag "why is this plugin silent?" with a stable `NET_SB.PLUGIN_TIER_GATE_REJECTED` code rather than guessing.
- Lifecycle: `start()` opens the gateway only on active hosts; `stop()` flushes the multiplexer regardless so any pending heartbeats reach the server before teardown.
- Public subsystem references (`plugin.gateway`, `plugin.buffer`, …) so callers can compose with the bus / audit-mesh / reconciliation flows directly.
## Usage: Basic Example
```ts
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import {
FailoverCoordinator,
ServerBridgePlugin,
} from "@suaveplan/network-plugin-server-bridge";
const node = new NetworkNode({ id: "host-1", networkBus: new NetworkBus() });
const plugin = new ServerBridgePlugin({
tierGate: () => topology.localTier() === 0,
transportFactory: () => openWebSocket(serverUrl),
heartbeatIntervalMs: 1_000,
bufferCapacity: 10_000,
tokenGraceMs: 30_000,
extendedTokenGraceMs: 5 * 60_000,
verifyCommand: (canon, sig) => ed25519.verify(sig, canon, serverPubKey),
currentMeshEpoch: () => meshEpoch.current(),
sessionAttributor: (entry) => extractSession(entry),
hostingCoordinator: new FailoverCoordinator({
serverAvailable: () => gateway.connectionState() === "open",
mintToken: (req) => sign(req),
}),
heartbeatBatchWindowMs: 500,
heartbeatSink: async (batch) => gateway.send(encodeBatch(batch)),
});
node.use(plugin);
await node.start();
```
## Usage: Advanced Example
```ts
import { ServerBridgePlugin } from "@suaveplan/network-plugin-server-bridge";
const plugin = new ServerBridgePlugin({
tierGate: () => topology.localTier() === 0,
transportFactory: openTransport,
heartbeatIntervalMs: 1_000,
bufferCapacity: 50_000,
tokenGraceMs: 30_000,
extendedTokenGraceMs: 5 * 60_000,
verifyCommand: ed25519Verify,
currentMeshEpoch: meshEpoch.current,
sessionAttributor,
hostingCoordinator,
heartbeatBatchWindowMs: 500,
heartbeatSink,
clock: () => fakeClock.now(),
commandHandlers: {
"kick-peer": (cmd) => peers.kick(cmd.targetPeerId, cmd.reason),
"shutdown-mesh": (cmd) => mesh.scheduleShutdown(cmd.deadlineMs),
},
gatewayHandlers: {
onConnect: () => bus.emit("server:connected", { at: Date.now() }),
onDisconnect: (reason) =>
bus.emit("server:disconnected", { reason, at: Date.now() }),
},
});
```
## API Reference
- `new ServerBridgePlugin(config)` — wires every subsystem; the only required side effects happen in `install` / `start`.
- `install(node)` / `start()` / `stop()` / `dispose()` — `NetworkPlugin` lifecycle hooks.
- `tierRejection(): string | null` — `"NET_SB.PLUGIN_TIER_GATE_REJECTED"` after a tier-gate denial; `null` otherwise.
- `host()` / `isStarted()` / `isActive()` — observability.
- Subsystem accessors: `fsm`, `buffer`, `offline`, `gateway`, `dispatcher`, `multiplexer`, `reconciliation`, `hosting`.
- `TierGate`, `ServerBridgePluginConfig` — types.
## Implementation Notes
- The plugin deliberately constructs its subsystems eagerly (in the constructor) so `plugin.gateway` etc. are always non-null. Consumers can wire bus subscriptions or composition before `install()` runs without timing concerns.
- `stop()` flushes the multiplexer even when `active === false` because the multiplexer's flush is an `await` over its sink — a no-op on a dispose'd multiplexer, but a safety net otherwise.
- The hosting coordinator is injected (rather than constructed inside the plugin) so consumers retain full control over the model and its config (`serverAvailable` callback, `mintToken`, peer count for quorum).

View File

@@ -0,0 +1,129 @@
import { NetworkNode } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { BridgeTransport } from "../gateway/server-gateway.js";
import { FailoverCoordinator } from "../hosting/failover-coordinator.js";
import { ServerBridgePlugin } from "./server-bridge-plugin.js";
const TOKEN = new Uint8Array([0x01]);
const fakeTransport = (): BridgeTransport => ({
send: () => undefined,
onMessage: () => () => undefined,
onClose: () => () => undefined,
close: () => undefined,
});
const baseConfig = (overrides: Partial<{ tierGate: () => boolean }> = {}) => ({
tierGate: overrides.tierGate ?? (() => true),
transportFactory: () => fakeTransport(),
heartbeatIntervalMs: 100,
bufferCapacity: 16,
tokenGraceMs: 100,
extendedTokenGraceMs: 500,
verifyCommand: () => true,
currentMeshEpoch: () => 1,
sessionAttributor: () => null,
hostingCoordinator: new FailoverCoordinator({
serverAvailable: () => true,
mintToken: () => TOKEN,
}),
heartbeatBatchWindowMs: 250,
heartbeatSink: () => undefined,
});
const newNode = () => new NetworkNode();
describe("ServerBridgePlugin", () => {
it("constructs and exposes every subsystem", () => {
const p = new ServerBridgePlugin(baseConfig());
expect(p.name).toBe("server-bridge");
expect(p.fsm).toBeDefined();
expect(p.buffer).toBeDefined();
expect(p.offline).toBeDefined();
expect(p.gateway).toBeDefined();
expect(p.dispatcher).toBeDefined();
expect(p.multiplexer).toBeDefined();
expect(p.reconciliation).toBeDefined();
expect(p.hosting).toBeDefined();
});
it("install / start / stop / dispose lifecycle on Tier 0", async () => {
const p = new ServerBridgePlugin(baseConfig());
const node = newNode();
node.use(p);
expect(p.host()).not.toBeNull();
await node.start();
expect(p.isStarted()).toBe(true);
expect(p.isActive()).toBe(true);
expect(p.tierRejection()).toBeNull();
await node.stop();
expect(p.isStarted()).toBe(false);
p.dispose();
expect(p.host()).toBeNull();
});
it("no-ops on non-Tier-0 hosts", async () => {
const p = new ServerBridgePlugin(baseConfig({ tierGate: () => false }));
const node = newNode();
node.use(p);
await node.start();
expect(p.isActive()).toBe(false);
expect(p.isStarted()).toBe(true);
expect(p.tierRejection()).toBe("NET_SB.PLUGIN_TIER_GATE_REJECTED");
await node.stop();
p.dispose();
});
it("flushes the multiplexer on stop", async () => {
const flushed: number[] = [];
const p = new ServerBridgePlugin({
...baseConfig(),
heartbeatSink: (b) => {
flushed.push(b.heartbeats.length);
},
});
const node = newNode();
node.use(p);
await node.start();
p.multiplexer.ingest({ peerId: "p1", seq: 1, receivedAt: 1 });
await node.stop();
expect(flushed).toEqual([1]);
});
it("threads commandHandlers through the dispatcher", async () => {
const calls: string[] = [];
const p = new ServerBridgePlugin({
...baseConfig(),
commandHandlers: {
"kick-peer": (cmd) => {
calls.push(cmd.targetPeerId);
},
},
});
await p.dispatcher.dispatch({
command: { type: "kick-peer", targetPeerId: "x", reason: "r" },
issuedAt: Date.now(),
nonce: "n1",
signature: new Uint8Array([1]),
});
expect(calls).toEqual(["x"]);
});
it("uses an injected clock when provided", () => {
const p = new ServerBridgePlugin({ ...baseConfig(), clock: () => 42 });
expect(p.fsm.state()).toBe("online");
});
it("forwards gateway handlers", async () => {
const events: string[] = [];
const p = new ServerBridgePlugin({
...baseConfig(),
gatewayHandlers: { onConnect: () => events.push("connect") },
});
const node = newNode();
node.use(p);
await node.start();
expect(events).toEqual(["connect"]);
await node.stop();
});
});

View File

@@ -0,0 +1,157 @@
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
import { LogBuffer } from "../buffer/log-buffer.js";
import { CommandDispatcher } from "../command/command-dispatcher.js";
import type {
CommandHandlerMap,
SignatureVerifier,
} from "../command/command-dispatcher.types.js";
import { NETWORK_SB_CODES } from "../error-codes/error-codes.js";
import { GracefulDegradationFsm } from "../fsm/graceful-degradation-fsm.js";
import {
type BridgeTransportFactory,
ServerGateway,
type ServerGatewayHandlers,
} from "../gateway/server-gateway.js";
import type { HostingCoordinator } from "../hosting/coordinator.types.js";
import { HeartbeatMultiplexer } from "../multiplexer/heartbeat-multiplexer.js";
import { OfflineModeCoordinator } from "../offline/offline-mode-coordinator.js";
import { ReconciliationProtocol } from "../reconciliation/reconciliation-protocol.js";
import type { SessionAttributor } from "../reconciliation/reconciliation-protocol.types.js";
/**
* The plugin runs only on Tier-0 hosts. The gate is injected as a
* function so the plugin doesn't depend on `network-plugin-topology` at
* runtime — the consumer wires whatever tier-detection mechanism it
* already has.
*/
export type TierGate = () => boolean;
export interface ServerBridgePluginConfig {
readonly tierGate: TierGate;
readonly transportFactory: BridgeTransportFactory;
readonly heartbeatIntervalMs: number;
readonly bufferCapacity: number;
readonly tokenGraceMs: number;
readonly extendedTokenGraceMs: number;
readonly verifyCommand: SignatureVerifier;
readonly currentMeshEpoch: () => number;
readonly sessionAttributor: SessionAttributor;
readonly hostingCoordinator: HostingCoordinator;
readonly heartbeatBatchWindowMs: number;
readonly heartbeatSink: (
batch: import("../multiplexer/heartbeat-multiplexer.js").HeartbeatBatch
) => void | Promise<void>;
readonly clock?: () => number;
readonly commandHandlers?: CommandHandlerMap;
readonly gatewayHandlers?: ServerGatewayHandlers;
}
export class ServerBridgePlugin implements NetworkPlugin {
public readonly name = "server-bridge";
public readonly fsm: GracefulDegradationFsm;
public readonly buffer: LogBuffer;
public readonly offline: OfflineModeCoordinator;
public readonly gateway: ServerGateway;
public readonly dispatcher: CommandDispatcher;
public readonly multiplexer: HeartbeatMultiplexer;
public readonly reconciliation: ReconciliationProtocol;
public readonly hosting: HostingCoordinator;
private readonly tierGate: TierGate;
private node: NetworkNode | null = null;
private started = false;
private active = false;
private tierRejectionReason: string | null = null;
public constructor(config: ServerBridgePluginConfig) {
this.tierGate = config.tierGate;
this.fsm = new GracefulDegradationFsm(
config.clock === undefined ? {} : { clock: config.clock }
);
this.buffer = new LogBuffer({
capacity: config.bufferCapacity,
...(config.clock === undefined ? {} : { clock: config.clock }),
});
this.offline = new OfflineModeCoordinator({
fsm: this.fsm,
tokenGraceMs: config.tokenGraceMs,
extendedTokenGraceMs: config.extendedTokenGraceMs,
});
this.gateway = new ServerGateway(
{
transportFactory: config.transportFactory,
heartbeatIntervalMs: config.heartbeatIntervalMs,
...(config.clock === undefined ? {} : { clock: config.clock }),
},
config.gatewayHandlers ?? {}
);
this.dispatcher = new CommandDispatcher({
verify: config.verifyCommand,
...(config.clock === undefined ? {} : { clock: config.clock }),
...(config.commandHandlers === undefined
? {}
: { handlers: config.commandHandlers }),
});
this.multiplexer = new HeartbeatMultiplexer({
batchWindowMs: config.heartbeatBatchWindowMs,
...(config.clock === undefined ? {} : { clock: config.clock }),
sink: config.heartbeatSink,
});
this.reconciliation = new ReconciliationProtocol({
buffer: this.buffer,
sessionAttributor: config.sessionAttributor,
currentMeshEpoch: config.currentMeshEpoch,
});
this.hosting = config.hostingCoordinator;
}
public install(node: NetworkNode): void {
this.node = node;
this.active = this.tierGate();
}
public async start(): Promise<void> {
if (!this.active) {
// No-op on lower tiers; record the rejection on the structured
// log so observability can surface "why is the bridge silent?"
// without guessing. Tier-0 is the only plugin scope.
this.tierRejectionReason =
NETWORK_SB_CODES.PLUGIN_TIER_GATE_REJECTED;
this.started = true;
return;
}
await this.gateway.connect();
this.started = true;
}
public tierRejection(): string | null {
return this.tierRejectionReason;
}
public async stop(): Promise<void> {
if (this.active) this.gateway.close("plugin-stop");
await this.multiplexer.flushNow();
this.started = false;
}
public dispose(): void {
this.offline.dispose();
this.multiplexer.dispose();
this.buffer.clear();
this.node = null;
this.active = false;
this.tierRejectionReason = null;
}
public host(): NetworkNode | null {
return this.node;
}
public isStarted(): boolean {
return this.started;
}
public isActive(): boolean {
return this.active;
}
}

View File

@@ -0,0 +1,11 @@
export {
ReconciliationProtocol,
type ReconciliationProtocolConfig,
} from "./reconciliation-protocol.js";
export type {
ConflictReport,
MeshSequence,
ReconciliationOutcome,
ServerWatermark,
SessionAttributor,
} from "./reconciliation-protocol.types.js";

View File

@@ -0,0 +1,77 @@
# reconciliation-protocol
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ReconciliationProtocol` runs on the Tier-0 host the moment the server reconnects. It honours the server-supplied watermark (`meshEpoch` + per-session `sessionSequenceNumbers`), computes the differential set of buffered entries the server has not yet seen, attaches a `ConflictReport` whenever the host holds the same seq the server already records, and produces an ordered list of `ReconciliationOutcome`s the host can ship upstream. After the server acknowledges the diff, `commit(acceptedSeqs)` drops the reconciled entries from the local buffer.
The protocol is deliberately a pure-data engine: it does not own the gateway, the bus, or the network round-trip. Wiring those concerns is the plugin façade's job — this layer is unit-testable end-to-end without a server in the loop.
## Features
- Strict epoch validation: rejects watermarks more than one epoch behind, and any watermark ahead of the host. `RECONCILIATION_EPOCH_INVALID` is thrown synchronously so a misconfigured server can never silently corrupt local state.
- Configurable conflict resolver: defaults to last-writer-wins keyed by `bufferedAt` vs `issuedAt`, with ties resolving in favour of the host. Callers can swap in any `(report) => "host" | "server"` strategy without subclassing.
- Session-keyed differential: the `sessionAttributor` callback maps an entry to its session id; entries that have no session attribution flow through as host-priority (the server never had a chance to record them).
- Strict mode via `requireNoConflicts(outcomes)` — throws `RECONCILIATION_CONFLICT` if any conflict report is present. Used inside integration tests where silent LWW would mask a defect.
- `commit(acceptedSeqs)` is the only mutating method; `diff` is pure so it can be re-run safely (e.g. after a server-side rejection).
## Usage: Basic Example
```ts
import {
LogBuffer,
ReconciliationProtocol,
} from "@suaveplan/network-plugin-server-bridge";
const buffer = new LogBuffer({ capacity: 10_000 });
const proto = new ReconciliationProtocol({
buffer,
sessionAttributor: (entry) => extractSession(entry),
currentMeshEpoch: () => meshEpochManager.current(),
});
const outcomes = proto.diff(serverWatermark);
await gateway.send(serializeOutcomes(outcomes));
proto.commit(new Set(outcomes.map((o) => o.entry.seq)));
```
## Usage: Advanced Example
```ts
import { ReconciliationProtocol } from "@suaveplan/network-plugin-server-bridge";
const proto = new ReconciliationProtocol({
buffer,
sessionAttributor: (entry) => routeToSession(entry),
currentMeshEpoch: () => meshEpoch,
resolveConflict: (report) => {
// Custom strategy: server always wins for token-issuance entries,
// host wins for everything else.
if (report.sessionId.startsWith("token:")) return "server";
return report.hostBufferedAt >= report.serverIssuedAt
? "host"
: "server";
},
});
const outcomes = proto.diff(watermark);
proto.requireNoConflicts(outcomes); // fail-loud test mode
const acked = await server.replay(outcomes);
proto.commit(acked);
```
## API Reference
- `new ReconciliationProtocol(config)` — `buffer`, `sessionAttributor`, `currentMeshEpoch` are required.
- `acceptWatermark(watermark): void` — validates the epoch range. Throws `RECONCILIATION_EPOCH_INVALID` outside `[hostEpoch − 1, hostEpoch]`.
- `diff(watermark): ReconciliationOutcome[]` — computes the differential. Pure; safe to call repeatedly.
- `commit(acceptedSeqs): number` — drops acknowledged entries from the buffer. Returns the count dropped.
- `requireNoConflicts(outcomes): void` — strict mode; throws `RECONCILIATION_CONFLICT` if any conflict was applied.
## Implementation Notes
- The default LWW tiebreak favours the host because the host owns local activity by definition; the server only overrides when its own clock is strictly later. This matches the proposal's "server is authority but host is the source of activity" framing.
- `commit` calls `LogBuffer.ackThrough(seq)` per entry rather than scanning the whole set, so the cost is `O(accepted × bufferTail)`. For typical reconnect sizes (≤ a few thousand entries) this is well below the wall-clock cost of the network round-trip.
- The protocol is deliberately session-keyed rather than entry-keyed — per-session sequence numbers are what the server tracks at rest, and aligning the diff with that key avoids whole-buffer hash maps on every reconnect.

View File

@@ -0,0 +1,228 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LogBuffer } from "../buffer/log-buffer.js";
import {
NETWORK_SB_CODES,
NetworkSbError,
} from "../error-codes/error-codes.js";
import { ReconciliationProtocol } from "./reconciliation-protocol.js";
import type { ServerWatermark } from "./reconciliation-protocol.types.js";
const wm = (overrides: Partial<ServerWatermark> = {}): ServerWatermark => ({
meshEpoch: 1,
sessionSequenceNumbers: {},
issuedAt: 100,
...overrides,
});
const setupBuffer = (clock = () => 100) => {
const buf = new LogBuffer({ capacity: 16, clock });
buf.append({ seq: 1, entryHash: "h1", bytes: new Uint8Array([1]) });
buf.append({ seq: 2, entryHash: "h2", bytes: new Uint8Array([2]) });
buf.append({ seq: 3, entryHash: "h3", bytes: new Uint8Array([3]) });
return buf;
};
describe("ReconciliationProtocol", () => {
it("acceptWatermark accepts current epoch", () => {
const buf = setupBuffer();
const r = new ReconciliationProtocol({
buffer: buf,
sessionAttributor: () => null,
currentMeshEpoch: () => 5,
});
expect(() => r.acceptWatermark(wm({ meshEpoch: 5 }))).not.toThrow();
});
it("acceptWatermark accepts epoch one behind", () => {
const buf = setupBuffer();
const r = new ReconciliationProtocol({
buffer: buf,
sessionAttributor: () => null,
currentMeshEpoch: () => 5,
});
expect(() => r.acceptWatermark(wm({ meshEpoch: 4 }))).not.toThrow();
});
it("acceptWatermark rejects epoch ahead of host", () => {
const buf = setupBuffer();
const r = new ReconciliationProtocol({
buffer: buf,
sessionAttributor: () => null,
currentMeshEpoch: () => 5,
});
expect(() => r.acceptWatermark(wm({ meshEpoch: 6 }))).toThrow(
NetworkSbError
);
});
it("acceptWatermark rejects epoch too far behind", () => {
const buf = setupBuffer();
const r = new ReconciliationProtocol({
buffer: buf,
sessionAttributor: () => null,
currentMeshEpoch: () => 5,
});
expect(() => r.acceptWatermark(wm({ meshEpoch: 3 }))).toThrow(
NetworkSbError
);
});
it("ships every entry as host-priority when attributor returns null", () => {
const buf = setupBuffer();
const r = new ReconciliationProtocol({
buffer: buf,
sessionAttributor: () => null,
currentMeshEpoch: () => 1,
});
const outcomes = r.diff(wm());
expect(outcomes).toHaveLength(3);
for (const o of outcomes) expect(o.winner).toBe("host");
});
it("treats missing session watermarks as -1 (every entry ships)", () => {
const buf = setupBuffer();
const r = new ReconciliationProtocol({
buffer: buf,
sessionAttributor: () => "unknown-session",
currentMeshEpoch: () => 1,
});
const outcomes = r.diff(wm({ sessionSequenceNumbers: {} }));
expect(outcomes).toHaveLength(3);
for (const o of outcomes) expect(o.winner).toBe("host");
});
it("ships only entries past the per-session watermark", () => {
const buf = setupBuffer();
const r = new ReconciliationProtocol({
buffer: buf,
sessionAttributor: () => "S1",
currentMeshEpoch: () => 1,
});
const outcomes = r.diff(wm({ sessionSequenceNumbers: { S1: 1 } }));
// Entry seq=1 collides with watermark; entries 2,3 are clean
expect(outcomes.length).toBeGreaterThanOrEqual(2);
expect(outcomes.map((o) => o.entry.seq)).toContain(2);
expect(outcomes.map((o) => o.entry.seq)).toContain(3);
});
it("LWW: server wins when watermark is newer", () => {
const buf = setupBuffer(() => 50); // host bufferedAt = 50
const r = new ReconciliationProtocol({
buffer: buf,
sessionAttributor: () => "S",
currentMeshEpoch: () => 1,
});
const outcomes = r.diff(
wm({
issuedAt: 200,
sessionSequenceNumbers: { S: 1 },
})
);
// Entry 1 conflicts; server wins (200 > 50) — entry 1 is dropped
expect(outcomes.find((o) => o.entry.seq === 1)).toBeUndefined();
});
it("LWW: host wins when host buffered later", () => {
const buf = setupBuffer(() => 500); // host newer
const r = new ReconciliationProtocol({
buffer: buf,
sessionAttributor: () => "S",
currentMeshEpoch: () => 1,
});
const outcomes = r.diff(
wm({
issuedAt: 200,
sessionSequenceNumbers: { S: 1 },
})
);
const seq1 = outcomes.find((o) => o.entry.seq === 1);
expect(seq1?.winner).toBe("host");
expect(seq1?.conflict?.resolution).toBe("lww-host");
});
it("LWW tie defaults to host", () => {
const buf = setupBuffer(() => 100); // exactly equal to issuedAt
const r = new ReconciliationProtocol({
buffer: buf,
sessionAttributor: () => "S",
currentMeshEpoch: () => 1,
});
const outcomes = r.diff(
wm({ issuedAt: 100, sessionSequenceNumbers: { S: 1 } })
);
const seq1 = outcomes.find((o) => o.entry.seq === 1);
expect(seq1?.conflict?.resolution).toBe("tie-host");
});
it("custom resolver overrides default", () => {
const buf = setupBuffer(() => 500);
const r = new ReconciliationProtocol({
buffer: buf,
sessionAttributor: () => "S",
currentMeshEpoch: () => 1,
resolveConflict: () => "server",
});
const outcomes = r.diff(
wm({ issuedAt: 200, sessionSequenceNumbers: { S: 1 } })
);
// entry 1 was server-wins → dropped
expect(outcomes.find((o) => o.entry.seq === 1)).toBeUndefined();
});
it("commit drops acknowledged entries from the buffer", () => {
const buf = setupBuffer();
const r = new ReconciliationProtocol({
buffer: buf,
sessionAttributor: () => null,
currentMeshEpoch: () => 1,
});
const dropped = r.commit(new Set([1, 2]));
expect(dropped).toBe(2);
expect(buf.peekAll().map((e) => e.seq)).toEqual([3]);
});
it("requireNoConflicts throws when conflicts present", () => {
const buf = setupBuffer(() => 500);
const r = new ReconciliationProtocol({
buffer: buf,
sessionAttributor: () => "S",
currentMeshEpoch: () => 1,
});
const outcomes = r.diff(
wm({ issuedAt: 200, sessionSequenceNumbers: { S: 1 } })
);
expect(() => r.requireNoConflicts(outcomes)).toThrow(NetworkSbError);
});
it("requireNoConflicts is a no-op when clean", () => {
const buf = setupBuffer();
const r = new ReconciliationProtocol({
buffer: buf,
sessionAttributor: () => null,
currentMeshEpoch: () => 1,
});
const outcomes = r.diff(wm());
expect(() => r.requireNoConflicts(outcomes)).not.toThrow();
});
it("requireNoConflicts surfaces NETWORK_SB_CODES.RECONCILIATION_CONFLICT", () => {
const buf = setupBuffer(() => 500);
const r = new ReconciliationProtocol({
buffer: buf,
sessionAttributor: () => "S",
currentMeshEpoch: () => 1,
});
const outcomes = r.diff(
wm({ issuedAt: 200, sessionSequenceNumbers: { S: 1 } })
);
try {
r.requireNoConflicts(outcomes);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(NetworkSbError);
expect((err as NetworkSbError).code).toBe(
NETWORK_SB_CODES.RECONCILIATION_CONFLICT
);
}
});
});

View File

@@ -0,0 +1,180 @@
import type { LogBuffer } from "../buffer/log-buffer.js";
import {
NETWORK_SB_CODES,
NetworkSbError,
} from "../error-codes/error-codes.js";
import type { BufferedEntry } from "../types/buffer.types.js";
import type {
ConflictReport,
ReconciliationOutcome,
ServerWatermark,
SessionAttributor,
} from "./reconciliation-protocol.types.js";
export interface ReconciliationProtocolConfig {
readonly buffer: LogBuffer;
/**
* Maps a buffered entry to a session id. Returning `null` ships the
* entry as a host-priority diff (no per-session sequence comparison).
*/
readonly sessionAttributor: SessionAttributor;
readonly clock?: () => number;
readonly currentMeshEpoch: () => number;
/**
* Conflict resolver. Default is last-writer-wins keyed by the larger
* of `hostBufferedAt` and `serverIssuedAt`; ties resolve in favour of
* the host (the proposal explicitly states the host owns local
* activity until the server overrides it).
*/
readonly resolveConflict?: (report: ConflictReport) => "host" | "server";
}
const defaultResolve = (report: ConflictReport): "host" | "server" => {
if (report.hostBufferedAt > report.serverIssuedAt) return "host";
if (report.serverIssuedAt > report.hostBufferedAt) return "server";
return "host";
};
/**
* Reconciliation protocol — runs on the Tier-0 host when the server
* reconnects. Handshake:
*
* 1. Server sends its `ServerWatermark` (`meshEpoch`,
* `sessionSequenceNumbers`).
* 2. Host validates the epoch (must equal the host's current epoch
* OR be exactly one behind — anything further apart is a fatal
* `RECONCILIATION_EPOCH_INVALID`).
* 3. Host computes the differential — every buffered entry whose seq
* is strictly greater than the watermark for its session.
* 4. Each differential entry is run through the resolver to attach a
* conflict report when the server has a competing record.
* 5. The output array is the ordered list the host sends upstream.
*/
export class ReconciliationProtocol {
private readonly buffer: LogBuffer;
private readonly attributor: SessionAttributor;
private readonly currentMeshEpoch: () => number;
private readonly resolve: (report: ConflictReport) => "host" | "server";
public constructor(config: ReconciliationProtocolConfig) {
this.buffer = config.buffer;
this.attributor = config.sessionAttributor;
this.currentMeshEpoch = config.currentMeshEpoch;
this.resolve = config.resolveConflict ?? defaultResolve;
}
/**
* Validate the server-supplied watermark. Throws
* `NetworkSbError(RECONCILIATION_EPOCH_INVALID)` if the epoch is
* unreasonable.
*/
public acceptWatermark(watermark: ServerWatermark): void {
const ours = this.currentMeshEpoch();
const drift = ours - watermark.meshEpoch;
if (drift < 0 || drift > 1) {
throw new NetworkSbError(
NETWORK_SB_CODES.RECONCILIATION_EPOCH_INVALID,
"sb.reconciliation.epoch_invalid",
{
context: {
hostEpoch: ours,
serverEpoch: watermark.meshEpoch,
},
}
);
}
}
/**
* Compute the ordered diff to ship upstream. Does NOT mutate the
* buffer — call `commit` after the server acknowledges the diff.
*/
public diff(watermark: ServerWatermark): ReconciliationOutcome[] {
this.acceptWatermark(watermark);
const out: ReconciliationOutcome[] = [];
for (const entry of this.buffer.peekAll()) {
const sessionId = this.attributor(entry);
if (sessionId === null) {
out.push({ entry, winner: "host" });
continue;
}
const watermarkSeq =
watermark.sessionSequenceNumbers[sessionId] ?? -1;
if (entry.seq <= watermarkSeq) {
// Server already has this seq — that's a conflict only when
// we genuinely ran ahead; if the server's seq dominates we
// simply skip our copy.
const report = this.buildConflict(entry, sessionId, watermark);
const winner = this.resolve(report);
if (winner === "host") {
out.push({ entry, winner: "host", conflict: report });
} else {
}
} else {
out.push({ entry, winner: "host" });
}
}
return out;
}
/**
* Once the server acknowledges the diff, drop reconciled entries
* from the local buffer. `acceptedSeqs` is the set of seqs the
* server confirmed.
*/
public commit(acceptedSeqs: ReadonlySet<number>): number {
let dropped = 0;
for (const entry of this.buffer.peekAll()) {
if (acceptedSeqs.has(entry.seq)) {
this.buffer.ackThrough(entry.seq);
dropped++;
}
}
return dropped;
}
/**
* Strict resolver — throws `NetworkSbError(RECONCILIATION_CONFLICT)`
* when any entry produces a conflict report. Use during integration
* tests to fail loud rather than silently picking a winner.
*/
public requireNoConflicts(
outcomes: readonly ReconciliationOutcome[]
): void {
for (const o of outcomes) {
if (o.conflict !== undefined) {
throw new NetworkSbError(
NETWORK_SB_CODES.RECONCILIATION_CONFLICT,
"sb.reconciliation.conflict",
{
context: {
sessionId: o.conflict.sessionId,
seq: o.conflict.seq,
resolution: o.conflict.resolution,
},
}
);
}
}
}
private buildConflict(
entry: BufferedEntry,
sessionId: string,
watermark: ServerWatermark
): ConflictReport {
const winner =
entry.bufferedAt > watermark.issuedAt
? "lww-host"
: entry.bufferedAt < watermark.issuedAt
? "lww-server"
: "tie-host";
return {
sessionId,
seq: entry.seq,
hostBufferedAt: entry.bufferedAt,
serverIssuedAt: watermark.issuedAt,
resolution: winner,
};
}
}

View File

@@ -0,0 +1,72 @@
# reconciliation-protocol.types
## Purpose
Companion type contracts for the reconciliation protocol. Defines `MeshSequence` (the abstract `(meshEpoch, sessionSequenceNumbers)` pair), `ServerWatermark` (the `MeshSequence` plus an `issuedAt` instant), `ReconciliationOutcome` (an entry-plus-winner record), `ConflictReport` (the per-conflict diagnostic the resolver receives), and `SessionAttributor` (the `(entry) => sessionId | null` callback the protocol uses to route entries through the per-session watermark check).
These types form the on-the-wire contract for the reconnect handshake. Both server and host must agree on their shape; the conformance test pins this so a server implementation can validate against this package's types via shared schema generation.
## Features
- `ServerWatermark.sessionSequenceNumbers` is `Readonly<Record<string, number>>` — the host treats every key as opaque; the server is the only side that mints session ids.
- `ReconciliationOutcome.winner` is a 2-value union (`"host" | "server"`); future tiebreaks like `"merged"` can extend the union without breaking existing consumers.
- `ConflictReport.resolution` is a 3-value union (`"lww-server" | "lww-host" | "tie-host"`) so dashboards can break down resolution distribution at a glance.
- `SessionAttributor` returning `null` is the explicit opt-out from per-session reconciliation; the entry still ships, but always as host-priority.
## Usage: Basic Example
```ts
import type {
ServerWatermark,
SessionAttributor,
} from "@suaveplan/network-plugin-server-bridge";
const watermark: ServerWatermark = {
meshEpoch: 1,
sessionSequenceNumbers: { "session-A": 42, "session-B": 17 },
issuedAt: Date.now(),
};
const attributor: SessionAttributor = (entry) => {
const view = new DataView(entry.bytes.buffer);
return view.getUint32(0).toString(16);
};
```
## Usage: Advanced Example
```ts
import type {
ConflictReport,
ReconciliationOutcome,
} from "@suaveplan/network-plugin-server-bridge";
function summarize(outcomes: readonly ReconciliationOutcome[]): {
hostWins: number;
serverWins: number;
conflicts: ConflictReport[];
} {
let hostWins = 0;
let serverWins = 0;
const conflicts: ConflictReport[] = [];
for (const o of outcomes) {
if (o.winner === "host") hostWins++;
else serverWins++;
if (o.conflict !== undefined) conflicts.push(o.conflict);
}
return { hostWins, serverWins, conflicts };
}
```
## API Reference
- `MeshSequence` — `{ meshEpoch: number; sessionSequenceNumbers: Readonly<Record<string, number>> }`.
- `ServerWatermark` — `MeshSequence & { issuedAt: number }`.
- `ReconciliationOutcome` — `{ entry: BufferedEntry; winner: "host" | "server"; conflict?: ConflictReport }`.
- `ConflictReport` — `{ sessionId; seq; hostBufferedAt; serverIssuedAt; resolution }`.
- `SessionAttributor` — `(entry: BufferedEntry) => string | null`.
## Implementation Notes
- Every member is `readonly` to discourage accidental mutation by handlers downstream of `diff()`. The protocol itself emits fresh objects per call so handlers are free to share references.
- The 64-bit `meshEpoch` is intentionally `number` (not `bigint`) — JavaScript's `Number.MAX_SAFE_INTEGER` is `2^53 − 1`, far above the realistic upper bound for mesh-epoch counters (one increment per mesh restart, ~9 quadrillion increments before precision loss).

View File

@@ -0,0 +1,43 @@
import type { BufferedEntry } from "../types/buffer.types.js";
export interface MeshSequence {
readonly meshEpoch: number;
readonly sessionSequenceNumbers: Readonly<Record<string, number>>;
}
/**
* Watermark the server reports during the reconnect handshake. The host
* replays every buffered entry whose `seq` is strictly greater than the
* matching `sessionSequenceNumbers[sessionId]` (or every entry if the
* server has no record for that session).
*/
export interface ServerWatermark extends MeshSequence {
/** Wall-clock instant the watermark was issued (ms since epoch). */
readonly issuedAt: number;
}
/**
* Single reconciled entry. Carries the original `BufferedEntry`,
* the resolved `winner` (in case of conflict), and a `conflict?: ConflictReport`
* when the protocol had to apply a tiebreak rule.
*/
export interface ReconciliationOutcome {
readonly entry: BufferedEntry;
readonly winner: "host" | "server";
readonly conflict?: ConflictReport;
}
export interface ConflictReport {
readonly sessionId: string;
readonly seq: number;
readonly hostBufferedAt: number;
readonly serverIssuedAt: number;
readonly resolution: "lww-server" | "lww-host" | "tie-host";
}
/**
* Function the host calls to attribute a buffered entry to a session.
* Returning `null` excludes the entry from session-keyed reconciliation
* (it will still ship as a host-priority entry).
*/
export type SessionAttributor = (entry: BufferedEntry) => string | null;

View File

@@ -0,0 +1,73 @@
/**
* NET-33 — log replay and reconciliation on reconnection.
*
* Pins: epoch sync, watermark validation, conflict detection.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LogBuffer } from "../buffer/log-buffer.js";
import { NETWORK_SB_CODES } from "../error-codes/error-codes.js";
import { ReconciliationProtocol } from "./reconciliation-protocol.js";
describe("NET-33 — reconciliation", () => {
it("validates server epoch", () => {
const buf = new LogBuffer({ capacity: 4 });
const r = new ReconciliationProtocol({
buffer: buf,
sessionAttributor: () => null,
currentMeshEpoch: () => 5,
});
try {
r.acceptWatermark({
meshEpoch: 99,
sessionSequenceNumbers: {},
issuedAt: 1,
});
throw new Error("expected throw");
} catch (err) {
expect((err as { code: string }).code).toBe(
NETWORK_SB_CODES.RECONCILIATION_EPOCH_INVALID
);
}
});
it("ships only entries past the per-session watermark", () => {
const buf = new LogBuffer({ capacity: 8 });
for (const seq of [1, 2, 3, 4]) {
buf.append({
seq,
entryHash: `h${seq}`,
bytes: new Uint8Array([seq]),
});
}
const r = new ReconciliationProtocol({
buffer: buf,
sessionAttributor: () => "S",
currentMeshEpoch: () => 1,
});
const out = r.diff({
meshEpoch: 1,
sessionSequenceNumbers: { S: 2 },
issuedAt: 0,
});
const seqs = out.map((o) => o.entry.seq);
expect(seqs).toContain(3);
expect(seqs).toContain(4);
});
it("flags conflicts when the server holds the same seq", () => {
const buf = new LogBuffer({ capacity: 8, clock: () => 50 });
buf.append({ seq: 1, entryHash: "h1", bytes: new Uint8Array([1]) });
const r = new ReconciliationProtocol({
buffer: buf,
sessionAttributor: () => "S",
currentMeshEpoch: () => 1,
});
const out = r.diff({
meshEpoch: 1,
sessionSequenceNumbers: { S: 1 },
issuedAt: 100,
});
// server-wins (issuedAt > bufferedAt) so the entry drops
expect(out).toHaveLength(0);
});
});

View File

@@ -0,0 +1,56 @@
# buffer.types
## Purpose
Wire-stable shapes for the offline log buffer. `BufferedEntry` carries the minimum the buffer needs to replay the entry on reconnect: a monotonic `seq` (used for ordering across replays), an `entryHash` (the SHA-256 of the entry's canonical bytes — used for dedup), the raw `bytes` of the signed activity log, and a `bufferedAt` timestamp for telemetry / TTL eviction policies that follow-up changes might add. `LogBufferConfig` is the construction options — a fixed capacity plus an injectable clock.
These types are deliberately minimal; the buffer is an in-memory FIFO with hash-dedup, not a persistence layer. Replay semantics that touch the network (the actual replay-and-reconcile flow) live in the deferred `network-plugin-server-bridge-reconciliation` follow-up change.
## Features
- `seq` is the ordering key for replay — entries pop in monotonically increasing seq order.
- `entryHash` is the dedup key — re-buffering the same entry (e.g. after the gateway redrives a partial flush) is a no-op.
- `bytes` is `Uint8Array` so the buffer is agnostic to the inner record's serialization (typically `@suaveplan/network-plugin-audit-mesh` envelope bytes).
- `LogBufferConfig.capacity` is the hard cap; once full, the oldest entry is evicted to make room (circular semantics).
## Usage: Basic Example
```ts
import type {
BufferedEntry,
LogBufferConfig,
} from "@suaveplan/network-plugin-server-bridge";
const config: LogBufferConfig = { capacity: 10_000 };
const entry: BufferedEntry = {
seq: 1,
entryHash: "abc...",
bytes: new Uint8Array([1, 2, 3]),
bufferedAt: Date.now(),
};
```
## Usage: Advanced Example
```ts
import { LogBuffer } from "@suaveplan/network-plugin-server-bridge";
const buffer = new LogBuffer({
capacity: 50_000,
clock: () => 1_700_000_000_000,
});
buffer.append({ seq: 1, entryHash: "a", bytes: new Uint8Array([1]) });
const replay = buffer.drain();
console.log(replay.length);
```
## API Reference
- `BufferedEntry` — `{ seq; entryHash; bytes; bufferedAt }`.
- `LogBufferConfig` — `{ capacity: number; clock?: () => number }`.
## Implementation Notes
- The buffer is in-memory only; the proposal explicitly assigns durable persistence to the audit-mesh subsystem. This buffer is a server-facing replay queue, not a crash-safe log.
- `bufferedAt` is captured at append time (using the injected clock) so a long offline period leaves the timestamp honest for telemetry. We don't rely on it for ordering — `seq` is the ordering key.

View File

@@ -0,0 +1,11 @@
export interface BufferedEntry {
readonly seq: number;
readonly entryHash: string;
readonly bytes: Uint8Array;
readonly bufferedAt: number;
}
export interface LogBufferConfig {
readonly capacity: number;
readonly clock?: () => number;
}

View File

@@ -0,0 +1,60 @@
# command.types
## Purpose
Discriminated-union shapes for the six server-issued commands the bridge accepts: `kick-peer`, `merge-sessions`, `split-mesh`, `issue-token`, `broadcast`, `shutdown-mesh`. Each command carries the parameters its target subsystem needs; the per-variant `readonly` fields make exhaustive switch-case dispatch the natural pattern. `SignedCommand` is the wire shape — the command bytes plus an `issuedAt`, `nonce` (replay protection), and Ed25519 `signature` from the central server's key.
The closed `CommandType` union prevents drift between docs, dispatch, and tests: adding a new command requires touching the type, the dispatcher, and the conformance test in lockstep. `COMMAND_TYPES` exports the enum-like array for consumers that need to enumerate (e.g. an admin UI dropdown).
## Features
- Six command variants matching the proposal's `ServerCommand` table, each carrying only the fields its dispatch path needs.
- `SignedCommand.nonce` is a string — the dispatcher tracks recent nonces to reject replays without coupling to a specific RNG (callers supply UUIDs, increasing ints, etc.).
- `SignedCommand.signature` is `Uint8Array` (raw Ed25519) so the dispatcher does not pay for base64 round-trips on hot paths.
- `issue-token.tokenBytes` is `Uint8Array` so callers can hand the raw bytes to whatever token authority module consumes them; we don't decode here.
- `COMMAND_TYPES` exported as a `readonly` array for enumeration.
## Usage: Basic Example
```ts
import type {
CommandType,
ServerCommand,
} from "@suaveplan/network-plugin-server-bridge";
const cmd: ServerCommand = {
type: "kick-peer" satisfies CommandType,
targetPeerId: "peer-9",
reason: "policy-violation",
};
```
## Usage: Advanced Example
```ts
import type { SignedCommand } from "@suaveplan/network-plugin-server-bridge";
const signed: SignedCommand = {
command: {
type: "shutdown-mesh",
meshId: "mesh-a",
deadlineMs: Date.now() + 60_000,
},
issuedAt: Date.now(),
nonce: crypto.randomUUID(),
signature: serverKey.sign(canonicalBytes),
};
```
## API Reference
- `CommandType` — `"kick-peer" | "merge-sessions" | "split-mesh" | "issue-token" | "broadcast" | "shutdown-mesh"`.
- `ServerCommand` — discriminated union over `type`, with per-variant fields documented in source.
- `SignedCommand` — `{ command; issuedAt; nonce; signature }`.
- `COMMAND_TYPES` — `readonly CommandType[]`.
## Implementation Notes
- The variants are explicit `readonly` types rather than `type` aliases over an interface so each branch is a distinct literal object — `switch (cmd.type)` narrows to the exact variant.
- The dispatcher does not validate semantic content (e.g. "is this peerId actually in the mesh?"); it validates the signature and routes the command to the responsible plugin.
- New commands land via additive variants — adding a variant requires touching the union, the dispatcher's switch, and the conformance test, which is exactly the audit trail we want.

View File

@@ -0,0 +1,56 @@
export type CommandType =
| "kick-peer"
| "merge-sessions"
| "split-mesh"
| "issue-token"
| "broadcast"
| "shutdown-mesh";
export type ServerCommand =
| {
readonly type: "kick-peer";
readonly targetPeerId: string;
readonly reason: string;
}
| {
readonly type: "merge-sessions";
readonly fromSessionId: string;
readonly toSessionId: string;
}
| {
readonly type: "split-mesh";
readonly meshId: string;
readonly newMeshId: string;
readonly peerIds: readonly string[];
}
| {
readonly type: "issue-token";
readonly tokenType: "session" | "mesh";
readonly tokenBytes: Uint8Array;
}
| {
readonly type: "broadcast";
readonly message: string;
readonly scope: "mesh" | "all";
}
| {
readonly type: "shutdown-mesh";
readonly meshId: string;
readonly deadlineMs: number;
};
export interface SignedCommand {
readonly command: ServerCommand;
readonly issuedAt: number;
readonly nonce: string;
readonly signature: Uint8Array;
}
export const COMMAND_TYPES: readonly CommandType[] = [
"kick-peer",
"merge-sessions",
"split-mesh",
"issue-token",
"broadcast",
"shutdown-mesh",
];

View File

@@ -0,0 +1,52 @@
# state.types
## Purpose
Wire-stable shapes for the bridge's lifecycle state. `BridgeState` is the four-state union the `GracefulDegradationFsm` cycles through (`online` → `degrading` → `offline` → `recovering` → `online`). `OfflineMode` is the binary indicator the `OfflineModeCoordinator` flips when the FSM enters / leaves the `offline` state — it lives separately from `BridgeState` because some operations (extending token grace periods, blocking new session creation) depend on whether the *mesh* is in autonomous mode, not strictly on the FSM state. `BridgeStateChange` is the per-transition record the FSM emits and the coordinator consumes.
These types live in a shared `types/` directory so every subsystem can agree without a circular import.
## Features
- `BridgeState` is a closed union of four labels — adding a state is a code change so docs and dispatch can never drift.
- `OfflineMode` is `"active" | "inactive"` rather than a boolean because future modes (`degraded` partial-online) can extend the union without a breaking change.
- `BridgeStateChange.reason` is a free-form string for human-readable logs; structured analytics should rely on `from`/`to` plus the producing module's own typed event.
- All fields are `readonly`.
## Usage: Basic Example
```ts
import type {
BridgeState,
BridgeStateChange,
} from "@suaveplan/network-plugin-server-bridge";
let state: BridgeState = "online";
const change: BridgeStateChange = {
from: "online",
to: "degrading",
at: Date.now(),
reason: "heartbeat-timeout",
};
```
## Usage: Advanced Example
```ts
import type { OfflineMode } from "@suaveplan/network-plugin-server-bridge";
function shouldExtendGrace(mode: OfflineMode): boolean {
return mode === "active";
}
```
## API Reference
- `BridgeState` — `"online" | "degrading" | "offline" | "recovering"`.
- `OfflineMode` — `"active" | "inactive"`.
- `BridgeStateChange` — `{ from: BridgeState; to: BridgeState; at: number; reason: string }`.
## Implementation Notes
- The `recovering` state exists distinct from `online` because reconciliation work happens after reconnect but before the mesh is fully trusted again; consumers may want to surface a "resyncing" indicator to operators.
- `at` is a monotonic-leaning timestamp (`Date.now()`-shaped). The injected `clock` in the FSM lets tests pin it.

View File

@@ -0,0 +1,10 @@
export type BridgeState = "online" | "degrading" | "offline" | "recovering";
export type OfflineMode = "active" | "inactive";
export interface BridgeStateChange {
readonly from: BridgeState;
readonly to: BridgeState;
readonly at: number;
readonly reason: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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