Open your first connect session
This tutorial establishes a SPOKE Connect session end-to-end: derive your peer_id, sign a hello, verify the remote's signed hello against an allowlist, and invoke an op with correlation. It uses the TypeScript language-native client (@42ch/spoke-connect) against a local peer, and points to the Rust reference crate for the peer side.
Connect is the opt-in interaction envelope family (spoke-connect capability flag) for cross-process SPOKE hosts. You should have completed Install and create your first KnowledgeEntry first — this tutorial builds identity and session concepts on top of the data/ops story.
1. Install the client
pnpm add @42ch/[email protected]The package ships the isomorphic core, the Node connectClient, the Noise subpath, and the RemoteAdapter module; Connect from the TypeScript client documents each entry point and its helpers.
2. Derive your peer identity
Every connect host has an Ed25519 keypair. The wire peer_id is derived from the 32-byte public key — the identity multihash of the libp2p PublicKey protobuf, base58btc-encoded. The derivation is byte-identical across the TypeScript client, the Rust reference, and all native bindings (locked by shared golden vectors).
peer_id is the network trust root — distinct from the advisory host_id carried inside the manifest. Derive it with derivePeerIdFromEd25519Pubkey(getPublicKeyEd25519(seed)); the identity helpers are documented in Connect from the TypeScript client.
3. Sign and verify a hello
The handshake is a signed ConnectHello: each side signs a JCS-canonicalized object — {protocol_version, peer_id, nonce, host} for the initiator, that object plus peer_nonce (the initiator's nonce, the dial binding) for the responder — with its Ed25519 key, carried as base64url without padding.
import { generateNonce, signHelloEd25519, verifyHelloEd25519 } from "@42ch/spoke-connect";
import type { HostCapabilityManifest } from "@42ch/spoke-schemas";
const seed = new TextEncoder().encode("..."); // 32-byte Ed25519 seed
const manifest: HostCapabilityManifest = {
schema_version: 1,
host_id: "host_tutorial",
roles: ["input-source"],
capabilities: ["spoke-baseline"],
namespaces: ["tutorial"],
extensions: {},
};
const nonce = generateNonce(); // 16 CSPRNG bytes, base64url — ≥ the 16-char wire floor
const hello = await signHelloEd25519(seed, nonce, manifest);
// On the receiving side: verify against the sender's public key AND its
// derived peer id — a key that derives a different peer id cannot attest
// that peer's identity.
await verifyHelloEd25519(remotePubkey, remotePeerId, hello);Nonces are single-use per sender: the receiver records each accepted (peer_id, nonce) pair and rejects replays. The responder signs the same object with its own nonce plus the initiator's nonce (signHelloEd25519's fourth argument), and the initiator passes its own nonce into verification — so a captured responder hello cannot be replayed into a fresh dial. The full hello walkthrough — canonical bytes, the nonce floor, and the NonceStore replay guard — is in Connect from the TypeScript client.
4. Configure the allowlist
Admission is fail-closed: an empty allowlist rejects every peer. The receiving host accepts a connection only when the authenticated remote peer_id is listed.
import { isAllowlisted } from "@42ch/spoke-connect";
const allowlist = [remotePeerId];
if (!isAllowlisted(allowlist, remotePeerId)) {
throw new Error(`peer ${remotePeerId} is not allowlisted`);
}5. Sequence and correlation
Each session maintains per-direction monotonic sequence counters starting at 0. Every invoke attaches a request_id; the response must echo session_id, sequence, and request_id or the correlation check fails.
import { OutboundSequence, checkResponseCorrelation, correlationFromRequest, correlationFromResponse } from "@42ch/spoke-connect";
const outbound = new OutboundSequence();
const request = {
session_id: "sess_1",
sequence: outbound.allocate(), // first call → 0
request_id: crypto.randomUUID(),
op: "check",
payload: { scope: { scope_id: "book-harbor" } },
extensions: {},
};
// When the response arrives:
checkResponseCorrelation(correlationFromRequest(request), correlationFromResponse(response));6. Full session: connectClient
connectClient (from the ./node subpath) dials a WebSocket, performs the signed hello exchange, validates the session snapshot (peer binding, initial_sequence 0), and routes correlated invokes by request_id:
import { derivePeerIdFromEd25519Pubkey } from "@42ch/spoke-connect";
import { connectClient } from "@42ch/spoke-connect/node";
const remotePubkey = /* the peer's 32-byte Ed25519 public key */;
const client = await connectClient({
url: "ws://127.0.0.1:8080",
identity: { seed },
manifest,
remotePubkey,
allowlist: [derivePeerIdFromEd25519Pubkey(remotePubkey)],
});
const response = await client.invoke("check", { scope: { scope_id: "book-harbor" } });
client.close();The client rejects before the handshake starts when the remote peer id is missing from the allowlist, and rejects the session when the snapshot's peer ids do not match the authenticated hellos.
7. The peer side
connectClient connects to any SPOKE host that speaks the connect wire family over an ordered reliable stream. The Rust reference crate (spoke-connect on crates.io) is the reference host implementation — it maps the envelopes onto rust-libp2p (noise, yamux, request-response) and demonstrates a two-node session where one node dials the other, exchanges signed hellos, and invokes check:
cargo add [email protected]
cargo run -p spoke-connect --example two_node_usageThe compiled example source (examples/two_node_usage.rs) shows both sides: SpokeConnectNode::start with peer_allowlist and a local manifest, then connect(addr) and session.invoke("check", payload) — the same session rules the TypeScript client implements. The crate README (crates/spoke-connect/README.md) documents the full flow, including capability-token step-up auth.
What you now know
peer_idderivation from an Ed25519 public key, and why it is the trust root.- The signed hello (
spoke-connect-hello-jcs-v1): JCS over{protocol_version, peer_id, nonce, host}(initiator) / pluspeer_nonce(responder), Ed25519 signature, base64url, and the dial binding that rejects replayed responder hellos. - Fail-closed allowlist admission and single-use nonce replay protection.
- Per-session sequence and
request_idcorrelation.
Next steps
- Integrate a RemoteAdapter against a live host — implement a
Transport, dial withconnectRemoteAdapter, and call theBaselinePortssurface against the demo mock inference host. - Connect from the TypeScript client — the full client surface, browser vs Node, and core helpers.
- Connect from native bindings — the same session core from C#, Kotlin, Swift, Go, or Python.
- Connect wire reference — envelope field tables and identity binding rules.