first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,153 @@
# @suaveplan/network-discovery-gundb - Browser-tier GunDB DiscoveryStrategy: gossip-over-WebSocket peer discovery via Gun. Relay list REQUIRED — no public-relay fallback. Mandatory Ed25519-signed peer announces. Zod wire-payload validation. Constant-time peer-ID compare. AbortSignal + EventBusLike + ServiceRegistry + OTel + Zod boundaries.
## Summary
Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap`
chains. Wraps a [GunDB](https://gun.eco) instance — gossip-over-
WebSocket decentralised graph database — and exposes a deduped, capped
peer set to the bootstrap pipeline.
GunDB is flagged security-fragile upstream; this release tightens
every relevant attack surface.
<!-- begin:auto-docs -->
## Documentation tree
- [gundb-discovery](./src/core/gundb-discovery/gundb-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [gundb-discovery](./src/core/gundb-discovery/gundb-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [envelope-signing](./src/transport/envelope-signing.md) — Provides the cryptographic helpers used by `GunDBTransport` to sign every outgoing `MessageEnvelope` and verify every i…
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [gundb-discovery](./src/core/gundb-discovery/gundb-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope-signing](./src/transport/envelope-signing.md) — Provides the cryptographic helpers used by `GunDBTransport` to sign every outgoing `MessageEnvelope` and verify every i…
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [gundb-discovery](./src/core/gundb-discovery/gundb-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope-signing](./src/transport/envelope-signing.md) — Provides the cryptographic helpers used by `GunDBTransport` to sign every outgoing `MessageEnvelope` and verify every i…
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [gundb-discovery](./src/core/gundb-discovery/gundb-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope-signing](./src/transport/envelope-signing.md) — Provides the cryptographic helpers used by `GunDBTransport` to sign every outgoing `MessageEnvelope` and verify every i…
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [gundb-discovery](./src/core/gundb-discovery/gundb-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_ - [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_ - [envelope-signing](./src/transport/envelope-signing.md) — Provides the cryptographic helpers used by `GunDBTransport` to sign every outgoing `MessageEnvelope` and verify every i…
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_
## Why this package
- Plug-and-play `DiscoveryStrategy` slot for network bootstrap
chains. Works alongside `@suaveplan/network-discovery-peerjs`,
`@suaveplan/network-discovery-trystero`,
`@suaveplan/network-discovery-webtorrent`, and
`@suaveplan/network-discovery-crdt`.
- **Relay list REQUIRED.** Upstream's public-relay fallback
(`https://gun-manhattan.herokuapp.com/gun`) is removed. Callers must
supply their own relays.
- **Every outbound announce is Ed25519-signed.** The local peer's
32-byte secret is the contract input.
- **Every inbound announce is Zod-parsed AND Ed25519-verified before
admission.** Failures silently drop the payload and increment
`gundb.invalid_messages_total{reason}` /
`gundb.signature_rejected_total{reason}` — a malicious peer cannot
crash a node by gossiping garbage.
- **Constant-time peer-ID compare** via `constantTimeEqual` from
`@suaveplan/crypto-utils`.
- Injectable `gunFactory` — production wires the real `Gun(config)`;
tests bind a deterministic `GunNodeLike` fake (no `vi.mock` ever
used).
- Typed `EventBusLike<GunDBEventMap>` integration.
- Optional `@suaveplan/service-discovery` `ServiceRegistry` mirroring.
- OTel surface: `disc_gundb.start` span; counters
`peers_discovered_total{outcome}`,
`gundb.invalid_messages_total{reason}`,
`gundb.signature_rejected_total{reason}`,
`disc_gundb.relay_errors_total{relay}`; histogram
`gundb_announce_duration_ms`.
- `AbortSignal` honoured on every async public method.
- 100% test + branch + function + line coverage.
## Basic example
```ts
import { GunDBDiscovery, publicKeyHex } from "@suaveplan/network-discovery-gundb";
const signingKey = crypto.getRandomValues(new Uint8Array(32));
const localPeerId = publicKeyHex(signingKey);
const discovery = new GunDBDiscovery({
peers: ["wss://my-private-relay.example.com/gun"],
gunFactory: async (cfg) => {
const { default: Gun } = await import("gun");
return Gun({ peers: cfg.peers as string[] }) as unknown as GunNodeLike;
},
localPeerId,
signingKey,
});
await discovery.start();
const peers = await discovery.discover(5_000);
console.log(`found ${peers.length} peers`);
await discovery.stop();
```
## Advanced example
```ts
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import { DiscoveryChain } from "@suaveplan/network-bootstrap";
import {
GunDBDiscovery,
publicKeyHex,
type GunDBEventMap,
} from "@suaveplan/network-discovery-gundb";
const bus = new EventBus<GunDBEventMap>();
const registry = new ServiceRegistry();
const signingKey = crypto.getRandomValues(new Uint8Array(32));
bus.on("gundb:peer-discovered", ({ peer }) => {
console.log("peer:", peer.id, peer.addresses);
});
bus.on("gundb:relay-error", ({ reason }) => {
console.warn("relay:", reason);
});
const discovery = new GunDBDiscovery({
peers: [
"wss://relay-a.example.com/gun",
"wss://relay-b.example.com/gun",
],
room: "my-app/v1",
maxPeers: 256,
announceIntervalMs: 15_000,
gunFactory: async (cfg) => {
const { default: Gun } = await import("gun");
return Gun({
peers: cfg.peers as string[],
localStorage: cfg.localStorage,
}) as unknown as GunNodeLike;
},
localPeerId: publicKeyHex(signingKey),
signingKey,
bus,
registry,
});
const chain = new DiscoveryChain([discovery]);
const peers = await chain.discover(5_000);
```
## API
See sibling co-located markdown:
- [`src/types/types.md`](./src/types/types.md)
- [`src/errors/errors.md`](./src/errors/errors.md)
- [`src/otel/otel.md`](./src/otel/otel.md)
- [`src/signing/signing.md`](./src/signing/signing.md)
- [`src/core/gundb-discovery/gundb-discovery.md`](./src/core/gundb-discovery/gundb-discovery.md)
## License
MIT

