Skip to content

Connect from the TypeScript client ​

The language-native client (@42ch/spoke-connect) implements the connect wire contract and session-core rules in TypeScript: peer_id derivation, Ed25519 hello signing, RFC 8785 JCS canonicalization, one-JSON-per-message WebSocket framing, and the pure session-core rules (allowlist, nonce, sequence, correlation, dispatch gate, capability tokens). It pairs with the platform WebSocket — no Rust runtime involved.

bash
pnpm add @42ch/[email protected]

Entry points:

  • . — the isomorphic core: identity, crypto, JCS, and session core. Works in browsers and Node.
  • ./node — the Node connectClient (depends on ws), which dials a WebSocket and completes the handshake.
  • ./noise — the opt-in Noise XX mesh transport subpath for direct libp2p-noise interoperability (see Noise transport subpath). Its dependencies load only when the subpath is imported, so the default . and ./node bundles stay thin.
  • ./remote — the opt-in RemoteAdapter module: connectRemoteAdapter (dial a remote peer over a consumer Transport), connectMultiPeerRouter, and the message-oriented Transport interface with the in-repo loopback pair (see RemoteAdapter over a Transport and Route across multiple peers).

Identity ​

ts
import { derivePeerIdFromEd25519Pubkey, ed25519PubkeyFromPeerId, getPublicKeyEd25519 } from "@42ch/spoke-connect";

const publicKey = getPublicKeyEd25519(seed);            // 32-byte Ed25519 public key
const peerId = derivePeerIdFromEd25519Pubkey(publicKey); // wire peer_id (base58btc)
const roundTrip = ed25519PubkeyFromPeerId(peerId);       // reverse derivation

The derivation formula is the protocol identity binding: protobuf PublicKey → identity multihash 0x00 → base58btc. Byte parity with the Rust reference and all native bindings is locked by shared golden vectors.

Crypto and JCS ​

ts
import { base64UrlEncode, signEd25519, verifyEd25519, webcryptoEd25519Available } from "@42ch/spoke-connect";

const bytes = new TextEncoder().encode("...");
const signature = await signEd25519(seed, bytes);
const ok = await verifyEd25519(publicKey, bytes, signature);

Ed25519 uses WebCrypto where available with an @noble/ed25519 fallback on the same code path (webcryptoEd25519Available() reports which path is active). Signatures are encoded base64url without padding (RFC 4648 §5).

canonicalHelloBytes(peerId, nonce, host) produces the RFC 8785 JCS bytes (RFC 8785) of the signed hello object {protocol_version, peer_id, nonce, host} — absent optional members are omitted from the canonical object.

Signed hello and replay protection ​

ts
import { generateNonce, signHelloEd25519, verifyHelloEd25519, NonceStore } from "@42ch/spoke-connect";

const nonce = generateNonce(); // 16 CSPRNG bytes, base64url
const hello = await signHelloEd25519(seed, nonce, manifest);

const store = new NonceStore();
store.checkAndRecord(remotePeerId, hello.nonce); // false when the (peer_id, nonce) pair was already accepted
await verifyHelloEd25519(remotePubkey, remotePeerId, hello);

The nonce floor is 16 characters; signHelloEd25519 enforces it (invalid_nonce). The NonceStore records only accepted hellos, so a hello rejected by an earlier gate stays retry-safe.

Allowlist and dispatch ​

ts
import { isAllowlisted, dispatchAllowed, requiredCapability, CAPABILITY_SPOKE_BASELINE } from "@42ch/spoke-connect";

isAllowlisted(["12D3KooW..."], peerId);         // fail-closed: empty allowlist rejects all
dispatchAllowed("check", ["spoke-baseline"]);   // core-op capability ⊆ negotiated capabilities
requiredCapability("check");                    // "spoke-baseline" for core ops; null for product ops

The dispatch gate maps core ops to required capabilities (upsert, promote, relate, check, assemble, project, compute) and fails closed on unknown ops.

Capability tokens ​

ts
import { issueCapabilityToken, verifyCapabilityToken, TOKEN_VERSION, CLOCK_SKEW_SECONDS } from "@42ch/spoke-connect";

const proof = await issueCapabilityToken(issuerSeed, {
  iss: issuerPeerId,            // derived from issuerSeed's public key
  sub: subjectPeerId,           // who may present the token
  aud: verifierPeerId,          // the verifying node's peer_id
  capabilities: ["spoke-baseline"],
  exp: Math.floor(Date.now() / 1000) + 60,
  iat: Math.floor(Date.now() / 1000),
});

