wire-mesh-core 3.3.1 → 3.5.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.
Files changed (53) hide show
  1. package/dist/adapters/byte-stream-connection.d.cts +1 -1
  2. package/dist/adapters/byte-stream-connection.d.mts +1 -1
  3. package/dist/adapters/tcp-transport.d.cts +1 -1
  4. package/dist/adapters/tcp-transport.d.mts +1 -1
  5. package/dist/adapters/threshold-identity.cjs +2 -2
  6. package/dist/adapters/threshold-identity.mjs +2 -2
  7. package/dist/adapters/tls-transport.d.cts +1 -1
  8. package/dist/adapters/tls-transport.d.mts +1 -1
  9. package/dist/domain/bulk.d.cts +1 -1
  10. package/dist/domain/bulk.d.mts +1 -1
  11. package/dist/domain/capability-grant.cjs +1 -1
  12. package/dist/domain/capability-grant.mjs +1 -1
  13. package/dist/domain/capability-request.cjs +1 -1
  14. package/dist/domain/capability-request.mjs +1 -1
  15. package/dist/domain/coordinator-election.cjs +81 -0
  16. package/dist/domain/coordinator-election.d.cts +44 -0
  17. package/dist/domain/coordinator-election.d.mts +44 -0
  18. package/dist/domain/coordinator-election.mjs +79 -0
  19. package/dist/domain/direct-manage-request.d.cts +1 -1
  20. package/dist/domain/direct-manage-request.d.mts +1 -1
  21. package/dist/domain/mesh-session.cjs +27 -5
  22. package/dist/domain/mesh-session.d.cts +6 -2
  23. package/dist/domain/mesh-session.d.mts +6 -2
  24. package/dist/domain/mesh-session.mjs +27 -5
  25. package/dist/domain/pinned-address.cjs +51 -0
  26. package/dist/domain/pinned-address.d.cts +15 -0
  27. package/dist/domain/pinned-address.d.mts +15 -0
  28. package/dist/domain/pinned-address.mjs +47 -1
  29. package/dist/domain/relay-advert.cjs +30 -0
  30. package/dist/domain/relay-advert.d.cts +13 -0
  31. package/dist/domain/relay-advert.d.mts +13 -0
  32. package/dist/domain/relay-advert.mjs +27 -0
  33. package/dist/domain/relay-hub.d.cts +1 -1
  34. package/dist/domain/relay-hub.d.mts +1 -1
  35. package/dist/domain/relay-use-gate.d.cts +1 -1
  36. package/dist/domain/relay-use-gate.d.mts +1 -1
  37. package/dist/domain/revocation-view.cjs +1 -1
  38. package/dist/domain/revocation-view.mjs +1 -1
  39. package/dist/domain/room-token-verification.cjs +1 -1
  40. package/dist/domain/room-token-verification.mjs +1 -1
  41. package/dist/domain/threshold-subject.cjs +1 -1
  42. package/dist/domain/threshold-subject.mjs +1 -1
  43. package/dist/domain/tokens.cjs +1 -1
  44. package/dist/domain/tokens.mjs +1 -1
  45. package/dist/domain/webrtc-signaling.cjs +1 -1
  46. package/dist/domain/webrtc-signaling.mjs +1 -1
  47. package/dist/ports/transport.d.cts +1 -1
  48. package/dist/ports/transport.d.mts +1 -1
  49. package/dist/{transport-iLBxIkRS.d.mts → transport-B0GywEzG.d.mts} +2 -0
  50. package/dist/{transport-CfW7fsGK.d.cts → transport-DIXjoQKn.d.cts} +2 -0
  51. package/package.json +9 -1
  52. package/dist/{tokens-B0dpTLhS.mjs → tokens-CMdWiRBb.mjs} +1 -1
  53. package/dist/{tokens-Dh6mG6p_.cjs → tokens-DSG3SDr8.cjs} +1 -1
@@ -1,5 +1,5 @@
1
1
  import { N as Frame } from "../protocol-DCDae3z4.cjs";
2
- import { t as Connection } from "../transport-CfW7fsGK.cjs";
2
+ import { t as Connection } from "../transport-DIXjoQKn.cjs";
3
3
  //#region src/adapters/byte-stream-connection.d.ts
4
4
  /** The largest frame body a peer may announce in a length prefix, matching the 100 MiB the `ws` package applies to one message on the same hub, so a WebSocket peer and a byte-stream peer are bounded alike. Without a bound a peer-controlled 4 GiB prefix makes the reader buffer until memory runs out. */
5
5
  export declare const MAX_FRAME_BYTES: number;
@@ -1,5 +1,5 @@
1
1
  import { N as Frame } from "../protocol-DCDae3z4.mjs";
2
- import { t as Connection } from "../transport-iLBxIkRS.mjs";
2
+ import { t as Connection } from "../transport-B0GywEzG.mjs";
3
3
  //#region src/adapters/byte-stream-connection.d.ts
4
4
  /** The largest frame body a peer may announce in a length prefix, matching the 100 MiB the `ws` package applies to one message on the same hub, so a WebSocket peer and a byte-stream peer are bounded alike. Without a bound a peer-controlled 4 GiB prefix makes the reader buffer until memory runs out. */
5
5
  export declare const MAX_FRAME_BYTES: number;
@@ -1,4 +1,4 @@
1
- import { r as Transport } from "../transport-CfW7fsGK.cjs";
1
+ import { r as Transport } from "../transport-DIXjoQKn.cjs";
2
2
  //#region src/adapters/tcp-transport.d.ts
3
3
  /** A Node net.Socket-based Transport: length-prefixed, CBOR-encoded frames over plain TCP -- matching Cascade's own transport shape, since interop with Cascade nodes is wire-mesh's stated goal. Framing (not TLS) is this adapter's own concern; a TLS-terminated variant is a separate adapter behind the same Transport contract. */
4
4
  export declare function createTcpTransport(): Transport;
@@ -1,4 +1,4 @@
1
- import { r as Transport } from "../transport-iLBxIkRS.mjs";
1
+ import { r as Transport } from "../transport-B0GywEzG.mjs";
2
2
  //#region src/adapters/tcp-transport.d.ts
3
3
  /** A Node net.Socket-based Transport: length-prefixed, CBOR-encoded frames over plain TCP -- matching Cascade's own transport shape, since interop with Cascade nodes is wire-mesh's stated goal. Framing (not TLS) is this adapter's own concern; a TLS-terminated variant is a separate adapter behind the same Transport contract. */
4
4
  export declare function createTcpTransport(): Transport;
@@ -1,7 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_domain_threshold_subject = require("../domain/threshold-subject.cjs");
3
- const require_adapters_threshold_wasm = require("./threshold-wasm.cjs");
4
2
  const require_adapters_node_identity = require("./node-identity.cjs");
3
+ const require_adapters_threshold_wasm = require("./threshold-wasm.cjs");
4
+ const require_domain_threshold_subject = require("../domain/threshold-subject.cjs");
5
5
  //#region src/adapters/threshold-identity.ts
6
6
  const ALG_ED25519 = -8;
7
7
  const FIRST_SESSION_ID = 1n;
@@ -1,6 +1,6 @@
1
- import { toBeSigned } from "../domain/threshold-subject.mjs";
2
- import { signingAggregate, signingBuildPackage } from "./threshold-wasm.mjs";
3
1
  import { deriveDeviceId, verifyWithPublicKey } from "./node-identity.mjs";
2
+ import { signingAggregate, signingBuildPackage } from "./threshold-wasm.mjs";
3
+ import { toBeSigned } from "../domain/threshold-subject.mjs";
4
4
  //#region src/adapters/threshold-identity.ts
5
5
  const ALG_ED25519 = -8;
6
6
  const FIRST_SESSION_ID = 1n;
@@ -1,4 +1,4 @@
1
- import { r as Transport } from "../transport-CfW7fsGK.cjs";
1
+ import { r as Transport } from "../transport-DIXjoQKn.cjs";
2
2
  //#region src/adapters/tls-transport.d.ts