View File

@@ -0,0 +1,198 @@
# gundb-discovery
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GunDBDiscovery` is the browser-tier `DiscoveryStrategy` for
`@suaveplan/network-bootstrap` chains. It wraps a single `Gun` instance
(constructed via the injected factory) and exposes a deduped, capped
peer set to the bootstrap pipeline. GunDB itself is a gossip-over-
WebSocket decentralised graph database; this strategy publishes the
local peer's announce into a room-scoped record, subscribes to the
room's child records, and lifts every fresh inbound announce into a
typed peer-record.
The strategy is the **single security-hardening layer** in the upstream
GunDB migration. Upstream's hardcoded public-relay defaults
(`https://gun-manhattan.herokuapp.com/gun` and friends) are gone — the
`peers` list is mandatory and non-empty, enforced at construction time
by Zod. Every outbound announce is Ed25519-signed by the local peer's
secret; every inbound announce is Zod-parsed AND Ed25519-verified
before it can enter the local peer set. Failures silently drop the
payload and increment the `gundb.invalid_messages_total{reason}` and
`gundb.signature_rejected_total{reason}` counters — a malicious peer
cannot crash a node by gossiping garbage, and operators alert on the
counters instead.
## Features
- Implements `DiscoveryStrategy` (`name`, `discover`, `isAvailable`)
so it slots directly into `@suaveplan/network-bootstrap`
`DiscoveryChain` instances.
- Lifecycle: idempotent `start()` / `stop()` / `dispose()`.
- Injectable `gunFactory` — production wires the real `Gun(config)`;
tests pass a deterministic in-memory `GunNodeLike` fake (no
`vi.mock` ever used).
- Zod-validated configuration. Relay list is non-optional and
`.min(1)`-enforced. The factory is enforced at runtime in addition
to schema-level acceptance. `signingKey` validated as exactly
32-byte `Uint8Array`.
- **Every outbound announce is Ed25519-signed** by `signingKey`.
- **Every inbound announce is Zod-parsed**
(`GunDBPeerAnnounceSchema`) AND signature-verified
(`verifyAnnounceSignature` over the canonical body).
- **Constant-time peer-ID compare** via `constantTimeEqual` from
`@suaveplan/crypto-utils` — short-circuiting byte-level compares
would leak a side channel.
- Typed `EventBusLike<GunDBEventMap>` integration: `started`,
`peer-discovered`, `peer-lost`, `relay-error`, `stopped`.
- Optional `@suaveplan/service-discovery` `ServiceRegistry`
integration — peers auto-register on join, auto-deregister on
drop and on `stop()`/`dispose()`.
- OTel instrumentation: `disc_gundb.start` span; counters
`disc_gundb.peers_discovered_total{outcome}`,
`gundb.invalid_messages_total{reason}`,
`gundb.signature_rejected_total{reason}`,
`disc_gundb.relay_errors_total{relay}`; histogram
`gundb_announce_duration_ms`.
- `AbortSignal` honoured on every async public method.
- Peer cap (`maxPeers`, default `100`) enforced at admission.
- Spoofing guard: an inbound announce whose `peerId` does not match
the GunDB record key it was published under is rejected as
`wrong-kind`.
## Usage: Basic Example
```ts
import { GunDBDiscovery } from "@suaveplan/network-discovery-gundb";
const signingKey = crypto.getRandomValues(new Uint8Array(32));
const discovery = new GunDBDiscovery({
peers: ["wss://my-private-relay.example.com/gun"],
gunFactory: async (cfg) => {
const { default: Gun } = await import("gun");
return Gun({ peers: cfg.peers as string[] }) as unknown as GunNodeLike;
},
localPeerId: "peer-local",
signingKey,
});
await discovery.start();
const peers = await discovery.discover(5_000);
console.log(`found ${peers.length} peers`);
await discovery.stop();
```
## Usage: Advanced Example
```ts
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import { DiscoveryChain } from "@suaveplan/network-bootstrap";
import {
GunDBDiscovery,
publicKeyHex,
type GunDBEventMap,
} from "@suaveplan/network-discovery-gundb";
const bus = new EventBus<GunDBEventMap>();
const registry = new ServiceRegistry();
const signingKey = crypto.getRandomValues(new Uint8Array(32));
const localPeerId = publicKeyHex(signingKey);
bus.on("gundb:peer-discovered", ({ peer }) => {
console.log("peer joined:", peer.id, peer.addresses);
});
bus.on("gundb:relay-error", ({ reason }) => {
console.warn("relay:", reason);
});
const discovery = new GunDBDiscovery({
peers: [
"wss://relay-a.example.com/gun",
"wss://relay-b.example.com/gun",
],
room: "my-app/v1",
maxPeers: 256,
announceIntervalMs: 15_000,
gunFactory: async (cfg) => {
const { default: Gun } = await import("gun");
return Gun({
peers: cfg.peers as string[],
localStorage: cfg.localStorage,
}) as unknown as GunNodeLike;
},
localPeerId,
signingKey,
bus,
registry,
});
const chain = new DiscoveryChain([discovery]);
const peers = await chain.discover(5_000);
```
## API Reference
- `new GunDBDiscovery(config, clock?)` — Zod-validates the config,
enforces a callable `gunFactory`, stores the resolved
configuration. Throws `NET_DISC_GUNDB.INVALID_OPTIONS` on
validation / missing factory / wrong-length signing key.
- `start(signal?)` — instantiates the Gun root via the factory,
scopes the peers node by room, subscribes to the room's children,
emits `started`. Throws `RELAY_FAILED` (with the underlying error
as `cause`) on factory failure; throws `DISPOSED` if disposed;
rethrows abort-error if signalled.
- `stop(signal?)` — idempotent. Detaches every subscription,
deregisters every peer from the registry, clears local state.
Emits `stopped` when the call wasn't aborted.
- `discover(timeoutMs, signal?)` — returns the current peer
snapshot mapped into `network-bootstrap` `PeerInfo[]`. Honours
signal up-front.
- `isAvailable(signal?)` — `true` only while running and not
disposed.
- `announce(peerInfo, signal?)` — signs a fresh
`GunDBPeerAnnounce` over `localPeerId` and publishes it into the
room. Also mirrors the local peer into the optional
`ServiceRegistry`. Throws `DISPOSED` / `NOT_RUNNING` when
appropriate; throws `RELAY_FAILED` if `put` rejects.
- `getPeers()` / `getPeerCount()` / `isRunning()` — synchronous
introspection.
- `dispose()` — permanent teardown; subsequent lifecycle calls
throw `DISPOSED`.
## Implementation Notes
- Inbound dispatch is a four-step pipeline: (1) skip-if-local
(constant-time compare); (2) Zod parse; (3) signature verify;
(4) admit / update / cap. Each step's failure mode has a
dedicated counter label so dashboards can separate protocol
malformations from cryptographic ones from spoofing attempts.
- `peerIdEquals` is the constant-time comparator. When the two
inputs differ in length, it pads to equal length and still calls
`constantTimeEqual` so the wall-clock cost is independent of the
shorter input.
- `classifyParseFailure` directly inspects the raw payload to tell
apart "unsigned" (missing `signature` / `signaturePublicKey`)
from "wrong-kind" (explicit non-`peer-announce` discriminator)
from "malformed" (anything else). This ensures the
signature_rejected counter increments correctly even when Zod's
failure path collapses both into a single rejection.
- The strategy never throws out of the inbound handler — a
malicious gossip neighbour must not be able to crash a node by
publishing garbage. Failures surface only via counters and the
optional `gundb:relay-error` bus event.
- The factory is `async`-tolerant so production code can
dynamically `import("gun")` (a Node-flavoured package with
`window` side-effects) without forcing this package itself to
bundle it.
- `dispose()` is idempotent; subsequent lifecycle calls throw
`DISPOSED`. `stop()` plus `dispose()` is the canonical shutdown
sequence, but `dispose()` alone is safe (it implies `stop()`).
- The `ServiceRegistry` registration id form is
`gundb:<room>:<peerId>` mirroring the WebTorrent / Trystero
family so a single `ServiceRegistry` watcher can see peers from
every discovery family without colliding.

