Initial Commit

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

View File

@@ -0,0 +1,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…

View File

@@ -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"
}
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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));
}

View File

@@ -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.

View File

@@ -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
);
}
});
});

View File

@@ -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),
};
}
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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";
}
}

View File

@@ -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";

View File

@@ -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.

View File

@@ -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
);
});
});

View File

@@ -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);
}
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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).

View File

@@ -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();
});
});

View File

@@ -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;
}
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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);
}
}

View File

@@ -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.

View File

@@ -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[];
}

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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");
});
});

View File

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

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

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

View File

@@ -0,0 +1,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:/,
],
},
},
});

View File

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