wire-mesh-core 3.10.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ The TypeScript implementation of wire-mesh's protocol, built ports/adapters: dom
14
14
  - **Handshake negotiation** (`src/domain/handshake.ts`) -- protocol-version and capability-domain negotiation between two peers, the mechanism agent-comms issue #31 is fixed by.
15
15
  - **Capability-token verification** (`src/domain/tokens.ts`) -- the full chain tokens.cddl documents: COSE_Sign1 signature verification, the self-certifying issuer-key check (`sha256(issuer-key.public-key) == issuer`), expiry/not-before, issuer-matched revocation (only a token's own issuer's signed revocation-entry counts, checked across every ancestor in the delegation chain, not just the leaf), and recursive delegation-chain narrowing across all three axes of authority: a delegated token's issuer must be its parent's bearer (the chain is unbroken), its expiry must not exceed its parent's, and its scope must narrow its parent's (identical kind, equal-or-descendant path when the parent carries one) with an identical capability verb (the verb grammar has no sub-verb relation, so a different verb is different authority, not narrower). Also exports `verifyRevocationEntry` for ingesting gossiped revocation-announce frames: each entry is itself a signed, self-certifying COSE_Sign1 over revocation-claims, verified before it may enter the revocation view.
16
16
  - **Peer-advert verification** (`src/domain/peer-advert.ts`): the two checks transport.cddl states for a gossiped advert, applied by the relay hub before it registers or forwards one and by every session before one reaches its directory: the embedded identity-key self-certifies (`sha256(identity-key.public-key) == device`), and the signature verifies under that same key over the domain-separated canonical encoding of the advert with its signature removed, which covers the open extension tail as well. Deliberately not tokens.cddl's COSE Sig_structure: an advert's open tail lets an attacker craft one whose encoding also satisfies token-claims, so the prefixed construction keeps the two byte strings disjoint. Reaches a verdict for every input rather than throwing, since an advert's algorithm and key bytes are attacker-chosen.
17
- - **Secure channel** (`src/domain/secure-channel.ts`, `src/domain/relay-channels.ts`): the end-to-end channel spec/secure-channel.cddl defines between two devices that reach each other through a relay. A session opens one with each peer it addresses through a relay pairing, seals every manage-request and manage-response in it, drops any that arrives as plain relay-data, and attributes a request to the identity the peer's handshake proved rather than to the `from-device` a hub stamps. There is no plaintext fallback, so a peer that does not speak it cannot be reached through a relay.
17
+ - **Secure channel** (`src/domain/secure-channel.ts`, `src/domain/relay-channels.ts`): the end-to-end channel spec/secure-channel.cddl defines between two devices that reach each other through a relay. A session opens one with each peer it addresses through a relay pairing, seals every manage-request, manage-response and core/data frame in it, drops any that arrives as plain relay-data, and attributes a request to the identity the peer's handshake proved rather than to the `from-device` a hub stamps. There is no plaintext fallback, so a peer that does not speak it cannot be reached through a relay.
18
18
 
19
19
  ## Deliberately deferred
20
20
 
@@ -6,6 +6,7 @@ const require_gossip_extensions = require("../gossip-extensions-BBYgMZ2N.cjs");
6
6
  const require_domain_peer_advert = require("./peer-advert.cjs");
7
7
  const require_domain_async_queue = require("./async-queue.cjs");
8
8
  const require_domain_handshake = require("./handshake.cjs");
9
+ const require_domain_hub_mailbox = require("./hub-mailbox.cjs");
9
10
  const require_domain_ping_round_trips = require("./ping-round-trips.cjs");
10
11
  const require_domain_topology_snapshot = require("./topology-snapshot.cjs");
11
12
  //#region src/domain/relay-pairing.ts
@@ -326,6 +327,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
326
327
  const incomingQueue = require_domain_async_queue.createAsyncQueue();
327
328
  const revocationQueue = require_domain_async_queue.createAsyncQueue();
328
329
  const coordinatorQueue = require_domain_async_queue.createAsyncQueue();