View File

@@ -0,0 +1,108 @@
# errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Structured error registry for `@suaveplan/network-discovery-gundb`.
Registers eight typed codes via `defineCodes("NET_DISC_GUNDB", ...)` from
`@suaveplan/error`. The `NetDiscGunDBError` class extends
`SuaveplanError<NetDiscGunDBErrorCode>` so every error thrown out of the
package carries a stable `code` consumers can branch on, plus a
canonical `cause` chain and the i18n message-key contract used across
Suaveplan.
The wire-payload rejection codes (`UNSIGNED_ANNOUNCE`, `BAD_SIGNATURE`,
`INVALID_WIRE_PAYLOAD`) are deliberately **not** thrown out of the
inbound-message handler — a malicious peer must not be able to crash a
node by gossiping garbage. They are surfaced via the OTel
`gundb.invalid_messages_total{reason}` /
`gundb.signature_rejected_total{reason}` counters and the optional
`gundb:relay-error` bus event instead. The codes are still defined here
so callers can construct them in tests and so the reason labels stay
synchronized between the counters and the error registry.
## Features
- Eleven stable codes covering every public-API failure path plus the
three wire-payload drop reasons.
- `NetDiscGunDBError` extends `SuaveplanError<NetDiscGunDBErrorCode>` —
inherits `code`, `cause`, `messageKey`, and `.name`.
- Frozen const-tuple input to `defineCodes` so consumer code branching
on `error.code` gets full IntelliSense.
- `NetDiscGunDBErrorCode` value type derived from the const-tuple.
## Usage: Basic Example
```ts
import {
NET_DISC_GUNDB_CODES,
NetDiscGunDBError,
} from "@suaveplan/network-discovery-gundb";
try {
await discovery.start();
} catch (err) {
if (
err instanceof NetDiscGunDBError &&
err.code === NET_DISC_GUNDB_CODES.RELAY_FAILED
) {
// every relay was unreachable — fall through to the next
// strategy in the chain
}
}
```
## Usage: Advanced Example
```ts
import {
NET_DISC_GUNDB_CODES,
NetDiscGunDBError,
} from "@suaveplan/network-discovery-gundb";
function classify(err: unknown): string {
if (!(err instanceof NetDiscGunDBError)) return "unknown";
switch (err.code) {
case NET_DISC_GUNDB_CODES.RELAY_FAILED:
return "relay-unreachable";
case NET_DISC_GUNDB_CODES.DISPOSED:
return "post-dispose-call";
case NET_DISC_GUNDB_CODES.NOT_RUNNING:
return "before-start";
case NET_DISC_GUNDB_CODES.INVALID_OPTIONS:
return "bad-config";
case NET_DISC_GUNDB_CODES.PEER_CONNECT_FAILED:
return "peer-rejected";
default:
return "other";
}
}
```
## API Reference
- `NET_DISC_GUNDB_CODES` — frozen object exposing each code as both a
property key and a string value. Generated via `defineCodes`.
- `NetDiscGunDBErrorCode` — string-literal union of every code value.
- `NetDiscGunDBError` — concrete error class.
- `code: NetDiscGunDBErrorCode`
- `messageKey: string` — i18n key (e.g. `"errors.gundb.relay_failed"`).
- `cause?: unknown` — original error / structured info preserved.
- `name: "NetDiscGunDBError"` — for stack traces and `instanceof`-light
branch logic.
## Implementation Notes
- All public-API entry points use the same `NetDiscGunDBError` class —
consumers do not have to discriminate by `name`.
- `UNSIGNED_ANNOUNCE` / `BAD_SIGNATURE` / `INVALID_WIRE_PAYLOAD` are
listed in the registry so the counter `reason` labels and the error
codes use one canonical set of strings, which keeps the dashboards
aligned with the error taxonomy. They are not thrown out of the
message handler — see file-level comment.
- `defineCodes` enforces the `NET_DISC_GUNDB.` prefix at the registry
level so two packages can't collide on a bare code value.
- The class explicitly sets `this.name = "NetDiscGunDBError"` so
uglifier rename-passes don't corrupt the runtime brand.

