Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ jobs:
working-directory: conformance
run: pnpm turbo run _test

ts-core-verify:
name: ts/packages/core Verify
ts-verify:
name: ts/ Verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
Expand All @@ -133,6 +133,10 @@ jobs:
working-directory: ts
run: pnpm turbo run _typecheck

- name: Build every package (cloudflare-hub's wrangler deploy --dry-run validates the Worker bundle without credentials)
working-directory: ts
run: pnpm turbo run _build

- name: Confirm generate.ts's output matches the committed generated schema
working-directory: ts/packages/core
run: |
Expand Down Expand Up @@ -192,7 +196,7 @@ jobs:

required-checks:
name: Required Checks
needs: [cddl-validate, conformance-verify, ts-core-verify, rust-verify]
needs: [cddl-validate, conformance-verify, ts-verify, rust-verify]
if: always()
runs-on: ubuntu-latest
steps:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ dist/
!ts/packages/core/dist/
!ts/packages/core/dist/**
rust/target/
.wrangler/
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ ts/

`ts/packages/core` exists: a ports/adapters implementation (Transport, Storage, Identity/crypto, and Clock as first-class ports) consuming Zod schemas generated from `spec/protocol.cddl` by [cddl.js](https://github.com/ExaDev/cddl.js), with real domain logic for handshake negotiation and capability-token verification (including the delegation-chain narrowing rules `tokens.cddl` documents); its `conformance-check` round-trips every vector in `conformance/`'s golden suite through the generated schemas.

`ts/packages/cloudflare-hub` exists: a reference Cloudflare Worker deployment of a public, always-on hub node, depending on core as an ordinary workspace consumer — Worker-shaped adapters for core's ports (WebSocket-message Transport, Web Crypto Identity with signature interop proven against core's Node adapter in both directions) rather than any reinvented protocol logic, serving the relay role with gossip-based pairing. CI validates the bundle with `wrangler deploy --dry-run`; a real deploy is an authenticated one-off, not part of CI.

`rust/` exists too, mirroring the same architecture: `wire-mesh-wire` carries the CDDL model as hand-written types over a minicbor codec with CDE (canonical) encoding by construction and strict decoding (unknown keys, indefinite lengths, and non-canonical shapes all rejected), and `wire-mesh-core` carries the ports (Transport, KeyValueStorage, Identity, Clock), the same domain logic (handshake negotiation, capability-token verification with narrowing/expiry-clamping and the signed-revocation obligations, including the ancestor-chain sweep), and Tokio TCP / in-memory / Ed25519+ES256 adapters. Its `conformance-check` binary proves every golden vector round-trips byte-exactly in both directions. There is no CDDL-to-Rust generator (cddl.js emits TypeScript/Zod only), so the wire types are hand-written against `spec/*.cddl` — the frozen vectors are the cross-language pin, exactly why they exist.

- **[Cascade](https://github.com/Mearman/cascade)** refactors its own hand-written protocol code onto `rust/` as an ordinary Cargo dependency, rather than maintaining a parallel implementation.
Expand Down
33 changes: 33 additions & 0 deletions ts/packages/cloudflare-hub/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# @exadev/wire-mesh-cloudflare-hub

A reference deployment of a wire-mesh hub node on Cloudflare Workers: a public, always-on node other peers dial into, serving the relay role the repo README names for this package (transport.cddl's `relay-offer`/`relay-connect`/`relay-data`/`relay-inbound` — an opaque byte pipe when two peers can't connect directly, with device discovery over gossip). It depends on `@exadev/wire-mesh-core` as an ordinary workspace consumer and reinvents nothing the core owns: all protocol logic is core's, reached through its ports.

## Why the hub lives in a Durable Object

A plain Worker's request context cannot host the hub: each connection is driven by a long-lived pull loop (`for await` over the `receive()` iteration, parked on a pure-JS waiter), and workerd's hang detection cancels any request whose promise chain parks that way — empirically, the original plain-Worker entry relayed zero frames across every run while looking alive (the socket-level listeners still fired; `wrangler deploy --dry-run` passed because bundling executes nothing). A Durable Object is the documented home for exactly this shape: its lifetime is tied to the accepted WebSockets rather than to a single fetch.

So the entrypoint (`src/worker.ts`) defines `RelayHubDurableObject` directly (wrangler resolves the binding against the entrypoint's own exports) — it owns the hub state, accepts the server side of the runtime's `WebSocketPair` on each upgrade, and drives the hub per connection, while the default export stays a thin router forwarding upgrades to the single named DO instance. The DO is SQLite-backed (hibernation-capable) per current wrangler guidance, though it keeps connections alive for its own lifetime rather than hibernating — see the deferrals below.

## How it maps onto core's ports

The ports architecture is what makes a Worker possible at all — Workers have no `net.Server`, no full `node:crypto`, no filesystem, so core's Node adapters can't run there and don't try to. This package supplies Worker-shaped implementations for the same contracts:

- **Connection** (`src/adapters/websocket-transport.ts`): WebSocket messages instead of TCP streams. Each binary WebSocket message is self-delimiting, so one message carries exactly one CBOR frame with no length prefix. Undecodable bytes and non-binary messages reject that connection; a decodable but schema-invalid frame drops without disconnecting — mirroring core's TCP adapter's split between connection-level and frame-level failure.
- **Identity** (`src/adapters/web-crypto-identity.ts`): Web Crypto (`crypto.subtle`) ECDSA P-256, deriving `device-id` as SHA-256 of the raw public-key bytes — never certificate DER. The test suite proves signature interop with core's Node identity adapter in both directions.

The hub domain logic itself (`src/hub.ts`) is deliberately thin and transport-agnostic (it runs unchanged over core's TCP adapter in tests): pairing `relay-connect` initiators with gossiped targets and forwarding `relay-data` both ways, tearing down both sides of a pairing when either endpoint re-pairs or disconnects, and moving a device's mapping when a newer gossip arrives on a fresher connection.

## What is real versus deferred

Real and tested: the WebSocket connection adapter (hostile-input behaviour included), the Web Crypto identity adapter with cross-adapter signature interop, the relay pairing logic — and the full entry path verified against the real workerd runtime, not just bundling: `pnpm dev` plus `node scripts/live-check.mjs` drives two genuine WebSocket clients through gossip → relay-connect → relay-inbound → bidirectional relay-data and asserts every hop, exiting non-zero and naming the failing step if the runtime ever regresses to the hang-cancellation behaviour. Unit tests additionally drive the hub over the real adapter, not only fakes.

Deferred deliberately:

- **Hibernation** — the DO keeps its connections alive for its own lifetime, which is correct but not the idle-cost optimum; the hibernation API (`state.acceptWebSocket` + `webSocketMessage` handlers) is the follow-up.
- **Raw TCP ingress** via `cloudflare:sockets` — the WebSocket ingress is the sound first pass; raw TCP is a follow-up adapter behind the same Connection contract.
- **The announcer role** (the second role the repo README names: `discovery.cddl`'s `mailboxes` — holding peers' handle-records as `core/data` entries) needs a Storage port adapter over KV or Durable Object storage.
- **A real deployment** — `wrangler deploy --dry-run --outdir=dist` (the `_build` task, and what CI runs) validates the bundle without Cloudflare credentials; an actual deploy needs `wrangler deploy` with an authenticated account and is not part of CI.

## Type environment

`src/` is pure Worker code and typechecks against `@cloudflare/workers-types` alone. The tests run in Node under vitest but import that src, so the test tsconfig loads both type packages — under which the `Buffer` global's overloads resolve as `any` for eslint, which is why the test helpers construct bytes from hex with a plain loop instead of `Buffer.from(hex, "hex")`.
36 changes: 36 additions & 0 deletions ts/packages/cloudflare-hub/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { exadevConfig } from "@exadev/eslint-config";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import globals from "globals";

export default exadevConfig(
{},
{
// scripts/ holds plain-JS operational scripts (the live-runtime check) with no TS project to type them against.
ignores: [
"dist",
"coverage",
"node_modules",
".turbo",
".wrangler",
"scripts",
],
},
{
languageOptions: {
parserOptions: {
project: ["./tsconfig.json", "./tsconfig.node.json"],
tsconfigRootDir: import.meta.dirname,
},
globals: { ...globals.node },
},
},
{
rules: {
"@typescript-eslint/consistent-type-imports": [
"error",
{ fixStyle: "inline-type-imports" },
],
},
},
eslintPluginPrettierRecommended,
);
37 changes: 37 additions & 0 deletions ts/packages/cloudflare-hub/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@exadev/wire-mesh-cloudflare-hub",
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.0",
"scripts": {
"build": "turbo run _build",
"_build": "wrangler deploy --dry-run --outdir=dist",
"dev": "wrangler dev",
"test": "turbo run _test",
"_test": "vitest run",
"typecheck": "turbo run _typecheck",
"_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit",
"lint": "turbo run _lint",
"_lint": "eslint . --fix --cache --max-warnings 0"
},
"dependencies": {
"@exadev/wire-mesh-core": "workspace:*",
"cbor2": "2.3.0"
},
"devDependencies": {
"@cloudflare/workers-types": "5.20260905.1",
"@exadev/eslint-config": "2.10.6",
"@types/node": "26.4.1",
"eslint": "10.10.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-prettier": "5.5.6",
"globals": "17.12.0",
"jiti": "2.7.0",
"prettier": "3.9.6",
"turbo": "2.10.12",
"typescript": "6.0.3",
"vitest": "5.0.0",
"wrangler": "4.42.0"
}
}
143 changes: 143 additions & 0 deletions ts/packages/cloudflare-hub/scripts/live-check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Live-runtime verification: drives the hub end to end against a real `wrangler dev` workerd process -- two genuine WebSocket clients exchanging gossip, relay-connect, and relay-data through the Durable Object. This is the check `wrangler deploy --dry-run` (the CI gate) cannot make: bundling executes nothing, and the original plain-Worker entry passed dry-run while never relaying a frame on the real runtime.
//
// Usage: start the dev server in one terminal (`pnpm dev`, serving on :8787), then `node scripts/live-check.mjs`. Exits non-zero naming the failing step.

import { encode, decode, cdeEncodeOptions, cdeDecodeOptions } from "cbor2";

const HUB_URL = "ws://localhost:8787/";
const SHA256_BYTE_LENGTH = 32;
const CONNECT_TIMEOUT_MS = 5000;
const FRAME_TIMEOUT_MS = 3000;
const GOSSIP_SETTLE_MS = 300;

const deviceA = new Uint8Array(SHA256_BYTE_LENGTH).fill(0x11);
const deviceB = new Uint8Array(SHA256_BYTE_LENGTH).fill(0x22);
const relayPayload = Uint8Array.from([0xde, 0xad, 0xbe, 0xef]);

function fail(step, detail) {
console.error(`FAIL [${step}]: ${detail}`);
process.exit(1);
}

function gossipFor(device) {
return {
type: "gossip",
peers: [
{
device,
addresses: ["203.0.113.5:4433"],
"snapshot-seconds": 1861833600,
},
],
};
}

function connect(name) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(HUB_URL);
ws.binaryType = "arraybuffer";
const timer = setTimeout(
() => reject(new Error(`${name}: connect/open timed out`)),
CONNECT_TIMEOUT_MS,
);
ws.addEventListener("open", () => {
clearTimeout(timer);
resolve(ws);
});
ws.addEventListener("error", () => {
clearTimeout(timer);
reject(new Error(`${name}: connection error`));
});
});
}

function sendFrame(ws, frame) {
ws.send(new Uint8Array(encode(frame, cdeEncodeOptions)));
}

function bytesEqual(a, b) {
return a.length === b.length && a.every((byte, i) => byte === b[i]);
}

/** Polls a per-client frame queue for the next frame of the expected type (other types stay queued). */
function waitFor(queue, expectedType) {
return new Promise((resolve, reject) => {
const started = Date.now();
const poll = () => {
const index = queue.findIndex((frame) => frame.type === expectedType);
if (index !== -1) {
resolve(queue.splice(index, 1)[0]);
return;
}
if (Date.now() - started > FRAME_TIMEOUT_MS) {
reject(
new Error(
`timed out waiting for ${expectedType}; queue holds ${JSON.stringify(queue.map((f) => f.type))}`,
),
);
return;
}
setTimeout(poll, 20);
};
poll();
});
}

function collectFrames(ws, queue) {
ws.addEventListener("message", (event) => {
queue.push(decode(new Uint8Array(event.data), cdeDecodeOptions));
});
}

const a = await connect("client A").catch((error) =>
fail("connect A", error.message),
);
const b = await connect("client B").catch((error) =>
fail("connect B", error.message),
);
const queueA = [];
const queueB = [];
collectFrames(a, queueA);
collectFrames(b, queueB);

sendFrame(a, gossipFor(deviceA));
sendFrame(b, gossipFor(deviceB));
await new Promise((resolve) => setTimeout(resolve, GOSSIP_SETTLE_MS));

sendFrame(a, { type: "relay-connect", "target-device": deviceB });
const inbound = await waitFor(queueB, "relay-inbound").catch((error) =>
fail("relay-inbound", error.message),
);
if (
inbound.type !== "relay-inbound" ||
!bytesEqual(new Uint8Array(inbound["source-device"]), deviceA)
) {
fail("relay-inbound", `unexpected frame: ${JSON.stringify(inbound)}`);
}

sendFrame(a, { type: "relay-data", payload: relayPayload });
sendFrame(b, { type: "relay-data", payload: relayPayload });
const toB = await waitFor(queueB, "relay-data").catch((error) =>
fail("a->b relay-data", error.message),
);
const toA = await waitFor(queueA, "relay-data").catch((error) =>
fail("b->a relay-data", error.message),
);
for (const [label, frame] of [
["a->b", toB],
["b->a", toA],
]) {
if (
frame.type !== "relay-data" ||
!bytesEqual(new Uint8Array(frame.payload), relayPayload)
) {
fail(`${label} relay-data`, `unexpected frame: ${JSON.stringify(frame)}`);
}
}

a.close();
b.close();
console.log(
"PASS: two real WebSocket clients relayed gossip -> relay-connect -> relay-inbound -> bidirectional relay-data through the Durable Object hub",
);
process.exit(0);
99 changes: 99 additions & 0 deletions ts/packages/cloudflare-hub/src/adapters/web-crypto-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// A Worker-runtime IdentityPort implementation using the standard Web Crypto API (globalThis.crypto.subtle), which exists identically in Cloudflare Workers and in Node >= 19 -- mirroring core's node-identity adapter's algorithm coverage (ES256 P-256 and Ed25519, the two algorithms identity-key.alg carries in the spec's own conformance vectors) so the two adapters are drop-in substitutes for each other behind the same port.

import type {
DeviceId,
IdentityKey,
} from "@exadev/wire-mesh-core/generated/protocol";
import type { IdentityPort } from "@exadev/wire-mesh-core/ports/identity";

const ES256 = -7;
const EDDSA = -8;

function algParams(alg: number): EcdsaParams | { name: "Ed25519" } {
if (alg === ES256) {
return { name: "ECDSA", hash: "SHA-256" };
}
if (alg === EDDSA) {
return { name: "Ed25519" };
}
throw new Error(`unsupported identity-key alg ${String(alg)}`);
}

/** Copies into a fresh, non-shared, whole-buffer Uint8Array -- Web Crypto's BufferSource parameters reject a view over a SharedArrayBuffer or a sub-range view, neither of which a caller-supplied Uint8Array is guaranteed not to be. */
function toBufferSource(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
return Uint8Array.from(bytes);
}

async function importPublicKey(key: IdentityKey): Promise<CryptoKey> {
if (key.alg === ES256) {
return crypto.subtle.importKey(
"raw",
toBufferSource(key["public-key"]),
{ name: "ECDSA", namedCurve: "P-256" },
false,
["verify"],
);
}
if (key.alg === EDDSA) {
return crypto.subtle.importKey(
"raw",
toBufferSource(key["public-key"]),
{ name: "Ed25519" },
false,
["verify"],
);
}
throw new Error(`unsupported identity-key alg ${String(key.alg)}`);
}

export async function deriveDeviceId(publicKey: Uint8Array): Promise<DeviceId> {
return new Uint8Array(
await crypto.subtle.digest("SHA-256", toBufferSource(publicKey)),
);
}

export async function verifyWithPublicKey(
key: IdentityKey,
message: Uint8Array,
signature: Uint8Array,
): Promise<boolean> {
const cryptoKey = await importPublicKey(key);
return crypto.subtle.verify(
algParams(key.alg),
cryptoKey,
toBufferSource(signature),
toBufferSource(message),
);
}

/** Generates a fresh ES256 (ECDSA P-256) identity for this node and builds an IdentityPort from it -- P-256 because it is the one curve Web Crypto's non-extractable key generation supports uniformly across the Worker runtime and Node, and ES256 is the algorithm the spec's own conformance vectors use for every token issuer. */
export async function createWebCryptoIdentity(): Promise<IdentityPort> {
const keyPair = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" },
false,
["sign", "verify"],
);
const publicKeyBytes = new Uint8Array(
await crypto.subtle.exportKey("raw", keyPair.publicKey),
);
const identityKey: IdentityKey = {
alg: ES256,
"public-key": publicKeyBytes,
};
const deviceId = await deriveDeviceId(publicKeyBytes);

return {
deviceId,
identityKey,
async sign(message) {
const signature = await crypto.subtle.sign(
algParams(ES256),
keyPair.privateKey,
toBufferSource(message),
);
return new Uint8Array(signature);
},
verify: verifyWithPublicKey,
deriveDeviceId,
};
}
Loading