3
3
  export interface TlsIdentity {
4
4
  certificatePem: string;
@@ -1,4 +1,4 @@
1
- import { r as Transport } from "../transport-iLBxIkRS.mjs";
1
+ import { r as Transport } from "../transport-B0GywEzG.mjs";
2
2
  //#region src/adapters/tls-transport.d.ts
3
3
  export interface TlsIdentity {
4
4
  certificatePem: string;
@@ -1,5 +1,5 @@
1
1
  import { D as DeviceId, N as Frame, V as ManageCommand, f as CapabilityScope, p as CapabilityToken } from "../protocol-DCDae3z4.cjs";
2
- import { t as Connection } from "../transport-CfW7fsGK.cjs";
2
+ import { t as Connection } from "../transport-DIXjoQKn.cjs";
3
3
  import { t as KeyValueStorage } from "../storage-B403CRyu.cjs";
4
4
  import { IncomingManageRequest, MeshSession } from "./mesh-session.cjs";
5
5
  //#region src/domain/bulk.d.ts
@@ -1,5 +1,5 @@
1
1
  import { D as DeviceId, N as Frame, V as ManageCommand, f as CapabilityScope, p as CapabilityToken } from "../protocol-DCDae3z4.mjs";
2
- import { t as Connection } from "../transport-iLBxIkRS.mjs";
2
+ import { t as Connection } from "../transport-B0GywEzG.mjs";
3
3
  import { t as KeyValueStorage } from "../storage-B403CRyu.mjs";
4
4
  import { IncomingManageRequest, MeshSession } from "./mesh-session.mjs";
5
5
  //#region src/domain/bulk.d.ts
@@ -1,7 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_generated_protocol = require("../generated/protocol.cjs");
3
3
  const require_token_scope = require("../token-scope-CxHXTT3u.cjs");
4
- const require_tokens = require("../tokens-Dh6mG6p_.cjs");
4
+ const require_tokens = require("../tokens-DSG3SDr8.cjs");
5
5
  //#region src/domain/capability-grant.ts
6
6
  /**
7
7
  * The generic, capability-agnostic capability-grant primitive (wire-mesh#117, spec/management.cddl) -- the unsolicited push counterpart to capability-request.ts's own ask/response primitive. Where capability-request lifts core/room's room.join shape (a pull: the requester asks, the owner mints and returns a grant on the same response) out of that one domain, capability-grant lifts room.invite's own shape (a push: the owner mints unprompted and delivers the grant in the request itself, since there is no approval round-trip to carry it back) the same way. core/room's own room-client.ts is the reference consumer: it builds sendRoomInvite as a thin wrapper over sendCapabilityGrant below, and wires createRoomRouter's dispatch onto createCapabilityGrantHandler for the receiving side.
@@ -1,6 +1,6 @@
1
1
  import { capabilityGrantSchema } from "../generated/protocol.mjs";
2
2
  import { n as scopeNarrows } from "../token-scope-Z4bmci4M.mjs";
3
- import { a as verifyCapabilityToken } from "../tokens-B0dpTLhS.mjs";
3
+ import { a as verifyCapabilityToken } from "../tokens-CMdWiRBb.mjs";
4
4
  //#region src/domain/capability-grant.ts
5
5
  /**
6
6
  * The generic, capability-agnostic capability-grant primitive (wire-mesh#117, spec/management.cddl) -- the unsolicited push counterpart to capability-request.ts's own ask/response primitive. Where capability-request lifts core/room's room.join shape (a pull: the requester asks, the owner mints and returns a grant on the same response) out of that one domain, capability-grant lifts room.invite's own shape (a push: the owner mints unprompted and delivers the grant in the request itself, since there is no approval round-trip to carry it back) the same way. core/room's own room-client.ts is the reference consumer: it builds sendRoomInvite as a thin wrapper over sendCapabilityGrant below, and wires createRoomRouter's dispatch onto createCapabilityGrantHandler for the receiving side.
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_generated_protocol = require("../generated/protocol.cjs");
3
- const require_tokens = require("../tokens-Dh6mG6p_.cjs");
3
+ const require_tokens = require("../tokens-DSG3SDr8.cjs");
4
4
  //#region src/domain/capability-request.ts
5
5
  /**
6
6
  * The generic, capability-agnostic capability-request / capability-grant-ok primitive (wire-mesh#78, spec/management.cddl). Lifts core/room's own room.join shape -- an ungated ask, held open, a human (or any other domain-supplied decision) accepts or refuses, acceptance mints and returns a token on the same response -- out of that one domain so any capability can reuse it, not just room:member. core/room's own room-client.ts is the reference consumer: it rewires requestToJoin/createRoomRouter's room.join handling onto requestCapability/createCapabilityRequestHandler below, supplying room-specific extension fields (its member list) through this module's own extensions mechanism rather than this module knowing anything about rooms.
@@ -1,5 +1,5 @@
1
1
  import { capabilityGrantOkSchema, capabilityRequestSchema } from "../generated/protocol.mjs";
2
- import { n as mintCapabilityToken } from "../tokens-B0dpTLhS.mjs";
2
+ import { n as mintCapabilityToken } from "../tokens-CMdWiRBb.mjs";
3
3
  //#region src/domain/capability-request.ts
4
4
  /**
5
5
  * The generic, capability-agnostic capability-request / capability-grant-ok primitive (wire-mesh#78, spec/management.cddl). Lifts core/room's own room.join shape -- an ungated ask, held open, a human (or any other domain-supplied decision) accepts or refuses, acceptance mints and returns a token on the same response -- out of that one domain so any capability can reuse it, not just room:member. core/room's own room-client.ts is the reference consumer: it rewires requestToJoin/createRoomRouter's room.join handling onto requestCapability/createCapabilityRequestHandler below, supplying room-specific extension fields (its member list) through this module's own extensions mechanism rather than this module knowing anything about rooms.
@@ -0,0 +1,81 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/domain/coordinator-election.ts
3
+ /** Bytewise "lowest device-id" comparison, the equal-term tiebreak the spec names: shorter is lower when one id is a prefix of the other, and equal ids compare equal. Device-ids are a fixed 32 bytes today, so the prefix branch is completeness rather than a live case. */
4
+ function compareDeviceIds(a, b) {
5
+ const length = Math.min(a.length, b.length);
6
+ for (let i = 0; i < length; i++) {
7
+ const left = a[i];
8
+ const right = b[i];
9
+ if (left === void 0 || right === void 0) break;
10
+ if (left !== right) return left < right ? -1 : 1;
11
+ }
12
+ return a.length - b.length;
13
+ }
14
+ var CoordinatorElection = class {
15
+ ownDevice;
16
+ incumbent;
17
+ constructor(options) {
18
+ this.ownDevice = options.ownDevice;
19
+ }
20
+ /** The claim this side currently accepts, or undefined before any claim has been made or heard. */
21
+ current() {
22
+ return this.incumbent;
23
+ }
24
+ /** Whether this side's own device is the incumbent. */
25
+ isSelf() {
26
+ return this.incumbent !== void 0 && compareDeviceIds(this.incumbent.coordinator, this.ownDevice) === 0;
27
+ }
28
+ /**
29
+ * Claims the role for this side's own device at a term above every term seen so far (lastTerm + 1, so a first-ever claim is term 0), and returns the frame to gossip. Claiming over a claim this side already holds is a deliberate takeover: it raises the term, which is exactly what a peer recovering the role after losing track of the mesh should do, and what a routine refresh should not (announceCurrent exists for that).
30
+ */
31
+ claim(capacityHint) {
32
+ const frame = {
33
+ type: "coordinator",
34
+ term: (this.incumbent?.term ?? -1) + 1,
35
+ coordinator: this.ownDevice,
36
+ ...capacityHint !== void 0 ? { "capacity-hint": capacityHint } : {}
37
+ };
38
+ this.incumbent = {
39
+ term: frame.term,
40
+ coordinator: this.ownDevice,
41
+ ...capacityHint !== void 0 ? { capacityHint } : {}
42
+ };
43
+ return frame;
44
+ }
45
+ /**
46
+ * The incumbent claim as a frame to re-gossip, for a holder refreshing what late joiners see (and for answering a stale claim, which costs the incumbent nothing to squash): the term is unchanged, because a refresh is not a takeover. Undefined when no claim has been made or heard yet.
47
+ */
48
+ announceCurrent() {
49
+ if (this.incumbent === void 0) return void 0;
50
+ return {
51
+ type: "coordinator",
52
+ term: this.incumbent.term,
53
+ coordinator: this.incumbent.coordinator,
54
+ ...this.incumbent.capacityHint !== void 0 ? { "capacity-hint": this.incumbent.capacityHint } : {}
55
+ };
56
+ }
57
+ /**
58
+ * Evaluates an incoming claim against the one this side currently accepts: a strictly higher term always wins; an equal term breaks by lowest device-id, so an incoming claim naming a lower device-id than the incumbent's takes the role and one naming a higher device-id loses; a lower term never wins. "accepted" means the incumbent changed (the caller should gossip the new incumbent onward so the supersession propagates); "retained" means it did not (the caller may answer a stale claim by re-gossiping announceCurrent(), and should drop a lost equal-term claim it originated, since the tiebreak has settled it).
59
+ */
60
+ evaluate(frame) {
61
+ const previous = this.incumbent;
62
+ if (previous === void 0 || frame.term > previous.term || frame.term === previous.term && compareDeviceIds(frame.coordinator, previous.coordinator) < 0) {
63
+ this.incumbent = {
64
+ term: frame.term,
65
+ coordinator: frame.coordinator,
66
+ ...frame["capacity-hint"] !== void 0 ? { capacityHint: frame["capacity-hint"] } : {}
67
+ };
68
+ return {
69
+ outcome: "accepted",
70
+ incumbent: this.incumbent
71
+ };
72
+ }
73
+ return {
74
+ outcome: "retained",
75
+ incumbent: previous
76
+ };
77
+ }
78
+ };
79
+ //#endregion
80
+ exports.CoordinatorElection = CoordinatorElection;
81
+ exports.compareDeviceIds = compareDeviceIds;
@@ -0,0 +1,44 @@
1
+ import { D as DeviceId, g as CoordinatorFrame } from "../protocol-DCDae3z4.cjs";
2
+ //#region src/domain/coordinator-election.d.ts
3
+ /** The claim this side currently accepts: one coordinator, at the term that put it there. */
4
+ export interface CoordinatorClaim {
5
+ term: number;
6
+ coordinator: DeviceId;
7
+ capacityHint?: number | undefined;
8
+ }
9
+ /** Bytewise "lowest device-id" comparison, the equal-term tiebreak the spec names: shorter is lower when one id is a prefix of the other, and equal ids compare equal. Device-ids are a fixed 32 bytes today, so the prefix branch is completeness rather than a live case. */
10
+ export declare function compareDeviceIds(a: DeviceId, b: DeviceId): number;
11
+ /** What evaluating an incoming claim concluded, so the caller can react without re-deriving the comparison: "accepted" means this claim is now the incumbent (it superseded a previous one, or there was none), "retained" means the incumbent survived (the incoming claim was stale, or lost the equal-term tiebreak), and the incumbent is returned either way so a caller squashing a stale claim re-announces exactly what it already holds. */
12
+ export type EvaluationOutcome = {
13
+ outcome: "accepted";
14
+ incumbent: CoordinatorClaim;
15
+ } | {
16
+ outcome: "retained";
17
+ incumbent: CoordinatorClaim;
18
+ };
19
+ export interface CoordinatorElectionOptions {
20
+ /** This device's own id: the coordinator named by a claim this side mints. */
21
+ ownDevice: DeviceId;
22
+ }
23
+ export declare class CoordinatorElection {
24
+ private readonly ownDevice;
25
+ private incumbent;
26
+ constructor(options: Readonly<CoordinatorElectionOptions>);
27
+ /** The claim this side currently accepts, or undefined before any claim has been made or heard. */
28
+ current(): CoordinatorClaim | undefined;
29
+ /** Whether this side's own device is the incumbent. */
30
+ isSelf(): boolean;
31
+ /**
32
+ * Claims the role for this side's own device at a term above every term seen so far (lastTerm + 1, so a first-ever claim is term 0), and returns the frame to gossip. Claiming over a claim this side already holds is a deliberate takeover: it raises the term, which is exactly what a peer recovering the role after losing track of the mesh should do, and what a routine refresh should not (announceCurrent exists for that).
33
+ */
34
+ claim(capacityHint?: number): CoordinatorFrame;
35
+ /**
36
+ * The incumbent claim as a frame to re-gossip, for a holder refreshing what late joiners see (and for answering a stale claim, which costs the incumbent nothing to squash): the term is unchanged, because a refresh is not a takeover. Undefined when no claim has been made or heard yet.
37
+ */
38
+ announceCurrent(): CoordinatorFrame | undefined;
39
+ /**
40
+ * Evaluates an incoming claim against the one this side currently accepts: a strictly higher term always wins; an equal term breaks by lowest device-id, so an incoming claim naming a lower device-id than the incumbent's takes the role and one naming a higher device-id loses; a lower term never wins. "accepted" means the incumbent changed (the caller should gossip the new incumbent onward so the supersession propagates); "retained" means it did not (the caller may answer a stale claim by re-gossiping announceCurrent(), and should drop a lost equal-term claim it originated, since the tiebreak has settled it).
41
+ */
42
+ evaluate(frame: Readonly<CoordinatorFrame>): EvaluationOutcome;
43
+ }
44
+ //#endregion
@@ -0,0 +1,44 @@
1
+ import { D as DeviceId, g as CoordinatorFrame } from "../protocol-DCDae3z4.mjs";
2
+ //#region src/domain/coordinator-election.d.ts
3
+ /** The claim this side currently accepts: one coordinator, at the term that put it there. */
4
+ export interface CoordinatorClaim {
5
+ term: number;
6
+ coordinator: DeviceId;
7
+ capacityHint?: number | undefined;
8
+ }
9
+ /** Bytewise "lowest device-id" comparison, the equal-term tiebreak the spec names: shorter is lower when one id is a prefix of the other, and equal ids compare equal. Device-ids are a fixed 32 bytes today, so the prefix branch is completeness rather than a live case. */
10
+ export declare function compareDeviceIds(a: DeviceId, b: DeviceId): number;
11
+ /** What evaluating an incoming claim concluded, so the caller can react without re-deriving the comparison: "accepted" means this claim is now the incumbent (it superseded a previous one, or there was none), "retained" means the incumbent survived (the incoming claim was stale, or lost the equal-term tiebreak), and the incumbent is returned either way so a caller squashing a stale claim re-announces exactly what it already holds. */
12
+ export type EvaluationOutcome = {
13
+ outcome: "accepted";
14
+ incumbent: CoordinatorClaim;
15
+ } | {
16
+ outcome: "retained";
17
+ incumbent: CoordinatorClaim;
18
+ };
19
+ export interface CoordinatorElectionOptions {
20
+ /** This device's own id: the coordinator named by a claim this side mints. */
21
+ ownDevice: DeviceId;
22
+ }
23
+ export declare class CoordinatorElection {
24
+ private readonly ownDevice;
25
+ private incumbent;
26
+ constructor(options: Readonly<CoordinatorElectionOptions>);
27
+ /** The claim this side currently accepts, or undefined before any claim has been made or heard. */
28
+ current(): CoordinatorClaim | undefined;
29
+ /** Whether this side's own device is the incumbent. */
30
+ isSelf(): boolean;
31
+ /**
32
+ * Claims the role for this side's own device at a term above every term seen so far (lastTerm + 1, so a first-ever claim is term 0), and returns the frame to gossip. Claiming over a claim this side already holds is a deliberate takeover: it raises the term, which is exactly what a peer recovering the role after losing track of the mesh should do, and what a routine refresh should not (announceCurrent exists for that).
33
+ */
34
+ claim(capacityHint?: number): CoordinatorFrame;
35
+ /**
36
+ * The incumbent claim as a frame to re-gossip, for a holder refreshing what late joiners see (and for answering a stale claim, which costs the incumbent nothing to squash): the term is unchanged, because a refresh is not a takeover. Undefined when no claim has been made or heard yet.
37
+ */
38
+ announceCurrent(): CoordinatorFrame | undefined;
39
+ /**
40
+ * Evaluates an incoming claim against the one this side currently accepts: a strictly higher term always wins; an equal term breaks by lowest device-id, so an incoming claim naming a lower device-id than the incumbent's takes the role and one naming a higher device-id loses; a lower term never wins. "accepted" means the incumbent changed (the caller should gossip the new incumbent onward so the supersession propagates); "retained" means it did not (the caller may answer a stale claim by re-gossiping announceCurrent(), and should drop a lost equal-term claim it originated, since the tiebreak has settled it).
41
+ */
42
+ evaluate(frame: Readonly<CoordinatorFrame>): EvaluationOutcome;
43
+ }
44
+ //#endregion
@@ -0,0 +1,79 @@
1
+ //#region src/domain/coordinator-election.ts
2
+ /** Bytewise "lowest device-id" comparison, the equal-term tiebreak the spec names: shorter is lower when one id is a prefix of the other, and equal ids compare equal. Device-ids are a fixed 32 bytes today, so the prefix branch is completeness rather than a live case. */
3
+ function compareDeviceIds(a, b) {
4
+ const length = Math.min(a.length, b.length);
5
+ for (let i = 0; i < length; i++) {
6
+ const left = a[i];
7
+ const right = b[i];
8
+ if (left === void 0 || right === void 0) break;
9
+ if (left !== right) return left < right ? -1 : 1;
10
+ }
11
+ return a.length - b.length;
12
+ }
13
+ var CoordinatorElection = class {
14
+ ownDevice;
15
+ incumbent;
16
+ constructor(options) {
17
+ this.ownDevice = options.ownDevice;
18
+ }
19
+ /** The claim this side currently accepts, or undefined before any claim has been made or heard. */
20
+ current() {
21
+ return this.incumbent;
22
+ }
23
+ /** Whether this side's own device is the incumbent. */
24
+ isSelf() {
25
+ return this.incumbent !== void 0 && compareDeviceIds(this.incumbent.coordinator, this.ownDevice) === 0;
26
+ }
27
+ /**
28
+ * Claims the role for this side's own device at a term above every term seen so far (lastTerm + 1, so a first-ever claim is term 0), and returns the frame to gossip. Claiming over a claim this side already holds is a deliberate takeover: it raises the term, which is exactly what a peer recovering the role after losing track of the mesh should do, and what a routine refresh should not (announceCurrent exists for that).
29
+ */
30
+ claim(capacityHint) {
31
+ const frame = {
32
+ type: "coordinator",
33
+ term: (this.incumbent?.term ?? -1) + 1,
34
+ coordinator: this.ownDevice,
35
+ ...capacityHint !== void 0 ? { "capacity-hint": capacityHint } : {}
36
+ };
37
+ this.incumbent = {
38
+ term: frame.term,
39
+ coordinator: this.ownDevice,
40
+ ...capacityHint !== void 0 ? { capacityHint } : {}
41
+ };
42
+ return frame;
43
+ }
44
+ /**
45
+ * The incumbent claim as a frame to re-gossip, for a holder refreshing what late joiners see (and for answering a stale claim, which costs the incumbent nothing to squash): the term is unchanged, because a refresh is not a takeover. Undefined when no claim has been made or heard yet.
46
+ */
47
+ announceCurrent() {
48
+ if (this.incumbent === void 0) return void 0;
49
+ return {
50
+ type: "coordinator",
51
+ term: this.incumbent.term,
52
+ coordinator: this.incumbent.coordinator,
53
+ ...this.incumbent.capacityHint !== void 0 ? { "capacity-hint": this.incumbent.capacityHint } : {}
54
+ };
55
+ }
56
+ /**
57
+ * Evaluates an incoming claim against the one this side currently accepts: a strictly higher term always wins; an equal term breaks by lowest device-id, so an incoming claim naming a lower device-id than the incumbent's takes the role and one naming a higher device-id loses; a lower term never wins. "accepted" means the incumbent changed (the caller should gossip the new incumbent onward so the supersession propagates); "retained" means it did not (the caller may answer a stale claim by re-gossiping announceCurrent(), and should drop a lost equal-term claim it originated, since the tiebreak has settled it).
58
+ */
59
+ evaluate(frame) {
60
+ const previous = this.incumbent;
61
+ if (previous === void 0 || frame.term > previous.term || frame.term === previous.term && compareDeviceIds(frame.coordinator, previous.coordinator) < 0) {
62
+ this.incumbent = {
63
+ term: frame.term,
64
+ coordinator: frame.coordinator,
65
+ ...frame["capacity-hint"] !== void 0 ? { capacityHint: frame["capacity-hint"] } : {}
66
+ };
67
+ return {
68
+ outcome: "accepted",
69
+ incumbent: this.incumbent
70
+ };
71
+ }
72
+ return {
73
+ outcome: "retained",
74
+ incumbent: previous
75
+ };
76
+ }
77
+ };
78
+ //#endregion
79
+ export { CoordinatorElection, compareDeviceIds };
@@ -1,5 +1,5 @@
1
1
  import { D as DeviceId, V as ManageCommand, f as CapabilityScope, p as CapabilityToken } from "../protocol-DCDae3z4.cjs";
2
- import { r as Transport, t as Connection } from "../transport-CfW7fsGK.cjs";
2
+ import { r as Transport, t as Connection } from "../transport-DIXjoQKn.cjs";
3
3
  import { IncomingManageRequest, ManageOutcome } from "./mesh-session.cjs";
4
4
  //#region src/domain/direct-manage-request.d.ts
5
5
  /** Reads exactly one frame off a freshly-accepted, bare connection and, if it's a manage-request, returns an IncomingManageRequest ready to hand to the same application-level dispatch logic session.incomingManageRequests already feeds elsewhere -- no handshake or gossip is ever read or sent on this connection (wire-mesh#38: the manage-request/manage-response exchange has no dependency on handshake state at the dispatch layer, confirmed against mesh-session.ts's own applyManageRequest/applyFrame). respond() sends the manage-response directly and closes the connection, since a one-off request/response is this connection's entire purpose -- unlike a real MeshSession, there is nothing further to do with it afterwards. Resolves null, without closing the connection (that's the caller's call), for any other first frame or if the connection ends before one arrives: interpreting either case is a Transport.listen() caller's own business, e.g. peeking the first frame to route between this path and acceptMeshSession's own handshake path. */
@@ -1,5 +1,5 @@
1
1
  import { D as DeviceId, V as ManageCommand, f as CapabilityScope, p as CapabilityToken } from "../protocol-DCDae3z4.mjs";
2
- import { r as Transport, t as Connection } from "../transport-iLBxIkRS.mjs";
2
+ import { r as Transport, t as Connection } from "../transport-B0GywEzG.mjs";
3
3
  import { IncomingManageRequest, ManageOutcome } from "./mesh-session.mjs";
4
4
  //#region src/domain/direct-manage-request.d.ts
5
5
  /** Reads exactly one frame off a freshly-accepted, bare connection and, if it's a manage-request, returns an IncomingManageRequest ready to hand to the same application-level dispatch logic session.incomingManageRequests already feeds elsewhere -- no handshake or gossip is ever read or sent on this connection (wire-mesh#38: the manage-request/manage-response exchange has no dependency on handshake state at the dispatch layer, confirmed against mesh-session.ts's own applyManageRequest/applyFrame). respond() sends the manage-response directly and closes the connection, since a one-off request/response is this connection's entire purpose -- unlike a real MeshSession, there is nothing further to do with it afterwards. Resolves null, without closing the connection (that's the caller's call), for any other first frame or if the connection ends before one arrives: interpreting either case is a Transport.listen() caller's own business, e.g. peeking the first frame to route between this path and acceptMeshSession's own handshake path. */
@@ -1,13 +1,13 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_gossip_extensions = require("../gossip-extensions-BBYgMZ2N.cjs");
2
+ const require_adapters_frame_codec = require("../adapters/frame-codec.cjs");
3
3
  const require_token_scope = require("../token-scope-CxHXTT3u.cjs");
4
+ const require_gossip_extensions = require("../gossip-extensions-BBYgMZ2N.cjs");
4
5
  const require_domain_peer_advert = require("./peer-advert.cjs");
6
+ const require_domain_async_queue = require("./async-queue.cjs");
5
7
  const require_domain_device_id = require("./device-id.cjs");
6
8
  const require_domain_handshake = require("./handshake.cjs");
7
- const require_domain_async_queue = require("./async-queue.cjs");
8
9
  const require_domain_ping_round_trips = require("./ping-round-trips.cjs");
9
10
  const require_domain_topology_snapshot = require("./topology-snapshot.cjs");
10
- const require_adapters_frame_codec = require("../adapters/frame-codec.cjs");
11
11
  //#region src/domain/relay-pairing.ts
12
12
  function createRelayPairings() {
13
13
  const established = /* @__PURE__ */ new Map();
@@ -325,6 +325,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
325
325
  });
326
326
  const incomingQueue = require_domain_async_queue.createAsyncQueue();
327
327
  const revocationQueue = require_domain_async_queue.createAsyncQueue();
328
+ const coordinatorQueue = require_domain_async_queue.createAsyncQueue();
328
329
  const pingRoundTrips = require_domain_ping_round_trips.createPingRoundTrips();
329
330
  function snapshot() {
330
331
  return {
@@ -466,6 +467,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
466
467
  else if (frame.type === "manage-request") applyManageRequest(frame);
467
468
  else if (frame.type === "pong") pingRoundTrips.resolveOldest(clock.now());
468
469
  else if (frame.type === "revocation-announce") for (const entry of frame.entries) revocationQueue.push(entry);
470
+ else if (frame.type === "coordinator") coordinatorQueue.push(frame);
469
471
  }
470
472
  /** 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. */
471
473
  async function ensureRelayPairing(targetDevice) {
@@ -504,6 +506,10 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
504
506
  handshake
505
507
  };
506
508
  }
509
+ /** The address to dial after `link` ended: the one the adapter says reaches the same peer now, if it says anything, else the one this session dialled. */
510
+ function redialAddress(link, dialled) {
511
+ return link.redialAddress?.() ?? dialled;
512
+ }
507
513
  function handleDisconnect(reason, address, localDomains) {
508
514
  if (feedCancelled) return;
509
515
  rejectPendingManageRequests("disconnected before a response arrived");
@@ -534,14 +540,19 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
534
540
  emit();
535
541
  }
536
542
  async function consume(link, address, localDomains) {
543
+ let heardFromPeer = false;
537
544
  for await (const frame of link.receive()) {
538
545
  if (feedCancelled) return;
546
+ if (!heardFromPeer) {
547
+ heardFromPeer = true;
548
+ attempt = 0;
549
+ }
539
550
  await applyFrame(frame);
540
551
  await onFrame?.(link, frame);
541
552
  emit();
542
553
  }
543
554
  onSessionEnd?.(link);
544
- if (state.status === "connected") handleDisconnect("node closed the connection", address, localDomains);
555
+ if (state.status === "connected") handleDisconnect("node closed the connection", redialAddress(link, address), localDomains);
545
556
  }
546
557
  /** Builds this side's own self-advert and signs it with this node's own key (wire-mesh#225), which is what makes it usable by a receiver that learned of it through a hub or a gateway rather than directly from here. this node's own directly-reachable addresses (wire-mesh#38) are advertised, or none for a caller with nothing to offer (a browser client, which cannot accept inbound connections) -- either is an honest advert, not a stopgap. extensions merge onto peer-advert's own open `* tstr => any` tail -- the mechanism sendGossipUpdate uses to keep a gossiped fact (presence status, an accept/refuse policy, or any future domain's own) live over the connection's lifetime, and every such key is covered by the signature. Extensions, CORE_VERSION_GOSSIP_KEY, and topology/peers are all spread before the mandatory fields (never after) so a caller-supplied key of the same name can never shadow them on the wire -- validateGossipExtensions already rejects every such collision loudly, but the field order is kept safe in its own right rather than relying solely on the guard staying in sync. topology/peers (wire-mesh#180) is recomputed fresh on every call, the same "always current, never cached" treatment snapshot-seconds already gets, since a session's own connection identity and relay pairings can change between one self-advert and the next. */
547
558
  async function buildSelfAdvert(extensions) {
@@ -596,7 +607,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
596
607
  }
597
608
  }, HANDSHAKE_TIMEOUT_MS);