View File

@@ -0,0 +1,115 @@
# otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry surface for `@suaveplan/network-discovery-gundb`. Exposes
one span wrapper (`withStartSpan`), four counters
(`disc_gundb.peers_discovered_total`,
`gundb.invalid_messages_total`, `gundb.signature_rejected_total`,
`disc_gundb.relay_errors_total`), and one histogram
(`gundb_announce_duration_ms`). All instruments resolve lazily via the
global OTel API so the package works with or without an SDK
registered.
The `gundb.invalid_messages_total{reason}` counter and the
`gundb.signature_rejected_total{reason}` counter are the
**security-observability lever** for the strategy — they are the only
external evidence of a misbehaving peer because the orchestrator
deliberately swallows inbound-message exceptions to keep nodes alive
under adversarial conditions. Operators MUST alert on non-zero rates
here.
## Features
- `withStartSpan(fn)` — wraps `start()` in `disc_gundb.start` with
guaranteed `span.end()` even on rejection. Attributes such as
`room` and `relay_count` are set by the caller inside the body.
- `recordPeersDiscovered(outcome, count?)` — increments
`disc_gundb.peers_discovered_total{outcome}`. Guards against
zero / negative counts.
- `recordInvalidMessage(reason)` — increments
`gundb.invalid_messages_total{reason}` where reason is one of the
literal labels `"malformed"`, `"wrong-kind"`, `"unsigned"`,
`"bad-signature"`.
- `recordSignatureRejection(reason)` — increments
`gundb.signature_rejected_total{reason}` where reason is
`"unsigned"` or `"bad-signature"`.
- `recordRelayError(url?)` — increments
`disc_gundb.relay_errors_total{relay}` with `"unknown"` fallback.
- `recordAnnounceDuration(ms)` — records a value into
`gundb_announce_duration_ms`. Negative inputs clamp to zero.
## Usage: Basic Example
```ts
import {
recordPeersDiscovered,
recordInvalidMessage,
} from "@suaveplan/network-discovery-gundb";
recordPeersDiscovered("found");
recordInvalidMessage("bad-signature");
```
## Usage: Advanced Example
```ts
import {
withStartSpan,
recordPeersDiscovered,
recordInvalidMessage,
recordSignatureRejection,
recordRelayError,
recordAnnounceDuration,
} from "@suaveplan/network-discovery-gundb";
await withStartSpan(async (span) => {
span.setAttribute("room", "my-app/v1");
span.setAttribute("relay_count", 2);
const start = performance.now();
try {
// ... publish announce ...
recordAnnounceDuration(performance.now() - start);
recordPeersDiscovered("found", 3);
} catch (err) {
recordRelayError("wss://relay.example.com/gun");
throw err;
}
});
// On the inbound side: caller dispatches a single rejection per
// dropped message.
recordInvalidMessage("wrong-kind");
recordSignatureRejection("unsigned");
recordSignatureRejection("bad-signature");
```
## API Reference
- `withStartSpan<T>(fn: (span) => Promise<T>): Promise<T>` — runs
`fn` inside a `disc_gundb.start` span; ends the span on both
resolution and rejection.
- `recordPeersDiscovered(outcome: "found" | "duplicate" | "rejected", count?: number): void`.
- `recordInvalidMessage(reason: "malformed" | "wrong-kind" | "unsigned" | "bad-signature"): void`.
- `recordSignatureRejection(reason: "unsigned" | "bad-signature"): void`.
- `recordRelayError(relay?: string): void` — `"unknown"` when omitted.
- `recordAnnounceDuration(ms: number): void` — negative clamped to 0.
## Implementation Notes
- Uses `@opentelemetry/api` only — no SDK lock-in. With no SDK
registered, every call is a no-op via the OTel noop
meter/tracer.
- The `reason` labels on both counters are deliberately bounded — the
package will never emit a free-form `reason` value, which prevents
cardinality blow-up on the metrics backend.
- `signature_rejected_total` and `invalid_messages_total` are kept
separate so dashboards can tell apart "garbage payload" from "valid
shape but bad crypto". An attacker probing the signature path
shows up only on the second counter.
- `gundb_announce_duration_ms` is a duration histogram; the bucket
layout is whatever the exporter / view configures.

View File

