Initial Commit
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# @suaveplan/network-plugin-audit-mesh - Tamper-evident, cryptographically signed, append-only audit log chain for the Suaveplan mesh: SignedLog, LogReplicator (R-copy with tier-aware target selection), LogRetriever (range queries + pagination), IntegrityChecker, ConflictResolver, LogCompaction
|
||||
|
||||
## Summary
|
||||
|
||||
Tamper-evident, cryptographically signed, append-only audit log chain for the Suaveplan mesh. `SignedLog` produces hash-chained `LogEntry` records keyed by monotonic sequence with caller-injected `SignFn` (Ed25519 typically) so each entry's signature covers the deterministic SHA-256 of the prior entry plus the current payload; `LogReplicator` ships entries to `R` sibling hosts (default 2) with tier-aware target selection and synchronous-for-CRITICAL / async-for-NORMAL semantics; `LogRetriever` answers paginated `getLogs(meshId, fromSeq, toSeq)` range queries; `IntegrityChecker` walks the chain, detecting gaps / forks / tampered entries and emitting `audit:corruption-detected`; `ConflictResolver` reconciles divergent chains via longest-chain-wins with deterministic lex tiebreak; `LogCompaction` snapshots state into signed checkpoints to reduce reconciliation volume after server reconnect. Wrapped in a single `AuditMeshPlugin` satisfying the `NetworkPlugin` lifecycle.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [compaction](./src/compaction/log-compaction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [log-compaction](./src/compaction/log-compaction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [conflict](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [conflict-resolver](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [integrity](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [integrity-checker](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [signed-log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [audit-mesh-plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [log-replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [log-retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes.md) — Frozen `NET_AUDIT` error registry for `@suaveplan/network-plugin-audit-mesh`. Every failure path the audit subsystem ca…
|
||||
<!-- end:auto-docs --> - [log-compaction](./src/compaction/log-compaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict-resolver](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity-checker](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [signed-log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-mesh-plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_AUDIT` error registry for `@suaveplan/network-plugin-audit-mesh`. Every failure path the audit subsystem ca…
|
||||
- [log-compaction](./src/compaction/log-compaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict-resolver](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity-checker](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [signed-log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-mesh-plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_AUDIT` error registry for `@suaveplan/network-plugin-audit-mesh`. Every failure path the audit subsystem ca…
|
||||
- [log-compaction](./src/compaction/log-compaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict-resolver](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity-checker](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [signed-log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-mesh-plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_AUDIT` error registry for `@suaveplan/network-plugin-audit-mesh`. Every failure path the audit subsystem ca…
|
||||
- [log-compaction](./src/compaction/log-compaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ - [conflict-resolver](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_ - [integrity-checker](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_ - [signed-log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [audit-mesh-plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [log-replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_ - [log-retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_AUDIT` error registry for `@suaveplan/network-plugin-audit-mesh`. Every failure path the audit subsystem ca…
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@suaveplan/network-plugin-audit-mesh",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Tamper-evident, cryptographically signed, append-only audit log chain for the Suaveplan mesh: SignedLog, LogReplicator (R-copy with tier-aware target selection), LogRetriever (range queries + pagination), IntegrityChecker, ConflictResolver, LogCompaction",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist", "src", "README.md"],
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "bunx biome check src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/crypto-hash": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/network": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# LogCompaction
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Periodically compacts a long signed audit chain into a single signed `LogCheckpoint`, dramatically reducing the volume of entries the server has to replay after a long outage. A checkpoint is a snapshot of mesh/session state at a particular sequence number, signed by the same host (and the same key) that authored the surrounding chain — so consumers can verify the snapshot in isolation without walking back to the suaveplan entry.
|
||||
|
||||
The compaction module is deliberately stateless. It does not hold the chain or the snapshots; it just produces signed checkpoints from the inputs you give it and provides a `truncate` helper to discard already-checkpointed entries. This keeps the storage policy decoupled from the cryptographic policy.
|
||||
|
||||
## Features
|
||||
|
||||
- Builds a `LogCheckpoint` over `(seq, timestamp, meshId, hostId, stateHash, entryHashAtSeq)` and signs the canonical bytes.
|
||||
- Two anchors per checkpoint — `entryHashAtSeq` ties the checkpoint to a specific entry in the chain (so the server can verify it against its retrieval), and `stateHash` ties it to the application-level snapshot.
|
||||
- `verify(checkpoint, verifyFn)` round-trips the canonical bytes and asks the injected verifier whether the signature is valid.
|
||||
- `truncate(entries, checkpoint)` returns the entries with `seq > checkpoint.seq` so callers can prune their hot log to just the post-checkpoint suffix.
|
||||
- Empty entry list and missing-anchor errors raise `NET_AUDIT.CHECKPOINT_INVALID` with explanatory `error.context`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ed25519 } from "@suaveplan/crypto-curve";
|
||||
import { LogCompaction } from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const compactor = new LogCompaction({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: (bytes) => ed25519.sign(bytes, hostPrivateKey),
|
||||
});
|
||||
|
||||
const checkpoint = await compactor.createCheckpoint({
|
||||
atSeq: 1024,
|
||||
entries: log.entries(),
|
||||
stateSnapshot: { peers: snapshot.peers, sessions: snapshot.sessions },
|
||||
});
|
||||
|
||||
const remaining = compactor.truncate(log.entries(), checkpoint);
|
||||
console.log(remaining.length); // post-1024 only
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ed25519 } from "@suaveplan/crypto-curve";
|
||||
import { LogCompaction } from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const compactor = new LogCompaction({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
clock: () => 1_700_000_000_000,
|
||||
sign: async (bytes) => await hardwareSigner.sign(bytes),
|
||||
});
|
||||
|
||||
const checkpoint = await compactor.createCheckpoint({ atSeq, entries, stateSnapshot });
|
||||
const valid = await compactor.verify(
|
||||
checkpoint,
|
||||
(bytes, sig, hostId) => ed25519.verify(sig, bytes, hostKeys.get(hostId)!)
|
||||
);
|
||||
if (!valid) throw new Error("checkpoint signature failed");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `LogCompaction(config)` — `config: { meshId; hostId; sign: SignFn; clock?: () => number }`.
|
||||
- `createCheckpoint(input)` — `input: { atSeq; entries; stateSnapshot }`. Returns `Promise<LogCheckpoint>`. Raises `NET_AUDIT.CHECKPOINT_INVALID` on empty entries or missing anchor.
|
||||
- `verify(checkpoint, verify)` — `verify: CheckpointVerifyFn`. Returns `Promise<boolean>`.
|
||||
- `truncate(entries, checkpoint)` — returns the entries whose `seq` is strictly greater than `checkpoint.seq`.
|
||||
- `checkpointSigningBytes(value)` — exported helper that returns the canonical bytes a verifier should hash and signature-check. Useful when the caller already has a checkpoint and just wants to validate it without instantiating `LogCompaction`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The canonical bytes for a checkpoint are the same sorted-key JSON encoding used for entries; we keep two helpers (`entryHashBytes` for entries, `checkpointSigningBytes` for checkpoints) so that one cannot be accidentally substituted for the other.
|
||||
- `stateHash` is computed over the caller-supplied `stateSnapshot` value via the same canonicalize routine, so two snapshots with semantically equal contents but different key insertion order produce identical hashes.
|
||||
- `truncate` returns a fresh array so the caller can keep using the original entries for archival.
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_AUDIT_CODES } from "../error-codes.js";
|
||||
import { SignedLog } from "../log/signed-log.js";
|
||||
import { checkpointSigningBytes, LogCompaction } from "./log-compaction.js";
|
||||
|
||||
const stubSign = (bytes: Uint8Array): Uint8Array => {
|
||||
const sig = new Uint8Array(64);
|
||||
sig.set(bytes.slice(0, Math.min(64, bytes.length)));
|
||||
return sig;
|
||||
};
|
||||
|
||||
const buildLog = async (count: number) => {
|
||||
const log = new SignedLog({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
await log.append({
|
||||
eventType: "peer-joined",
|
||||
sessionId: null,
|
||||
severity: "normal",
|
||||
payload: { i },
|
||||
});
|
||||
}
|
||||
return log;
|
||||
};
|
||||
|
||||
describe("LogCompaction.createCheckpoint", () => {
|
||||
it("produces a checkpoint with anchor + state hash + signature", async () => {
|
||||
const log = await buildLog(5);
|
||||
const compaction = new LogCompaction({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
clock: () => 1234,
|
||||
});
|
||||
const checkpoint = await compaction.createCheckpoint({
|
||||
atSeq: 4,
|
||||
entries: log.entries(),
|
||||
stateSnapshot: { peers: ["a", "b"], sessions: [] },
|
||||
});
|
||||
expect(checkpoint.seq).toBe(4);
|
||||
expect(checkpoint.timestamp).toBe(1234);
|
||||
expect(checkpoint.entryHashAtSeq.length).toBeGreaterThan(0);
|
||||
expect(checkpoint.stateHash.length).toBeGreaterThan(0);
|
||||
expect(checkpoint.signature.byteLength).toBe(64);
|
||||
});
|
||||
|
||||
it("throws CHECKPOINT_INVALID on empty entries", async () => {
|
||||
const compaction = new LogCompaction({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
await expect(
|
||||
compaction.createCheckpoint({
|
||||
atSeq: 0,
|
||||
entries: [],
|
||||
stateSnapshot: {},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: NETWORK_AUDIT_CODES.CHECKPOINT_INVALID,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws CHECKPOINT_INVALID when the anchor seq is missing", async () => {
|
||||
const log = await buildLog(3);
|
||||
const compaction = new LogCompaction({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
await expect(
|
||||
compaction.createCheckpoint({
|
||||
atSeq: 99,
|
||||
entries: log.entries(),
|
||||
stateSnapshot: {},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: NETWORK_AUDIT_CODES.CHECKPOINT_INVALID,
|
||||
});
|
||||
});
|
||||
|
||||
it("supports an async signer", async () => {
|
||||
const log = await buildLog(1);
|
||||
const compaction = new LogCompaction({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: async (bytes) => {
|
||||
await Promise.resolve();
|
||||
return stubSign(bytes);
|
||||
},
|
||||
});
|
||||
const checkpoint = await compaction.createCheckpoint({
|
||||
atSeq: 0,
|
||||
entries: log.entries(),
|
||||
stateSnapshot: {},
|
||||
});
|
||||
expect(checkpoint.signature.byteLength).toBe(64);
|
||||
});
|
||||
|
||||
it("uses Date.now when no clock is injected", async () => {
|
||||
const log = await buildLog(1);
|
||||
const compaction = new LogCompaction({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
const before = Date.now();
|
||||
const cp = await compaction.createCheckpoint({
|
||||
atSeq: 0,
|
||||
entries: log.entries(),
|
||||
stateSnapshot: {},
|
||||
});
|
||||
const after = Date.now();
|
||||
expect(cp.timestamp).toBeGreaterThanOrEqual(before);
|
||||
expect(cp.timestamp).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LogCompaction.verify", () => {
|
||||
it("returns true for a signature the verifier accepts", async () => {
|
||||
const log = await buildLog(1);
|
||||
const compaction = new LogCompaction({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
const cp = await compaction.createCheckpoint({
|
||||
atSeq: 0,
|
||||
entries: log.entries(),
|
||||
stateSnapshot: {},
|
||||
});
|
||||
const ok = await compaction.verify(cp, () => true);
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the verifier rejects", async () => {
|
||||
const log = await buildLog(1);
|
||||
const compaction = new LogCompaction({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
const cp = await compaction.createCheckpoint({
|
||||
atSeq: 0,
|
||||
entries: log.entries(),
|
||||
stateSnapshot: {},
|
||||
});
|
||||
const ok = await compaction.verify(cp, async () => {
|
||||
await Promise.resolve();
|
||||
return false;
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LogCompaction.truncate", () => {
|
||||
it("returns only entries with seq > checkpoint.seq", async () => {
|
||||
const log = await buildLog(5);
|
||||
const compaction = new LogCompaction({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
const cp = await compaction.createCheckpoint({
|
||||
atSeq: 2,
|
||||
entries: log.entries(),
|
||||
stateSnapshot: {},
|
||||
});
|
||||
const remaining = compaction.truncate(log.entries(), cp);
|
||||
expect(remaining.map((e) => e.seq)).toEqual([3, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkpointSigningBytes", () => {
|
||||
it("is deterministic and includes every signed field", () => {
|
||||
const a = checkpointSigningBytes({
|
||||
seq: 0,
|
||||
timestamp: 0,
|
||||
meshId: "m",
|
||||
hostId: "h",
|
||||
stateHash: "s",
|
||||
entryHashAtSeq: "e",
|
||||
});
|
||||
const b = checkpointSigningBytes({
|
||||
entryHashAtSeq: "e",
|
||||
stateHash: "s",
|
||||
hostId: "h",
|
||||
meshId: "m",
|
||||
timestamp: 0,
|
||||
seq: 0,
|
||||
});
|
||||
expect(new TextDecoder().decode(a)).toBe(new TextDecoder().decode(b));
|
||||
});
|
||||
|
||||
it("produces different bytes for different states", () => {
|
||||
const a = checkpointSigningBytes({
|
||||
seq: 0,
|
||||
timestamp: 0,
|
||||
meshId: "m",
|
||||
hostId: "h",
|
||||
stateHash: "alpha",
|
||||
entryHashAtSeq: "e",
|
||||
});
|
||||
const b = checkpointSigningBytes({
|
||||
seq: 0,
|
||||
timestamp: 0,
|
||||
meshId: "m",
|
||||
hostId: "h",
|
||||
stateHash: "beta",
|
||||
entryHashAtSeq: "e",
|
||||
});
|
||||
expect(new TextDecoder().decode(a)).not.toBe(
|
||||
new TextDecoder().decode(b)
|
||||
);
|
||||
});
|
||||
|
||||
it("handles arrays, booleans, null, infinity, and unknown objects in state-snapshot canonicalization", async () => {
|
||||
const log = await buildLog(1);
|
||||
const compaction = new LogCompaction({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
const cp = await compaction.createCheckpoint({
|
||||
atSeq: 0,
|
||||
entries: log.entries(),
|
||||
stateSnapshot: {
|
||||
peers: ["a", "b"],
|
||||
active: true,
|
||||
missing: null,
|
||||
edge: Number.POSITIVE_INFINITY,
|
||||
size: 7,
|
||||
},
|
||||
});
|
||||
expect(cp.stateHash.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { sha256 } from "@suaveplan/crypto-hash";
|
||||
import { NETWORK_AUDIT_CODES, NetworkAuditError } from "../error-codes.js";
|
||||
import {
|
||||
bytesToHex,
|
||||
canonicalize,
|
||||
entryHashBytes,
|
||||
type SignFn,
|
||||
} from "../log/signed-log.js";
|
||||
import type { LogCheckpoint, LogEntry } from "../types/log.types.js";
|
||||
|
||||
export interface LogCompactionConfig {
|
||||
readonly meshId: string;
|
||||
readonly hostId: string;
|
||||
readonly sign: SignFn;
|
||||
readonly clock?: () => number;
|
||||
}
|
||||
|
||||
export interface CheckpointInput {
|
||||
readonly atSeq: number;
|
||||
readonly entries: readonly LogEntry[];
|
||||
readonly stateSnapshot: unknown;
|
||||
}
|
||||
|
||||
export type CheckpointVerifyFn = (
|
||||
bytes: Uint8Array,
|
||||
signature: Uint8Array,
|
||||
hostId: string
|
||||
) => boolean | Promise<boolean>;
|
||||
|
||||
export class LogCompaction {
|
||||
public readonly meshId: string;
|
||||
public readonly hostId: string;
|
||||
private readonly sign: SignFn;
|
||||
private readonly clock: () => number;
|
||||
|
||||
public constructor(config: LogCompactionConfig) {
|
||||
this.meshId = config.meshId;
|
||||
this.hostId = config.hostId;
|
||||
this.sign = config.sign;
|
||||
this.clock = config.clock ?? (() => Date.now());
|
||||
}
|
||||
|
||||
public async createCheckpoint(
|
||||
input: CheckpointInput
|
||||
): Promise<LogCheckpoint> {
|
||||
if (input.entries.length === 0) {
|
||||
throw new NetworkAuditError(
|
||||
NETWORK_AUDIT_CODES.CHECKPOINT_INVALID,
|
||||
"audit.checkpoint.empty",
|
||||
{}
|
||||
);
|
||||
}
|
||||
const anchor = input.entries.find((e) => e.seq === input.atSeq);
|
||||
if (anchor === undefined) {
|
||||
throw new NetworkAuditError(
|
||||
NETWORK_AUDIT_CODES.CHECKPOINT_INVALID,
|
||||
"audit.checkpoint.anchor_missing",
|
||||
{
|
||||
context: {
|
||||
atSeq: input.atSeq,
|
||||
provided: input.entries.length,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
const entryHashAtSeq = bytesToHex(
|
||||
new Uint8Array(sha256(entryHashBytes(anchor)))
|
||||
);
|
||||
const stateBytes = canonicalBytes(input.stateSnapshot);
|
||||
const stateHash = bytesToHex(new Uint8Array(sha256(stateBytes)));
|
||||
const timestamp = this.clock();
|
||||
const unsigned = {
|
||||
seq: input.atSeq,
|
||||
timestamp,
|
||||
meshId: this.meshId,
|
||||
hostId: this.hostId,
|
||||
stateHash,
|
||||
entryHashAtSeq,
|
||||
};
|
||||
const signatureBytes = checkpointSigningBytes(unsigned);
|
||||
const signature = await Promise.resolve(this.sign(signatureBytes));
|
||||
return { ...unsigned, signature };
|
||||
}
|
||||
|
||||
public async verify(
|
||||
checkpoint: LogCheckpoint,
|
||||
verify: CheckpointVerifyFn
|
||||
): Promise<boolean> {
|
||||
const unsigned = {
|
||||
seq: checkpoint.seq,
|
||||
timestamp: checkpoint.timestamp,
|
||||
meshId: checkpoint.meshId,
|
||||
hostId: checkpoint.hostId,
|
||||
stateHash: checkpoint.stateHash,
|
||||
entryHashAtSeq: checkpoint.entryHashAtSeq,
|
||||
};
|
||||
const bytes = checkpointSigningBytes(unsigned);
|
||||
return await Promise.resolve(
|
||||
verify(bytes, checkpoint.signature, checkpoint.hostId)
|
||||
);
|
||||
}
|
||||
|
||||
public truncate(
|
||||
entries: readonly LogEntry[],
|
||||
checkpoint: LogCheckpoint
|
||||
): LogEntry[] {
|
||||
return entries.filter((entry) => entry.seq > checkpoint.seq);
|
||||
}
|
||||
}
|
||||
|
||||
export function checkpointSigningBytes(value: {
|
||||
seq: number;
|
||||
timestamp: number;
|
||||
meshId: string;
|
||||
hostId: string;
|
||||
stateHash: string;
|
||||
entryHashAtSeq: string;
|
||||
}): Uint8Array {
|
||||
return canonicalBytes(value);
|
||||
}
|
||||
|
||||
function canonicalBytes(value: unknown): Uint8Array {
|
||||
return new TextEncoder().encode(canonicalize(value));
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# ConflictResolver
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Picks the canonical chain when the server pulls audit logs from multiple Tier 0 hosts and finds disagreement. The rule is two-step and deterministic: (1) longest chain wins; (2) on equal length, the chain whose `lastHash` sorts lexicographically smallest wins. The deterministic tiebreak matters because the reconciler is itself replicated — two reconciler instances seeing the same divergent set must reach the same conclusion or the mesh keeps thrashing on every server reconnection.
|
||||
|
||||
The resolver is pure: input goes in, an outcome comes out, no side effects, no time, no randomness. That makes it the safest possible thing to call inside a property test or replay scenario.
|
||||
|
||||
## Features
|
||||
|
||||
- Longest-chain-wins primary rule.
|
||||
- Lexicographic-tiebreak secondary rule on `lastHash`, with a final stable sort key on `hostId` so two chains that somehow share both length and `lastHash` still produce a deterministic answer.
|
||||
- Returns the full winning chain, the winning host, the discriminating `reason` (`"longest-chain"` vs `"lex-tiebreak"`), and the list of `losers` so callers can rollback those hosts via the mesh.
|
||||
- Empty input throws `NET_AUDIT.CONFLICT_UNRESOLVED` rather than returning a sentinel — there is no meaningful "no chains" winner.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ConflictResolver } from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const resolver = new ConflictResolver();
|
||||
const result = resolver.resolve([
|
||||
{ hostId: "host-1", entries: chain1, lastHash: "abc..." },
|
||||
{ hostId: "host-2", entries: chain2, lastHash: "def..." },
|
||||
]);
|
||||
console.log(result.winningHostId, result.reason, result.losers);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ConflictResolver,
|
||||
type ConflictResolution,
|
||||
} from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
function reconcile(
|
||||
pulled: ReadonlyMap<string, LogEntry[]>
|
||||
): ConflictResolution {
|
||||
const chains = Array.from(pulled.entries()).map(([hostId, entries]) => ({
|
||||
hostId,
|
||||
entries,
|
||||
lastHash: entries.length === 0
|
||||
? ""
|
||||
: entryDigestHex(entries[entries.length - 1]!),
|
||||
}));
|
||||
return new ConflictResolver().resolve(chains);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ConflictResolver` — stateless class; the no-arg constructor is intentional so all callers get the same rule set.
|
||||
- `resolve(chains)` — `chains: readonly DivergentChain[]`. Returns `ConflictResolution`. Throws `NET_AUDIT.CONFLICT_UNRESOLVED` if `chains.length === 0`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The "longest" reduce uses `>` rather than `>=` so the first chain wins on equal length; the `tied` filter then catches the equal-length case explicitly. This avoids a subtle bug where a strict-greater reduce would leak the iteration order into the result.
|
||||
- `hostId` is the final sort key because two chains with identical content (and therefore identical `lastHash`) can in theory exist when the same content was independently produced by two hosts; this is rare but not impossible, and we want the resolver to be total.
|
||||
- The losers list is produced by filtering the input by `winningHostId`, not by negating the tiebreak result, so a five-way conflict produces exactly four losers regardless of how deep the tiebreak chain went.
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_AUDIT_CODES, NetworkAuditError } from "../error-codes.js";
|
||||
import type { DivergentChain } from "../types/conflict.types.js";
|
||||
import type { LogEntry } from "../types/log.types.js";
|
||||
import { ConflictResolver } from "./conflict-resolver.js";
|
||||
|
||||
const dummyEntry = (seq: number): LogEntry => ({
|
||||
seq,
|
||||
timestamp: 0,
|
||||
meshId: "mesh-a",
|
||||
sessionId: null,
|
||||
eventType: "peer-joined",
|
||||
severity: "normal",
|
||||
payload: {},
|
||||
prevHash: "",
|
||||
hostId: "host-x",
|
||||
hostSignature: new Uint8Array(0),
|
||||
});
|
||||
|
||||
describe("ConflictResolver.resolve", () => {
|
||||
it("longest chain wins", () => {
|
||||
const chains: DivergentChain[] = [
|
||||
{ hostId: "h-1", entries: [dummyEntry(0)], lastHash: "aa" },
|
||||
{
|
||||
hostId: "h-2",
|
||||
entries: [dummyEntry(0), dummyEntry(1)],
|
||||
lastHash: "bb",
|
||||
},
|
||||
];
|
||||
const result = new ConflictResolver().resolve(chains);
|
||||
expect(result.winningHostId).toBe("h-2");
|
||||
expect(result.reason).toBe("longest-chain");
|
||||
expect(result.losers).toEqual(["h-1"]);
|
||||
expect(result.winningChain.length).toBe(2);
|
||||
});
|
||||
|
||||
it("equal length: lex-smallest lastHash wins", () => {
|
||||
const chains: DivergentChain[] = [
|
||||
{ hostId: "h-1", entries: [dummyEntry(0)], lastHash: "ff" },
|
||||
{ hostId: "h-2", entries: [dummyEntry(0)], lastHash: "aa" },
|
||||
{ hostId: "h-3", entries: [dummyEntry(0)], lastHash: "cc" },
|
||||
];
|
||||
const result = new ConflictResolver().resolve(chains);
|
||||
expect(result.winningHostId).toBe("h-2");
|
||||
expect(result.reason).toBe("lex-tiebreak");
|
||||
expect(result.losers.sort()).toEqual(["h-1", "h-3"]);
|
||||
});
|
||||
|
||||
it("longest tied with one chain → longest-chain reason", () => {
|
||||
const chains: DivergentChain[] = [
|
||||
{ hostId: "h-1", entries: [dummyEntry(0)], lastHash: "zz" },
|
||||
{
|
||||
hostId: "h-2",
|
||||
entries: [dummyEntry(0), dummyEntry(1), dummyEntry(2)],
|
||||
lastHash: "aa",
|
||||
},
|
||||
];
|
||||
const result = new ConflictResolver().resolve(chains);
|
||||
expect(result.winningHostId).toBe("h-2");
|
||||
expect(result.reason).toBe("longest-chain");
|
||||
});
|
||||
|
||||
it("equal length AND equal lastHash → falls through to hostId tiebreak", () => {
|
||||
const chains: DivergentChain[] = [
|
||||
{ hostId: "h-2", entries: [dummyEntry(0)], lastHash: "same" },
|
||||
{ hostId: "h-1", entries: [dummyEntry(0)], lastHash: "same" },
|
||||
];
|
||||
const result = new ConflictResolver().resolve(chains);
|
||||
expect(result.winningHostId).toBe("h-1");
|
||||
expect(result.reason).toBe("lex-tiebreak");
|
||||
});
|
||||
|
||||
it("throws NET_AUDIT.CONFLICT_UNRESOLVED on empty input", () => {
|
||||
const resolver = new ConflictResolver();
|
||||
try {
|
||||
resolver.resolve([]);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(NetworkAuditError);
|
||||
expect((err as NetworkAuditError).code).toBe(
|
||||
NETWORK_AUDIT_CODES.CONFLICT_UNRESOLVED
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NETWORK_AUDIT_CODES, NetworkAuditError } from "../error-codes.js";
|
||||
import type {
|
||||
ConflictResolution,
|
||||
DivergentChain,
|
||||
} from "../types/conflict.types.js";
|
||||
|
||||
export class ConflictResolver {
|
||||
public resolve(chains: readonly DivergentChain[]): ConflictResolution {
|
||||
if (chains.length === 0) {
|
||||
throw new NetworkAuditError(
|
||||
NETWORK_AUDIT_CODES.CONFLICT_UNRESOLVED,
|
||||
"audit.conflict.empty",
|
||||
{}
|
||||
);
|
||||
}
|
||||
const longest = chains.reduce((max, chain) =>
|
||||
chain.entries.length > max.entries.length ? chain : max
|
||||
);
|
||||
const tied = chains.filter(
|
||||
(c) => c.entries.length === longest.entries.length
|
||||
);
|
||||
if (tied.length === 1) {
|
||||
return {
|
||||
winningHostId: longest.hostId,
|
||||
winningChain: longest.entries,
|
||||
reason: "longest-chain",
|
||||
losers: chains
|
||||
.filter((c) => c.hostId !== longest.hostId)
|
||||
.map((c) => c.hostId),
|
||||
};
|
||||
}
|
||||
const sorted = [...tied].sort((a, b) => {
|
||||
if (a.lastHash !== b.lastHash) {
|
||||
return a.lastHash.localeCompare(b.lastHash);
|
||||
}
|
||||
return a.hostId.localeCompare(b.hostId);
|
||||
});
|
||||
const winner = sorted[0] as DivergentChain;
|
||||
return {
|
||||
winningHostId: winner.hostId,
|
||||
winningChain: winner.entries,
|
||||
reason: "lex-tiebreak",
|
||||
losers: chains
|
||||
.filter((c) => c.hostId !== winner.hostId)
|
||||
.map((c) => c.hostId),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# error-codes
|
||||
|
||||
## Purpose
|
||||
|
||||
Frozen `NET_AUDIT` error registry for `@suaveplan/network-plugin-audit-mesh`. Every failure path the audit subsystem can surface — a missing sequence number in the chain, a tampered entry, a checkpoint that fails signature verification, a server reconciliation that cannot decide between divergent chains — is mapped onto one of these stable codes. Codes are emitted by `defineCodes("NET_AUDIT", [...] as const)` so the registry is locked at module load time and consumers downstream (logger redaction maps, i18n catalogs, the OpenSpec conformance checker) can rely on the set never silently growing.
|
||||
|
||||
The registry exists in its own module so the `index.ts` barrel can do `import "./error-codes.js"` first to register the codes via side effect before any subsystem starts referencing them.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight codes covering chain integrity (`LOG_GAP`, `CHAIN_TAMPERED`, `SIGNATURE_INVALID`), sequencing (`INVALID_SEQ`), replication failure (`REPLICATION_FAILED`), checkpoint failures (`CHECKPOINT_INVALID`), conflict resolution failures (`CONFLICT_UNRESOLVED`), and range-query input validation (`RANGE_INVALID`).
|
||||
- Sibling `NetworkAuditError` extends `SuaveplanError<NetworkAuditErrorCode>` so consumers can write `instanceof NetworkAuditError` checks and access `error.code` with full type narrowing.
|
||||
- The registry is frozen by `defineCodes`; attempting to mutate it throws at runtime, and TypeScript's `as const` narrowing prevents adding new keys without a code change.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NETWORK_AUDIT_CODES,
|
||||
NetworkAuditError,
|
||||
} from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
throw new NetworkAuditError(
|
||||
NETWORK_AUDIT_CODES.LOG_GAP,
|
||||
"audit.log_gap",
|
||||
{ context: { meshId: "mesh-a", missingSeq: 7 } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NetworkAuditError,
|
||||
type NetworkAuditErrorCode,
|
||||
} from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
function classify(err: unknown): NetworkAuditErrorCode | null {
|
||||
return err instanceof NetworkAuditError ? err.code : null;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NETWORK_AUDIT_CODES` — frozen object with eight keys: `LOG_GAP`, `CHAIN_TAMPERED`, `SIGNATURE_INVALID`, `REPLICATION_FAILED`, `CHECKPOINT_INVALID`, `CONFLICT_UNRESOLVED`, `INVALID_SEQ`, `RANGE_INVALID`. Values are `"NET_AUDIT.<KEY>"` strings.
|
||||
- `NetworkAuditErrorCode` — string-literal union of the registry values.
|
||||
- `NetworkAuditError` — error class extending `SuaveplanError<NetworkAuditErrorCode>`. Constructor: `(code, messageKey, options?)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The list grows only by adding to the array literal in `error-codes.ts`; there is no programmatic `register()` API by design — the `as const` plus `defineCodes` arrangement is the contract.
|
||||
- Every code in the registry must be referenced from `src/` somewhere — typically as the argument to `new NetworkAuditError(...)` (thrown classes), but `LOG_GAP` / `CHAIN_TAMPERED` / `SIGNATURE_INVALID` are **report-only** codes carried on `CorruptionReport.code` rather than thrown. The `CORRUPTION_KIND_TO_CODE` map in `integrity-checker.ts` is the single source of truth wiring those three codes onto the integrity-report stream. The conformance gate enforces that the codes are referenced at least once so dead codes cannot accumulate.
|
||||
- Code messages use dotted i18n keys (e.g. `audit.log_gap`) so the logger's i18n adapter can resolve them at format time without coupling this package to a specific locale catalog.
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_AUDIT_CODES, NetworkAuditError } from "./error-codes.js";
|
||||
|
||||
describe("NETWORK_AUDIT_CODES", () => {
|
||||
it("registers under NET_AUDIT", () => {
|
||||
for (const value of Object.values(NETWORK_AUDIT_CODES)) {
|
||||
expect(value).toMatch(/^NET_AUDIT\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("is frozen", () => {
|
||||
expect(Object.isFrozen(NETWORK_AUDIT_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("contains the documented codes", () => {
|
||||
for (const key of [
|
||||
"LOG_GAP",
|
||||
"CHAIN_TAMPERED",
|
||||
"SIGNATURE_INVALID",
|
||||
"REPLICATION_FAILED",
|
||||
"CHECKPOINT_INVALID",
|
||||
"CONFLICT_UNRESOLVED",
|
||||
"INVALID_SEQ",
|
||||
"RANGE_INVALID",
|
||||
] as const) {
|
||||
expect(NETWORK_AUDIT_CODES).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("NetworkAuditError", () => {
|
||||
it("extends SuaveplanError and preserves the code", () => {
|
||||
const err = new NetworkAuditError(
|
||||
NETWORK_AUDIT_CODES.LOG_GAP,
|
||||
"audit.log_gap"
|
||||
);
|
||||
expect(err.name).toBe("NetworkAuditError");
|
||||
expect(err.code).toBe(NETWORK_AUDIT_CODES.LOG_GAP);
|
||||
});
|
||||
|
||||
it("preserves the cause", () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new NetworkAuditError(
|
||||
NETWORK_AUDIT_CODES.CHAIN_TAMPERED,
|
||||
"audit.chain_tampered",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
export const NETWORK_AUDIT_CODES = defineCodes("NET_AUDIT", [
|
||||
"LOG_GAP",
|
||||
"CHAIN_TAMPERED",
|
||||
"SIGNATURE_INVALID",
|
||||
"REPLICATION_FAILED",
|
||||
"CHECKPOINT_INVALID",
|
||||
"CONFLICT_UNRESOLVED",
|
||||
"INVALID_SEQ",
|
||||
"RANGE_INVALID",
|
||||
] as const);
|
||||
|
||||
export type NetworkAuditErrorCode =
|
||||
(typeof NETWORK_AUDIT_CODES)[keyof typeof NETWORK_AUDIT_CODES];
|
||||
|
||||
export class NetworkAuditError extends SuaveplanError<NetworkAuditErrorCode> {
|
||||
public constructor(
|
||||
code: NetworkAuditErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "NetworkAuditError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Side-effect: register error codes
|
||||
import "./error-codes.js";
|
||||
|
||||
// Compaction
|
||||
export {
|
||||
type CheckpointInput,
|
||||
type CheckpointVerifyFn,
|
||||
checkpointSigningBytes,
|
||||
LogCompaction,
|
||||
type LogCompactionConfig,
|
||||
} from "./compaction/log-compaction.js";
|
||||
// Conflict
|
||||
export { ConflictResolver } from "./conflict/conflict-resolver.js";
|
||||
// Errors
|
||||
export {
|
||||
NETWORK_AUDIT_CODES,
|
||||
NetworkAuditError,
|
||||
type NetworkAuditErrorCode,
|
||||
} from "./error-codes.js";
|
||||
// Integrity
|
||||
export {
|
||||
CORRUPTION_KIND_TO_CODE,
|
||||
type CorruptionKind,
|
||||
type CorruptionReport,
|
||||
type IntegrityBus,
|
||||
IntegrityChecker,
|
||||
type IntegrityCheckerConfig,
|
||||
type IntegrityReport,
|
||||
type SigVerifyFn,
|
||||
} from "./integrity/integrity-checker.js";
|
||||
// Log
|
||||
export {
|
||||
type AppendInput,
|
||||
bytesToHex,
|
||||
entryDigestHex,
|
||||
entryHashBytes,
|
||||
SignedLog,
|
||||
type SignedLogConfig,
|
||||
type SignFn,
|
||||
} from "./log/signed-log.js";
|
||||
// Plugin
|
||||
export {
|
||||
AuditMeshPlugin,
|
||||
type AuditMeshPluginConfig,
|
||||
} from "./plugin/audit-mesh-plugin.js";
|
||||
// Replicator
|
||||
export {
|
||||
LogReplicator,
|
||||
type LogReplicatorConfig,
|
||||
type ReplicationOutcome,
|
||||
type ReplicatorBus,
|
||||
type SendToReplicaFn,
|
||||
} from "./replicator/log-replicator.js";
|
||||
// Retriever
|
||||
export {
|
||||
LogRetriever,
|
||||
type LogRetrieverConfig,
|
||||
type RangeQuery,
|
||||
type RangeResult,
|
||||
} from "./retriever/log-retriever.js";
|
||||
// Types
|
||||
export type {
|
||||
ConflictResolution,
|
||||
ConflictResolutionReason,
|
||||
DivergentChain,
|
||||
} from "./types/conflict.types.js";
|
||||
export type {
|
||||
LogCheckpoint,
|
||||
LogEntry,
|
||||
LogEventType,
|
||||
LogSeverity,
|
||||
} from "./types/log.types.js";
|
||||
export type {
|
||||
ReplicationConfig,
|
||||
ReplicationMode,
|
||||
ReplicationTarget,
|
||||
} from "./types/replication.types.js";
|
||||
@@ -0,0 +1,74 @@
|
||||
# IntegrityChecker
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Walks a sequence of `LogEntry` records and decides whether the chain is intact: every `seq` is present (no gaps), every `prevHash` matches the SHA-256 of the previous entry's canonical bytes (no tamper, no fork), and — when an injected `verifySignature` is supplied — every `hostSignature` is a valid Ed25519 signature over those same bytes (no tamper of fields under signature). Any failure produces a `CorruptionReport` and emits `audit:corruption-detected` on the optional bus, so observability adapters can dashboard corruption rates.
|
||||
|
||||
The checker is the only module in audit-mesh that owns the canonical chain-walk algorithm. Both the integrity self-check on the originating host and the server-side cross-host validation step in `ConflictResolver` go through this module — keeping the rules in one place keeps the system honest.
|
||||
|
||||
## Features
|
||||
|
||||
- Detects **gaps** — when `entry.seq !== expectedSeq` (skipped sequences in a delivered chunk). Reports with `code: NETWORK_AUDIT_CODES.LOG_GAP`.
|
||||
- Detects **forks** — when an entry's `prevHash` is empty but the chain has continued (a re-suaveplan attempt) or differs from what the chain so far implies. Reports with `code: NETWORK_AUDIT_CODES.CHAIN_TAMPERED`.
|
||||
- Detects **tampered prev-hash** — when an entry's `prevHash` doesn't match the actual SHA-256 of the previous entry. Reports with `code: NETWORK_AUDIT_CODES.CHAIN_TAMPERED`.
|
||||
- Detects **tampered signatures** — when the injected verifier returns false, with the exact `seq` and `hostId` in the report. Reports with `code: NETWORK_AUDIT_CODES.SIGNATURE_INVALID`.
|
||||
- Each `CorruptionReport` carries both a granular `kind` (string discriminant for debugging) and a stable `code` (registry-backed identifier from `NETWORK_AUDIT_CODES`) so downstream consumers (logger, alerting, server reconciler) can match on the registry code without coupling to the human-readable string.
|
||||
- Optional `verifySignature` (skip if signing keys aren't available — e.g. integrity check on a single-host log) keeps the checker reusable in test contexts.
|
||||
- Optional `bus` emits `audit:corruption-detected` per finding for fan-out to alerting/observability.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SignedLog,
|
||||
IntegrityChecker,
|
||||
} from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const checker = new IntegrityChecker();
|
||||
const report = await checker.check(log.entries());
|
||||
if (!report.ok) console.warn(report.issues);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ed25519 } from "@suaveplan/crypto-curve";
|
||||
import { IntegrityChecker } from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const hostKeys = new Map<string, Uint8Array>([
|
||||
["host-1", host1Public],
|
||||
["host-2", host2Public],
|
||||
]);
|
||||
|
||||
const checker = new IntegrityChecker({
|
||||
verifySignature: (bytes, signature, hostId) => {
|
||||
const pub = hostKeys.get(hostId);
|
||||
if (pub === undefined) return false;
|
||||
return ed25519.verify(signature, bytes, pub);
|
||||
},
|
||||
bus: { emit: (topic, payload) => observability.publish(topic, payload) },
|
||||
});
|
||||
|
||||
const report = await checker.check(retriever.getLogs(query).entries);
|
||||
metrics.record("audit.integrity.issues", report.issues.length);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `IntegrityChecker(config?)` — `config: { verifySignature?: SigVerifyFn; bus?: IntegrityBus }`.
|
||||
- `check(entries)` — returns `Promise<IntegrityReport>`. Walks entries in iteration order; the first entry's expected `prevHash` is `""`.
|
||||
- `CorruptionKind` — `"gap" | "fork" | "tampered-prev-hash" | "tampered-signature"`.
|
||||
- `CorruptionReport` — `{ kind; code: NetworkAuditErrorCode; seq; hostId; detail? }`.
|
||||
- `CORRUPTION_KIND_TO_CODE` — frozen `Record<CorruptionKind, NetworkAuditErrorCode>` mapping each kind to its registry code; the integrity checker uses this so the kind→code mapping has a single source of truth.
|
||||
- `IntegrityReport` — `{ ok; inspected; issues }`.
|
||||
- `SigVerifyFn` — `(bytes, signature, hostId) => boolean | Promise<boolean>`.
|
||||
- `IntegrityBus` — `emit("audit:corruption-detected", report)` shape.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The checker continues past a discovered issue rather than throwing on first error — a single retrieval pass surfaces every problem at once, so the operator does not need to re-run after each fix.
|
||||
- "Fork at first entry" (a re-suaveplan when the chain already has history) is a special case of `prevHash` mismatch and is reported as `kind: "fork"` to distinguish it from a mid-chain tamper. All other prevHash mismatches are `kind: "tampered-prev-hash"`.
|
||||
- Signature verification is async-aware; even though `ed25519.verify` is synchronous, the verifier signature accepts `Promise<boolean>` so a hardware-backed verifier can plug in without forcing the caller to wrap.
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_AUDIT_CODES } from "../error-codes.js";
|
||||
import { SignedLog } from "../log/signed-log.js";
|
||||
import type { LogEntry } from "../types/log.types.js";
|
||||
import {
|
||||
CORRUPTION_KIND_TO_CODE,
|
||||
IntegrityChecker,
|
||||
} from "./integrity-checker.js";
|
||||
|
||||
const stubSign = (bytes: Uint8Array): Uint8Array => {
|
||||
const sig = new Uint8Array(64);
|
||||
sig.set(bytes.slice(0, Math.min(64, bytes.length)));
|
||||
return sig;
|
||||
};
|
||||
|
||||
const buildChain = async (count: number): Promise<LogEntry[]> => {
|
||||
const log = new SignedLog({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
await log.append({
|
||||
eventType: "peer-joined",
|
||||
sessionId: null,
|
||||
severity: "normal",
|
||||
payload: { i },
|
||||
});
|
||||
}
|
||||
return [...log.entries()];
|
||||
};
|
||||
|
||||
describe("IntegrityChecker.check", () => {
|
||||
it("reports ok=true on a pristine chain", async () => {
|
||||
const chain = await buildChain(5);
|
||||
const checker = new IntegrityChecker();
|
||||
const report = await checker.check(chain);
|
||||
expect(report.ok).toBe(true);
|
||||
expect(report.inspected).toBe(5);
|
||||
expect(report.issues.length).toBe(0);
|
||||
});
|
||||
|
||||
it("detects a gap when seq jumps", async () => {
|
||||
const chain = await buildChain(5);
|
||||
const truncated = [chain[0]!, chain[1]!, chain[3]!, chain[4]!];
|
||||
const events: unknown[] = [];
|
||||
const checker = new IntegrityChecker({
|
||||
bus: { emit: (_, p) => events.push(p) },
|
||||
});
|
||||
const report = await checker.check(truncated);
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.issues.find((i) => i.kind === "gap")).toBeDefined();
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("detects tampered prevHash", async () => {
|
||||
const chain = await buildChain(3);
|
||||
const tampered = [
|
||||
chain[0]!,
|
||||
{ ...chain[1]!, prevHash: "deadbeef" },
|
||||
chain[2]!,
|
||||
];
|
||||
const checker = new IntegrityChecker();
|
||||
const report = await checker.check(tampered);
|
||||
expect(report.ok).toBe(false);
|
||||
expect(
|
||||
report.issues.find((i) => i.kind === "tampered-prev-hash")
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("detects a fork (re-genesis) mid-chain", async () => {
|
||||
const chain = await buildChain(3);
|
||||
const forked = [chain[0]!, { ...chain[1]!, prevHash: "" }, chain[2]!];
|
||||
const checker = new IntegrityChecker();
|
||||
const report = await checker.check(forked);
|
||||
const fork = report.issues.find((i) => i.kind === "fork");
|
||||
expect(fork).toBeDefined();
|
||||
expect(fork?.seq).toBe(1);
|
||||
});
|
||||
|
||||
it("detects tampered signature when verifySignature is supplied", async () => {
|
||||
const chain = await buildChain(2);
|
||||
const checker = new IntegrityChecker({
|
||||
verifySignature: () => false,
|
||||
});
|
||||
const report = await checker.check(chain);
|
||||
expect(
|
||||
report.issues.find((i) => i.kind === "tampered-signature")
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("supports an async verifier", async () => {
|
||||
const chain = await buildChain(1);
|
||||
const checker = new IntegrityChecker({
|
||||
verifySignature: async (_b, _s, _h) => {
|
||||
await Promise.resolve();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const report = await checker.check(chain);
|
||||
expect(report.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns ok=true with inspected=0 for an empty input", async () => {
|
||||
const checker = new IntegrityChecker();
|
||||
const report = await checker.check([]);
|
||||
expect(report.ok).toBe(true);
|
||||
expect(report.inspected).toBe(0);
|
||||
});
|
||||
|
||||
it("emits audit:corruption-detected for each issue", async () => {
|
||||
const chain = await buildChain(2);
|
||||
const tampered = [{ ...chain[0]!, prevHash: "deadbeef" }, chain[1]!];
|
||||
const events: Array<{ kind: string }> = [];
|
||||
const checker = new IntegrityChecker({
|
||||
bus: {
|
||||
emit: (topic, payload) => {
|
||||
expect(topic).toBe("audit:corruption-detected");
|
||||
events.push(payload as { kind: string });
|
||||
},
|
||||
},
|
||||
});
|
||||
await checker.check(tampered);
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("reports tag every issue with the registry code matching its kind", async () => {
|
||||
const chain = await buildChain(3);
|
||||
const truncated = [chain[0]!, chain[2]!];
|
||||
const tampered = [chain[0]!, { ...chain[1]!, prevHash: "deadbeef" }];
|
||||
const forked = [chain[0]!, { ...chain[1]!, prevHash: "" }];
|
||||
|
||||
const checker = new IntegrityChecker({
|
||||
verifySignature: () => false,
|
||||
});
|
||||
const gapReport = await checker.check(truncated);
|
||||
expect(gapReport.issues.find((i) => i.kind === "gap")?.code).toBe(
|
||||
NETWORK_AUDIT_CODES.LOG_GAP
|
||||
);
|
||||
const tamperedReport = await checker.check(tampered);
|
||||
expect(
|
||||
tamperedReport.issues.find((i) => i.kind === "tampered-prev-hash")
|
||||
?.code
|
||||
).toBe(NETWORK_AUDIT_CODES.CHAIN_TAMPERED);
|
||||
expect(
|
||||
tamperedReport.issues.find((i) => i.kind === "tampered-signature")
|
||||
?.code
|
||||
).toBe(NETWORK_AUDIT_CODES.SIGNATURE_INVALID);
|
||||
const forkReport = await new IntegrityChecker().check(forked);
|
||||
expect(forkReport.issues.find((i) => i.kind === "fork")?.code).toBe(
|
||||
NETWORK_AUDIT_CODES.CHAIN_TAMPERED
|
||||
);
|
||||
});
|
||||
|
||||
it("CORRUPTION_KIND_TO_CODE covers every kind", () => {
|
||||
expect(CORRUPTION_KIND_TO_CODE.gap).toBe(NETWORK_AUDIT_CODES.LOG_GAP);
|
||||
expect(CORRUPTION_KIND_TO_CODE.fork).toBe(
|
||||
NETWORK_AUDIT_CODES.CHAIN_TAMPERED
|
||||
);
|
||||
expect(CORRUPTION_KIND_TO_CODE["tampered-prev-hash"]).toBe(
|
||||
NETWORK_AUDIT_CODES.CHAIN_TAMPERED
|
||||
);
|
||||
expect(CORRUPTION_KIND_TO_CODE["tampered-signature"]).toBe(
|
||||
NETWORK_AUDIT_CODES.SIGNATURE_INVALID
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { sha256 } from "@suaveplan/crypto-hash";
|
||||
import {
|
||||
NETWORK_AUDIT_CODES,
|
||||
type NetworkAuditErrorCode,
|
||||
} from "../error-codes.js";
|
||||
import { bytesToHex, entryHashBytes } from "../log/signed-log.js";
|
||||
import type { LogEntry } from "../types/log.types.js";
|
||||
|
||||
export type CorruptionKind =
|
||||
| "gap"
|
||||
| "fork"
|
||||
| "tampered-prev-hash"
|
||||
| "tampered-signature";
|
||||
|
||||
export interface CorruptionReport {
|
||||
readonly kind: CorruptionKind;
|
||||
readonly code: NetworkAuditErrorCode;
|
||||
readonly seq: number;
|
||||
readonly hostId: string;
|
||||
readonly detail?: string;
|
||||
}
|
||||
|
||||
export const CORRUPTION_KIND_TO_CODE: Readonly<
|
||||
Record<CorruptionKind, NetworkAuditErrorCode>
|
||||
> = Object.freeze({
|
||||
gap: NETWORK_AUDIT_CODES.LOG_GAP,
|
||||
fork: NETWORK_AUDIT_CODES.CHAIN_TAMPERED,
|
||||
"tampered-prev-hash": NETWORK_AUDIT_CODES.CHAIN_TAMPERED,
|
||||
"tampered-signature": NETWORK_AUDIT_CODES.SIGNATURE_INVALID,
|
||||
});
|
||||
|
||||
export interface IntegrityReport {
|
||||
readonly ok: boolean;
|
||||
readonly inspected: number;
|
||||
readonly issues: readonly CorruptionReport[];
|
||||
}
|
||||
|
||||
export type SigVerifyFn = (
|
||||
bytes: Uint8Array,
|
||||
signature: Uint8Array,
|
||||
hostId: string
|
||||
) => boolean | Promise<boolean>;
|
||||
|
||||
export interface IntegrityBus {
|
||||
emit(topic: "audit:corruption-detected", payload: CorruptionReport): void;
|
||||
}
|
||||
|
||||
export interface IntegrityCheckerConfig {
|
||||
readonly verifySignature?: SigVerifyFn;
|
||||
readonly bus?: IntegrityBus;
|
||||
}
|
||||
|
||||
export class IntegrityChecker {
|
||||
private readonly verifySignature: SigVerifyFn | undefined;
|
||||
private readonly bus: IntegrityBus | undefined;
|
||||
|
||||
public constructor(config: IntegrityCheckerConfig = {}) {
|
||||
this.verifySignature = config.verifySignature;
|
||||
this.bus = config.bus;
|
||||
}
|
||||
|
||||
public async check(entries: readonly LogEntry[]): Promise<IntegrityReport> {
|
||||
const issues: CorruptionReport[] = [];
|
||||
let expectedPrev = "";
|
||||
let expectedSeq = entries.length === 0 ? 0 : entries[0]!.seq;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.seq !== expectedSeq) {
|
||||
this.report(issues, "gap", {
|
||||
seq: expectedSeq,
|
||||
hostId: entry.hostId,
|
||||
detail: `expected seq ${expectedSeq}, found ${entry.seq}`,
|
||||
});
|
||||
expectedSeq = entry.seq;
|
||||
}
|
||||
if (entry.prevHash !== expectedPrev) {
|
||||
const kind: CorruptionKind =
|
||||
entry.prevHash === "" && expectedPrev !== ""
|
||||
? "fork"
|
||||
: "tampered-prev-hash";
|
||||
this.report(issues, kind, {
|
||||
seq: entry.seq,
|
||||
hostId: entry.hostId,
|
||||
detail: `expected prevHash=${expectedPrev}, found ${entry.prevHash}`,
|
||||
});
|
||||
}
|
||||
if (this.verifySignature !== undefined) {
|
||||
const bytes = entryHashBytes(entry);
|
||||
const ok = await Promise.resolve(
|
||||
this.verifySignature(
|
||||
bytes,
|
||||
entry.hostSignature,
|
||||
entry.hostId
|
||||
)
|
||||
);
|
||||
if (!ok) {
|
||||
this.report(issues, "tampered-signature", {
|
||||
seq: entry.seq,
|
||||
hostId: entry.hostId,
|
||||
});
|
||||
}
|
||||
}
|
||||
expectedPrev = bytesToHex(
|
||||
new Uint8Array(sha256(entryHashBytes(entry)))
|
||||
);
|
||||
expectedSeq = entry.seq + 1;
|
||||
}
|
||||
return {
|
||||
ok: issues.length === 0,
|
||||
inspected: entries.length,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
private report(
|
||||
sink: CorruptionReport[],
|
||||
kind: CorruptionKind,
|
||||
partial: { seq: number; hostId: string; detail?: string }
|
||||
): void {
|
||||
const report: CorruptionReport = {
|
||||
kind,
|
||||
code: CORRUPTION_KIND_TO_CODE[kind],
|
||||
seq: partial.seq,
|
||||
hostId: partial.hostId,
|
||||
...(partial.detail === undefined ? {} : { detail: partial.detail }),
|
||||
};
|
||||
sink.push(report);
|
||||
this.bus?.emit("audit:corruption-detected", report);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
# SignedLog
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Append-only, hash-chained, cryptographically signed audit log per `(meshId, hostId)` pair. Every entry's `prevHash` is the hex-encoded SHA-256 of the previous entry's canonical bytes, and every entry's `hostSignature` covers those same canonical bytes — so flipping any field (including `seq`, `timestamp`, or `prevHash`) breaks both the chain and the signature.
|
||||
|
||||
The class deliberately decouples the signing primitive: callers inject a `SignFn` (typically `(bytes) => ed25519.sign(bytes, hostPrivateKey)`) so the log itself depends only on `@suaveplan/crypto-hash`. This keeps audit-mesh independent of any specific signature engine and makes property-based tests trivial — substitute a deterministic test signer and the chain becomes byte-stable.
|
||||
|
||||
## Features
|
||||
|
||||
- Monotonic sequence (`seq` starts at 0 and increments per append, never resets except via a fresh `SignedLog` instance).
|
||||
- Hash-chained — first entry has `prevHash = ""`; subsequent entries have `prevHash = hex(sha256(canonicalBytes(prev)))`.
|
||||
- Canonical serialization uses sorted-key JSON over the entry-without-signature; non-finite numbers serialize as `null` to keep bytes deterministic.
|
||||
- Injectable `SignFn` may return either a `Uint8Array` synchronously or a `Promise<Uint8Array>` — `append` awaits the result, so async signers (e.g. hardware-backed Ed25519) work transparently.
|
||||
- Injectable clock (defaults to `Date.now`) keeps tests deterministic.
|
||||
- `getBySeq` raises `NET_AUDIT.INVALID_SEQ` on out-of-range lookups.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ed25519 } from "@suaveplan/crypto-curve";
|
||||
import { SignedLog } from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const privateKey = ed25519.randomPrivateKey();
|
||||
const log = new SignedLog({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: (bytes) => ed25519.sign(bytes, privateKey),
|
||||
});
|
||||
|
||||
const entry = await log.append({
|
||||
eventType: "host-elected",
|
||||
sessionId: null,
|
||||
severity: "critical",
|
||||
payload: { electedHost: "host-1" },
|
||||
});
|
||||
|
||||
console.log(entry.seq, entry.prevHash, entry.hostSignature.length);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SignedLog,
|
||||
entryDigestHex,
|
||||
} from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const log = new SignedLog({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
clock: () => 1_700_000_000_000,
|
||||
sign: async (bytes) => await hardwareSigner.sign(bytes),
|
||||
});
|
||||
|
||||
await log.append({
|
||||
eventType: "session-created",
|
||||
sessionId: "sess-1",
|
||||
severity: "critical",
|
||||
payload: { peers: ["peer-a", "peer-b"] },
|
||||
});
|
||||
|
||||
const digest = entryDigestHex(log.entries()[0]!);
|
||||
console.log(`tip digest = ${digest}; chain size = ${log.size()}`);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SignedLog(config)` — constructs an empty log. `config: { meshId; hostId; sign: SignFn; clock?: () => number }`.
|
||||
- `append(input)` — appends one entry. `input: { eventType; sessionId; severity; payload }`. Returns the persisted `LogEntry` after signing.
|
||||
- `entries()` — returns a defensive shallow copy of all entries in append order.
|
||||
- `size()` — number of entries appended.
|
||||
- `tipHash()` — hex SHA-256 of the most recent entry, or `""` for an empty log.
|
||||
- `getBySeq(seq)` — returns the entry at sequence `seq` or throws `NET_AUDIT.INVALID_SEQ`.
|
||||
- `entryHashBytes(entry)` — exported helper that returns the canonical bytes a verifier should hash and signature-check.
|
||||
- `entryDigestHex(entry)` — exported helper that returns the hex SHA-256 of those bytes (handy for debug/inspection).
|
||||
- `bytesToHex(bytes)` — exported lowercase-hex utility used by both this module and `IntegrityChecker`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `prevHash` is a hex string rather than `Uint8Array` so chain entries are inspectable in JSON dumps and equality comparisons fall back on string compare without a length-prefix dance.
|
||||
- `canonicalize` walks the entry-without-signature in sorted-key order; the sort is stable across all V8/JSC versions so two correct implementations produce byte-identical output.
|
||||
- The `Omit<LogEntry, "hostSignature">` shape is the implicit "thing under signature" — any future field added to `LogEntry` is included automatically because canonicalization is structural.
|
||||
@@ -0,0 +1,305 @@
|
||||
import { sha256 } from "@suaveplan/crypto-hash";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_AUDIT_CODES, NetworkAuditError } from "../error-codes.js";
|
||||
import {
|
||||
bytesToHex,
|
||||
entryDigestHex,
|
||||
entryHashBytes,
|
||||
SignedLog,
|
||||
} from "./signed-log.js";
|
||||
|
||||
const stubSign = (bytes: Uint8Array): Uint8Array => {
|
||||
const sig = new Uint8Array(64);
|
||||
for (let i = 0; i < bytes.length && i < 64; i += 1) {
|
||||
sig[i] = bytes[i] as number;
|
||||
}
|
||||
return sig;
|
||||
};
|
||||
|
||||
const asyncSign = async (bytes: Uint8Array): Promise<Uint8Array> => {
|
||||
await Promise.resolve();
|
||||
return stubSign(bytes);
|
||||
};
|
||||
|
||||
describe("SignedLog", () => {
|
||||
it("appends with monotonic seq starting at 0", async () => {
|
||||
const log = new SignedLog({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
clock: () => 1_000,
|
||||
});
|
||||
const a = await log.append({
|
||||
eventType: "peer-joined",
|
||||
sessionId: null,
|
||||
severity: "normal",
|
||||
payload: { peer: "p1" },
|
||||
});
|
||||
const b = await log.append({
|
||||
eventType: "peer-left",
|
||||
sessionId: null,
|
||||
severity: "normal",
|
||||
payload: { peer: "p1" },
|
||||
});
|
||||
expect(a.seq).toBe(0);
|
||||
expect(b.seq).toBe(1);
|
||||
expect(log.size()).toBe(2);
|
||||
});
|
||||
|
||||
it("produces an empty prevHash on the genesis entry", async () => {
|
||||
const log = new SignedLog({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
const entry = await log.append({
|
||||
eventType: "host-elected",
|
||||
sessionId: null,
|
||||
severity: "critical",
|
||||
payload: { electedHost: "host-1" },
|
||||
});
|
||||
expect(entry.prevHash).toBe("");
|
||||
});
|
||||
|
||||
it("chains prevHash to the SHA-256 of the previous entry", async () => {
|
||||
const log = new SignedLog({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
const a = await log.append({
|
||||
eventType: "peer-joined",
|
||||
sessionId: null,
|
||||
severity: "normal",
|
||||
payload: { peer: "p1" },
|
||||
});
|
||||
const b = await log.append({
|
||||
eventType: "peer-left",
|
||||
sessionId: null,
|
||||
severity: "normal",
|
||||
payload: { peer: "p1" },
|
||||
});
|
||||
expect(b.prevHash).toBe(
|
||||
bytesToHex(new Uint8Array(sha256(entryHashBytes(a))))
|
||||
);
|
||||
});
|
||||
|
||||
it("invokes the injected SignFn over the canonical bytes", async () => {
|
||||
const captured: Uint8Array[] = [];
|
||||
const log = new SignedLog({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: (bytes) => {
|
||||
captured.push(bytes);
|
||||
return stubSign(bytes);
|
||||
},
|
||||
});
|
||||
await log.append({
|
||||
eventType: "state-update",
|
||||
sessionId: null,
|
||||
severity: "normal",
|
||||
payload: { k: "v" },
|
||||
});
|
||||
expect(captured.length).toBe(1);
|
||||
expect(captured[0]!.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("supports an async SignFn", async () => {
|
||||
const log = new SignedLog({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: asyncSign,
|
||||
});
|
||||
const entry = await log.append({
|
||||
eventType: "policy-change",
|
||||
sessionId: null,
|
||||
severity: "critical",
|
||||
payload: { policy: "new" },
|
||||
});
|
||||
expect(entry.hostSignature.byteLength).toBe(64);
|
||||
});
|
||||
|
||||
it("uses the injected clock for timestamps", async () => {
|
||||
const log = new SignedLog({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
clock: () => 42,
|
||||
});
|
||||
const entry = await log.append({
|
||||
eventType: "state-update",
|
||||
sessionId: null,
|
||||
severity: "normal",
|
||||
payload: {},
|
||||
});
|
||||
expect(entry.timestamp).toBe(42);
|
||||
});
|
||||
|
||||
it("returns a defensive copy from entries()", async () => {
|
||||
const log = new SignedLog({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
await log.append({
|
||||
eventType: "peer-joined",
|
||||
sessionId: null,
|
||||
severity: "normal",
|
||||
payload: {},
|
||||
});
|
||||
const a = log.entries();
|
||||
const b = log.entries();
|
||||
expect(a).not.toBe(b);
|
||||
expect(a.length).toBe(b.length);
|
||||
});
|
||||
|
||||
it("getBySeq throws NET_AUDIT.INVALID_SEQ for out-of-range seq", async () => {
|
||||
const log = new SignedLog({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
expect(() => log.getBySeq(0)).toThrow(NetworkAuditError);
|
||||
try {
|
||||
log.getBySeq(0);
|
||||
} catch (err) {
|
||||
expect((err as NetworkAuditError).code).toBe(
|
||||
NETWORK_AUDIT_CODES.INVALID_SEQ
|
||||
);
|
||||
}
|
||||
expect(() => log.getBySeq(-1)).toThrow(NetworkAuditError);
|
||||
});
|
||||
|
||||
it("getBySeq returns the entry for an in-range seq", async () => {
|
||||
const log = new SignedLog({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
const entry = await log.append({
|
||||
eventType: "peer-joined",
|
||||
sessionId: null,
|
||||
severity: "normal",
|
||||
payload: {},
|
||||
});
|
||||
expect(log.getBySeq(0)).toBe(entry);
|
||||
});
|
||||
|
||||
it("tipHash is empty before any append", () => {
|
||||
const log = new SignedLog({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
expect(log.tipHash()).toBe("");
|
||||
});
|
||||
|
||||
it("uses Date.now when no clock is injected", async () => {
|
||||
const log = new SignedLog({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
const before = Date.now();
|
||||
const entry = await log.append({
|
||||
eventType: "peer-joined",
|
||||
sessionId: null,
|
||||
severity: "normal",
|
||||
payload: {},
|
||||
});
|
||||
const after = Date.now();
|
||||
expect(entry.timestamp).toBeGreaterThanOrEqual(before);
|
||||
expect(entry.timestamp).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entryDigestHex / entryHashBytes / bytesToHex", () => {
|
||||
it("entryDigestHex returns the SHA-256 hex of canonical bytes", async () => {
|
||||
const log = new SignedLog({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
const entry = await log.append({
|
||||
eventType: "peer-joined",
|
||||
sessionId: null,
|
||||
severity: "normal",
|
||||
payload: {},
|
||||
});
|
||||
expect(entryDigestHex(entry)).toBe(
|
||||
bytesToHex(new Uint8Array(sha256(entryHashBytes(entry))))
|
||||
);
|
||||
});
|
||||
|
||||
it("canonicalize handles non-finite numbers, nested arrays, booleans, null, and unknown types", () => {
|
||||
const a = entryHashBytes({
|
||||
seq: 0,
|
||||
timestamp: 1,
|
||||
meshId: "m",
|
||||
sessionId: null,
|
||||
eventType: "peer-joined",
|
||||
severity: "normal",
|
||||
payload: {
|
||||
inf: Number.POSITIVE_INFINITY,
|
||||
arr: [1, "two", true, null, [3, 4]],
|
||||
flag: false,
|
||||
},
|
||||
prevHash: "",
|
||||
hostId: "h",
|
||||
});
|
||||
const b = entryHashBytes({
|
||||
seq: 0,
|
||||
timestamp: 1,
|
||||
meshId: "m",
|
||||
sessionId: null,
|
||||
eventType: "peer-joined",
|
||||
severity: "normal",
|
||||
payload: {
|
||||
flag: false,
|
||||
arr: [1, "two", true, null, [3, 4]],
|
||||
inf: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
prevHash: "",
|
||||
hostId: "h",
|
||||
});
|
||||
expect(bytesToHex(a)).toBe(bytesToHex(b));
|
||||
});
|
||||
|
||||
it("bytesToHex pads single-digit nibbles", () => {
|
||||
expect(bytesToHex(new Uint8Array([0x00, 0x0f, 0xff]))).toBe("000fff");
|
||||
});
|
||||
|
||||
it("canonicalize treats unsupported scalars (undefined / function / symbol / bigint) as null", async () => {
|
||||
const log = new SignedLog({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
const a = await log.append({
|
||||
eventType: "state-update",
|
||||
sessionId: null,
|
||||
severity: "normal",
|
||||
payload: {
|
||||
undef: undefined as unknown as null,
|
||||
fn: ((): void => {}) as unknown as null,
|
||||
sym: Symbol("s") as unknown as null,
|
||||
big: 1n as unknown as null,
|
||||
},
|
||||
});
|
||||
const b = await log.append({
|
||||
eventType: "state-update",
|
||||
sessionId: null,
|
||||
severity: "normal",
|
||||
payload: {
|
||||
undef: null,
|
||||
fn: null,
|
||||
sym: null,
|
||||
big: null,
|
||||
},
|
||||
});
|
||||
// canonicalize maps unsupported values to "null" so payloads that differ
|
||||
// only in their unsupported scalars still chain coherently.
|
||||
expect(a.payload).not.toBe(b.payload);
|
||||
expect(b.prevHash.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { sha256 } from "@suaveplan/crypto-hash";
|
||||
import { NETWORK_AUDIT_CODES, NetworkAuditError } from "../error-codes.js";
|
||||
import type {
|
||||
LogEntry,
|
||||
LogEventType,
|
||||
LogSeverity,
|
||||
} from "../types/log.types.js";
|
||||
|
||||
export type SignFn = (bytes: Uint8Array) => Promise<Uint8Array> | Uint8Array;
|
||||
|
||||
export interface AppendInput {
|
||||
readonly eventType: LogEventType;
|
||||
readonly sessionId: string | null;
|
||||
readonly severity: LogSeverity;
|
||||
readonly payload: unknown;
|
||||
}
|
||||
|
||||
export interface SignedLogConfig {
|
||||
readonly meshId: string;
|
||||
readonly hostId: string;
|
||||
readonly sign: SignFn;
|
||||
readonly clock?: () => number;
|
||||
}
|
||||
|
||||
const HEX_TABLE = "0123456789abcdef";
|
||||
|
||||
export class SignedLog {
|
||||
public readonly meshId: string;
|
||||
public readonly hostId: string;
|
||||
private readonly sign: SignFn;
|
||||
private readonly clock: () => number;
|
||||
private readonly buffer: LogEntry[] = [];
|
||||
private nextSeq = 0;
|
||||
private tipHashHex = "";
|
||||
|
||||
public constructor(config: SignedLogConfig) {
|
||||
this.meshId = config.meshId;
|
||||
this.hostId = config.hostId;
|
||||
this.sign = config.sign;
|
||||
this.clock = config.clock ?? (() => Date.now());
|
||||
}
|
||||
|
||||
public async append(input: AppendInput): Promise<LogEntry> {
|
||||
const seq = this.nextSeq;
|
||||
const timestamp = this.clock();
|
||||
const prevHash = this.tipHashHex;
|
||||
const unsigned: Omit<LogEntry, "hostSignature"> = {
|
||||
seq,
|
||||
timestamp,
|
||||
meshId: this.meshId,
|
||||
sessionId: input.sessionId,
|
||||
eventType: input.eventType,
|
||||
severity: input.severity,
|
||||
payload: input.payload,
|
||||
prevHash,
|
||||
hostId: this.hostId,
|
||||
};
|
||||
const bytes = entryHashBytes(unsigned);
|
||||
const hostSignature = await Promise.resolve(this.sign(bytes));
|
||||
const entry: LogEntry = { ...unsigned, hostSignature };
|
||||
this.buffer.push(entry);
|
||||
this.nextSeq += 1;
|
||||
this.tipHashHex = bytesToHex(new Uint8Array(sha256(bytes)));
|
||||
return entry;
|
||||
}
|
||||
|
||||
public entries(): readonly LogEntry[] {
|
||||
return [...this.buffer];
|
||||
}
|
||||
|
||||
public size(): number {
|
||||
return this.buffer.length;
|
||||
}
|
||||
|
||||
public tipHash(): string {
|
||||
return this.tipHashHex;
|
||||
}
|
||||
|
||||
public getBySeq(seq: number): LogEntry {
|
||||
if (seq < 0 || seq >= this.buffer.length) {
|
||||
throw new NetworkAuditError(
|
||||
NETWORK_AUDIT_CODES.INVALID_SEQ,
|
||||
"audit.invalid_seq",
|
||||
{ context: { seq, size: this.buffer.length } }
|
||||
);
|
||||
}
|
||||
return this.buffer[seq] as LogEntry;
|
||||
}
|
||||
}
|
||||
|
||||
export function entryHashBytes(
|
||||
entry: Omit<LogEntry, "hostSignature"> | LogEntry
|
||||
): Uint8Array {
|
||||
const unsigned = {
|
||||
seq: entry.seq,
|
||||
timestamp: entry.timestamp,
|
||||
meshId: entry.meshId,
|
||||
sessionId: entry.sessionId,
|
||||
eventType: entry.eventType,
|
||||
severity: entry.severity,
|
||||
payload: entry.payload,
|
||||
prevHash: entry.prevHash,
|
||||
hostId: entry.hostId,
|
||||
};
|
||||
return new TextEncoder().encode(canonicalize(unsigned));
|
||||
}
|
||||
|
||||
export function entryDigestHex(
|
||||
entry: Omit<LogEntry, "hostSignature"> | LogEntry
|
||||
): string {
|
||||
return bytesToHex(new Uint8Array(sha256(entryHashBytes(entry))));
|
||||
}
|
||||
|
||||
export function canonicalize(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "number")
|
||||
return Number.isFinite(value) ? `${value}` : "null";
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(canonicalize).join(",")}]`;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
const parts = keys.map(
|
||||
(k) => `${JSON.stringify(k)}:${canonicalize(obj[k])}`
|
||||
);
|
||||
return `{${parts.join(",")}}`;
|
||||
}
|
||||
return "null";
|
||||
}
|
||||
|
||||
export function bytesToHex(bytes: Uint8Array): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
const b = bytes[i] as number;
|
||||
out += HEX_TABLE[(b >>> 4) & 0xf];
|
||||
out += HEX_TABLE[b & 0xf];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
# AuditMeshPlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Single `NetworkPlugin` facade that wires `SignedLog`, `LogReplicator`, `LogRetriever`, `IntegrityChecker`, `ConflictResolver`, and `LogCompaction` into a cohesive subsystem and registers them with a `NetworkNode` via the standard plugin lifecycle (`install` / `start` / `stop` / `dispose`). Consumers register exactly one of these per host; everything else is reachable via the public properties on the plugin instance.
|
||||
|
||||
The plugin keeps its own state minimal — installation just remembers the node, and the start/stop pair is a flag — because the subsystems it composes are independently useful and testable. The plugin is the wire-up; the modules are the work.
|
||||
|
||||
## Features
|
||||
|
||||
- One-shot construction wires every subsystem from a single config object.
|
||||
- Public read-only access to `log`, `replicator`, `retriever`, `integrity`, `conflicts`, and `compaction` so consumers can drop directly into any of them.
|
||||
- Lifecycle hooks are idempotent and side-effect-light; `start` only flips the started flag (replication is per-append, not background).
|
||||
- `dispose` clears the node reference so the plugin can be re-installed later if the host's `NetworkNode` is rebuilt.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ed25519 } from "@suaveplan/crypto-curve";
|
||||
import { NetworkNode } from "@suaveplan/network";
|
||||
import { AuditMeshPlugin } from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const privateKey = ed25519.randomPrivateKey();
|
||||
const node = new NetworkNode({ id: "host-1" });
|
||||
const audit = new AuditMeshPlugin({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: (bytes) => ed25519.sign(bytes, privateKey),
|
||||
replication: {
|
||||
replicationFactor: 2,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
send: async (target, entry) => network.send(target.hostId, entry),
|
||||
});
|
||||
|
||||
await node.use(audit).start();
|
||||
await audit.log.append({
|
||||
eventType: "host-elected",
|
||||
sessionId: null,
|
||||
severity: "critical",
|
||||
payload: { electedHost: "host-1" },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ed25519 } from "@suaveplan/crypto-curve";
|
||||
import { AuditMeshPlugin } from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const audit = new AuditMeshPlugin({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: hardwareSigner.sign,
|
||||
replication: { replicationFactor: 3, localTier: 1, localMeshId: "mesh-a", localHostId: "subhost-c" },
|
||||
send: transportManager.relay,
|
||||
candidates: peerRegistry.snapshot(),
|
||||
verifySignature: (bytes, sig, hostId) =>
|
||||
ed25519.verify(sig, bytes, hostKeys.get(hostId)!),
|
||||
bus: { emit: (topic, payload) => observability.publish(topic, payload) },
|
||||
defaultPageSize: 250,
|
||||
maxPageSize: 5000,
|
||||
});
|
||||
|
||||
const entry = await audit.log.append({ ... });
|
||||
await audit.replicator.replicate(entry);
|
||||
const report = await audit.integrity.check(audit.log.entries());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AuditMeshPlugin(config)` — `config: AuditMeshPluginConfig`. See type definition for the full surface.
|
||||
- `name` — `"audit-mesh"` (constant).
|
||||
- Subsystem properties: `log`, `replicator`, `retriever`, `integrity`, `conflicts`, `compaction`.
|
||||
- `install(node)` / `start()` / `stop()` / `dispose()` — `NetworkPlugin` lifecycle.
|
||||
- `host()` — returns the installed `NetworkNode` or `null`.
|
||||
- `isStarted()` — boolean lifecycle inspector.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The plugin uses spread-with-conditional-key tricks (`...(config.x === undefined ? {} : { x: config.x })`) to honour `exactOptionalPropertyTypes` — passing `undefined` to a sub-component that expects an _absent_ key would type-error at construction time, so we elide the key entirely instead.
|
||||
- The `bus` property is reused as both `ReplicatorBus` and `IntegrityBus` (their `emit` shapes are compatible in practice); if a consumer ever needs to route the two streams separately they can construct the subsystems individually rather than going through the plugin facade.
|
||||
- Replication is intentionally not awaited inside `log.append`; the caller decides whether to `await replicator.replicate(entry)` immediately (sync semantics) or fire-and-forget (async semantics is also achieved by passing a `severity: "normal"` entry).
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { NetworkNode } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AuditMeshPlugin } from "./audit-mesh-plugin.js";
|
||||
|
||||
const stubSign = (bytes: Uint8Array): Uint8Array => {
|
||||
const sig = new Uint8Array(64);
|
||||
sig.set(bytes.slice(0, Math.min(64, bytes.length)));
|
||||
return sig;
|
||||
};
|
||||
|
||||
const baseConfig = () => ({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
replication: {
|
||||
replicationFactor: 1,
|
||||
localTier: 0 as const,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
send: async () => true,
|
||||
});
|
||||
|
||||
describe("AuditMeshPlugin", () => {
|
||||
it("exposes its name as 'audit-mesh'", () => {
|
||||
const plugin = new AuditMeshPlugin(baseConfig());
|
||||
expect(plugin.name).toBe("audit-mesh");
|
||||
});
|
||||
|
||||
it("wires up every subsystem", () => {
|
||||
const plugin = new AuditMeshPlugin(baseConfig());
|
||||
expect(plugin.log).toBeDefined();
|
||||
expect(plugin.replicator).toBeDefined();
|
||||
expect(plugin.retriever).toBeDefined();
|
||||
expect(plugin.integrity).toBeDefined();
|
||||
expect(plugin.conflicts).toBeDefined();
|
||||
expect(plugin.compaction).toBeDefined();
|
||||
});
|
||||
|
||||
it("forwards the optional clock through to subsystems", async () => {
|
||||
const plugin = new AuditMeshPlugin({
|
||||
...baseConfig(),
|
||||
clock: () => 42,
|
||||
});
|
||||
const entry = await plugin.log.append({
|
||||
eventType: "peer-joined",
|
||||
sessionId: null,
|
||||
severity: "normal",
|
||||
payload: {},
|
||||
});
|
||||
expect(entry.timestamp).toBe(42);
|
||||
});
|
||||
|
||||
it("forwards optional pageSize options through to the retriever", () => {
|
||||
const plugin = new AuditMeshPlugin({
|
||||
...baseConfig(),
|
||||
defaultPageSize: 5,
|
||||
maxPageSize: 50,
|
||||
});
|
||||
const result = plugin.retriever.getLogs({
|
||||
meshId: "mesh-a",
|
||||
fromSeq: 0,
|
||||
toSeq: 0,
|
||||
});
|
||||
expect(result.pageSize).toBe(5);
|
||||
});
|
||||
|
||||
it("forwards optional bus + verifier through to the integrity checker", async () => {
|
||||
const events: unknown[] = [];
|
||||
const plugin = new AuditMeshPlugin({
|
||||
...baseConfig(),
|
||||
verifySignature: () => false,
|
||||
bus: { emit: (_t, p) => events.push(p) },
|
||||
});
|
||||
await plugin.log.append({
|
||||
eventType: "peer-joined",
|
||||
sessionId: null,
|
||||
severity: "normal",
|
||||
payload: {},
|
||||
});
|
||||
const report = await plugin.integrity.check(plugin.log.entries());
|
||||
expect(report.ok).toBe(false);
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("forwards initial candidates through to the replicator", () => {
|
||||
const plugin = new AuditMeshPlugin({
|
||||
...baseConfig(),
|
||||
candidates: [{ hostId: "host-2", tier: 0, meshId: "mesh-b" }],
|
||||
});
|
||||
expect(plugin.replicator.listCandidates().length).toBe(1);
|
||||
});
|
||||
|
||||
it("install/start/stop/dispose lifecycle", () => {
|
||||
const plugin = new AuditMeshPlugin(baseConfig());
|
||||
const fakeNode = { id: "host-1" } as unknown as NetworkNode;
|
||||
plugin.install(fakeNode);
|
||||
expect(plugin.host()).toBe(fakeNode);
|
||||
plugin.start();
|
||||
expect(plugin.isStarted()).toBe(true);
|
||||
plugin.stop();
|
||||
expect(plugin.isStarted()).toBe(false);
|
||||
plugin.dispose();
|
||||
expect(plugin.host()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
|
||||
import { LogCompaction } from "../compaction/log-compaction.js";
|
||||
import { ConflictResolver } from "../conflict/conflict-resolver.js";
|
||||
import {
|
||||
type IntegrityBus,
|
||||
IntegrityChecker,
|
||||
type SigVerifyFn,
|
||||
} from "../integrity/integrity-checker.js";
|
||||
import { SignedLog, type SignFn } from "../log/signed-log.js";
|
||||
import {
|
||||
LogReplicator,
|
||||
type ReplicatorBus,
|
||||
type SendToReplicaFn,
|
||||
} from "../replicator/log-replicator.js";
|
||||
import { LogRetriever } from "../retriever/log-retriever.js";
|
||||
import type {
|
||||
ReplicationConfig,
|
||||
ReplicationTarget,
|
||||
} from "../types/replication.types.js";
|
||||
|
||||
export interface AuditMeshPluginConfig {
|
||||
readonly meshId: string;
|
||||
readonly hostId: string;
|
||||
readonly sign: SignFn;
|
||||
readonly replication: ReplicationConfig;
|
||||
readonly send: SendToReplicaFn;
|
||||
readonly candidates?: readonly ReplicationTarget[];
|
||||
readonly clock?: () => number;
|
||||
readonly verifySignature?: SigVerifyFn;
|
||||
readonly bus?: ReplicatorBus & IntegrityBus;
|
||||
readonly defaultPageSize?: number;
|
||||
readonly maxPageSize?: number;
|
||||
}
|
||||
|
||||
export class AuditMeshPlugin implements NetworkPlugin {
|
||||
public readonly name = "audit-mesh";
|
||||
public readonly log: SignedLog;
|
||||
public readonly replicator: LogReplicator;
|
||||
public readonly retriever: LogRetriever;
|
||||
public readonly integrity: IntegrityChecker;
|
||||
public readonly conflicts: ConflictResolver;
|
||||
public readonly compaction: LogCompaction;
|
||||
private node: NetworkNode | null = null;
|
||||
private started = false;
|
||||
|
||||
public constructor(config: AuditMeshPluginConfig) {
|
||||
const clockOption =
|
||||
config.clock === undefined ? {} : { clock: config.clock };
|
||||
this.log = new SignedLog({
|
||||
meshId: config.meshId,
|
||||
hostId: config.hostId,
|
||||
sign: config.sign,
|
||||
...clockOption,
|
||||
});
|
||||
this.replicator = new LogReplicator({
|
||||
config: config.replication,
|
||||
send: config.send,
|
||||
...(config.bus === undefined ? {} : { bus: config.bus }),
|
||||
...(config.candidates === undefined
|
||||
? {}
|
||||
: { candidates: config.candidates }),
|
||||
});
|
||||
this.retriever = new LogRetriever({
|
||||
log: this.log,
|
||||
...(config.defaultPageSize === undefined
|
||||
? {}
|
||||
: { defaultPageSize: config.defaultPageSize }),
|
||||
...(config.maxPageSize === undefined
|
||||
? {}
|
||||
: { maxPageSize: config.maxPageSize }),
|
||||
});
|
||||
this.integrity = new IntegrityChecker({
|
||||
...(config.verifySignature === undefined
|
||||
? {}
|
||||
: { verifySignature: config.verifySignature }),
|
||||
...(config.bus === undefined ? {} : { bus: config.bus }),
|
||||
});
|
||||
this.conflicts = new ConflictResolver();
|
||||
this.compaction = new LogCompaction({
|
||||
meshId: config.meshId,
|
||||
hostId: config.hostId,
|
||||
sign: config.sign,
|
||||
...clockOption,
|
||||
});
|
||||
}
|
||||
|
||||
public install(node: NetworkNode): void {
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.node = null;
|
||||
}
|
||||
|
||||
public host(): NetworkNode | null {
|
||||
return this.node;
|
||||
}
|
||||
|
||||
public isStarted(): boolean {
|
||||
return this.started;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# LogReplicator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Ships every signed log entry to `R` sibling hosts so a single host failure cannot erase audit history. The replicator is tier-aware: a Tier 0 host preferentially replicates to other Tier 0s (cross-mesh durability) and falls back to a Tier 1 child within its own mesh (local redundancy); a Tier 1 host replicates to its Tier 0 parent and a sibling Tier 1. The mode is determined by the entry's severity — critical events (host election, session creation) ship synchronously, normal events (peer churn, state updates) ship asynchronously.
|
||||
|
||||
The replicator is intentionally side-effect-light: routing decisions and retry policy are pure; the only outward I/O is the injected `SendToReplicaFn`. This makes it composable with any transport (`@suaveplan/network-plugin-transport-manager`, a real WebRTC mesh, or a memory-only test harness).
|
||||
|
||||
## Features
|
||||
|
||||
- R-copy replication; default `replicationFactor` is 2 (so each entry lives on 3 hosts: originator + 2 replicas).
|
||||
- Tier-aware target scoring (Tier 0 → other Tier 0s preferred; Tier 1 → Tier 0 parent preferred); deterministic alphabetical tiebreak on score collisions so replication is reproducible across runs.
|
||||
- Severity-mode mapping: `"critical"` → synchronous (all R awaited; throws `NET_AUDIT.REPLICATION_FAILED` if every replica fails), `"normal"` → fire-and-forget with bus failure events.
|
||||
- Per-target failures emit `audit:replication-failed` on the optional bus rather than throwing — keeps the originating host's append loop fast.
|
||||
- `registerCandidate` / `unregisterCandidate` so topology changes (peer joins / disconnects) flow into the replicator without rebuilding it.
|
||||
- Self-exclusion: a candidate matching `localHostId` is silently dropped at registration time and at selection time.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { LogReplicator } from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const replicator = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 2,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
candidates: [
|
||||
{ hostId: "host-2", tier: 0, meshId: "mesh-b" },
|
||||
{ hostId: "host-3", tier: 1, meshId: "mesh-a" },
|
||||
],
|
||||
send: async (target, entry) => transport.send(target.hostId, entry),
|
||||
});
|
||||
|
||||
await replicator.replicate(entry);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { LogReplicator } from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const replicator = new LogReplicator({
|
||||
config: { replicationFactor: 3, localTier: 1, localMeshId: "mesh-a", localHostId: "subhost-c" },
|
||||
bus: { emit: (topic, payload) => observability.publish(topic, payload) },
|
||||
send: async (target, entry) => {
|
||||
try {
|
||||
await network.send({ to: target.hostId, payload: entry });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
replicator.registerCandidate({ hostId: "host-1", tier: 0, meshId: "mesh-a" });
|
||||
const outcome = await replicator.replicate(criticalEntry);
|
||||
console.log(outcome.mode, outcome.succeeded.length, outcome.failed.length);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `LogReplicator(input)` — `input: { config: ReplicationConfig; send: SendToReplicaFn; bus?: ReplicatorBus; candidates?: readonly ReplicationTarget[] }`. Throws `NET_AUDIT.REPLICATION_FAILED` if `replicationFactor < 1`.
|
||||
- `replicate(entry)` — chooses targets, dispatches, returns `ReplicationOutcome`. Synchronous mode throws `NET_AUDIT.REPLICATION_FAILED` if every replica fails. Asynchronous mode returns an outcome with empty `succeeded`/`failed` (failures arrive on the bus).
|
||||
- `selectTargets()` — pure target-ranking helper; returns up to `replicationFactor` candidates ordered by tier-aware score then by `hostId`.
|
||||
- `registerCandidate(target)` / `unregisterCandidate(hostId)` / `listCandidates()` — candidate-set management.
|
||||
- `ReplicationOutcome` — `{ mode; attempted; succeeded; failed }`.
|
||||
- `ReplicatorBus` — minimal `emit(topic, payload)` shape so the package does not couple to `@suaveplan/events`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The scoring function gives `100` to the most-preferred class and `50` to the fallback class; any other candidate scores `0` and is dropped only if there are enough higher-scored candidates. This keeps the mesh "best-effort R" rather than "exact R or fail" — durability degrades gracefully.
|
||||
- Sync mode awaits all replicas via `Promise.all` so the originator can record both the success count and the failure list in a single round-trip.
|
||||
- Async mode dispatches via `void this.sendOne(...).then(...)`; thrown errors inside the send function are caught and surfaced as failures rather than uncaught promise rejections.
|
||||
- A bus is optional. If one is wired, the replicator emits `audit:replication-failed` for every per-target failure (even in sync mode where the throw also fires), so the observability adapter can count failures without needing access to the throw site.
|
||||
@@ -0,0 +1,366 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_AUDIT_CODES, NetworkAuditError } from "../error-codes.js";
|
||||
import type { LogEntry } from "../types/log.types.js";
|
||||
import type { ReplicationTarget } from "../types/replication.types.js";
|
||||
import { LogReplicator } from "./log-replicator.js";
|
||||
|
||||
const makeEntry = (overrides: Partial<LogEntry> = {}): LogEntry => ({
|
||||
seq: 0,
|
||||
timestamp: 1,
|
||||
meshId: "mesh-a",
|
||||
sessionId: null,
|
||||
eventType: "peer-joined",
|
||||
severity: "normal",
|
||||
payload: {},
|
||||
prevHash: "",
|
||||
hostId: "host-1",
|
||||
hostSignature: new Uint8Array(64),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("LogReplicator construction", () => {
|
||||
it("rejects replicationFactor < 1", () => {
|
||||
expect(
|
||||
() =>
|
||||
new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 0,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
send: async () => true,
|
||||
})
|
||||
).toThrow(NetworkAuditError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LogReplicator candidate management", () => {
|
||||
it("registers and lists candidates, deduping by hostId", () => {
|
||||
const r = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 2,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
send: async () => true,
|
||||
});
|
||||
r.registerCandidate({ hostId: "host-2", tier: 0, meshId: "mesh-b" });
|
||||
r.registerCandidate({ hostId: "host-2", tier: 0, meshId: "mesh-b" });
|
||||
expect(r.listCandidates().length).toBe(1);
|
||||
});
|
||||
|
||||
it("silently drops the local hostId from registration", () => {
|
||||
const r = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 2,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
send: async () => true,
|
||||
});
|
||||
r.registerCandidate({ hostId: "host-1", tier: 0, meshId: "mesh-a" });
|
||||
expect(r.listCandidates().length).toBe(0);
|
||||
});
|
||||
|
||||
it("unregisters by hostId", () => {
|
||||
const r = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 2,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
send: async () => true,
|
||||
candidates: [{ hostId: "host-2", tier: 0, meshId: "mesh-b" }],
|
||||
});
|
||||
r.unregisterCandidate("host-2");
|
||||
expect(r.listCandidates().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LogReplicator selectTargets", () => {
|
||||
it("Tier 0 prefers other Tier 0s in different meshes", () => {
|
||||
const r = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 2,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
send: async () => true,
|
||||
candidates: [
|
||||
{ hostId: "host-2", tier: 0, meshId: "mesh-b" },
|
||||
{ hostId: "host-3", tier: 1, meshId: "mesh-a" },
|
||||
{ hostId: "host-4", tier: 0, meshId: "mesh-c" },
|
||||
],
|
||||
});
|
||||
const targets = r.selectTargets();
|
||||
expect(targets.map((t) => t.hostId)).toEqual(["host-2", "host-4"]);
|
||||
});
|
||||
|
||||
it("Tier 0 falls back to a Tier 1 child within own mesh", () => {
|
||||
const r = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 2,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
send: async () => true,
|
||||
candidates: [
|
||||
{ hostId: "host-2", tier: 0, meshId: "mesh-b" },
|
||||
{ hostId: "host-3", tier: 1, meshId: "mesh-a" },
|
||||
],
|
||||
});
|
||||
const targets = r.selectTargets();
|
||||
expect(targets.map((t) => t.hostId)).toEqual(["host-2", "host-3"]);
|
||||
});
|
||||
|
||||
it("Tier 1 prefers a Tier 0 parent within own mesh", () => {
|
||||
const r = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 2,
|
||||
localTier: 1,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "subhost-c",
|
||||
},
|
||||
send: async () => true,
|
||||
candidates: [
|
||||
{ hostId: "host-1", tier: 0, meshId: "mesh-a" },
|
||||
{ hostId: "subhost-d", tier: 1, meshId: "mesh-a" },
|
||||
{ hostId: "subhost-e", tier: 1, meshId: "mesh-b" },
|
||||
],
|
||||
});
|
||||
const targets = r.selectTargets();
|
||||
expect(targets.map((t) => t.hostId)).toEqual(["host-1", "subhost-d"]);
|
||||
});
|
||||
|
||||
it("scores Tier 0 same-mesh candidates as zero (no rule matched)", () => {
|
||||
const r = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 1,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
send: async () => true,
|
||||
candidates: [
|
||||
{ hostId: "host-2", tier: 0, meshId: "mesh-a" },
|
||||
{ hostId: "host-3", tier: 0, meshId: "mesh-b" },
|
||||
],
|
||||
});
|
||||
const targets = r.selectTargets();
|
||||
expect(targets[0]?.hostId).toBe("host-3");
|
||||
});
|
||||
|
||||
it("scores Tier N candidates that don't match any Tier-1 rule as zero", () => {
|
||||
const r = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 1,
|
||||
localTier: 1,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "subhost-c",
|
||||
},
|
||||
send: async () => true,
|
||||
candidates: [
|
||||
{ hostId: "subhost-x", tier: 2, meshId: "mesh-b" },
|
||||
{ hostId: "host-parent", tier: 0, meshId: "mesh-a" },
|
||||
],
|
||||
});
|
||||
const targets = r.selectTargets();
|
||||
expect(targets[0]?.hostId).toBe("host-parent");
|
||||
});
|
||||
|
||||
it("breaks ties on hostId alphabetically", () => {
|
||||
const r = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 2,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
send: async () => true,
|
||||
candidates: [
|
||||
{ hostId: "host-z", tier: 0, meshId: "mesh-b" },
|
||||
{ hostId: "host-a", tier: 0, meshId: "mesh-c" },
|
||||
],
|
||||
});
|
||||
const targets = r.selectTargets();
|
||||
expect(targets.map((t) => t.hostId)).toEqual(["host-a", "host-z"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LogReplicator replicate (sync)", () => {
|
||||
it("awaits all replicas for severity=critical", async () => {
|
||||
const seen: string[] = [];
|
||||
const r = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 2,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
send: async (target) => {
|
||||
seen.push(target.hostId);
|
||||
return true;
|
||||
},
|
||||
candidates: [
|
||||
{ hostId: "host-2", tier: 0, meshId: "mesh-b" },
|
||||
{ hostId: "host-3", tier: 0, meshId: "mesh-c" },
|
||||
],
|
||||
});
|
||||
const outcome = await r.replicate(makeEntry({ severity: "critical" }));
|
||||
expect(outcome.mode).toBe("sync");
|
||||
expect(outcome.succeeded.length).toBe(2);
|
||||
expect(seen.length).toBe(2);
|
||||
});
|
||||
|
||||
it("throws REPLICATION_FAILED when every sync replica fails", async () => {
|
||||
const r = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 2,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
send: async () => false,
|
||||
candidates: [
|
||||
{ hostId: "host-2", tier: 0, meshId: "mesh-b" },
|
||||
{ hostId: "host-3", tier: 0, meshId: "mesh-c" },
|
||||
],
|
||||
});
|
||||
await expect(
|
||||
r.replicate(makeEntry({ severity: "critical" }))
|
||||
).rejects.toMatchObject({
|
||||
code: NETWORK_AUDIT_CODES.REPLICATION_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits audit:replication-failed for partial sync failures", async () => {
|
||||
const events: Array<{ topic: string; payload: unknown }> = [];
|
||||
const r = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 2,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
send: async (target) => target.hostId === "host-2",
|
||||
bus: {
|
||||
emit: (topic, payload) => events.push({ topic, payload }),
|
||||
},
|
||||
candidates: [
|
||||
{ hostId: "host-2", tier: 0, meshId: "mesh-b" },
|
||||
{ hostId: "host-3", tier: 0, meshId: "mesh-c" },
|
||||
],
|
||||
});
|
||||
const outcome = await r.replicate(makeEntry({ severity: "critical" }));
|
||||
expect(outcome.failed.length).toBe(1);
|
||||
expect(events[0]?.topic).toBe("audit:replication-failed");
|
||||
});
|
||||
|
||||
it("treats a thrown send as a failure", async () => {
|
||||
const r = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 1,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
send: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
candidates: [{ hostId: "host-2", tier: 0, meshId: "mesh-b" }],
|
||||
});
|
||||
await expect(
|
||||
r.replicate(makeEntry({ severity: "critical" }))
|
||||
).rejects.toMatchObject({
|
||||
code: NETWORK_AUDIT_CODES.REPLICATION_FAILED,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LogReplicator replicate (async)", () => {
|
||||
it("returns immediately for severity=normal and dispatches in the background", async () => {
|
||||
const dispatched: string[] = [];
|
||||
const r = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 1,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
send: async (target) => {
|
||||
dispatched.push(target.hostId);
|
||||
return true;
|
||||
},
|
||||
candidates: [{ hostId: "host-2", tier: 0, meshId: "mesh-b" }],
|
||||
});
|
||||
const outcome = await r.replicate(makeEntry({ severity: "normal" }));
|
||||
expect(outcome.mode).toBe("async");
|
||||
expect(outcome.succeeded.length).toBe(0);
|
||||
expect(outcome.attempted.length).toBe(1);
|
||||
await new Promise((r2) => setTimeout(r2, 5));
|
||||
expect(dispatched).toEqual(["host-2"]);
|
||||
});
|
||||
|
||||
it("emits audit:replication-failed in async mode when send returns false", async () => {
|
||||
const events: Array<{ topic: string }> = [];
|
||||
const r = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 1,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
send: async () => false,
|
||||
bus: { emit: (topic) => events.push({ topic }) },
|
||||
candidates: [{ hostId: "host-2", tier: 0, meshId: "mesh-b" }],
|
||||
});
|
||||
await r.replicate(makeEntry({ severity: "normal" }));
|
||||
await new Promise((r2) => setTimeout(r2, 5));
|
||||
expect(
|
||||
events.find((e) => e.topic === "audit:replication-failed")
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LogReplicator replicate (no candidates)", () => {
|
||||
it("throws REPLICATION_FAILED when no targets remain", async () => {
|
||||
const r = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 1,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
send: async () => true,
|
||||
});
|
||||
await expect(r.replicate(makeEntry())).rejects.toMatchObject({
|
||||
code: NETWORK_AUDIT_CODES.REPLICATION_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("self-excludes localHostId from selectTargets too", () => {
|
||||
const r = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 1,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
},
|
||||
send: async () => true,
|
||||
candidates: [
|
||||
{
|
||||
hostId: "host-1",
|
||||
tier: 0,
|
||||
meshId: "mesh-a",
|
||||
} as ReplicationTarget,
|
||||
],
|
||||
});
|
||||
expect(r.selectTargets().length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { NETWORK_AUDIT_CODES, NetworkAuditError } from "../error-codes.js";
|
||||
import type { LogEntry } from "../types/log.types.js";
|
||||
import type {
|
||||
ReplicationConfig,
|
||||
ReplicationMode,
|
||||
ReplicationTarget,
|
||||
} from "../types/replication.types.js";
|
||||
|
||||
export type SendToReplicaFn = (
|
||||
target: ReplicationTarget,
|
||||
entry: LogEntry
|
||||
) => Promise<boolean>;
|
||||
|
||||
export interface ReplicatorBus {
|
||||
emit(topic: "audit:replication-failed", payload: unknown): void;
|
||||
}
|
||||
|
||||
export interface LogReplicatorConfig {
|
||||
readonly config: ReplicationConfig;
|
||||
readonly send: SendToReplicaFn;
|
||||
readonly bus?: ReplicatorBus;
|
||||
readonly candidates?: readonly ReplicationTarget[];
|
||||
}
|
||||
|
||||
export interface ReplicationOutcome {
|
||||
readonly mode: ReplicationMode;
|
||||
readonly attempted: readonly ReplicationTarget[];
|
||||
readonly succeeded: readonly ReplicationTarget[];
|
||||
readonly failed: readonly ReplicationTarget[];
|
||||
}
|
||||
|
||||
export class LogReplicator {
|
||||
public readonly config: ReplicationConfig;
|
||||
private readonly send: SendToReplicaFn;
|
||||
private readonly bus: ReplicatorBus | undefined;
|
||||
private candidates: ReplicationTarget[];
|
||||
|
||||
public constructor(input: LogReplicatorConfig) {
|
||||
if (input.config.replicationFactor < 1) {
|
||||
throw new NetworkAuditError(
|
||||
NETWORK_AUDIT_CODES.REPLICATION_FAILED,
|
||||
"audit.replication.factor_invalid",
|
||||
{
|
||||
context: {
|
||||
replicationFactor: input.config.replicationFactor,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
this.config = input.config;
|
||||
this.send = input.send;
|
||||
this.bus = input.bus;
|
||||
this.candidates = [...(input.candidates ?? [])];
|
||||
}
|
||||
|
||||
public registerCandidate(target: ReplicationTarget): void {
|
||||
if (target.hostId === this.config.localHostId) return;
|
||||
if (this.candidates.some((c) => c.hostId === target.hostId)) return;
|
||||
this.candidates.push(target);
|
||||
}
|
||||
|
||||
public unregisterCandidate(hostId: string): void {
|
||||
this.candidates = this.candidates.filter((c) => c.hostId !== hostId);
|
||||
}
|
||||
|
||||
public listCandidates(): readonly ReplicationTarget[] {
|
||||
return [...this.candidates];
|
||||
}
|
||||
|
||||
public selectTargets(): ReplicationTarget[] {
|
||||
const eligible = this.candidates.filter(
|
||||
(c) => c.hostId !== this.config.localHostId
|
||||
);
|
||||
const ranked = eligible
|
||||
.map((target) => ({ target, score: this.score(target) }))
|
||||
.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return a.target.hostId.localeCompare(b.target.hostId);
|
||||
});
|
||||
return ranked
|
||||
.slice(0, this.config.replicationFactor)
|
||||
.map((entry) => entry.target);
|
||||
}
|
||||
|
||||
public async replicate(entry: LogEntry): Promise<ReplicationOutcome> {
|
||||
const targets = this.selectTargets();
|
||||
if (targets.length === 0) {
|
||||
throw new NetworkAuditError(
|
||||
NETWORK_AUDIT_CODES.REPLICATION_FAILED,
|
||||
"audit.replication.no_targets",
|
||||
{ context: { meshId: entry.meshId, seq: entry.seq } }
|
||||
);
|
||||
}
|
||||
const mode: ReplicationMode =
|
||||
entry.severity === "critical" ? "sync" : "async";
|
||||
if (mode === "sync") {
|
||||
return await this.replicateSync(entry, targets);
|
||||
}
|
||||
return this.replicateAsync(entry, targets);
|
||||
}
|
||||
|
||||
private async replicateSync(
|
||||
entry: LogEntry,
|
||||
targets: ReplicationTarget[]
|
||||
): Promise<ReplicationOutcome> {
|
||||
const results = await Promise.all(
|
||||
targets.map(async (target) => ({
|
||||
target,
|
||||
ok: await this.sendOne(target, entry),
|
||||
}))
|
||||
);
|
||||
const succeeded = results.filter((r) => r.ok).map((r) => r.target);
|
||||
const failed = results.filter((r) => !r.ok).map((r) => r.target);
|
||||
if (succeeded.length === 0) {
|
||||
this.emitFailure(entry, failed);
|
||||
throw new NetworkAuditError(
|
||||
NETWORK_AUDIT_CODES.REPLICATION_FAILED,
|
||||
"audit.replication.sync_all_failed",
|
||||
{
|
||||
context: {
|
||||
meshId: entry.meshId,
|
||||
seq: entry.seq,
|
||||
attempted: targets.map((t) => t.hostId),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
if (failed.length > 0) this.emitFailure(entry, failed);
|
||||
return { mode: "sync", attempted: targets, succeeded, failed };
|
||||
}
|
||||
|
||||
private replicateAsync(
|
||||
entry: LogEntry,
|
||||
targets: ReplicationTarget[]
|
||||
): ReplicationOutcome {
|
||||
for (const target of targets) {
|
||||
void this.sendOne(target, entry).then((ok) => {
|
||||
if (!ok) this.emitFailure(entry, [target]);
|
||||
});
|
||||
}
|
||||
return {
|
||||
mode: "async",
|
||||
attempted: targets,
|
||||
succeeded: [],
|
||||
failed: [],
|
||||
};
|
||||
}
|
||||
|
||||
private async sendOne(
|
||||
target: ReplicationTarget,
|
||||
entry: LogEntry
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await this.send(target, entry);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private emitFailure(
|
||||
entry: LogEntry,
|
||||
failed: readonly ReplicationTarget[]
|
||||
): void {
|
||||
this.bus?.emit("audit:replication-failed", {
|
||||
meshId: entry.meshId,
|
||||
seq: entry.seq,
|
||||
failed: failed.map((t) => t.hostId),
|
||||
});
|
||||
}
|
||||
|
||||
private score(target: ReplicationTarget): number {
|
||||
let score = 0;
|
||||
if (this.config.localTier === 0) {
|
||||
if (
|
||||
target.tier === 0 &&
|
||||
target.meshId !== this.config.localMeshId
|
||||
) {
|
||||
score += 100;
|
||||
} else if (
|
||||
target.tier === 1 &&
|
||||
target.meshId === this.config.localMeshId
|
||||
) {
|
||||
score += 50;
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
target.tier === 0 &&
|
||||
target.meshId === this.config.localMeshId
|
||||
) {
|
||||
score += 100;
|
||||
} else if (
|
||||
target.tier === this.config.localTier &&
|
||||
target.meshId === this.config.localMeshId
|
||||
) {
|
||||
score += 50;
|
||||
}
|
||||
}
|
||||
return score;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# LogRetriever
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Answers range queries against a `SignedLog`, the natural query shape for the server-bridge reconciliation flow: "give me every entry in mesh X with sequence between A and B". Pagination is the surface that keeps reconciliation tractable when a host has accumulated tens of thousands of entries during a long server outage — the server iterates page-by-page rather than buffering the entire chain.
|
||||
|
||||
The retriever is deliberately a thin read facade. It has no signature verification logic (that lives in `IntegrityChecker`) and no authorization logic (that lives upstream in `network-plugin-auth`). Its only job is to take a well-formed range request and return well-paginated results.
|
||||
|
||||
## Features
|
||||
|
||||
- `getLogs(query)` returns one page of entries plus pagination metadata (`page`, `pageSize`, `totalPages`).
|
||||
- `iterate(query)` is a generator that yields every entry across all pages, so callers can stream a long chain without juggling pagination state.
|
||||
- Per-call `pageSize` is clamped to `maxPageSize` (default 1000) so a malformed request cannot exhaust memory.
|
||||
- Mesh-mismatch (`requested.meshId !== log.meshId`) raises `NET_AUDIT.RANGE_INVALID` rather than silently returning an empty result, so misrouted queries fail loudly.
|
||||
- Negative `fromSeq`, `toSeq < fromSeq`, negative page index, and zero-or-negative `pageSize` all raise `NET_AUDIT.RANGE_INVALID` with explanatory `error.context`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SignedLog,
|
||||
LogRetriever,
|
||||
} from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const log = new SignedLog({
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
sign: signFn,
|
||||
});
|
||||
const retriever = new LogRetriever({ log });
|
||||
|
||||
const result = retriever.getLogs({
|
||||
meshId: "mesh-a",
|
||||
fromSeq: 0,
|
||||
toSeq: 99,
|
||||
});
|
||||
console.log(result.page, result.totalPages, result.entries.length);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { LogRetriever } from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const retriever = new LogRetriever({
|
||||
log,
|
||||
defaultPageSize: 250,
|
||||
maxPageSize: 5000,
|
||||
});
|
||||
|
||||
for (const entry of retriever.iterate({
|
||||
meshId: "mesh-a",
|
||||
fromSeq: 1000,
|
||||
toSeq: 9999,
|
||||
pageSize: 500,
|
||||
})) {
|
||||
await server.ingest(entry);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `LogRetriever(config)` — `config: { log: SignedLog; defaultPageSize?: number; maxPageSize?: number }`. Defaults: 100 / 1000.
|
||||
- `getLogs(query)` — `query: { meshId; fromSeq; toSeq; pageSize?; page? }`. Returns `RangeResult`.
|
||||
- `iterate(query)` — `Generator<LogEntry>`; auto-advances pages until the range is exhausted.
|
||||
- `RangeResult` — `{ meshId; fromSeq; toSeq; page; pageSize; totalPages; entries }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The mesh-mismatch check is the first guard so observability for "wrong mesh queried" is unambiguous in error logs.
|
||||
- `pageSize` is normalised at the call site (not at construction time) so a single retriever instance can serve heterogeneous query shapes.
|
||||
- The retriever filters by sequence range rather than slicing by index because `SignedLog.entries()` is `seq`-aligned (entry index === seq) but we treat that as an implementation detail rather than a contract — should `SignedLog` ever support truncation/checkpoint reset, the retriever continues to work correctly without changes.
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_AUDIT_CODES, NetworkAuditError } from "../error-codes.js";
|
||||
import { SignedLog } from "../log/signed-log.js";
|
||||
import { LogRetriever } from "./log-retriever.js";
|
||||
|
||||
const stubSign = (bytes: Uint8Array): Uint8Array => {
|
||||
const sig = new Uint8Array(64);
|
||||
sig.set(bytes.slice(0, Math.min(64, bytes.length)));
|
||||
return sig;
|
||||
};
|
||||
|
||||
const buildLog = async (count: number, meshId = "mesh-a") => {
|
||||
const log = new SignedLog({
|
||||
meshId,
|
||||
hostId: "host-1",
|
||||
sign: stubSign,
|
||||
});
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
await log.append({
|
||||
eventType: "peer-joined",
|
||||
sessionId: null,
|
||||
severity: "normal",
|
||||
payload: { i },
|
||||
});
|
||||
}
|
||||
return log;
|
||||
};
|
||||
|
||||
describe("LogRetriever.getLogs", () => {
|
||||
it("returns entries within the requested seq range", async () => {
|
||||
const log = await buildLog(10);
|
||||
const retriever = new LogRetriever({ log });
|
||||
const result = retriever.getLogs({
|
||||
meshId: "mesh-a",
|
||||
fromSeq: 2,
|
||||
toSeq: 5,
|
||||
});
|
||||
expect(result.entries.length).toBe(4);
|
||||
expect(result.entries[0]!.seq).toBe(2);
|
||||
expect(result.entries[3]!.seq).toBe(5);
|
||||
});
|
||||
|
||||
it("paginates large ranges with the default page size", async () => {
|
||||
const log = await buildLog(250);
|
||||
const retriever = new LogRetriever({ log });
|
||||
const first = retriever.getLogs({
|
||||
meshId: "mesh-a",
|
||||
fromSeq: 0,
|
||||
toSeq: 249,
|
||||
});
|
||||
expect(first.entries.length).toBe(100);
|
||||
expect(first.totalPages).toBe(3);
|
||||
expect(first.page).toBe(0);
|
||||
const last = retriever.getLogs({
|
||||
meshId: "mesh-a",
|
||||
fromSeq: 0,
|
||||
toSeq: 249,
|
||||
page: 2,
|
||||
});
|
||||
expect(last.entries.length).toBe(50);
|
||||
});
|
||||
|
||||
it("clamps pageSize to maxPageSize", async () => {
|
||||
const log = await buildLog(10);
|
||||
const retriever = new LogRetriever({ log, maxPageSize: 5 });
|
||||
const result = retriever.getLogs({
|
||||
meshId: "mesh-a",
|
||||
fromSeq: 0,
|
||||
toSeq: 9,
|
||||
pageSize: 100,
|
||||
});
|
||||
expect(result.pageSize).toBe(5);
|
||||
});
|
||||
|
||||
it("rejects mesh-mismatch with NET_AUDIT.RANGE_INVALID", async () => {
|
||||
const log = await buildLog(1);
|
||||
const retriever = new LogRetriever({ log });
|
||||
try {
|
||||
retriever.getLogs({
|
||||
meshId: "mesh-z",
|
||||
fromSeq: 0,
|
||||
toSeq: 0,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(NetworkAuditError);
|
||||
expect((err as NetworkAuditError).code).toBe(
|
||||
NETWORK_AUDIT_CODES.RANGE_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects negative fromSeq", async () => {
|
||||
const log = await buildLog(1);
|
||||
const retriever = new LogRetriever({ log });
|
||||
expect(() =>
|
||||
retriever.getLogs({
|
||||
meshId: "mesh-a",
|
||||
fromSeq: -1,
|
||||
toSeq: 0,
|
||||
})
|
||||
).toThrow(NetworkAuditError);
|
||||
});
|
||||
|
||||
it("rejects toSeq < fromSeq", async () => {
|
||||
const log = await buildLog(1);
|
||||
const retriever = new LogRetriever({ log });
|
||||
expect(() =>
|
||||
retriever.getLogs({
|
||||
meshId: "mesh-a",
|
||||
fromSeq: 5,
|
||||
toSeq: 2,
|
||||
})
|
||||
).toThrow(NetworkAuditError);
|
||||
});
|
||||
|
||||
it("rejects negative page", async () => {
|
||||
const log = await buildLog(1);
|
||||
const retriever = new LogRetriever({ log });
|
||||
expect(() =>
|
||||
retriever.getLogs({
|
||||
meshId: "mesh-a",
|
||||
fromSeq: 0,
|
||||
toSeq: 0,
|
||||
page: -1,
|
||||
})
|
||||
).toThrow(NetworkAuditError);
|
||||
});
|
||||
|
||||
it("rejects pageSize < 1", async () => {
|
||||
const log = await buildLog(1);
|
||||
const retriever = new LogRetriever({ log });
|
||||
expect(() =>
|
||||
retriever.getLogs({
|
||||
meshId: "mesh-a",
|
||||
fromSeq: 0,
|
||||
toSeq: 0,
|
||||
pageSize: 0,
|
||||
})
|
||||
).toThrow(NetworkAuditError);
|
||||
});
|
||||
|
||||
it("returns totalPages=0 for an empty result set", async () => {
|
||||
const log = await buildLog(0);
|
||||
const retriever = new LogRetriever({ log });
|
||||
const result = retriever.getLogs({
|
||||
meshId: "mesh-a",
|
||||
fromSeq: 0,
|
||||
toSeq: 0,
|
||||
});
|
||||
expect(result.totalPages).toBe(0);
|
||||
expect(result.entries.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LogRetriever.iterate", () => {
|
||||
it("yields every entry across page boundaries", async () => {
|
||||
const log = await buildLog(105);
|
||||
const retriever = new LogRetriever({ log });
|
||||
const seen: number[] = [];
|
||||
for (const entry of retriever.iterate({
|
||||
meshId: "mesh-a",
|
||||
fromSeq: 0,
|
||||
toSeq: 104,
|
||||
pageSize: 25,
|
||||
})) {
|
||||
seen.push(entry.seq);
|
||||
}
|
||||
expect(seen.length).toBe(105);
|
||||
expect(seen[0]).toBe(0);
|
||||
expect(seen[104]).toBe(104);
|
||||
});
|
||||
|
||||
it("returns immediately when the range is empty", async () => {
|
||||
const log = await buildLog(0);
|
||||
const retriever = new LogRetriever({ log });
|
||||
const seen = Array.from(
|
||||
retriever.iterate({
|
||||
meshId: "mesh-a",
|
||||
fromSeq: 0,
|
||||
toSeq: 0,
|
||||
})
|
||||
);
|
||||
expect(seen.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NETWORK_AUDIT_CODES, NetworkAuditError } from "../error-codes.js";
|
||||
import type { SignedLog } from "../log/signed-log.js";
|
||||
import type { LogEntry } from "../types/log.types.js";
|
||||
|
||||
export interface LogRetrieverConfig {
|
||||
readonly log: SignedLog;
|
||||
readonly defaultPageSize?: number;
|
||||
readonly maxPageSize?: number;
|
||||
}
|
||||
|
||||
export interface RangeQuery {
|
||||
readonly meshId: string;
|
||||
readonly fromSeq: number;
|
||||
readonly toSeq: number;
|
||||
readonly pageSize?: number;
|
||||
readonly page?: number;
|
||||
}
|
||||
|
||||
export interface RangeResult {
|
||||
readonly meshId: string;
|
||||
readonly fromSeq: number;
|
||||
readonly toSeq: number;
|
||||
readonly page: number;
|
||||
readonly pageSize: number;
|
||||
readonly totalPages: number;
|
||||
readonly entries: readonly LogEntry[];
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 100;
|
||||
const DEFAULT_MAX_PAGE_SIZE = 1000;
|
||||
|
||||
export class LogRetriever {
|
||||
private readonly log: SignedLog;
|
||||
private readonly defaultPageSize: number;
|
||||
private readonly maxPageSize: number;
|
||||
|
||||
public constructor(config: LogRetrieverConfig) {
|
||||
this.log = config.log;
|
||||
this.defaultPageSize = config.defaultPageSize ?? DEFAULT_PAGE_SIZE;
|
||||
this.maxPageSize = config.maxPageSize ?? DEFAULT_MAX_PAGE_SIZE;
|
||||
}
|
||||
|
||||
public getLogs(query: RangeQuery): RangeResult {
|
||||
if (query.meshId !== this.log.meshId) {
|
||||
throw new NetworkAuditError(
|
||||
NETWORK_AUDIT_CODES.RANGE_INVALID,
|
||||
"audit.range.mesh_mismatch",
|
||||
{
|
||||
context: {
|
||||
requested: query.meshId,
|
||||
local: this.log.meshId,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
if (query.fromSeq < 0 || query.toSeq < query.fromSeq) {
|
||||
throw new NetworkAuditError(
|
||||
NETWORK_AUDIT_CODES.RANGE_INVALID,
|
||||
"audit.range.bounds_invalid",
|
||||
{
|
||||
context: {
|
||||
fromSeq: query.fromSeq,
|
||||
toSeq: query.toSeq,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
const pageSize = this.normalisePageSize(query.pageSize);
|
||||
const page = query.page ?? 0;
|
||||
if (page < 0) {
|
||||
throw new NetworkAuditError(
|
||||
NETWORK_AUDIT_CODES.RANGE_INVALID,
|
||||
"audit.range.page_invalid",
|
||||
{ context: { page } }
|
||||
);
|
||||
}
|
||||
const all = this.log
|
||||
.entries()
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.seq >= query.fromSeq && entry.seq <= query.toSeq
|
||||
);
|
||||
const totalPages =
|
||||
all.length === 0 ? 0 : Math.ceil(all.length / pageSize);
|
||||
const start = page * pageSize;
|
||||
const slice = all.slice(start, start + pageSize);
|
||||
return {
|
||||
meshId: query.meshId,
|
||||
fromSeq: query.fromSeq,
|
||||
toSeq: query.toSeq,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
entries: slice,
|
||||
};
|
||||
}
|
||||
|
||||
public *iterate(query: RangeQuery): Generator<LogEntry> {
|
||||
const pageSize = this.normalisePageSize(query.pageSize);
|
||||
let page = 0;
|
||||
while (true) {
|
||||
const result = this.getLogs({ ...query, pageSize, page });
|
||||
if (result.entries.length === 0) return;
|
||||
for (const entry of result.entries) yield entry;
|
||||
page += 1;
|
||||
if (page >= result.totalPages) return;
|
||||
}
|
||||
}
|
||||
|
||||
private normalisePageSize(requested?: number): number {
|
||||
if (requested === undefined) return this.defaultPageSize;
|
||||
if (requested < 1) {
|
||||
throw new NetworkAuditError(
|
||||
NETWORK_AUDIT_CODES.RANGE_INVALID,
|
||||
"audit.range.page_size_invalid",
|
||||
{ context: { pageSize: requested } }
|
||||
);
|
||||
}
|
||||
return Math.min(requested, this.maxPageSize);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# conflict.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Captures the inputs and outputs of `ConflictResolver`. When the central server reconciles state after an outage, it pulls the per-host audit chains from every Tier 0 host. Hosts that were partitioned during the outage may have produced divergent suffixes, so the server needs a deterministic tiebreaking rule to pick the canonical chain. These types describe that rule's surface area.
|
||||
|
||||
The two-step resolution (longest chain wins; if equal length, the chain whose last entry hash sorts lexicographically smallest wins) is intentionally simple and side-effect-free so the same input is guaranteed to produce the same output across servers — important when the reconciler is itself replicated.
|
||||
|
||||
## Features
|
||||
|
||||
- `DivergentChain` carries one host's contribution to the conflict: the chain of entries it produced and a `lastHash` (the SHA-256 hex of the final entry) used as the tiebreak key.
|
||||
- `ConflictResolution` carries the winning chain plus a `reason` discriminant (`"longest-chain"` vs `"lex-tiebreak"`) for audit log output, and a `losers` array so callers can issue rollback instructions to the hosts whose chain was discarded.
|
||||
- All fields are `readonly` and the chain arrays are `readonly LogEntry[]` to keep resolver semantics referentially transparent.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ConflictResolution,
|
||||
DivergentChain,
|
||||
} from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const chains: DivergentChain[] = [
|
||||
{ hostId: "host-1", entries: [...], lastHash: "abc..." },
|
||||
{ hostId: "host-2", entries: [...], lastHash: "def..." },
|
||||
];
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ConflictResolver } from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const resolver = new ConflictResolver();
|
||||
const result: ConflictResolution = resolver.resolve(chains);
|
||||
console.log(result.winningHostId, result.reason, result.losers);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ConflictResolutionReason` — `"longest-chain" | "lex-tiebreak"`.
|
||||
- `DivergentChain` — `{ hostId; entries: readonly LogEntry[]; lastHash: string }`.
|
||||
- `ConflictResolution` — `{ winningHostId; winningChain: readonly LogEntry[]; reason; losers: readonly string[] }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `lastHash` is provided by the caller (typically computed by the integrity checker as it walks the chain) so the resolver does not depend on hashing utilities and stays trivially testable.
|
||||
- An empty chain set throws `NET_AUDIT.CONFLICT_UNRESOLVED` rather than returning a sentinel — there is no meaningful "no winner" answer.
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LogEntry } from "./log.types.js";
|
||||
|
||||
export type ConflictResolutionReason = "longest-chain" | "lex-tiebreak";
|
||||
|
||||
export interface DivergentChain {
|
||||
readonly hostId: string;
|
||||
readonly entries: readonly LogEntry[];
|
||||
readonly lastHash: string;
|
||||
}
|
||||
|
||||
export interface ConflictResolution {
|
||||
readonly winningHostId: string;
|
||||
readonly winningChain: readonly LogEntry[];
|
||||
readonly reason: ConflictResolutionReason;
|
||||
readonly losers: readonly string[];
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# log.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the wire-stable shape of every record that flows through the audit-mesh subsystem: append-only `LogEntry` records, the seven `LogEventType` discriminants the mesh emits, the two-level `LogSeverity` axis that drives synchronous-vs-asynchronous replication, and the periodic `LogCheckpoint` that compaction emits to bound replay volume after a long server outage.
|
||||
|
||||
These types are the contract every other module in `@suaveplan/network-plugin-audit-mesh` agrees on, so they live in a shared `types/` directory rather than next to any one consumer.
|
||||
|
||||
## Features
|
||||
|
||||
- Discriminated `LogEventType` covering peer lifecycle, host election, session lifecycle, and state changes
|
||||
- Two-tier `LogSeverity` (`"critical"` / `"normal"`) drives replicator mode selection (sync for critical, async for normal)
|
||||
- `LogEntry.prevHash` is a hex string (not bytes) to keep equality checks straightforward and make the chain inspectable in logs
|
||||
- `LogEntry.hostSignature` is the raw `Uint8Array` Ed25519 signature over `entryHashBytes(entry)` so consumers do not pay for base64 round-trips
|
||||
- `LogCheckpoint` carries both `stateHash` (snapshot fingerprint) and `entryHashAtSeq` (chain anchor) so the server can validate the checkpoint without replaying the chain
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
LogEntry,
|
||||
LogEventType,
|
||||
} from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const entry: LogEntry = {
|
||||
seq: 1,
|
||||
timestamp: 1_700_000_000_000,
|
||||
meshId: "mesh-a",
|
||||
sessionId: null,
|
||||
eventType: "host-elected" satisfies LogEventType,
|
||||
severity: "critical",
|
||||
payload: { electedHost: "host-1", term: 7 },
|
||||
prevHash: "",
|
||||
hostId: "host-1",
|
||||
hostSignature: new Uint8Array(64),
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { LogCheckpoint } from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const checkpoint: LogCheckpoint = {
|
||||
seq: 1024,
|
||||
timestamp: Date.now(),
|
||||
meshId: "mesh-a",
|
||||
hostId: "host-1",
|
||||
stateHash: "abcd1234...",
|
||||
entryHashAtSeq: "ef567890...",
|
||||
signature: new Uint8Array(64),
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `LogEventType` — union of `"peer-joined" | "peer-left" | "host-elected" | "session-created" | "session-closed" | "state-update" | "policy-change"`.
|
||||
- `LogSeverity` — `"critical" | "normal"`.
|
||||
- `LogEntry` — append-only record with `seq`, `timestamp`, `meshId`, `sessionId`, `eventType`, `severity`, `payload`, `prevHash`, `hostId`, `hostSignature`.
|
||||
- `LogCheckpoint` — signed snapshot `{ seq, timestamp, meshId, hostId, stateHash, entryHashAtSeq, signature }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All fields are `readonly`. Mutation should never happen post-append; downstream replicators copy entries by reference.
|
||||
- `prevHash` of the suaveplan entry is the empty string (`""`). Hash chain validators must allow this exactly once per `(meshId, hostId)` chain.
|
||||
- `hostSignature` covers the SHA-256 of every other field on the entry, so flipping any bit (including `seq` or `prevHash`) breaks signature verification — the integrity checker relies on this.
|
||||
@@ -0,0 +1,33 @@
|
||||
export type LogEventType =
|
||||
| "peer-joined"
|
||||
| "peer-left"
|
||||
| "host-elected"
|
||||
| "session-created"
|
||||
| "session-closed"
|
||||
| "state-update"
|
||||
| "policy-change";
|
||||
|
||||
export type LogSeverity = "critical" | "normal";
|
||||
|
||||
export interface LogEntry {
|
||||
readonly seq: number;
|
||||
readonly timestamp: number;
|
||||
readonly meshId: string;
|
||||
readonly sessionId: string | null;
|
||||
readonly eventType: LogEventType;
|
||||
readonly severity: LogSeverity;
|
||||
readonly payload: unknown;
|
||||
readonly prevHash: string;
|
||||
readonly hostId: string;
|
||||
readonly hostSignature: Uint8Array;
|
||||
}
|
||||
|
||||
export interface LogCheckpoint {
|
||||
readonly seq: number;
|
||||
readonly timestamp: number;
|
||||
readonly meshId: string;
|
||||
readonly hostId: string;
|
||||
readonly stateHash: string;
|
||||
readonly entryHashAtSeq: string;
|
||||
readonly signature: Uint8Array;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# replication.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Describes the data the `LogReplicator` needs to pick replication targets and to route an entry through the mesh under the right delivery guarantees. The two-mode `ReplicationMode` axis is what bridges audit-mesh to the broader topology contract: critical events (host election, session creation) ship synchronously so the originating host has confirmed durability before acknowledging the action upstream; normal events (peer join/leave, state updates) ship asynchronously to keep the mesh fast.
|
||||
|
||||
These types are a thin shape contract — no logic lives here. The replicator implementation reads `ReplicationConfig.localTier` to compute `tier-aware` routing rules (Tier 0 to other Tier 0s + a Tier 1 child; Tier 1 to its Tier 0 parent + a sibling Tier 1).
|
||||
|
||||
## Features
|
||||
|
||||
- `ReplicationMode` discriminates the two delivery guarantees (`"sync"` blocks the caller until R replicas ack; `"async"` fires-and-forgets and surfaces failures via OTel + bus events).
|
||||
- `ReplicationTarget` carries the minimum information the replicator needs to make a routing decision: peer identity + tier (for cross-tier rules) + mesh.
|
||||
- `ReplicationConfig` is read once at plugin construction time and supplies the local host's identity and the replication factor.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ReplicationConfig,
|
||||
ReplicationTarget,
|
||||
} from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const config: ReplicationConfig = {
|
||||
replicationFactor: 2,
|
||||
localTier: 0,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "host-1",
|
||||
};
|
||||
|
||||
const candidate: ReplicationTarget = {
|
||||
hostId: "host-2",
|
||||
tier: 0,
|
||||
meshId: "mesh-b",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { LogReplicator } from "@suaveplan/network-plugin-audit-mesh";
|
||||
|
||||
const replicator = new LogReplicator({
|
||||
config: {
|
||||
replicationFactor: 3,
|
||||
localTier: 1,
|
||||
localMeshId: "mesh-a",
|
||||
localHostId: "subhost-c",
|
||||
},
|
||||
sendFn: async (target, entry) => {
|
||||
await network.send({ to: target.hostId, payload: entry });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ReplicationMode` — `"sync" | "async"`.
|
||||
- `ReplicationTarget` — `{ hostId: string; tier: number; meshId: string }`.
|
||||
- `ReplicationConfig` — `{ replicationFactor: number; localTier: number; localMeshId: string; localHostId: string }`. The `replicationFactor` is the `R` in "R-copy"; the entry is stored on `R + 1` hosts (originator + R replicas).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Replication factor is intentionally not bounded at the type level. Callers should validate at construction time (`>= 1`) — the `LogReplicator` raises `NET_AUDIT.REPLICATION_FAILED` if no candidates remain after filtering.
|
||||
- `tier` is a number rather than an enum so future N-tier topologies (Tier 2+) can extend without a breaking change.
|
||||
@@ -0,0 +1,14 @@
|
||||
export type ReplicationMode = "sync" | "async";
|
||||
|
||||
export interface ReplicationTarget {
|
||||
readonly hostId: string;
|
||||
readonly tier: number;
|
||||
readonly meshId: string;
|
||||
}
|
||||
|
||||
export interface ReplicationConfig {
|
||||
readonly replicationFactor: number;
|
||||
readonly localTier: number;
|
||||
readonly localMeshId: string;
|
||||
readonly localHostId: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as audit from "../src/index.js";
|
||||
|
||||
describe("@suaveplan/network-plugin-audit-mesh public surface", () => {
|
||||
it("exports every documented class", () => {
|
||||
expect(audit.SignedLog).toBeDefined();
|
||||
expect(audit.LogReplicator).toBeDefined();
|
||||
expect(audit.LogRetriever).toBeDefined();
|
||||
expect(audit.IntegrityChecker).toBeDefined();
|
||||
expect(audit.ConflictResolver).toBeDefined();
|
||||
expect(audit.LogCompaction).toBeDefined();
|
||||
expect(audit.AuditMeshPlugin).toBeDefined();
|
||||
});
|
||||
|
||||
it("exports the error registry", () => {
|
||||
expect(audit.NETWORK_AUDIT_CODES).toBeDefined();
|
||||
expect(audit.NetworkAuditError).toBeDefined();
|
||||
});
|
||||
|
||||
it("exports helper functions", () => {
|
||||
expect(typeof audit.bytesToHex).toBe("function");
|
||||
expect(typeof audit.entryHashBytes).toBe("function");
|
||||
expect(typeof audit.entryDigestHex).toBe("function");
|
||||
expect(typeof audit.checkpointSigningBytes).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"references": [{ "path": "./tsconfig.lib.json" }]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/network-plugin-audit-mesh",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/crypto-hash",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/network",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: [
|
||||
"src/__benchmarks__/**",
|
||||
"benchmarks/**",
|
||||
"e2e/**",
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user