598
609
  consume(connection, address, localDomains).catch((error) => {
599
- if (state.status === "connected") handleDisconnect(error instanceof Error ? error.message : String(error), address, localDomains);
610
+ if (state.status === "connected") handleDisconnect(error instanceof Error ? error.message : String(error), redialAddress(link, address), localDomains);
600
611
  });
601
612
  }
602
613
  async function doConnect(address, localDomains) {
@@ -623,6 +634,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
623
634
  events: eventQueue.stream,
624
635
  incomingManageRequests: incomingQueue.stream,
625
636
  revocationAnnouncements: revocationQueue.stream,
637
+ coordinatorFrames: coordinatorQueue.stream,
626
638
  async connect(address, localDomains) {
627
639
  if (connection !== null) throw new Error("a session connects once; create a new one to reconnect");
628
640
  attempt = 0;
@@ -709,6 +721,16 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
709
721
  await transmit(frame, false);
710
722
  emit();
711
723
  },
724
+ async sendCoordinatorClaim(frame) {
725
+ requireConnectedLink();
726
+ const claim = { ...frame };
727
+ frameLog.push({
728
+ direction: "sent",
729
+ frame: claim
730
+ });
731
+ await transmit(claim, false);
732
+ emit();
733
+ },
712
734
  async sendGossipUpdate(extensions) {
713
735
  requireConnectedLink();
714
736
  const frame = await buildSelfAdvert(extensions);
@@ -1,5 +1,5 @@
1
- import { D as DeviceId, E as DataRequestFrame, N as Frame, T as DataHaveFrame, U as ManageError, V as ManageCommand, W as ManageOk, bt as RevocationEntry, et as PeerAdvert, f as CapabilityScope, lt as ProtocolVersion, nn as TopologyPeers, p as CapabilityToken, w as DataEntriesFrame } from "../protocol-DCDae3z4.cjs";
2
- import { r as Transport, t as Connection } from "../transport-CfW7fsGK.cjs";
1
+ import { D as DeviceId, E as DataRequestFrame, N as Frame, T as DataHaveFrame, U as ManageError, V as ManageCommand, W as ManageOk, bt as RevocationEntry, et as PeerAdvert, f as CapabilityScope, g as CoordinatorFrame, lt as ProtocolVersion, nn as TopologyPeers, p as CapabilityToken, w as DataEntriesFrame } from "../protocol-DCDae3z4.cjs";
2
+ import { r as Transport, t as Connection } from "../transport-DIXjoQKn.cjs";
3
3
  import { t as IdentityPort } from "../identity-DVCNuFLG.cjs";
4
4
  import { t as Clock } from "../clock-DiSx-WKM.cjs";
5
5
  //#region src/domain/mesh-session.d.ts
@@ -77,6 +77,8 @@ export interface MeshSession {
77
77
  readonly incomingManageRequests: AsyncIterable<IncomingManageRequest>;
78
78
  /** 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
79
  readonly revocationAnnouncements: AsyncIterable<RevocationEntry>;
80
+ /** 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. */
81
+ readonly coordinatorFrames: AsyncIterable<CoordinatorFrame>;
80
82
  connect: (address: string, localDomains: readonly string[]) => Promise<void>;
81
83
  sendPing: () => Promise<void>;
82
84
  /** Sends a ping-frame and resolves with the round-trip time in milliseconds once the correlated pong-frame arrives -- FIFO-paired against this call's own ping, since ping-frame carries no correlation id of its own (spec/transport.cddl): the Nth call's own promise resolves against the Nth pong received after it, never matched by any other means. Rejects if the connection closes, or (when timeoutMs is given) if no pong arrives within timeoutMs, rather than resolving a sentinel value the way sendManageRequest's own timeout does -- there is no natural "no answer" value for a bare millisecond count to double as. Unlike sendPing (fire-and-forget, answered by nothing on an ordinary peer connection), this is answered only by a peer that replies to ping with pong -- today, relay-hub's own frame handling (wire-mesh#181) -- so calling this against a connection to a plain peer that never sends pong hangs until timeoutMs (if given) or forever. Exists to isolate the sender-to-hub leg of a relayed path.trace round trip: time this over the same connection a relayed manage-request travelled, then subtract it from path.trace's own end-to-end RTT to recover the hub-to-target leg. */
@@ -85,6 +87,8 @@ export interface MeshSession {
85
87
  setToken: (token: CapabilityToken) => void;
86
88
  /** Announces one or more already-minted revocation-entries to the peer. Sent directly over the connection, never relay-wrapped -- revocation-announce is a gossiped broadcast, not a request addressed to a specific peer, so it has no targetDevice/token parameters the way sendManageRequest does. */
87
89
  sendRevocationAnnounce: (entries: readonly RevocationEntry[]) => Promise<void>;
90
+ /** Sends one coordinator-frame directly over this session's own connection, never relay-wrapped: the claim is a gossiped broadcast like revocation-announce, not a request addressed to a specific peer. The frame itself comes from a CoordinatorElection (claim, or announceCurrent for a refresh), so the session never invents a term on its own. */
91
+ sendCoordinatorClaim: (frame: Readonly<CoordinatorFrame>) => Promise<void>;
88
92
  /** Re-sends this side's own self-advert with a fresh snapshot-seconds and, when given, extensions merged onto peer-advert's own open `* tstr => any` tail -- the mechanism a caller uses to keep gossiped presence status (or any other advertised fact) live over a connection's lifetime, since the initial self-advert wireUpConnection sends at connect time is otherwise never repeated. Callers own their own re-advertisement cadence (there is no timer inside MeshSession itself, matching its own DOM-free, fully unit-testable design); a caller not calling this again after connecting is exactly today's existing gossip-once-on-connect behaviour. */
89
93
  sendGossipUpdate: (extensions?: Record<string, unknown>) => Promise<void>;
90
94
  /** 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). */
@@ -1,5 +1,5 @@
1
- import { D as DeviceId, E as DataRequestFrame, N as Frame, T as DataHaveFrame, U as ManageError, V as ManageCommand, W as ManageOk, bt as RevocationEntry, et as PeerAdvert, f as CapabilityScope, lt as ProtocolVersion, nn as TopologyPeers, p as CapabilityToken, w as DataEntriesFrame } from "../protocol-DCDae3z4.mjs";
2
- import { r as Transport, t as Connection } from "../transport-iLBxIkRS.mjs";
1
+ import { D as DeviceId, E as DataRequestFrame, N as Frame, T as DataHaveFrame, U as ManageError, V as ManageCommand, W as ManageOk, bt as RevocationEntry, et as PeerAdvert, f as CapabilityScope, g as CoordinatorFrame, lt as ProtocolVersion, nn as TopologyPeers, p as CapabilityToken, w as DataEntriesFrame } from "../protocol-DCDae3z4.mjs";
2
+ import { r as Transport, t as Connection } from "../transport-B0GywEzG.mjs";
3
3
  import { t as IdentityPort } from "../identity-DcaYgjXT.mjs";
4
4
  import { t as Clock } from "../clock-DiSx-WKM.mjs";
5
5
  //#region src/domain/mesh-session.d.ts
@@ -77,6 +77,8 @@ export interface MeshSession {
77
77
  readonly incomingManageRequests: AsyncIterable<IncomingManageRequest>;
78
78
  /** 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
79
  readonly revocationAnnouncements: AsyncIterable<RevocationEntry>;
80
+ /** 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. */
81
+ readonly coordinatorFrames: AsyncIterable<CoordinatorFrame>;
80
82
  connect: (address: string, localDomains: readonly string[]) => Promise<void>;
81
83
  sendPing: () => Promise<void>;
82
84
  /** Sends a ping-frame and resolves with the round-trip time in milliseconds once the correlated pong-frame arrives -- FIFO-paired against this call's own ping, since ping-frame carries no correlation id of its own (spec/transport.cddl): the Nth call's own promise resolves against the Nth pong received after it, never matched by any other means. Rejects if the connection closes, or (when timeoutMs is given) if no pong arrives within timeoutMs, rather than resolving a sentinel value the way sendManageRequest's own timeout does -- there is no natural "no answer" value for a bare millisecond count to double as. Unlike sendPing (fire-and-forget, answered by nothing on an ordinary peer connection), this is answered only by a peer that replies to ping with pong -- today, relay-hub's own frame handling (wire-mesh#181) -- so calling this against a connection to a plain peer that never sends pong hangs until timeoutMs (if given) or forever. Exists to isolate the sender-to-hub leg of a relayed path.trace round trip: time this over the same connection a relayed manage-request travelled, then subtract it from path.trace's own end-to-end RTT to recover the hub-to-target leg. */
@@ -85,6 +87,8 @@ export interface MeshSession {
85
87
  setToken: (token: CapabilityToken) => void;
86
88
  /** Announces one or more already-minted revocation-entries to the peer. Sent directly over the connection, never relay-wrapped -- revocation-announce is a gossiped broadcast, not a request addressed to a specific peer, so it has no targetDevice/token parameters the way sendManageRequest does. */
87
89
  sendRevocationAnnounce: (entries: readonly RevocationEntry[]) => Promise<void>;
90
+ /** Sends one coordinator-frame directly over this session's own connection, never relay-wrapped: the claim is a gossiped broadcast like revocation-announce, not a request addressed to a specific peer. The frame itself comes from a CoordinatorElection (claim, or announceCurrent for a refresh), so the session never invents a term on its own. */
91
+ sendCoordinatorClaim: (frame: Readonly<CoordinatorFrame>) => Promise<void>;
88
92
  /** Re-sends this side's own self-advert with a fresh snapshot-seconds and, when given, extensions merged onto peer-advert's own open `* tstr => any` tail -- the mechanism a caller uses to keep gossiped presence status (or any other advertised fact) live over a connection's lifetime, since the initial self-advert wireUpConnection sends at connect time is otherwise never repeated. Callers own their own re-advertisement cadence (there is no timer inside MeshSession itself, matching its own DOM-free, fully unit-testable design); a caller not calling this again after connecting is exactly today's existing gossip-once-on-connect behaviour. */
89
93
  sendGossipUpdate: (extensions?: Record<string, unknown>) => Promise<void>;
90
94
  /** 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). */
@@ -1,12 +1,12 @@
1
- import { a as OWN_VERSION, i as CORE_VERSION_GOSSIP_KEY, n as TOPOLOGY_PEERS_GOSSIP_KEY, o as isVersionGetCommand, r as validateGossipExtensions, s as versionGetOutcome } from "../gossip-extensions-B6WJjkAr.mjs";
1
+ import { messageFromFrame, tryDecodeFrame, wrapRelayData } from "../adapters/frame-codec.mjs";
2
2
  import { t as bytesEqual } from "../token-scope-Z4bmci4M.mjs";
3
+ import { a as OWN_VERSION, i as CORE_VERSION_GOSSIP_KEY, n as TOPOLOGY_PEERS_GOSSIP_KEY, o as isVersionGetCommand, r as validateGossipExtensions, s as versionGetOutcome } from "../gossip-extensions-B6WJjkAr.mjs";
3
4
  import { signPeerAdvert, verifyPeerAdvert } from "./peer-advert.mjs";
5
+ import { createAsyncQueue } from "./async-queue.mjs";
4
6
  import { deviceIdToHex } from "./device-id.mjs";
5
7
  import { negotiate } from "./handshake.mjs";
6
- import { createAsyncQueue } from "./async-queue.mjs";
7
8
  import { createPingRoundTrips } from "./ping-round-trips.mjs";
8
9
  import { createTopologySnapshotTracker } from "./topology-snapshot.mjs";
9
- import { messageFromFrame, tryDecodeFrame, wrapRelayData } from "../adapters/frame-codec.mjs";
10
10
  //#region src/domain/relay-pairing.ts
11
11
  function createRelayPairings() {
12
12
  const established = /* @__PURE__ */ new Map();
@@ -324,6 +324,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
324
324
  });
325
325
  const incomingQueue = createAsyncQueue();
326
326
  const revocationQueue = createAsyncQueue();
327
+ const coordinatorQueue = createAsyncQueue();
327
328
  const pingRoundTrips = createPingRoundTrips();
328
329
  function snapshot() {
329
330
  return {
@@ -465,6 +466,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
465
466
  else if (frame.type === "manage-request") applyManageRequest(frame);
466
467
  else if (frame.type === "pong") pingRoundTrips.resolveOldest(clock.now());
467
468
  else if (frame.type === "revocation-announce") for (const entry of frame.entries) revocationQueue.push(entry);
469
+ else if (frame.type === "coordinator") coordinatorQueue.push(frame);
468
470
  }
469
471
  /** 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. */
470
472
  async function ensureRelayPairing(targetDevice) {
@@ -503,6 +505,10 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
503
505
  handshake
504
506
  };
505
507
  }
508
+ /** The address to dial after `link` ended: the one the adapter says reaches the same peer now, if it says anything, else the one this session dialled. */
509
+ function redialAddress(link, dialled) {
510
+ return link.redialAddress?.() ?? dialled;
511
+ }
506
512
  function handleDisconnect(reason, address, localDomains) {
507
513
  if (feedCancelled) return;
508
514
  rejectPendingManageRequests("disconnected before a response arrived");
@@ -533,14 +539,19 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
533
539
  emit();
534
540
  }
535
541
  async function consume(link, address, localDomains) {
542
+ let heardFromPeer = false;
536
543
  for await (const frame of link.receive()) {
537
544
  if (feedCancelled) return;
545
+ if (!heardFromPeer) {
546
+ heardFromPeer = true;
547
+ attempt = 0;
548
+ }
538
549
  await applyFrame(frame);
539
550
  await onFrame?.(link, frame);
540
551
  emit();
541
552
  }
542
553
  onSessionEnd?.(link);
543
- if (state.status === "connected") handleDisconnect("node closed the connection", address, localDomains);
554
+ if (state.status === "connected") handleDisconnect("node closed the connection", redialAddress(link, address), localDomains);
544
555
  }
545
556
  /** Builds this side's own self-advert and signs it with this node's own key (wire-mesh#225), which is what makes it usable by a receiver that learned of it through a hub or a gateway rather than directly from here. this node's own directly-reachable addresses (wire-mesh#38) are advertised, or none for a caller with nothing to offer (a browser client, which cannot accept inbound connections) -- either is an honest advert, not a stopgap. extensions merge onto peer-advert's own open `* tstr => any` tail -- the mechanism sendGossipUpdate uses to keep a gossiped fact (presence status, an accept/refuse policy, or any future domain's own) live over the connection's lifetime, and every such key is covered by the signature. Extensions, CORE_VERSION_GOSSIP_KEY, and topology/peers are all spread before the mandatory fields (never after) so a caller-supplied key of the same name can never shadow them on the wire -- validateGossipExtensions already rejects every such collision loudly, but the field order is kept safe in its own right rather than relying solely on the guard staying in sync. topology/peers (wire-mesh#180) is recomputed fresh on every call, the same "always current, never cached" treatment snapshot-seconds already gets, since a session's own connection identity and relay pairings can change between one self-advert and the next. */
546
557
  async function buildSelfAdvert(extensions) {
@@ -595,7 +606,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
595
606
  }
596
607
  }, HANDSHAKE_TIMEOUT_MS);
597
608
  consume(connection, address, localDomains).catch((error) => {
598
- if (state.status === "connected") handleDisconnect(error instanceof Error ? error.message : String(error), address, localDomains);
609
+ if (state.status === "connected") handleDisconnect(error instanceof Error ? error.message : String(error), redialAddress(link, address), localDomains);
599
610
  });
600
611
  }
601
612
  async function doConnect(address, localDomains) {
@@ -622,6 +633,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
622
633
  events: eventQueue.stream,
623
634
  incomingManageRequests: incomingQueue.stream,
624
635
  revocationAnnouncements: revocationQueue.stream,
636
+ coordinatorFrames: coordinatorQueue.stream,
625
637
  async connect(address, localDomains) {
626
638
  if (connection !== null) throw new Error("a session connects once; create a new one to reconnect");
627
639
  attempt = 0;
@@ -708,6 +720,16 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
708
720
  await transmit(frame, false);
709
721
  emit();
710
722
  },
723
+ async sendCoordinatorClaim(frame) {
724
+ requireConnectedLink();
725
+ const claim = { ...frame };
726
+ frameLog.push({
727
+ direction: "sent",
728
+ frame: claim
729
+ });
730
+ await transmit(claim, false);
731
+ emit();
732
+ },
711
733
  async sendGossipUpdate(extensions) {
712
734
  requireConnectedLink();
713
735
  const frame = await buildSelfAdvert(extensions);
@@ -1,8 +1,10 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let cbor2 = require("cbor2");
2
3
  //#region src/domain/pinned-address.ts
3
4
  const PIN_FRAGMENT = "#sha256=";
4
5
  const HASH_SEPARATOR = ",";
5
6
  const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
7
+ const HEX_RADIX = 16;
6
8
  function hexToBytes(hex) {
7
9
  const bytes = new Uint8Array(hex.length / 2);
8
10
  for (let index = 0; index < bytes.length; index++) bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
@@ -34,7 +36,56 @@ function parsePinnedAddress(address) {
34
36
  sha256: hashes.map(hexToBytes)
35
37
  };
36
38
  }
39
+ /** The bytes in a SHA-256 hash. */
40
+ const SHA256_BYTES = 32;
41
+ /** The most hashes a node may announce at once. A browser is asked to accept any of them, and a longer list is a node misbehaving rather than one advertising a schedule. */
42
+ const MAX_ANNOUNCED_HASHES = 8;
43
+ /**
44
+ * The message a node sends a client, on a stream it opens to that client, to say which certificates it serves now and will serve next: a CBOR map with one key, `sha256`, holding an array of 32-byte hashes in the order they take over. It is authentic because it arrives on the session the client pinned, so no signature is needed.
45
+ */
46
+ function encodeCertificateHashes(sha256Hex) {
47
+ return (0, cbor2.encode)({ sha256: sha256Hex.map(hexToBytes) }, cbor2.cdeEncodeOptions);
48
+ }
49
+ /**
50
+ * Reads a message made by `encodeCertificateHashes`.
51
+ * @throws Error when it is not a map with a `sha256` array of one to MAX_ANNOUNCED_HASHES byte strings of 32 bytes each.
52
+ */
53
+ function decodeCertificateHashes(bytes) {
54
+ const decoded = (0, cbor2.decode)(bytes, cbor2.cdeDecodeOptions);
55
+ if (typeof decoded !== "object" || decoded === null || !("sha256" in decoded) || !Array.isArray(decoded.sha256)) throw new Error("expected a map with a sha256 array");
56
+ const announced = decoded.sha256;
57
+ if (announced.length === 0 || announced.length > 8) throw new Error(`expected 1 to ${String(8)} hashes, got ${String(announced.length)}`);
58
+ const hashes = [];
59
+ for (const hash of announced) {
60
+ if (!(hash instanceof Uint8Array) || hash.length !== SHA256_BYTES) throw new Error(`each hash must be ${String(SHA256_BYTES)} bytes`);
61
+ hashes.push(new Uint8Array(hash));
62
+ }
63
+ return hashes;
64
+ }
65
+ /** `hashes` followed by those of `more` that are not already in it, so a client can pin what it was given and what it has learned since. */
66
+ function mergeHashes(hashes, more) {
67
+ const known = new Set(hashes.map(bytesToHex));
68
+ const merged = [...hashes];
69
+ for (const hash of more) if (!known.has(bytesToHex(hash))) {
70
+ known.add(bytesToHex(hash));
71
+ merged.push(hash);
72
+ }
73
+ return merged;
74
+ }
75
+ function bytesToHex(bytes) {
76
+ return Array.from(bytes, (byte) => byte.toString(HEX_RADIX).padStart(2, "0")).join("");
77
+ }
78
+ /** `address` with its pinned hashes replaced by `sha256`. */
79
+ function withPinnedHashes(address, sha256) {
80
+ const { url } = parsePinnedAddress(address);
81
+ return formatPinnedAddress(new URL(url).host, sha256.map(bytesToHex));
82
+ }
37
83
  //#endregion
84
+ exports.MAX_ANNOUNCED_HASHES = MAX_ANNOUNCED_HASHES;
85
+ exports.decodeCertificateHashes = decodeCertificateHashes;
86
+ exports.encodeCertificateHashes = encodeCertificateHashes;
38
87
  exports.formatPinnedAddress = formatPinnedAddress;
39
88
  exports.isPinnedAddress = isPinnedAddress;
89
+ exports.mergeHashes = mergeHashes;
40
90
  exports.parsePinnedAddress = parsePinnedAddress;
91
+ exports.withPinnedHashes = withPinnedHashes;
@@ -19,4 +19,19 @@ export declare function isPinnedAddress(address: string): boolean;
19
19
  * @throws Error when the address is not `https://` or does not carry one or more 64-digit lowercase hex hashes.
20
20
  */
21
21
  export declare function parsePinnedAddress(address: string): PinnedAddress;
22
+ /** The most hashes a node may announce at once. A browser is asked to accept any of them, and a longer list is a node misbehaving rather than one advertising a schedule. */
23
+ export declare const MAX_ANNOUNCED_HASHES = 8;
24
+ /**
25
+ * The message a node sends a client, on a stream it opens to that client, to say which certificates it serves now and will serve next: a CBOR map with one key, `sha256`, holding an array of 32-byte hashes in the order they take over. It is authentic because it arrives on the session the client pinned, so no signature is needed.
26
+ */
27
+ export declare function encodeCertificateHashes(sha256Hex: readonly string[]): Uint8Array;
28
+ /**
29
+ * Reads a message made by `encodeCertificateHashes`.
30
+ * @throws Error when it is not a map with a `sha256` array of one to MAX_ANNOUNCED_HASHES byte strings of 32 bytes each.
31
+ */
32
+ export declare function decodeCertificateHashes(bytes: Readonly<Uint8Array>): Uint8Array<ArrayBuffer>[];
33
+ /** `hashes` followed by those of `more` that are not already in it, so a client can pin what it was given and what it has learned since. */
34
+ export declare function mergeHashes(hashes: readonly Uint8Array<ArrayBuffer>[], more: readonly Uint8Array<ArrayBuffer>[]): Uint8Array<ArrayBuffer>[];
35
+ /** `address` with its pinned hashes replaced by `sha256`. */
36
+ export declare function withPinnedHashes(address: string, sha256: readonly Uint8Array<ArrayBuffer>[]): string;
22
37
  //#endregion
@@ -19,4 +19,19 @@ export declare function isPinnedAddress(address: string): boolean;
19
19
  * @throws Error when the address is not `https://` or does not carry one or more 64-digit lowercase hex hashes.
20
20
  */
21
21
  export declare function parsePinnedAddress(address: string): PinnedAddress;
22
+ /** The most hashes a node may announce at once. A browser is asked to accept any of them, and a longer list is a node misbehaving rather than one advertising a schedule. */
23
+ export declare const MAX_ANNOUNCED_HASHES = 8;
24
+ /**
25
+ * The message a node sends a client, on a stream it opens to that client, to say which certificates it serves now and will serve next: a CBOR map with one key, `sha256`, holding an array of 32-byte hashes in the order they take over. It is authentic because it arrives on the session the client pinned, so no signature is needed.
26
+ */
27
+ export declare function encodeCertificateHashes(sha256Hex: readonly string[]): Uint8Array;
28
+ /**
29
+ * Reads a message made by `encodeCertificateHashes`.
30
+ * @throws Error when it is not a map with a `sha256` array of one to MAX_ANNOUNCED_HASHES byte strings of 32 bytes each.
31
+ */
32
+ export declare function decodeCertificateHashes(bytes: Readonly<Uint8Array>): Uint8Array<ArrayBuffer>[];
33
+ /** `hashes` followed by those of `more` that are not already in it, so a client can pin what it was given and what it has learned since. */
34
+ export declare function mergeHashes(hashes: readonly Uint8Array<ArrayBuffer>[], more: readonly Uint8Array<ArrayBuffer>[]): Uint8Array<ArrayBuffer>[];
35
+ /** `address` with its pinned hashes replaced by `sha256`. */
36
+ export declare function withPinnedHashes(address: string, sha256: readonly Uint8Array<ArrayBuffer>[]): string;
22
37
  //#endregion
@@ -1,7 +1,9 @@
1
+ import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2";
1
2
  //#region src/domain/pinned-address.ts
2
3
  const PIN_FRAGMENT = "#sha256=";
3
4
  const HASH_SEPARATOR = ",";
4
5
  const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
6
+ const HEX_RADIX = 16;
5
7
  function hexToBytes(hex) {
6
8
  const bytes = new Uint8Array(hex.length / 2);
7
9
  for (let index = 0; index < bytes.length; index++) bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
@@ -33,5 +35,49 @@ function parsePinnedAddress(address) {
33
35
  sha256: hashes.map(hexToBytes)
34
36
  };
35
37
  }
38
+ /** The bytes in a SHA-256 hash. */
39
+ const SHA256_BYTES = 32;
40
+ /** The most hashes a node may announce at once. A browser is asked to accept any of them, and a longer list is a node misbehaving rather than one advertising a schedule. */
41
+ const MAX_ANNOUNCED_HASHES = 8;
42
+ /**
43
+ * The message a node sends a client, on a stream it opens to that client, to say which certificates it serves now and will serve next: a CBOR map with one key, `sha256`, holding an array of 32-byte hashes in the order they take over. It is authentic because it arrives on the session the client pinned, so no signature is needed.
44
+ */
45
+ function encodeCertificateHashes(sha256Hex) {
46
+ return encode({ sha256: sha256Hex.map(hexToBytes) }, cdeEncodeOptions);
47
+ }
48
+ /**
49
+ * Reads a message made by `encodeCertificateHashes`.
50
+ * @throws Error when it is not a map with a `sha256` array of one to MAX_ANNOUNCED_HASHES byte strings of 32 bytes each.
51
+ */
52
+ function decodeCertificateHashes(bytes) {
53
+ const decoded = decode(bytes, cdeDecodeOptions);
54
+ if (typeof decoded !== "object" || decoded === null || !("sha256" in decoded) || !Array.isArray(decoded.sha256)) throw new Error("expected a map with a sha256 array");
55
+ const announced = decoded.sha256;
56
+ if (announced.length === 0 || announced.length > 8) throw new Error(`expected 1 to ${String(8)} hashes, got ${String(announced.length)}`);
57
+ const hashes = [];
58
+ for (const hash of announced) {
59
+ if (!(hash instanceof Uint8Array) || hash.length !== SHA256_BYTES) throw new Error(`each hash must be ${String(SHA256_BYTES)} bytes`);
60
+ hashes.push(new Uint8Array(hash));
61
+ }
62
+ return hashes;
63
+ }
64
+ /** `hashes` followed by those of `more` that are not already in it, so a client can pin what it was given and what it has learned since. */
65
+ function mergeHashes(hashes, more) {
66
+ const known = new Set(hashes.map(bytesToHex));
67
+ const merged = [...hashes];
68
+ for (const hash of more) if (!known.has(bytesToHex(hash))) {
69
+ known.add(bytesToHex(hash));
70
+ merged.push(hash);
71
+ }
72
+ return merged;
73
+ }
74
+ function bytesToHex(bytes) {
75
+ return Array.from(bytes, (byte) => byte.toString(HEX_RADIX).padStart(2, "0")).join("");
76
+ }
77
+ /** `address` with its pinned hashes replaced by `sha256`. */
78
+ function withPinnedHashes(address, sha256) {
79
+ const { url } = parsePinnedAddress(address);
80
+ return formatPinnedAddress(new URL(url).host, sha256.map(bytesToHex));
81
+ }
36
82
  //#endregion
37
- export { formatPinnedAddress, isPinnedAddress, parsePinnedAddress };
83
+ export { MAX_ANNOUNCED_HASHES, decodeCertificateHashes, encodeCertificateHashes, formatPinnedAddress, isPinnedAddress, mergeHashes, parsePinnedAddress, withPinnedHashes };
@@ -0,0 +1,30 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/domain/relay-advert.ts
3
+ /** The domain-qualified gossip extension key a node's relay offer rides under, the same `wire-mesh` domain the core's own gossiped version fact occupies. */
4
+ const RELAY_OFFER_GOSSIP_KEY = "wire-mesh/relay-offer";
5
+ /**
6
+ * Builds the relay-offer extension bag for `sendGossipUpdate`, carrying exactly the address list `relay-offer-frame` itself would name. Throws on an empty list or a non-string entry: an empty offer is not an offer (the caller should simply not advertise), and a malformed address on the wire would be every reader's problem rather than one caller's bug caught at the call site.
7
+ */
8
+ function buildRelayOfferExtension(addresses) {
9
+ if (addresses.length === 0) throw new Error("a relay offer names at least one address");
10
+ for (const address of addresses) if (typeof address !== "string") throw new Error(`a relay offer address is a string, got ${JSON.stringify(address)}`);
11
+ return { [RELAY_OFFER_GOSSIP_KEY]: [...addresses] };
12
+ }
13
+ /**
14
+ * Reads a peer's relay offer from its advert: the addresses it offers relay service at, or undefined when the peer advertises no offer (or the extension is malformed, which reads as no offer rather than an error). A receiver still decides for itself whether to use any offered address, exactly as it would an in-band relay-offer-frame.
15
+ */
16
+ function readRelayOffer(advert) {
17
+ const value = advert[RELAY_OFFER_GOSSIP_KEY];
18
+ if (!Array.isArray(value)) return void 0;
19
+ const addresses = [];
20
+ for (const entry of value) {
21
+ if (typeof entry !== "string") return void 0;
22
+ addresses.push(entry);
23
+ }
24
+ if (addresses.length === 0) return void 0;
25
+ return addresses;
26
+ }
27
+ //#endregion
28
+ exports.RELAY_OFFER_GOSSIP_KEY = RELAY_OFFER_GOSSIP_KEY;
29
+ exports.buildRelayOfferExtension = buildRelayOfferExtension;
30
+ exports.readRelayOffer = readRelayOffer;
@@ -0,0 +1,13 @@
1
+ import { et as PeerAdvert } from "../protocol-DCDae3z4.cjs";
2
+ //#region src/domain/relay-advert.d.ts
3
+ /** The domain-qualified gossip extension key a node's relay offer rides under, the same `wire-mesh` domain the core's own gossiped version fact occupies. */
4
+ export declare const RELAY_OFFER_GOSSIP_KEY = "wire-mesh/relay-offer";
5
+ /**
6
+ * Builds the relay-offer extension bag for `sendGossipUpdate`, carrying exactly the address list `relay-offer-frame` itself would name. Throws on an empty list or a non-string entry: an empty offer is not an offer (the caller should simply not advertise), and a malformed address on the wire would be every reader's problem rather than one caller's bug caught at the call site.
7
+ */
8
+ export declare function buildRelayOfferExtension(addresses: readonly string[]): Record<string, unknown>;
9
+ /**
10
+ * Reads a peer's relay offer from its advert: the addresses it offers relay service at, or undefined when the peer advertises no offer (or the extension is malformed, which reads as no offer rather than an error). A receiver still decides for itself whether to use any offered address, exactly as it would an in-band relay-offer-frame.
11
+ */
12
+ export declare function readRelayOffer(advert: Readonly<PeerAdvert>): readonly string[] | undefined;
13
+ //#endregion
@@ -0,0 +1,13 @@
1
+ import { et as PeerAdvert } from "../protocol-DCDae3z4.mjs";
2
+ //#region src/domain/relay-advert.d.ts
3
+ /** The domain-qualified gossip extension key a node's relay offer rides under, the same `wire-mesh` domain the core's own gossiped version fact occupies. */
4
+ export declare const RELAY_OFFER_GOSSIP_KEY = "wire-mesh/relay-offer";
5
+ /**
6
+ * Builds the relay-offer extension bag for `sendGossipUpdate`, carrying exactly the address list `relay-offer-frame` itself would name. Throws on an empty list or a non-string entry: an empty offer is not an offer (the caller should simply not advertise), and a malformed address on the wire would be every reader's problem rather than one caller's bug caught at the call site.
7
+ */
8
+ export declare function buildRelayOfferExtension(addresses: readonly string[]): Record<string, unknown>;
9
+ /**
10
+ * Reads a peer's relay offer from its advert: the addresses it offers relay service at, or undefined when the peer advertises no offer (or the extension is malformed, which reads as no offer rather than an error). A receiver still decides for itself whether to use any offered address, exactly as it would an in-band relay-offer-frame.
11
+ */
12
+ export declare function readRelayOffer(advert: Readonly<PeerAdvert>): readonly string[] | undefined;
13
+ //#endregion
@@ -0,0 +1,27 @@
1
+ //#region src/domain/relay-advert.ts
2
+ /** The domain-qualified gossip extension key a node's relay offer rides under, the same `wire-mesh` domain the core's own gossiped version fact occupies. */
3
+ const RELAY_OFFER_GOSSIP_KEY = "wire-mesh/relay-offer";
4
+ /**
5
+ * Builds the relay-offer extension bag for `sendGossipUpdate`, carrying exactly the address list `relay-offer-frame` itself would name. Throws on an empty list or a non-string entry: an empty offer is not an offer (the caller should simply not advertise), and a malformed address on the wire would be every reader's problem rather than one caller's bug caught at the call site.
6
+ */
7
+ function buildRelayOfferExtension(addresses) {
8
+ if (addresses.length === 0) throw new Error("a relay offer names at least one address");
9
+ for (const address of addresses) if (typeof address !== "string") throw new Error(`a relay offer address is a string, got ${JSON.stringify(address)}`);
10
+ return { [RELAY_OFFER_GOSSIP_KEY]: [...addresses] };
11
+ }
12
+ /**
13
+ * Reads a peer's relay offer from its advert: the addresses it offers relay service at, or undefined when the peer advertises no offer (or the extension is malformed, which reads as no offer rather than an error). A receiver still decides for itself whether to use any offered address, exactly as it would an in-band relay-offer-frame.
14
+ */
15
+ function readRelayOffer(advert) {
16
+ const value = advert[RELAY_OFFER_GOSSIP_KEY];
17
+ if (!Array.isArray(value)) return void 0;
18
+ const addresses = [];
19
+ for (const entry of value) {
20
+ if (typeof entry !== "string") return void 0;
21
+ addresses.push(entry);
22
+ }
23
+ if (addresses.length === 0) return void 0;
24
+ return addresses;
25
+ }
26
+ //#endregion
27
+ export { RELAY_OFFER_GOSSIP_KEY, buildRelayOfferExtension, readRelayOffer };
@@ -1,5 +1,5 @@
1
1
  import { D as DeviceId, N as Frame, et as PeerAdvert } from "../protocol-DCDae3z4.cjs";
2
- import { t as Connection } from "../transport-CfW7fsGK.cjs";
2
+ import { t as Connection } from "../transport-DIXjoQKn.cjs";
3
3
  import { AdvertExtensionPolicy } from "./advert-extension-policy.cjs";
4
4
  import { Mailbox, MailboxRefusalReason } from "./hub-mailbox.cjs";
5
5
  import { PeerAdvertVerifier } from "./peer-advert.cjs";
@@ -1,5 +1,5 @@
1
1
  import { D as DeviceId, N as Frame, et as PeerAdvert } from "../protocol-DCDae3z4.mjs";
2
- import { t as Connection } from "../transport-iLBxIkRS.mjs";
2
+ import { t as Connection } from "../transport-B0GywEzG.mjs";
3
3
  import { AdvertExtensionPolicy } from "./advert-extension-policy.mjs";
4
4
  import { Mailbox, MailboxRefusalReason } from "./hub-mailbox.mjs";
5
5
  import { PeerAdvertVerifier } from "./peer-advert.mjs";
@@ -1,5 +1,5 @@
1
1
  import { N as Frame } from "../protocol-DCDae3z4.cjs";
2
- import { t as Connection } from "../transport-CfW7fsGK.cjs";
2
+ import { t as Connection } from "../transport-DIXjoQKn.cjs";
3
3
  //#region src/domain/relay-use-gate.d.ts
4
4
  export interface RelayUseAuthorizationTracker {
5
5
  /** True once authorize() has been called for this exact connection and revoke() has not been called since. */
@@ -1,5 +1,5 @@
1
1
  import { N as Frame } from "../protocol-DCDae3z4.mjs";
2
- import { t as Connection } from "../transport-iLBxIkRS.mjs";
2
+ import { t as Connection } from "../transport-B0GywEzG.mjs";
3
3
  //#region src/domain/relay-use-gate.d.ts
4
4
  export interface RelayUseAuthorizationTracker {
5
5
  /** True once authorize() has been called for this exact connection and revoke() has not been called since. */
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_tokens = require("../tokens-DSG3SDr8.cjs");
2
3
  const require_domain_device_id = require("./device-id.cjs");
3
- const require_tokens = require("../tokens-Dh6mG6p_.cjs");
4
4
  //#region src/domain/revocation-view.ts
5
5
  function revocationKey(tokenId) {
6
6
  return require_domain_device_id.bytesToHex(tokenId);
@@ -1,5 +1,5 @@
1
+ import { o as verifyRevocationEntry } from "../tokens-CMdWiRBb.mjs";
1
2
  import { bytesToHex } from "./device-id.mjs";
2
- import { o as verifyRevocationEntry } from "../tokens-B0dpTLhS.mjs";
3
3
  //#region src/domain/revocation-view.ts
4
4
  function revocationKey(tokenId) {
5
5
  return bytesToHex(tokenId);
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_tokens = require("../tokens-DSG3SDr8.cjs");
2
3
  const require_domain_device_id = require("./device-id.cjs");
3
- const require_tokens = require("../tokens-Dh6mG6p_.cjs");
4
4
  const require_domain_room_path = require("./room-path.cjs");
5
5
  //#region src/domain/room-token-verification.ts
6
6
  /**
@@ -1,5 +1,5 @@
1
+ import { a as verifyCapabilityToken } from "../tokens-CMdWiRBb.mjs";
1
2
  import { deviceIdToHex } from "./device-id.mjs";
2
- import { a as verifyCapabilityToken } from "../tokens-B0dpTLhS.mjs";
3
3
  import { parseRoomPath } from "./room-path.mjs";
4
4
  //#region src/domain/room-token-verification.ts
5
5
  /**
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_tokens = require("../tokens-Dh6mG6p_.cjs");
2
+ const require_tokens = require("../tokens-DSG3SDr8.cjs");
3
3
  //#region src/domain/threshold-subject.ts
4
4
  /**
5
5
  * `threshold-subject` -- what a signing session actually asks a group to sign, and the single most important design decision in the whole protocol: it carries the FULL decoded content, never a bare hash. A participant that commits to an opaque digest is a blind signer, and the entire security value of T-of-N is that each participant independently reviews and authorises the content -- returning a round-1 commitment IS that act of authorisation. This module builds exactly the bytes a participant signs its round-1 commitment over: the RFC 9052 §4.4 `Sig_structure`, reusing `tokens.ts`'s own `sig1ToBeSigned` rather than a second hand-rolled construction, and NEVER accepting a pre-assembled to-be-signed blob from a coordinator. Mirrors `wire_mesh_threshold::subject` on the Rust side exactly.
@@ -1,4 +1,4 @@
1
- import { i as sig1ToBeSigned } from "../tokens-B0dpTLhS.mjs";
1
+ import { i as sig1ToBeSigned } from "../tokens-CMdWiRBb.mjs";
2
2
  //#region src/domain/threshold-subject.ts
3
3
  /**
4
4
  * `threshold-subject` -- what a signing session actually asks a group to sign, and the single most important design decision in the whole protocol: it carries the FULL decoded content, never a bare hash. A participant that commits to an opaque digest is a blind signer, and the entire security value of T-of-N is that each participant independently reviews and authorises the content -- returning a round-1 commitment IS that act of authorisation. This module builds exactly the bytes a participant signs its round-1 commitment over: the RFC 9052 §4.4 `Sig_structure`, reusing `tokens.ts`'s own `sig1ToBeSigned` rather than a second hand-rolled construction, and NEVER accepting a pre-assembled to-be-signed blob from a coordinator. Mirrors `wire_mesh_threshold::subject` on the Rust side exactly.
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_tokens = require("../tokens-Dh6mG6p_.cjs");
2
+ const require_tokens = require("../tokens-DSG3SDr8.cjs");
3
3
  exports.canGrant = require_tokens.canGrant;
4
4
  exports.mintCapabilityToken = require_tokens.mintCapabilityToken;
5
5
  exports.mintRevocationEntry = require_tokens.mintRevocationEntry;
@@ -1,2 +1,2 @@
1
- import { a as verifyCapabilityToken, i as sig1ToBeSigned, n as mintCapabilityToken, o as verifyRevocationEntry, r as mintRevocationEntry, t as canGrant } from "../tokens-B0dpTLhS.mjs";
1
+ import { a as verifyCapabilityToken, i as sig1ToBeSigned, n as mintCapabilityToken, o as verifyRevocationEntry, r as mintRevocationEntry, t as canGrant } from "../tokens-CMdWiRBb.mjs";
2
2
  export { canGrant, mintCapabilityToken, mintRevocationEntry, sig1ToBeSigned, verifyCapabilityToken, verifyRevocationEntry };
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_tokens = require("../tokens-Dh6mG6p_.cjs");
2
+ const require_tokens = require("../tokens-DSG3SDr8.cjs");
3
3
  //#region src/domain/webrtc-signaling.ts
4
4
  /** The one capability verb gating every core/webrtc message shape: an authority over this node's own signaling as a whole, not three separate resources, mirroring how core/exec's exec:pty gates all of its own inner verbs. webrtc.sfu-track-map is gated by this same verb (see spec/webrtc.cddl's own comment): an SFU able to shape a negotiation can already misreport whose media is on which mid, so a separate capability over the track-map itself would guard nothing a client doesn't already have to trust the SFU for. */
5
5
  const WEBRTC_SIGNAL_VERB = "webrtc:signal";
@@ -1,4 +1,4 @@
1
- import { a as verifyCapabilityToken } from "../tokens-B0dpTLhS.mjs";
1
+ import { a as verifyCapabilityToken } from "../tokens-CMdWiRBb.mjs";
2
2
  //#region src/domain/webrtc-signaling.ts
3
3
  /** The one capability verb gating every core/webrtc message shape: an authority over this node's own signaling as a whole, not three separate resources, mirroring how core/exec's exec:pty gates all of its own inner verbs. webrtc.sfu-track-map is gated by this same verb (see spec/webrtc.cddl's own comment): an SFU able to shape a negotiation can already misreport whose media is on which mid, so a separate capability over the track-map itself would guard nothing a client doesn't already have to trust the SFU for. */
4
4
  const WEBRTC_SIGNAL_VERB = "webrtc:signal";
@@ -1,2 +1,2 @@
1
- import { n as Listener, r as Transport, t as Connection } from "../transport-CfW7fsGK.cjs";
1
+ import { n as Listener, r as Transport, t as Connection } from "../transport-DIXjoQKn.cjs";
2
2
  export { Connection, Listener, Transport };
@@ -1,2 +1,2 @@
1
- import { n as Listener, r as Transport, t as Connection } from "../transport-iLBxIkRS.mjs";
1
+ import { n as Listener, r as Transport, t as Connection } from "../transport-B0GywEzG.mjs";
2
2
  export { Connection, Listener, Transport };
@@ -10,6 +10,8 @@ interface Connection {
10
10
  close: () => Promise<void>;
11
11
  /** The peer's device-id, cryptographically authenticated by the adapter itself (never a value read off the wire) -- absent when the adapter has nothing to authenticate against (createTcpTransport) or the peer presented no credential to check (an accepting createTlsTransport connection whose peer sent no certificate). A caller that later receives an application-level claim of who the peer is (e.g. an `introduce`-style message) MUST treat a mismatch against this field as a spoofing attempt and refuse the claim, exactly the same "reject and destroy" obligation self-certifying claims elsewhere in this spec already carry. */
12
12
  readonly peerDeviceId?: DeviceId;
13
+ /** The address to dial to reach this same peer again, when the adapter has learned a better one than the address this connection was dialled with: a node that rotates the certificate a browser pins tells each connection the certificates it will serve next, so a dial made after the address the user was given has aged still succeeds. Absent when the adapter learned nothing, in which case the dialled address stands. */
14
+ readonly redialAddress?: () => string;
13
15
  /** Lets the event loop exit while this connection is still open and functioning -- a caller that wants I/O to keep working without keeping the process alive (matching Node's own `net.Socket.unref()`) calls this once. Adapters with no underlying handle to unref (an in-memory test double, a browser WebSocket) simply omit it. */
14
16
  unref?: () => void;
15
17
  }
@@ -10,6 +10,8 @@ interface Connection {
10
10
  close: () => Promise<void>;
11
11
  /** The peer's device-id, cryptographically authenticated by the adapter itself (never a value read off the wire) -- absent when the adapter has nothing to authenticate against (createTcpTransport) or the peer presented no credential to check (an accepting createTlsTransport connection whose peer sent no certificate). A caller that later receives an application-level claim of who the peer is (e.g. an `introduce`-style message) MUST treat a mismatch against this field as a spoofing attempt and refuse the claim, exactly the same "reject and destroy" obligation self-certifying claims elsewhere in this spec already carry. */
12
12
  readonly peerDeviceId?: DeviceId;
13
+ /** The address to dial to reach this same peer again, when the adapter has learned a better one than the address this connection was dialled with: a node that rotates the certificate a browser pins tells each connection the certificates it will serve next, so a dial made after the address the user was given has aged still succeeds. Absent when the adapter learned nothing, in which case the dialled address stands. */
14
+ readonly redialAddress?: () => string;
13
15
  /** Lets the event loop exit while this connection is still open and functioning -- a caller that wants I/O to keep working without keeping the process alive (matching Node's own `net.Socket.unref()`) calls this once. Adapters with no underlying handle to unref (an in-memory test double, a browser WebSocket) simply omit it. */
14
16
  unref?: () => void;
15
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wire-mesh-core",
3
- "version": "3.3.1",
3
+ "version": "3.5.0",
4
4
  "dependencies": {
5
5
  "cbor2": "2.3.0",
6
6
  "cddl.js": "1.0.1",
@@ -86,6 +86,10 @@
86
86
  "import": "./dist/domain/capability-request.mjs",
87
87
  "require": "./dist/domain/capability-request.cjs"
88
88
  },
89
+ "./domain/coordinator-election": {
90
+ "import": "./dist/domain/coordinator-election.mjs",
91
+ "require": "./dist/domain/coordinator-election.cjs"
92
+ },
89
93
  "./domain/data-sync": {
90
94
  "import": "./dist/domain/data-sync.mjs",
91
95
  "require": "./dist/domain/data-sync.cjs"
@@ -150,6 +154,10 @@
150
154
  "import": "./dist/domain/pinned-address.mjs",
151
155
  "require": "./dist/domain/pinned-address.cjs"
152
156
  },
157
+ "./domain/relay-advert": {
158
+ "import": "./dist/domain/relay-advert.mjs",
159
+ "require": "./dist/domain/relay-advert.cjs"
160
+ },
153
161
  "./domain/relay-hub": {
154
162
  "import": "./dist/domain/relay-hub.mjs",
155
163
  "require": "./dist/domain/relay-hub.cjs"
@@ -1,7 +1,7 @@
1
1
  import { capabilityTokenSchema, revocationClaimsSchema, tokenClaimsSchema } from "./generated/protocol.mjs";
2
2
  import { n as scopeNarrows, t as bytesEqual } from "./token-scope-Z4bmci4M.mjs";
3
- import { z } from "zod";
4
3
  import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2";
4
+ import { z } from "zod";
5
5
  import { PredicateNodeSchema, evaluatePredicate } from "trilean";
6
6
  //#region src/domain/token-predicates.ts
7
7
  /** The wire shape of `token-claims.conditions` once CBOR-decoded: trilean's own PredicateNodeSchema is the single source of truth for what a condition entry may contain, re-validated here rather than trusted from a CDDL-generated shadow schema (see tokens.cddl's own comment on why `conditions` is an opaque bstr, not a native CDDL type) -- a token from an untrusted peer must pass trilean's real schema before any of its conditions are evaluated. Explicitly annotated: trilean's PredicateNodeSchema is a deeply recursive z.lazy() type whose inferred shape is too large for tsdown's declaration-file generator to serialise (TS7056) without this. */
@@ -1,7 +1,7 @@
1
1
  const require_generated_protocol = require("./generated/protocol.cjs");
2
2
  const require_token_scope = require("./token-scope-CxHXTT3u.cjs");
3
- let zod = require("zod");
4
3
  let cbor2 = require("cbor2");
4
+ let zod = require("zod");
5
5
  let trilean = require("trilean");
6
6
  //#region src/domain/token-predicates.ts
7
7
  /** The wire shape of `token-claims.conditions` once CBOR-decoded: trilean's own PredicateNodeSchema is the single source of truth for what a condition entry may contain, re-validated here rather than trusted from a CDDL-generated shadow schema (see tokens.cddl's own comment on why `conditions` is an opaque bstr, not a native CDDL type) -- a token from an untrusted peer must pass trilean's real schema before any of its conditions are evaluated. Explicitly annotated: trilean's PredicateNodeSchema is a deeply recursive z.lazy() type whose inferred shape is too large for tsdown's declaration-file generator to serialise (TS7056) without this. */