@@ -0,0 +1,84 @@
# GunDBPlugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GunDBPlugin` adapts `GunDBTransport` to the `NetworkPlugin` lifecycle that `@suaveplan/network` consumes. It is a thin three-method facade — `install` registers the transport with the node's router, `stop` closes the transport and unregisters it, `dispose` releases the node reference. There is no independent security surface; every wire-level operation is delegated to `GunDBTransport`, which signs every outbound envelope and verifies every inbound one. Treat the plugin as a registration shim, not a place to add new behaviour.
The plugin ships in the same package as the discovery strategy and the transport itself so a single signing key + Gun factory + relay set can power both peer discovery and message transport. Splitting the plugin into a separate package would have doubled the security surface for no design gain.
## Features
- Implements `NetworkPlugin` from `@suaveplan/network` (`install` / `start` / `stop` / `dispose`)
- Canonical plugin name `"gundb"`
- Constructs the transport eagerly so config errors surface at the plugin boundary
- `start()` is a no-op — Gun is opened lazily on first connect or send
- `stop()` calls `transport.close()` and unregisters from the node
- `dispose()` is idempotent
## Usage
### Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { NetworkNode } from "@suaveplan/network";
import {
GunDBPlugin,
publicKeyHex,
} from "@suaveplan/network-discovery-gundb";
const sk = ed25519.randomPrivateKey();
const node = new NetworkNode();
node.use(
new GunDBPlugin({
peers: ["https://relay.example.com/gun"],
gunFactory: myGunFactory,
localPeerId: publicKeyHex(sk),
signingKey: sk,
})
);
await node.start();
```
### Advanced Example
```ts
import { GunDBPlugin } from "@suaveplan/network-discovery-gundb";
const plugin = new GunDBPlugin({
peers,
gunFactory,
localPeerId,
signingKey: sk,
room: "suaveplan-transport",
});
const node = new NetworkNode();
node.use(plugin);
await node.start();
// Use the transport directly via the plugin handle.
plugin.transport.onMessage((peerId, envelope) => {
/* ... */
});
await node.stop();
```
## API Reference
- `new GunDBPlugin(config: GunDBTransportConfig)` — eagerly constructs the inner `GunDBTransport`. Throws `NetDiscGunDBError(INVALID_OPTIONS)` on bad config.
- `plugin.name: "gundb"`
- `plugin.transport: GunDBTransport` — public handle for tests + advanced wiring.
- `install(node): void` — registers the transport with the node's router.
- `start(): void` — no-op.
- `stop(): Promise<void>` — closes the transport and unregisters it.
- `dispose(): void` — releases the node reference; safe to call multiple times.
## Implementation Notes
- The plugin holds a single internal reference to the `NetworkNode` so `stop()` can call `unregisterTransport`. Reset to `null` in `dispose`.
- `stop()` is safe to call without an `install`: the optional-chained `this.node?.unregisterTransport` is a no-op when `node` is null. The transport itself is always closed.
- No security checks live here — they all live in `GunDBTransport`. Adding a "raw publish" or "skip-sign" API to this plugin would be a security regression.

View File

@@ -0,0 +1,156 @@
# signing
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Ed25519 signing + verification primitives for `GunDBPeerAnnounce`
payloads. Provides three exported helpers: `signAnnounce` builds a
signed announce from a private key + body; `verifyAnnounceSignature`
returns the cryptographic verdict over an inbound announce;
`publicKeyHex` derives the hex-encoded public key from a 32-byte
secret. Internally, both sign and verify funnel through one canonical
byte serializer (`canonicalSerializeAnnounceBody`) so the same
deterministic byte string is signed and verified — that is the entire
load-bearing invariant for the signature contract.
Every signed announce that crosses the gossip network MUST be
verifiable by the receiver. The signing key is the announcing peer's
own Ed25519 secret, and the verifying key is carried inside the
announce payload itself. This closes the trivial-spoofing hole
(anyone listening on the relay can no longer fabricate announces in
another peer's name) but does NOT establish higher-layer identity
trust — a separate trust-anchor mechanism is the consuming
application's responsibility.
## Features
- `signAnnounce(privateKey, body)` produces a complete
`GunDBPeerAnnounce` with both `signaturePublicKey` and `signature`
populated.
- `verifyAnnounceSignature(announce)` returns `true` iff the inbound
signature checks out over the canonical body. Returns `false` for
every other condition — malformed hex, wrong length, bad
signature.
- `canonicalSerializeAnnounceBody(body)` exposes the byte
representation that is signed / verified. Same bytes both
directions or the contract breaks.
- Deterministic JSON helper (`canonicalJSON`) sorts object keys
lexicographically at every level so two announce bodies with the
same logical content produce byte-identical canonical strings
regardless of runtime insertion order.
- Pure: depends only on `@suaveplan/crypto-curve` (Ed25519).
## Usage: Basic Example
```ts
import {
publicKeyHex,
signAnnounce,
} from "@suaveplan/network-discovery-gundb/signing";
const signingKey = crypto.getRandomValues(new Uint8Array(32));
const peerId = publicKeyHex(signingKey); // hex-encoded Ed25519 pub key
const announce = signAnnounce(signingKey, {
kind: "peer-announce",
peerId,
addresses: ["/webrtc/p2p/local"],
metadata: { region: "eu-west" },
capabilities: ["webrtc", "datachannel"],
lastSeen: Date.now(),
});
```
## Usage: Advanced Example
```ts
import {
signAnnounce,
verifyAnnounceSignature,
canonicalSerializeAnnounceBody,
} from "@suaveplan/network-discovery-gundb/signing";
import { ed25519 } from "@suaveplan/crypto-curve";
const sk = ed25519.randomPrivateKey();
const announce = signAnnounce(sk, {
kind: "peer-announce",
peerId: "local",
addresses: [],
metadata: {},
capabilities: [],
lastSeen: 1_700_000_000_000,
});
// Round-trip
const ok = verifyAnnounceSignature(announce); // → true
// Tampering breaks verification.
const tampered: typeof announce = {
...announce,
lastSeen: announce.lastSeen + 1,
};
verifyAnnounceSignature(tampered); // → false
// Single-bit flip in the signature also breaks verification.
const flipped: typeof announce = {
...announce,
signature:
announce.signature.slice(0, -1) +
(announce.signature.endsWith("0") ? "1" : "0"),
};
verifyAnnounceSignature(flipped); // → false
// Caller-facing canonical bytes (rarely needed; verifier uses it internally).
canonicalSerializeAnnounceBody({
kind: "peer-announce",
peerId: "local",
addresses: [],
metadata: {},
capabilities: [],
lastSeen: 1,
});
```
## API Reference
- `canonicalSerializeAnnounceBody(body: GunDBPeerAnnounceBody): Uint8Array`
— deterministic UTF-8 byte representation of the announce body. Field
order is fixed; nested objects are key-sorted at every level.
- `signAnnounce(privateKey: Uint8Array, body: GunDBPeerAnnounceBody): GunDBPeerAnnounce`
— throws if `privateKey.length !== 32`. Computes the public key from
the private key and signs the canonical bytes.
- `verifyAnnounceSignature(announce: GunDBPeerAnnounce): boolean` —
`true` iff every signature condition holds. Never throws.
- `publicKeyHex(privateKey: Uint8Array): string` — hex-encoded
Ed25519 public key derived from a 32-byte secret. Throws on wrong
length.
- `_hex.{toHex,fromHex}` — internal hex codec re-exposed so tests can
build malformed inputs without `node:buffer`.
## Implementation Notes
- The canonical line format is intentionally not valid JSON so callers
cannot accidentally substitute `JSON.stringify(body)` and silently
produce a non-deterministic, sometimes-verifiable signature. The
delimiter `|` is illegal in `kind` / hex `peerId` values so there is
no escaping ambiguity.
- Hex encoding uses lowercase `0-9a-f`; the Zod schema accepts either
case but the canonical output we sign / verify normalises to
lowercase. This is irrelevant to the contract because the hex
strings are never round-tripped through the canonical body — they
live only on the wire envelope.
- `verifyAnnounceSignature` never throws because it must be safe to
call on arbitrary attacker-controlled input. Every parse step
catches and returns `false`. The orchestrator distinguishes
"unsigned" (missing fields, caught at Zod time) from "bad-signature"
(verify returns `false`) at the call site.
- `ed25519.verify` is the noble-backed implementation from
`@suaveplan/crypto-curve` and itself returns `false` for invalid
inputs (it already catches internally), so the verifier here is a
thin orchestrator over the curve primitive.
- `signAnnounce` deliberately recomputes the public key from the
private key — callers cannot supply a mismatched (private, public)
pair, which would otherwise let a peer publish announces signed
with one key but verifiable with a different one.

View File

@@ -0,0 +1,61 @@
# envelope-signing
## Purpose
Provides the cryptographic helpers used by `GunDBTransport` to sign every outgoing `MessageEnvelope` and verify every inbound one. The transport adapter MUST go through these helpers — bypassing them would let a malicious relay or peer inject arbitrary messages into a node's inbox. The signing scheme is byte-identical in spirit to the peer-announce signing in `signing.ts`: a deterministic canonical serialization fed to Ed25519, with hex-encoded wire fields.
The canonical serializer sorts object keys lexicographically at every depth so any two impls produce the same bytes. Different bytes would silently allow signature forgery, so the canonicalization is the single most security-critical routine in this module.
## Features
- `signEnvelope(privateKey, senderPeerId, recipientPeerId, envelope) → SignedEnvelopeWire`
- `verifyEnvelopeSignature(wire) → boolean`
- `canonicalSerializeEnvelope(envelope) → Uint8Array`
- Hex-encoded public key + signature on the wire (64 / 128 chars)
- Returns `false` on every malformed input — never throws across the verify boundary
## Usage
### Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { createEnvelope } from "@suaveplan/network";
import {
signEnvelope,
verifyEnvelopeSignature,
} from "@suaveplan/network-discovery-gundb";
const sk = ed25519.randomPrivateKey();
const envelope = createEnvelope({
type: "ping",
senderId: "alice",
targetId: "bob",
payload: { hello: "world" },
});
const wire = signEnvelope(sk, "alice", "bob", envelope);
verifyEnvelopeSignature(wire); // true
```
### Advanced Example
A single-bit flip on the signature MUST cause `verifyEnvelopeSignature` to return `false`. The transport layer uses this to silently drop tampered envelopes and increment `gundb.signature_rejected_total{reason: "bad-signature"}`.
```ts
const wire = signEnvelope(sk, "alice", "bob", envelope);
const flippedLast = wire.signature.slice(0, -1) + "0";
const tampered = { ...wire, signature: flippedLast };
verifyEnvelopeSignature(tampered); // false — drop + count
```
## API Reference
- `canonicalSerializeEnvelope(envelope: MessageEnvelope): Uint8Array` — deterministic UTF-8 canonical form (sorted-keys JSON). Same envelope → same bytes regardless of insertion order.
- `signEnvelope(privateKey, senderPeerId, recipientPeerId, envelope) → SignedEnvelopeWire` — throws on non-32-byte keys.
- `verifyEnvelopeSignature(wire) → boolean` — returns `false` for malformed hex, wrong key/signature lengths, or verify rejection; never throws.
## Implementation Notes
The wire format is deliberately self-contained: `senderPeerId`, `recipientPeerId`, the envelope itself, the announcing public key, and the signature. The receiving transport cross-checks `senderPeerId` against `signaturePublicKey` so a peer cannot impersonate another by reusing a captured-but-still-valid envelope under a different identity. Recipient peerId is on the wire so a passive relay can route without inspecting the envelope.
Hex codecs are inlined (same as `signing.ts`) to keep the file dep-free beyond `@suaveplan/crypto-curve` and `@suaveplan/network`.