330
+ const dataQueue = require_domain_async_queue.createAsyncQueue();
329
331
  const pingRoundTrips = require_domain_ping_round_trips.createPingRoundTrips();
330
332
  function snapshot() {
331
333
  return {
@@ -430,6 +432,16 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
430
432
  frame: opened.frame
431
433
  });
432
434
  applyManageResponse(opened.frame, opened.from);
435
+ } else if (opened !== void 0 && require_domain_hub_mailbox.isDataFrame(opened.frame)) {
436
+ frameLog.push({
437
+ direction: "received",
438
+ frame: opened.frame
439
+ });
440
+ dataQueue.push({
441
+ frame: opened.frame,
442
+ fromDevice: opened.from,
443
+ ...frame["to-device"] !== void 0 ? { toDevice: frame["to-device"] } : {}
444
+ });
433
445
  } else if (opened?.frame.type === "manage-request") {
434
446
  frameLog.push({
435
447
  direction: "received",
@@ -467,6 +479,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
467
479
  else if (frame.type === "manage-request") applyManageRequest(frame);
468
480
  else if (frame.type === "pong") pingRoundTrips.resolveOldest(clock.now());
469
481
  else if (frame.type === "revocation-announce") for (const entry of frame.entries) revocationQueue.push(entry);
482
+ else if (require_domain_hub_mailbox.isDataFrame(frame)) dataQueue.push({ frame });
470
483
  else if (frame.type === "coordinator") coordinatorQueue.push(frame);
471
484
  }
472
485
  /** Establishes a relay-connect pairing to targetDevice if this session isn't already paired with it -- a no-op when it already is, whether that pairing was established by this session's own prior relay-connect (initiator role) or learned from an incoming relay-inbound (target role, replying back to whoever dialed it). Pairing with a new target never tears down an existing pairing with a different one: this connection can hold several simultaneously (wire-mesh#30's own multiplexed adjacency map), so a later request back to an already-paired target must not re-send relay-connect for it. relay-connect has no ack frame: the initiator proceeds to relay-data right after sending it. */
@@ -635,6 +648,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
635
648
  incomingManageRequests: incomingQueue.stream,
636
649
  revocationAnnouncements: revocationQueue.stream,
637
650
  coordinatorFrames: coordinatorQueue.stream,
651
+ incomingDataFrames: dataQueue.stream,
638
652
  async connect(address, localDomains) {
639
653
  if (connection !== null) throw new Error("a session connects once; create a new one to reconnect");
640
654
  attempt = 0;
@@ -747,13 +761,14 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
747
761
  getTopologyPeers() {
748
762
  return topology.compute();
749
763
  },
750
- async sendDataFrame(frame) {
764
+ async sendDataFrame(frame, targetDevice) {
751
765
  requireConnectedLink();
766
+ if (targetDevice !== void 0) await ensureRelayPairing(targetDevice);
752
767
  frameLog.push({
753
768
  direction: "sent",
754
769
  frame
755
770
  });
756
- await transmit(frame, false);
771
+ await transmit(frame, targetDevice !== void 0, targetDevice);
757
772
  emit();
758
773
  },
759
774
  async close() {
@@ -44,6 +44,16 @@ export interface IncomingManageRequest {
44
44
  toDevice?: DeviceId;
45
45
  respond: (outcome: ManageOutcome) => Promise<void>;
46
46
  }
47
+ /** The core/data frames: the raw have, request and entries primitive data-sync.ts answers. */
48
+ export type DataDomainFrame = DataHaveFrame | DataRequestFrame | DataEntriesFrame;
49
+ /** One core/data frame this session received, surfaced for the application's replication policy to answer. */
50
+ export interface IncomingDataFrame {
51
+ frame: DataDomainFrame;
52
+ /** The device-id of the peer that sent this frame through a relay pairing, as the end-to-end secure channel it arrived through authenticated it (spec/secure-channel.cddl), never the `from-device` a hub stamps. Absent for a frame that arrived directly on the connection, whose sender is the connection's own peer. A reply goes back to this device through sendDataFrame's targetDevice. */
53
+ fromDevice?: DeviceId;
54
+ /** The device-id the frame's relay-data was addressed to, for a caller fronting several local devices behind one hub connection, exactly as IncomingManageRequest.toDevice. Present only for a relayed frame that carried one. */
55
+ toDevice?: DeviceId;
56
+ }
47
57
  export type HandshakeStatus = {
48
58
  status: "pending";
49
59
  } | {
@@ -75,6 +85,8 @@ export interface MeshSession {
75
85
  readonly events: AsyncIterable<SessionEvent>;
76
86
  /** Every `manage-request` received from the peer, in arrival order. */
77
87
  readonly incomingManageRequests: AsyncIterable<IncomingManageRequest>;
88
+ /** Every core/data frame received, direct or through a relay pairing, in arrival order. A relayed one arrives only when it opened under the secure channel of the device that sent it, and names that device in `fromDevice`. What a frame means, and what to answer, is data-sync.ts's and the application's business, so the session only delivers it. */
89
+ readonly incomingDataFrames: AsyncIterable<IncomingDataFrame>;
78
90
  /** Every `revocation-entry` received from the peer, in arrival order -- a `revocation-announce` frame's own `entries` array is flattened to one item per entry, since each entry is independently verifiable and independently meaningful regardless of which frame carried it. A consumer typically feeds each one into a RevocationView's own `record`. */
79
91
  readonly revocationAnnouncements: AsyncIterable<RevocationEntry>;
80
92
  /** Every `coordinator-frame` received from the peer, in arrival order: a gossiped, term-based claim to the rendezvous role (spec/transport.cddl), surfaced raw exactly like revocationAnnouncements because what a claim means is coordinator-election.ts's own business, not the session's. A consumer feeds each one into a CoordinatorElection's evaluate, and sends its own claims with sendCoordinatorClaim. */
@@ -103,8 +115,8 @@ export interface MeshSession {
103
115
  sendGossipUpdate: (extensions?: Record<string, unknown>) => Promise<void>;
104
116
  /** This session's own current topology snapshot -- the identical `topology/peers` value buildSelfAdvert merges into every gossiped self-advert (wire-mesh#180), read back directly rather than only from a possibly-stale gossiped copy elsewhere in the mesh. The live, cache-bust half of the cached-vs-live split `topology.get` itself establishes: a caller answering an incoming topology.get manage-request (see topology.ts's createTopologyGetHandler) calls this to build the response. Synchronous and side-effect-free -- unlike every other method here, it sends nothing and works even before this session has ever connected (an unconnected session simply has no direct peer and no relay pairings yet, both honestly empty). */
105
117
  getTopologyPeers: () => TopologyPeers;
106
- /** Sends one core/data frame (data-have, data-request, or data-entries) directly over this session's own connection, never relay-wrapped -- the transport half of an application's own noticeboard replication policy (data-sync.ts owns what the frames MEAN; this owns getting one onto the wire), the same layering sendRevocationAnnounce already established for its own frame kind. Rejects when not connected, exactly like every other send method here. */
107
- sendDataFrame: (frame: DataHaveFrame | DataRequestFrame | DataEntriesFrame) => Promise<void>;
118
+ /** Sends one core/data frame (data-have, data-request, or data-entries): the transport half of an application's own noticeboard replication policy (data-sync.ts owns what the frames MEAN; this owns getting one onto the wire), the same layering sendRevocationAnnounce already established for its own frame kind. Without targetDevice it goes directly over this session's own connection, never relay-wrapped, which is how a hub's mailbox is addressed. With targetDevice it is routed to that specific peer through a relay pairing, sealed on the secure channel with that device like a manage-request (spec/secure-channel.cddl), so two devices that reach each other only through a hub can run the data-domain exchange; the hub sees ciphertext and its mailbox is not involved. The peer's own frames come back through incomingDataFrames. Rejects when not connected, exactly like every other send method here. */
119
+ sendDataFrame: (frame: DataDomainFrame, targetDevice?: DeviceId) => Promise<void>;
108
120
  /** Sends a manage-request and resolves with the matching manage-response's outcome, correlated by request-id. When targetDevice is given, the request is routed to that specific peer via an established relay-connect pairing (wrapped as relay-data) rather than sent directly over this session's own Connection -- relay-hub deliberately drops manage-request/manage-response frames sent to it directly, since routing between two connected peers is not the relay role's business, so a specific peer reachable only through a relay hub can only be addressed this way. Absent, this sends directly over the Connection exactly as before. When token is given, it is attached to this one request instead of whatever setToken last set -- a single session routinely needs a different token per request when its peer shares more than one scope with this side (e.g. several core/room memberships over one connection), and a session-global token can only ever be correct for one of them. Absent, this request carries setToken's own session-global token exactly as before. When timeoutMs is given, the returned promise resolves with `{ result: "error", code: "timeout" }` rather than hanging forever if no manage-response arrives in time -- a held-open request (a human approval, a not-yet-online peer) otherwise has no way for the caller to give up on it. Absent, this request waits exactly as before, with no time limit of its own. */
109
121
  sendManageRequest: (command: ManageCommand, scope: Readonly<CapabilityScope>, targetDevice?: DeviceId, token?: CapabilityToken, timeoutMs?: number) => Promise<ManageOutcome>;
110
122
  close: () => Promise<void>;
@@ -44,6 +44,16 @@ export interface IncomingManageRequest {
44
44
  toDevice?: DeviceId;
45
45
  respond: (outcome: ManageOutcome) => Promise<void>;
46
46
  }
47
+ /** The core/data frames: the raw have, request and entries primitive data-sync.ts answers. */
48
+ export type DataDomainFrame = DataHaveFrame | DataRequestFrame | DataEntriesFrame;
49
+ /** One core/data frame this session received, surfaced for the application's replication policy to answer. */
50
+ export interface IncomingDataFrame {
51
+ frame: DataDomainFrame;
52
+ /** The device-id of the peer that sent this frame through a relay pairing, as the end-to-end secure channel it arrived through authenticated it (spec/secure-channel.cddl), never the `from-device` a hub stamps. Absent for a frame that arrived directly on the connection, whose sender is the connection's own peer. A reply goes back to this device through sendDataFrame's targetDevice. */
53
+ fromDevice?: DeviceId;
54
+ /** The device-id the frame's relay-data was addressed to, for a caller fronting several local devices behind one hub connection, exactly as IncomingManageRequest.toDevice. Present only for a relayed frame that carried one. */
55
+ toDevice?: DeviceId;
56
+ }
47
57
  export type HandshakeStatus = {
48
58
  status: "pending";
49
59
  } | {
@@ -75,6 +85,8 @@ export interface MeshSession {
75
85
  readonly events: AsyncIterable<SessionEvent>;
76
86
  /** Every `manage-request` received from the peer, in arrival order. */
77
87
  readonly incomingManageRequests: AsyncIterable<IncomingManageRequest>;
88
+ /** Every core/data frame received, direct or through a relay pairing, in arrival order. A relayed one arrives only when it opened under the secure channel of the device that sent it, and names that device in `fromDevice`. What a frame means, and what to answer, is data-sync.ts's and the application's business, so the session only delivers it. */
89
+ readonly incomingDataFrames: AsyncIterable<IncomingDataFrame>;
78
90
  /** Every `revocation-entry` received from the peer, in arrival order -- a `revocation-announce` frame's own `entries` array is flattened to one item per entry, since each entry is independently verifiable and independently meaningful regardless of which frame carried it. A consumer typically feeds each one into a RevocationView's own `record`. */
79
91
  readonly revocationAnnouncements: AsyncIterable<RevocationEntry>;
80
92
  /** Every `coordinator-frame` received from the peer, in arrival order: a gossiped, term-based claim to the rendezvous role (spec/transport.cddl), surfaced raw exactly like revocationAnnouncements because what a claim means is coordinator-election.ts's own business, not the session's. A consumer feeds each one into a CoordinatorElection's evaluate, and sends its own claims with sendCoordinatorClaim. */
@@ -103,8 +115,8 @@ export interface MeshSession {
103
115
  sendGossipUpdate: (extensions?: Record<string, unknown>) => Promise<void>;
104
116
  /** This session's own current topology snapshot -- the identical `topology/peers` value buildSelfAdvert merges into every gossiped self-advert (wire-mesh#180), read back directly rather than only from a possibly-stale gossiped copy elsewhere in the mesh. The live, cache-bust half of the cached-vs-live split `topology.get` itself establishes: a caller answering an incoming topology.get manage-request (see topology.ts's createTopologyGetHandler) calls this to build the response. Synchronous and side-effect-free -- unlike every other method here, it sends nothing and works even before this session has ever connected (an unconnected session simply has no direct peer and no relay pairings yet, both honestly empty). */
105
117
  getTopologyPeers: () => TopologyPeers;
106
- /** Sends one core/data frame (data-have, data-request, or data-entries) directly over this session's own connection, never relay-wrapped -- the transport half of an application's own noticeboard replication policy (data-sync.ts owns what the frames MEAN; this owns getting one onto the wire), the same layering sendRevocationAnnounce already established for its own frame kind. Rejects when not connected, exactly like every other send method here. */
107
- sendDataFrame: (frame: DataHaveFrame | DataRequestFrame | DataEntriesFrame) => Promise<void>;
118
+ /** Sends one core/data frame (data-have, data-request, or data-entries): the transport half of an application's own noticeboard replication policy (data-sync.ts owns what the frames MEAN; this owns getting one onto the wire), the same layering sendRevocationAnnounce already established for its own frame kind. Without targetDevice it goes directly over this session's own connection, never relay-wrapped, which is how a hub's mailbox is addressed. With targetDevice it is routed to that specific peer through a relay pairing, sealed on the secure channel with that device like a manage-request (spec/secure-channel.cddl), so two devices that reach each other only through a hub can run the data-domain exchange; the hub sees ciphertext and its mailbox is not involved. The peer's own frames come back through incomingDataFrames. Rejects when not connected, exactly like every other send method here. */
119
+ sendDataFrame: (frame: DataDomainFrame, targetDevice?: DeviceId) => Promise<void>;
108
120
  /** Sends a manage-request and resolves with the matching manage-response's outcome, correlated by request-id. When targetDevice is given, the request is routed to that specific peer via an established relay-connect pairing (wrapped as relay-data) rather than sent directly over this session's own Connection -- relay-hub deliberately drops manage-request/manage-response frames sent to it directly, since routing between two connected peers is not the relay role's business, so a specific peer reachable only through a relay hub can only be addressed this way. Absent, this sends directly over the Connection exactly as before. When token is given, it is attached to this one request instead of whatever setToken last set -- a single session routinely needs a different token per request when its peer shares more than one scope with this side (e.g. several core/room memberships over one connection), and a session-global token can only ever be correct for one of them. Absent, this request carries setToken's own session-global token exactly as before. When timeoutMs is given, the returned promise resolves with `{ result: "error", code: "timeout" }` rather than hanging forever if no manage-response arrives in time -- a held-open request (a human approval, a not-yet-online peer) otherwise has no way for the caller to give up on it. Absent, this request waits exactly as before, with no time limit of its own. */
109
121
  sendManageRequest: (command: ManageCommand, scope: Readonly<CapabilityScope>, targetDevice?: DeviceId, token?: CapabilityToken, timeoutMs?: number) => Promise<ManageOutcome>;
110
122
  close: () => Promise<void>;
@@ -5,6 +5,7 @@ import { a as OWN_VERSION, i as CORE_VERSION_GOSSIP_KEY, n as TOPOLOGY_PEERS_GOS
5
5
  import { signPeerAdvert, verifyPeerAdvert } from "./peer-advert.mjs";
6
6
  import { createAsyncQueue } from "./async-queue.mjs";
7
7
  import { negotiate } from "./handshake.mjs";
8
+ import { isDataFrame } from "./hub-mailbox.mjs";
8
9
  import { createPingRoundTrips } from "./ping-round-trips.mjs";
9
10
  import { createTopologySnapshotTracker } from "./topology-snapshot.mjs";
10
11
  //#region src/domain/relay-pairing.ts
@@ -325,6 +326,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
325
326
  const incomingQueue = createAsyncQueue();
326
327
  const revocationQueue = createAsyncQueue();
327
328
  const coordinatorQueue = createAsyncQueue();
329
+ const dataQueue = createAsyncQueue();
328
330
  const pingRoundTrips = createPingRoundTrips();
329
331
  function snapshot() {
330
332
  return {
@@ -429,6 +431,16 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
429
431
  frame: opened.frame
430
432
  });
431
433
  applyManageResponse(opened.frame, opened.from);
434
+ } else if (opened !== void 0 && isDataFrame(opened.frame)) {
435
+ frameLog.push({
436
+ direction: "received",
437
+ frame: opened.frame
438
+ });
439
+ dataQueue.push({
440
+ frame: opened.frame,
441
+ fromDevice: opened.from,
442
+ ...frame["to-device"] !== void 0 ? { toDevice: frame["to-device"] } : {}
443
+ });
432
444
  } else if (opened?.frame.type === "manage-request") {
433
445
  frameLog.push({
434
446
  direction: "received",
@@ -466,6 +478,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
466
478
  else if (frame.type === "manage-request") applyManageRequest(frame);
467
479
  else if (frame.type === "pong") pingRoundTrips.resolveOldest(clock.now());
468
480
  else if (frame.type === "revocation-announce") for (const entry of frame.entries) revocationQueue.push(entry);
481
+ else if (isDataFrame(frame)) dataQueue.push({ frame });
469
482
  else if (frame.type === "coordinator") coordinatorQueue.push(frame);
470
483
  }
471
484
  /** Establishes a relay-connect pairing to targetDevice if this session isn't already paired with it -- a no-op when it already is, whether that pairing was established by this session's own prior relay-connect (initiator role) or learned from an incoming relay-inbound (target role, replying back to whoever dialed it). Pairing with a new target never tears down an existing pairing with a different one: this connection can hold several simultaneously (wire-mesh#30's own multiplexed adjacency map), so a later request back to an already-paired target must not re-send relay-connect for it. relay-connect has no ack frame: the initiator proceeds to relay-data right after sending it. */
@@ -634,6 +647,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
634
647
  incomingManageRequests: incomingQueue.stream,
635
648
  revocationAnnouncements: revocationQueue.stream,
636
649
  coordinatorFrames: coordinatorQueue.stream,
650
+ incomingDataFrames: dataQueue.stream,
637
651
  async connect(address, localDomains) {
638
652
  if (connection !== null) throw new Error("a session connects once; create a new one to reconnect");
639
653
  attempt = 0;
@@ -746,13 +760,14 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
746
760
  getTopologyPeers() {
747
761
  return topology.compute();
748
762
  },
749
- async sendDataFrame(frame) {
763
+ async sendDataFrame(frame, targetDevice) {
750
764
  requireConnectedLink();
765
+ if (targetDevice !== void 0) await ensureRelayPairing(targetDevice);
751
766
  frameLog.push({
752
767
  direction: "sent",
753
768
  frame
754
769
  });
755
- await transmit(frame, false);
770
+ await transmit(frame, targetDevice !== void 0, targetDevice);
756
771
  emit();
757
772
  },
758
773
  async close() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wire-mesh-core",
3
- "version": "3.10.0",
3
+ "version": "3.11.0",
4
4
  "dependencies": {
5
5
  "cbor2": "2.3.0",
6
6
  "cddl.js": "1.0.1",