first commit
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
115
docs/networking/network/network-discovery-gundb/src/otel/otel.md
Normal file
115
docs/networking/network/network-discovery-gundb/src/otel/otel.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user