View File

@@ -0,0 +1,120 @@
# GunDBTransport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GunDBTransport` implements the `Transport` contract from `@suaveplan/network` over a GunDB relay set, reusing the very same Ed25519 signing key + canonical signing path that the discovery layer uses for peer-announces. The adapter is a thin facade — NOT a bypass — around the existing security primitives in this package: every outbound envelope is signed before it leaves the local process, and every inbound envelope is Zod-validated AND signature-verified before any caller-supplied `onMessage` handler is invoked. Drops are silent and counted; an attacker cannot crash a node by gossiping garbage.
This package was the natural home for a GunDB transport because the discovery package already wires the relay list, the Gun factory, and the signing key. Folding the transport into the same package keeps a single security surface and a single trust anchor; splitting would have meant duplicating signing/verifying code and a risk of drift.
## Security-preserving guarantee
**The transport adapter MUST NOT weaken the security layer.** Specifically:
- Every `send(peerId, envelope)` goes through `signEnvelope` — there is NO unsigned-message path.
- Every inbound payload goes through (1) `SignedEnvelopeWireSchema` Zod parse, (2) `verifyEnvelopeSignature` Ed25519 verify, (3) `senderPeerId == signaturePublicKey` cross-check, (4) `recipientPeerId == localPeerId` cross-check — BEFORE any caller handler is invoked.
- Drops are silent + counted. Reasons land on `gundb.invalid_messages_total{reason}` (malformed / wrong-kind) and `gundb.signature_rejected_total{reason}` (unsigned / bad-signature) — exactly the same counters the discovery layer uses for peer-announces.
If you find yourself wanting to add a "skip signing" or "skip verification" code path, STOP and ask for review — that is by construction a regression in this package's threat model.
## Features
- Full `Transport` interface (`connect` / `disconnect` / `send` / `onMessage` / `onPeerEvent` / `getConnectedPeers` / `close`)
- Ed25519 sign + verify on every envelope, mandatory and not toggleable
- Zod `SignedEnvelopeWireSchema` on every inbound payload — malformed shapes silently dropped + counted
- Constant-time peer-id compare via `@suaveplan/crypto-utils`
- AbortSignal-aware `send`
- Idempotent `connect` and `close`
- Lazy Gun-instance construction (factory called at most once per transport lifecycle)
- Inbox-key dedup so re-broadcasts from GunDB do not re-invoke handlers
- OTel surface: `gundb_transport.{connect,disconnect,send}` spans, `gundb_transport.messages_total{direction,outcome}` counter, `gundb_transport.active_peers` gauge
## Usage
### Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { createEnvelope } from "@suaveplan/network";
import {
GunDBTransport,
publicKeyHex,
} from "@suaveplan/network-discovery-gundb";
const sk = ed25519.randomPrivateKey();
const localPeerId = publicKeyHex(sk);
const transport = new GunDBTransport({
peers: ["https://relay.example.com/gun"],
gunFactory: (cfg) => myGunFactory(cfg),
localPeerId,
signingKey: sk,
});
transport.onMessage((peerId, envelope) => {
console.log("from", peerId, envelope);
});
await transport.connect("peer-2", "ignored-for-gundb");
await transport.send(
"peer-2",
createEnvelope({
type: "ping",
senderId: localPeerId,
targetId: "peer-2",
payload: {},
})
);
```
### Advanced Example
Pair with `GunDBDiscovery` so a single Gun + signing-key powers both peer discovery and message transport. Each plugin gets its own room namespace so the wire paths do not collide.
```ts
import {
GunDBDiscovery,
GunDBTransport,
} from "@suaveplan/network-discovery-gundb";
const discovery = new GunDBDiscovery({
peers,
gunFactory,
localPeerId,
signingKey: sk,
room: "suaveplan-discovery",
});
const transport = new GunDBTransport({
peers,
gunFactory,
localPeerId,
signingKey: sk,
room: "suaveplan-transport",
});
await discovery.start();
await transport.connect(remotePeerId, "ignored");
```
## API Reference
- `new GunDBTransport(config: GunDBTransportConfig)` — throws `NetDiscGunDBError(INVALID_OPTIONS)` on missing/empty `peers`, missing `gunFactory`, or non-32-byte `signingKey`.
- `transport.id: "gundb"` — canonical transport id used by `NetworkNode` to route messages.
- `connect(peerId, address): Promise<void>` — records the peer locally + emits `connected`. Throws `TRANSPORT_CLOSED` after `close()`.
- `disconnect(peerId): Promise<void>` — removes the peer + emits `disconnected`. No-op if unknown.
- `send(peerId, envelope, signal?): Promise<void>` — signs the envelope and writes it to the remote inbox. Throws `PEER_NOT_CONNECTED` / `TRANSPORT_CLOSED` / `SEND_FAILED` on failure paths.
- `onMessage(handler): () => void` — subscribe to verified inbound envelopes only.
- `onPeerEvent(handler): () => void` — subscribe to local connect/disconnect events.
- `getConnectedPeers(): readonly PeerId[]` — snapshot of currently connected peers.
- `close(): Promise<void>` — releases the inbox subscription and clears every peer + handler.
## Implementation Notes
- Wire format is `{kind: "envelope", senderPeerId, recipientPeerId, envelope, signaturePublicKey, signature}` — fully self-contained so no out-of-band context is required to verify integrity.
- `senderPeerId` is required to equal `signaturePublicKey` hex; this enforces a "peerId-from-key" contract that prevents key-substitution attacks where a captured-but-valid envelope is replayed under a different claimed identity.
- The Gun instance is opened lazily on first `connect` / `send` / `close` so callers can construct a transport without committing to a relay connection.
- `inboxKeys` deduplicates by message-id so the GunDB re-broadcast / replay-on-subscribe semantics never re-deliver the same envelope to a handler.
- Handler errors are swallowed so one bad subscriber cannot starve another — same policy as the discovery event bus.
- All security counters (`gundb.invalid_messages_total`, `gundb.signature_rejected_total`) increment from the transport path too, so dashboards do not need separate alerts for "discovery" vs "transport" abuse.

View File

@@ -0,0 +1,132 @@
# types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Public type surface for `@suaveplan/network-discovery-gundb`. Defines the
configuration contract (`GunDBDiscoveryConfig` /
`ResolvedGunDBDiscoveryConfig`), the wire-payload shapes that traverse
the gossip network (`GunDBMessage`, `GunDBPeerAnnounce`,
`GunDBPeerAnnounceBody`), the in-memory peer-record shape
(`GunDBPeerMetadata`), the structural Gun-instance contract
(`GunNodeLike` + `GunFactory`), and the typed event map
(`GunDBEventMap`) emitted on the optional `EventBusLike`.
These types are referenced by the orchestrator, the signing helpers, and
every test file. Zod schemas mirroring this surface live in
`./types.schemas.ts` and are the runtime contract enforced at every
boundary — both for caller-supplied configuration and for incoming wire
messages.
## Features
- `GunDBDiscoveryConfig` makes the **relay list mandatory and non-empty**
via Zod's `peers: z.array(z.string().url()).min(1)`. There is no public
default. Upstream's `https://gun-manhattan.herokuapp.com/gun` fallback
is gone.
- `gunFactory` is mandatory — no implicit `Gun(config)` construction.
- `signingKey` is a 32-byte `Uint8Array` (Ed25519 secret); every
outbound announce is signed over the canonical body.
- `GunDBPeerAnnounce` carries both `signaturePublicKey` and `signature`;
the body that is signed is the strict subset `GunDBPeerAnnounceBody`
(announce minus the two signature fields).
- Wire-payload validation is enforced via `GunDBMessageSchema`
(`z.discriminatedUnion("kind", ...)`) so future message kinds can
extend the union without rewriting the dispatcher.
- `GunNodeLike` is intentionally tiny — `get` / `put` / `map` / `on` /
`off` — so the deterministic in-memory `FakeGun` test fake covers the
full surface without `vi.mock`.
- `InvalidMessageReason` enumerates exactly the four reasons an inbound
payload may be silently dropped: `malformed`, `wrong-kind`,
`unsigned`, `bad-signature`. The orchestrator increments
`gundb.invalid_messages_total{reason}` for each.
## Usage: Basic Example
```ts
import type { GunDBDiscoveryConfig } from "@suaveplan/network-discovery-gundb";
const config: GunDBDiscoveryConfig = {
peers: ["wss://my-private-relay.example.com/gun"],
gunFactory: async (cfg) => {
const { default: Gun } = await import("gun");
return Gun({ peers: cfg.peers as string[] });
},
localPeerId: "peer-local",
signingKey: new Uint8Array(32), // ⚠ real callers must use a random key
};
```
## Usage: Advanced Example
```ts
import type {
GunDBDiscoveryConfig,
GunDBEventMap,
GunDBPeerMetadata,
} from "@suaveplan/network-discovery-gundb";
import { EventBus } from "@suaveplan/events";
const bus = new EventBus<GunDBEventMap>();
bus.on("gundb:peer-discovered", ({ peer }: { peer: GunDBPeerMetadata }) => {
console.log("joined:", peer.id, peer.addresses);
});
bus.on("gundb:relay-error", ({ reason }) => {
console.warn("relay:", reason);
});
const config: GunDBDiscoveryConfig = {
peers: ["wss://relay-a.example.com/gun", "wss://relay-b.example.com/gun"],
room: "my-app/v1",
maxPeers: 256,
peerTtlMs: 120_000,
announceIntervalMs: 15_000,
gunFactory: async (cfg) => {
const { default: Gun } = await import("gun");
return Gun({ peers: cfg.peers as string[], localStorage: cfg.localStorage });
},
localPeerId: "peer-local",
signingKey: crypto.getRandomValues(new Uint8Array(32)),
bus,
};
```
## API Reference
- `GunDBDiscoveryConfig` — input config. `peers` non-empty, `gunFactory`
callable, `localPeerId` non-empty, `signingKey` 32-byte Ed25519
secret. Optional: `room`, `localStorage`, `announceIntervalMs`,
`maxPeers`, `peerTtlMs`, `bus`, `registry`.
- `ResolvedGunDBDiscoveryConfig` — same shape with every optional field
defaulted. Exposed as `discovery.config`.
- `GunDBPeerMetadata` — in-memory peer record. Mutable `lastSeen`.
- `GunDBPeerAnnounce` — full wire shape including `signature` +
`signaturePublicKey`.
- `GunDBPeerAnnounceBody` — the subset that is canonically serialized
and signed.
- `GunDBMessage` — discriminated-union envelope of every wire kind.
- `GunNodeLike` — structural Gun-instance contract used by the
orchestrator and the `FakeGun` test fake.
- `GunFactory` / `GunFactoryConfig` — sync-or-async factory contract.
- `GunDBEventMap` — typed event map for the optional `EventBusLike`.
- `InvalidMessageReason` — `"malformed" | "wrong-kind" | "unsigned" |
"bad-signature"`. Drives the
`gundb.invalid_messages_total{reason}` counter.
## Implementation Notes
- Type-only — no runtime code. The runtime Zod boundary lives in
`./types.schemas.ts`.
- `signaturePublicKey` is hex-encoded (64 hex chars = 32 bytes) rather
than base64 to match the Ed25519 conventions used elsewhere in the
project and to keep the canonical signing input string-comparable.
- The `kind: "peer-announce"` discriminator is mandatory on every wire
payload. Future message kinds (heartbeat, leave) extend the
discriminated union; the orchestrator rejects anything with an
unknown `kind` as `wrong-kind`.
- `GunNodeLike` deliberately omits Gun's chained `set`/`time`/`map(cb)`
shapes — the strategy never uses them, so the test fake doesn't need
to implement them, and consumers can't accidentally lean on
behaviours we don't test.