first commit
This commit is contained in:
125
docs/networking/network/network-plugin-server-bridge/README.md
Normal file
125
docs/networking/network/network-plugin-server-bridge/README.md
Normal 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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user