const granted = await verifyCapabilityToken(
  proof,
  [issuerPeerId],               // trusted issuers (fail-closed)
  thisPeerId,                   // this node's peer_id (aud check)
  sessionPeerId,                // the authenticated session peer
  Math.floor(Date.now() / 1000),
);
// granted = the validated capability list for the dispatch gate

Capability tokens are offline-validated, capability-scoped grants: a trusted issuer signs a short claim set (iss / sub / aud / capabilities / exp, optional iat / jti) over JCS with Ed25519, and the proof rides the auth challenge/response exchange or per-invoke auth. Verification enforces issuer trust, subject/audience binding, expiry, and clock skew (CLOCK_SKEW_SECONDS).

Session state ​

ts
import { Session, negotiatedCapabilities, OutboundSequence, InboundSequence, checkResponseCorrelation } from "@42ch/spoke-connect";

const session = new Session({
  session_id: "sess_1",
  initiator_peer_id: localPeerId,
  responder_peer_id: remotePeerId,
  negotiated_capabilities: negotiatedCapabilities(localCaps, remoteCaps),
});

session.allocateOutboundSequence(); // 0, 1, 2, … — no wrap past 2^53−1

Per-session, per-direction sequence counters start at 0; exhaustion closes the session and opens a new one. Responses echo session_id / sequence / request_id — checkResponseCorrelation enforces the match. negotiatedCapabilities computes the agreed subset of both hosts' capability lists.

End-to-end with connectClient ​

The Node client performs the full flow — dial, signed hello exchange, session snapshot validation, correlated invokes:

ts
import { derivePeerIdFromEd25519Pubkey } from "@42ch/spoke-connect";
import { connectClient } from "@42ch/spoke-connect/node";

const client = await connectClient({
  url: "ws://127.0.0.1:8080",
  identity: { seed },
  manifest: {
    schema_version: 1,
    host_id: "host_primary",
    roles: ["data-store"],
    capabilities: ["spoke-baseline"],
    namespaces: ["toy_world"],
    extensions: {},
  },
  remotePubkey,
  allowlist: [derivePeerIdFromEd25519Pubkey(remotePubkey)],
});

const response = await client.invoke("check", { scope: { scope_id: "book-harbor" } });
client.close();

The client rejects before dialing when the remote peer id is missing from the allowlist, and every handshake and invoke await is bounded by timeoutMs (default 5000).

Browsers vs Node ​

The core imports (@42ch/spoke-connect) are browser-swappable — the Node client and its ws dependency stay behind the ./node subpath. Browser consumers import the core only and pair it with the native WebSocket.

Noise transport subpath ​

For direct libp2p-mesh secure transport, the language-native client offers an opt-in Noise subpath:

ts
import { NoiseXX, NoiseTransport, createNoiseStaticKeypair } from "@42ch/spoke-connect/noise";

@42ch/spoke-connect/noise is a pure-TS Noise XX stack — Noise_XX_25519_ChaChaPoly_SHA256 (X25519 + ChaCha20-Poly1305 + HKDF-SHA256) — wire-compatible with the rust-libp2p Noise reference. The static key is a real X25519 key, and handshake flights 2–3 carry a NoiseHandshakePayload that binds the static key to the SPOKE peer's long-term Ed25519 identity (signature over "noise-libp2p-static-key:" || static_public), matching what rust-libp2p peers expect.

The subpath loads its own dependencies (@noble/ciphers, @noble/curves); importing the core from . or ./node keeps the default bundle thin. The SPOKE connect hello and session-core rules run above the Noise transport.

RemoteAdapter and routing ​

The ./remote subpath turns a remote connect peer into a drop-in async BaselinePorts surface: connectRemoteAdapter dials through a consumer-implemented Transport (the package ships the interface and a test-only loopback pair), and connectMultiPeerRouter composes N registered adapters behind the same port surface with capability-based selection. See RemoteAdapter over a Transport and Route across multiple peers.

Peer-side interoperability ​

The TypeScript client speaks the same session-core rules as the Rust reference (spoke-connect on crates.io) and every native binding — see Connect wire reference for the shared contract. For a Rust-side peer, use cargo add spoke-connect and follow the two-node example in the crate README.

Next steps ​