From da703fd4f772d7a59636f426b517a98d48d226f4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:22:59 +0100 Subject: [PATCH 1/4] feat(spec): open peer-advert's own extension tail (P4) peer-advert gains the same * tstr => any extension tail token-claims already carries, the generalised gossip-extension point named in the agent-comms migration design: a node's presence status, its own accept/refuse policy for a claim class, and any future domain's own gossiped fact all ride this one open tail, added once rather than as a series of separately-named fields each time a new fact needs gossiping. An unrecognised key here is exactly as trustworthy, and exactly as ignorable, as any other unrecognised value in an open discriminator elsewhere in this spec. --- spec/protocol.cddl | 13 +++++++++++++ spec/transport.cddl | 13 +++++++++++++ ts/packages/core/src/generated/protocol.ts | 2 +- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/spec/protocol.cddl b/spec/protocol.cddl index bb8f837..1e7a679 100644 --- a/spec/protocol.cddl +++ b/spec/protocol.cddl @@ -849,10 +849,23 @@ token-claims = { ping-frame = { type: "ping" } close-frame = { type: "close", ? reason: tstr } +; The extension tail mirrors token-claims' own `* tstr => any` (tokens.cddl) +; and is the generalised gossip-extension point named in the agent-comms +; migration design's own P4 refinement: a node's presence status, its own +; accept/refuse policy for a claim class (e.g. "does not accept messages +; carrying a valid-until claim"), and any future domain's own gossiped fact +; all ride this one open tail, added once rather than as a series of +; separately-named fields each time a new fact needs advertising. A field +; here is exactly as trustworthy as any other gossiped, self-asserted claim +; in this spec (peer-advert carries no signature of its own) — a reader +; treats an unrecognised key the same verifier-obligation way an unrecognised +; value in an open discriminator is treated elsewhere in this spec: ignored, +; never acted on without understanding it, never treated as an error. peer-advert = { device: device-id, addresses: [* tstr], ; "host:port" strings snapshot-seconds: int, ; Unix-seconds snapshot time + * tstr => any, } gossip-frame = { type: "gossip", peers: [* peer-advert] } diff --git a/spec/transport.cddl b/spec/transport.cddl index 93e64a8..ac1fac7 100644 --- a/spec/transport.cddl +++ b/spec/transport.cddl @@ -14,10 +14,23 @@ ping-frame = { type: "ping" } close-frame = { type: "close", ? reason: tstr } +; The extension tail mirrors token-claims' own `* tstr => any` (tokens.cddl) +; and is the generalised gossip-extension point named in the agent-comms +; migration design's own P4 refinement: a node's presence status, its own +; accept/refuse policy for a claim class (e.g. "does not accept messages +; carrying a valid-until claim"), and any future domain's own gossiped fact +; all ride this one open tail, added once rather than as a series of +; separately-named fields each time a new fact needs advertising. A field +; here is exactly as trustworthy as any other gossiped, self-asserted claim +; in this spec (peer-advert carries no signature of its own) — a reader +; treats an unrecognised key the same verifier-obligation way an unrecognised +; value in an open discriminator is treated elsewhere in this spec: ignored, +; never acted on without understanding it, never treated as an error. peer-advert = { device: device-id, addresses: [* tstr], ; "host:port" strings snapshot-seconds: int, ; Unix-seconds snapshot time + * tstr => any, } gossip-frame = { type: "gossip", peers: [* peer-advert] } diff --git a/ts/packages/core/src/generated/protocol.ts b/ts/packages/core/src/generated/protocol.ts index b15a0ea..360e31a 100644 --- a/ts/packages/core/src/generated/protocol.ts +++ b/ts/packages/core/src/generated/protocol.ts @@ -272,7 +272,7 @@ export const peerAdvertSchema = z.lazy(() => z.object({ "device": z.lazy(() => deviceIdSchema), "addresses": z.array(z.string()), "snapshot-seconds": z.number().int(), -})); +}).catchall(z.unknown())); export const gossipFrameSchema = z.lazy(() => z.object({ "type": z.literal("gossip"), "peers": z.array(z.lazy(() => peerAdvertSchema)), From ab54836cd27b24ecfd735c351e84936520c4ddd8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:23:09 +0100 Subject: [PATCH 2/4] feat(core): let a session re-advertise its own gossip extensions sendGossipUpdate re-sends this side's own self-advert with a fresh snapshot-seconds and any given extensions merged onto peer-advert's own open tail, since wireUpConnection's own initial self-advert is otherwise never repeated over a connection's lifetime -- there was no way to keep a gossiped fact (presence status, an accept/refuse policy) live once the connection settled. MeshSession owns no timer of its own for this: a caller decides its own re-advertisement cadence, matching the session's existing DOM-free, fully unit-testable design; a caller that never calls this again after connecting keeps today's exact gossip-once-on-connect behaviour. Factors the self-advert construction wireUpConnection already built inline into a shared buildSelfAdvert helper, so both call sites stay in sync rather than duplicating the frame shape. --- ts/packages/core/src/domain/mesh-session.ts | 40 +++++++++++++------ ts/packages/core/test/mesh-session.test.ts | 43 +++++++++++++++++++++ 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/ts/packages/core/src/domain/mesh-session.ts b/ts/packages/core/src/domain/mesh-session.ts index ed12574..748ea9c 100644 --- a/ts/packages/core/src/domain/mesh-session.ts +++ b/ts/packages/core/src/domain/mesh-session.ts @@ -95,6 +95,8 @@ export interface MeshSession { sendRevocationAnnounce: ( entries: readonly RevocationEntry[], ) => Promise; + /** 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. */ + sendGossipUpdate: (extensions?: Record) => Promise; /** 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. */ sendManageRequest: ( command: ManageCommand, @@ -418,6 +420,21 @@ function createSessionCore( } } + /** Builds this side's own self-advert: this node's own directly-reachable addresses (wire-mesh#38), 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. */ + function buildSelfAdvert(extensions?: Record): GossipFrame { + return { + type: "gossip", + peers: [ + { + device: identity.deviceId, + addresses: [...addresses], + "snapshot-seconds": Math.floor(clock.now() / MS_PER_SECOND), + ...extensions, + }, + ], + }; + } + /** Everything a connection needs once it exists, regardless of whether it was dialled (createMeshSession's own doConnect, below) or handed over already established (acceptMeshSession): send this side's handshake and self-advert, arm the handshake timeout, and start consuming frames. The two entry points differ only in how link itself came to exist and what address means for it -- a real dial target for one, a caller-chosen label for the other, since the Connection/Transport ports expose no remote-address concept of their own for an accepted connection. */ async function wireUpConnection( link: Readonly, @@ -431,17 +448,7 @@ function createSessionCore( frameLog.push({ direction: "sent", frame: localHandshakeSent }); await connection.send(localHandshakeSent); emit(); - // Self-advertisement: this node's own directly-reachable addresses (wire-mesh#38), 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. - const selfAdvert: GossipFrame = { - type: "gossip", - peers: [ - { - device: identity.deviceId, - addresses: [...addresses], - "snapshot-seconds": Math.floor(clock.now() / MS_PER_SECOND), - }, - ], - }; + const selfAdvert = buildSelfAdvert(); frameLog.push({ direction: "sent", frame: selfAdvert }); await connection.send(selfAdvert); emit(); @@ -614,6 +621,17 @@ function createSessionCore( await transmit(frame, false); emit(); }, + async sendGossipUpdate( + extensions?: Record, + ): Promise { + if (connection === null || state.status !== "connected") { + throw new Error("not connected"); + } + const frame = buildSelfAdvert(extensions); + frameLog.push({ direction: "sent", frame }); + await transmit(frame, false); + emit(); + }, async close(): Promise { feedCancelled = true; if (handshakeTimer !== null) { diff --git a/ts/packages/core/test/mesh-session.test.ts b/ts/packages/core/test/mesh-session.test.ts index 4a209b1..b4128ef 100644 --- a/ts/packages/core/test/mesh-session.test.ts +++ b/ts/packages/core/test/mesh-session.test.ts @@ -270,6 +270,49 @@ describe("createMeshSession", () => { await session.close(); }); + it("sendGossipUpdate re-sends a fresh self-advert with the given extensions merged in", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await session.sendGossipUpdate({ presence: "idle" }); + + const updated = connection.sent.at(-1) as GossipFrame; + expect(updated).toEqual({ + type: "gossip", + peers: [ + { + device: testIdentityDeviceId, + addresses: [], + "snapshot-seconds": Math.floor(TEST_CLOCK_NOW_MS / MS_PER_SECOND), + presence: "idle", + }, + ], + } satisfies GossipFrame); + await session.close(); + }); + + it("sendGossipUpdate with no extensions re-sends a plain self-advert", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await session.sendGossipUpdate(); + + const updated = connection.sent.at(-1) as GossipFrame; + expect(updated).toEqual({ + type: "gossip", + peers: [ + { + device: testIdentityDeviceId, + addresses: [], + "snapshot-seconds": Math.floor(TEST_CLOCK_NOW_MS / MS_PER_SECOND), + }, + ], + } satisfies GossipFrame); + await session.close(); + }); + it("excludes the retired core/federation domain even when both sides offer it", async () => { const { transport, connection } = fakeTransport(); const session = createMeshSession(transport, testIdentity, testClock); From adc01e6f0c044c37bfac817816a064e2724ab2ca Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:35:10 +0100 Subject: [PATCH 3/4] fix(core): accept peer-advert extension keys in the Rust codec, namespace and guard them in TS The Rust codec's peer_advert_from rejected any key beyond device/addresses/snapshot-seconds as DecodeError::UnknownKey, so a TS peer's first sendGossipUpdate call (carrying its new extension tail) would disconnect every Rust peer instead of being ignored as transport.cddl's own comment requires. PeerAdvert gains an extra: CanonicalMap field, decoded and re-encoded the same way room-notice-claims/token-claims already handle their own open tails. sendGossipUpdate's extension bag could previously shadow peer-advert's own mandatory fields (a caller passing { device: ... } silently overwrote the session's real device-id on the wire) and had no namespacing convention, so two independent applications advertising over one session could collide on an identical bare key like "status". validateGossipExtensions now rejects both cases immediately: a key colliding with device/addresses/snapshot-seconds, or a key not shaped "/". buildSelfAdvert also spreads extensions before the mandatory fields, not after, so the field order itself can't shadow them even if the guard were ever bypassed. Documents the domain-qualified key convention in spec/CONVENTIONS.md and spec/transport.cddl, marks the peer-advert extension bag as shipped rather than anticipated, and adds a conformance vector exercising an extension-bearing peer-advert end to end. --- conformance/frames.v1.json | 17 ++++++ rust/crates/wire-mesh-wire/src/transport.rs | 61 +++++++++++++++++---- spec/CONVENTIONS.md | 8 ++- spec/transport.cddl | 10 ++++ ts/packages/core/src/domain/mesh-session.ts | 33 ++++++++++- ts/packages/core/test/mesh-session.test.ts | 26 ++++++++- 6 files changed, 140 insertions(+), 15 deletions(-) diff --git a/conformance/frames.v1.json b/conformance/frames.v1.json index 3c05ecc..9bceea5 100644 --- a/conformance/frames.v1.json +++ b/conformance/frames.v1.json @@ -45,6 +45,23 @@ }, "wire_hex": "a2647479706566676f7373697065706565727382a366646576696365582011111111111111111111111111111111111111111111111111111111111111116961646472657373657381703230332e302e3131332e353a3434333370736e617073686f742d7365636f6e64731a6ef95380a366646576696365582022222222222222222222222222222222222222222222222222222222222222226961646472657373657382703230332e302e3131332e393a34343333713139382e35312e3130302e323a3434333370736e617073686f742d7365636f6e64731a6ef95381" }, + { + "name": "gossip_v1_peer_advert_with_extension", + "message": { + "type": "gossip", + "peers": [ + { + "device": { + "hex": "3333333333333333333333333333333333333333333333333333333333333333" + }, + "addresses": [], + "snapshot-seconds": 1861920000, + "presence/status": "idle" + } + ] + }, + "wire_hex": "a2647479706566676f7373697065706565727381a4666465766963655820333333333333333333333333333333333333333333333333333333333333333369616464726573736573806f70726573656e63652f7374617475736469646c6570736e617073686f742d7365636f6e64731a6efaa500" + }, { "name": "candidates_v1_host_and_relayed", "message": { diff --git a/rust/crates/wire-mesh-wire/src/transport.rs b/rust/crates/wire-mesh-wire/src/transport.rs index 0e904ac..9f6cbfc 100644 --- a/rust/crates/wire-mesh-wire/src/transport.rs +++ b/rust/crates/wire-mesh-wire/src/transport.rs @@ -8,6 +8,7 @@ use minicbor::{Decode, Decoder, Encode, Encoder}; use crate::error::DecodeError; use crate::identity::{device_id_from, DeviceId}; use crate::strict; +use crate::value::{CanonicalMap, CborValue, CdeKey, CdeMapBuilder}; /// `ping-frame = { type: "ping" }`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -91,9 +92,12 @@ pub(crate) fn close_from(d: &mut Decoder<'_>) -> Result Ok(CloseFrame { reason }) } -/// `peer-advert = { device, addresses, snapshot-seconds }`. +/// `peer-advert = { device, addresses, snapshot-seconds, * tstr => any }`. /// -/// CDE key order: `device` (7), `addresses` (10), `snapshot-seconds` (18). +/// CDE key order: `device` (7), `addresses` (10), `snapshot-seconds` (18), +/// with any extension key interleaved by its own encoded-key order (see +/// `spec/CONVENTIONS.md`'s gossip-extension-namespacing convention for the +/// `/` key shape a well-behaved extension key must use). #[derive(Debug, Clone, PartialEq, Eq)] pub struct PeerAdvert { pub device: DeviceId, @@ -101,6 +105,12 @@ pub struct PeerAdvert { pub addresses: Vec, /// Unix-seconds snapshot time. pub snapshot_seconds: i64, + /// Forward-compatible extension bag (presence status, an accept/refuse + /// policy, or any future gossiped fact) -- an unrecognised key here is + /// exactly as trustworthy as any other gossiped, self-asserted claim, + /// per the verifier obligation `spec/transport.cddl` states directly: + /// ignored, never acted on without understanding it, never an error. + pub extra: CanonicalMap, } impl Encode<()> for PeerAdvert { @@ -109,14 +119,19 @@ impl Encode<()> for PeerAdvert { e: &mut Encoder, _ctx: &mut (), ) -> Result<(), minicbor::encode::Error> { - e.map(3)?; - e.str("device")?.encode(self.device)?; - e.str("addresses")?.array(self.addresses.len() as u64)?; - for address in &self.addresses { - e.str(address)?; + let mut builder = CdeMapBuilder::new(); + builder.push("device", &self.device); + builder.push("addresses", &self.addresses); + builder.push("snapshot-seconds", &self.snapshot_seconds); + for (key, value) in self.extra.iter() { + let mut value_buf = Vec::new(); + let mut value_enc = Encoder::new(&mut value_buf); + value + .encode(&mut value_enc, &mut ()) + .unwrap_or_else(|_| unreachable!("Vec writes are infallible")); + builder.push_raw(key.encoded(), value_buf); } - e.str("snapshot-seconds")?.i64(self.snapshot_seconds)?; - e.ok() + builder.write(e) } } @@ -131,6 +146,7 @@ pub(crate) fn peer_advert_from(d: &mut Decoder<'_>) -> Result = None; let mut addresses: Option> = None; let mut snapshot_seconds: Option = None; + let mut extra = CanonicalMap::new(); while let Some(key) = map.next_key(d)? { match key { "device" => strict::set_once(&mut device, device_id_from(d)?)?, @@ -143,13 +159,17 @@ pub(crate) fn peer_advert_from(d: &mut Decoder<'_>) -> Result strict::set_once(&mut snapshot_seconds, strict::int_value(d)?)?, - other => return Err(DecodeError::UnknownKey(other.to_owned())), + other => { + let value = CborValue::decode_strict(d)?; + extra.insert(other.to_owned(), value)?; + } } } Ok(PeerAdvert { device: device.ok_or(DecodeError::MissingField("device"))?, addresses: addresses.ok_or(DecodeError::MissingField("addresses"))?, snapshot_seconds: snapshot_seconds.ok_or(DecodeError::MissingField("snapshot-seconds"))?, + extra, }) } @@ -784,6 +804,7 @@ mod tests { device: DeviceId([1; 32]), addresses: vec!["203.0.113.5:4433".to_owned()], snapshot_seconds: 1861833600, + extra: CanonicalMap::new(), }); let device_at = bytes .windows(6) @@ -800,6 +821,26 @@ mod tests { assert!(device_at < addresses_at && addresses_at < snapshot_at); } + #[test] + fn peer_advert_carries_extension_fields() { + // An unrecognised key is accepted into the open `* tstr => any` tail, the same forward-compatible-extension pattern room-notice-claims/token-claims already carry -- a TS peer's sendGossipUpdate(...) extensions must not disconnect a Rust peer. + round_trip(PeerAdvert { + device: DeviceId([3; 32]), + addresses: vec![], + snapshot_seconds: 1861920000, + extra: { + let mut extra = CanonicalMap::new(); + extra + .insert( + "presence/status".to_owned(), + CborValue::Text("idle".to_owned()), + ) + .expect("insert"); + extra + }, + }); + } + #[test] fn candidate_kind_literals() { assert_eq!(CandidateKind::Host.as_str(), "host"); diff --git a/spec/CONVENTIONS.md b/spec/CONVENTIONS.md index 05e975d..9115fc0 100644 --- a/spec/CONVENTIONS.md +++ b/spec/CONVENTIONS.md @@ -49,6 +49,12 @@ When a field's set of possible values is expected to grow as new domains adopt t Two shipped instances: `capability-scope.kind` (`tokens.cddl`) — "node" and "folder" are Cascade's own scopes, "room"/"org" are agent-comms', "group" is a person's/team's/organisation's own device set, and the comment states plainly that "a future application mints its own kind rather than needing this schema to change." `message-ref.relation` (`room.cddl`) — "reply" and "forward" are the two relations needed today, but the field is an open `tstr` specifically so a future relation (quote, edit-of, supersedes) is an additive value, never a schema change. -Two further instances are anticipated, not yet shipped: a `peer-advert` extension bag (mirroring `token-claims`' own `* tstr => any` tail, proposed so presence status, a peer's own accept/refuse policy, and future gossiped facts share one open extension point rather than each becoming its own bolted-on field) and `core/threshold`'s own `threshold-subject.kind` (the type of content a threshold signature covers) — named here so whoever builds either reaches for an open field from the start rather than shipping a closed enum and having to widen it later. +One further instance is shipped: `peer-advert`'s own extension bag (mirroring `token-claims`' own `* tstr => any` tail; `transport.cddl`), letting presence status, a peer's own accept/refuse policy, and future gossiped facts share one open extension point rather than each becoming its own bolted-on field — see the gossip-extension-namespacing convention below for the key-naming rule that keeps two independent applications from colliding on it. One further instance is anticipated, not yet shipped: `core/threshold`'s own `threshold-subject.kind` (the type of content a threshold signature covers) — named here so whoever builds it reaches for an open field from the start rather than shipping a closed enum and having to widen it later. + +## Namespacing keys in a shared gossip extension tail + +`peer-advert`'s extension tail (`transport.cddl`) is a single flat `* tstr => any` map shared by every application advertising presence, policy, or any other fact over a session — nothing in the shape itself stops two independent domains from choosing the same field name (both wanting a key called `status`, say) and silently overwriting or misreading each other's value. + +**Convention**: a gossip extension key MUST be domain-qualified as `/` (e.g. `presence/status`, not bare `status`), and MUST NOT repeat one of `peer-advert`'s own typed field names (`device`, `addresses`, `snapshot-seconds`) — a well-behaved sender rejects an attempt to advertise under either shape rather than let it collide with something else's meaning or shadow a real field. `wire-mesh-core`'s `sendGossipUpdate` enforces both rules at the call site, not only in prose: a caller passing a reserved or non-qualified key gets a thrown error immediately, rather than a frame that silently corrupts or ambiguously shares a key. **Convention**: before adding a closed enum for any field that names a *kind* of something (a scope kind, a relation, a content type, a subject type), ask whether a future domain might reasonably need a value this spec doesn't anticipate. If yes — which is the common case for anything describing "what kind of X is this" rather than a truly fixed, small, protocol-level choice — use an open `tstr` (optionally pattern-constrained) instead, and pair it with the verifier obligation above: an unrecognised value must be refused, never guessed at. diff --git a/spec/transport.cddl b/spec/transport.cddl index ac1fac7..036a8ac 100644 --- a/spec/transport.cddl +++ b/spec/transport.cddl @@ -26,6 +26,16 @@ close-frame = { type: "close", ? reason: tstr } ; treats an unrecognised key the same verifier-obligation way an unrecognised ; value in an open discriminator is treated elsewhere in this spec: ignored, ; never acted on without understanding it, never treated as an error. +; +; A key here MUST be domain-qualified as "/" (e.g. +; "presence/status", not bare "status") — see spec/CONVENTIONS.md's +; gossip-extension-namespacing convention. This is what lets two independent +; applications advertising over the same session coexist without one's +; extension silently shadowing the other's identically-named field. A key +; here MUST also never repeat one of this map's own typed field names +; (device/addresses/snapshot-seconds) — an implementation MUST reject an +; attempt to advertise under one of those names rather than let it shadow +; the real field. peer-advert = { device: device-id, addresses: [* tstr], ; "host:port" strings diff --git a/ts/packages/core/src/domain/mesh-session.ts b/ts/packages/core/src/domain/mesh-session.ts index 748ea9c..8e7fcf9 100644 --- a/ts/packages/core/src/domain/mesh-session.ts +++ b/ts/packages/core/src/domain/mesh-session.ts @@ -26,6 +26,32 @@ import { messageFromFrame, tryDecodeFrame } from "../adapters/frame-codec.js"; const MS_PER_SECOND = 1000; +/** peer-advert's own three typed fields -- reserved so a `sendGossipUpdate` caller can never override the session's own device-id, address list, or freshness timestamp by supplying an extension of the same name. */ +const RESERVED_PEER_ADVERT_KEYS = new Set([ + "device", + "addresses", + "snapshot-seconds", +]); + +/** A gossip extension key must be domain-qualified as `/` (lowercase kebab-case each side), per `spec/CONVENTIONS.md`'s gossip-extension-namespacing convention -- this is what stops two independent applications sharing one gossip tail from silently colliding on a bare name like "status". */ +const GOSSIP_EXTENSION_KEY_PATTERN = /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/; + +/** Rejects a `sendGossipUpdate` extension bag that would either shadow one of peer-advert's own mandatory fields or use a bare, non-domain-qualified key -- both are caller bugs that must fail loudly at the call site, not silently corrupt or ambiguously merge into the wire frame. */ +function validateGossipExtensions(extensions: Record): void { + for (const key of Object.keys(extensions)) { + if (RESERVED_PEER_ADVERT_KEYS.has(key)) { + throw new Error( + `sendGossipUpdate extension key "${key}" collides with a mandatory peer-advert field`, + ); + } + if (!GOSSIP_EXTENSION_KEY_PATTERN.test(key)) { + throw new Error( + `sendGossipUpdate extension key "${key}" must be domain-qualified as "/" (e.g. "presence/status")`, + ); + } + } +} + /** How long to wait for the node's handshake before calling it unanswered. A relay-only node never sends one; that is a state to display, not an error. */ export const HANDSHAKE_TIMEOUT_MS = 3_000; @@ -420,16 +446,19 @@ function createSessionCore( } } - /** Builds this side's own self-advert: this node's own directly-reachable addresses (wire-mesh#38), 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. */ + /** Builds this side's own self-advert: this node's own directly-reachable addresses (wire-mesh#38), 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. Extensions are spread before the three mandatory fields (never after) so a caller-supplied key of the same name can never shadow them on the wire -- validateGossipExtensions already rejects that case loudly, but the field order is kept safe in its own right rather than relying solely on the guard staying in sync. */ function buildSelfAdvert(extensions?: Record): GossipFrame { + if (extensions !== undefined) { + validateGossipExtensions(extensions); + } return { type: "gossip", peers: [ { + ...extensions, device: identity.deviceId, addresses: [...addresses], "snapshot-seconds": Math.floor(clock.now() / MS_PER_SECOND), - ...extensions, }, ], }; diff --git a/ts/packages/core/test/mesh-session.test.ts b/ts/packages/core/test/mesh-session.test.ts index b4128ef..85f8297 100644 --- a/ts/packages/core/test/mesh-session.test.ts +++ b/ts/packages/core/test/mesh-session.test.ts @@ -275,7 +275,7 @@ describe("createMeshSession", () => { const session = createMeshSession(transport, testIdentity, testClock); await session.connect("ws://node", ["core/data"]); - await session.sendGossipUpdate({ presence: "idle" }); + await session.sendGossipUpdate({ "presence/status": "idle" }); const updated = connection.sent.at(-1) as GossipFrame; expect(updated).toEqual({ @@ -285,13 +285,35 @@ describe("createMeshSession", () => { device: testIdentityDeviceId, addresses: [], "snapshot-seconds": Math.floor(TEST_CLOCK_NOW_MS / MS_PER_SECOND), - presence: "idle", + "presence/status": "idle", }, ], } satisfies GossipFrame); await session.close(); }); + it("sendGossipUpdate rejects an extension key that collides with a mandatory peer-advert field", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await expect( + session.sendGossipUpdate({ device: "spoofed" }), + ).rejects.toThrow(/collides with a mandatory peer-advert field/); + await session.close(); + }); + + it("sendGossipUpdate rejects a bare, non-domain-qualified extension key", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await expect( + session.sendGossipUpdate({ presence: "idle" }), + ).rejects.toThrow(/must be domain-qualified/); + await session.close(); + }); + it("sendGossipUpdate with no extensions re-sends a plain self-advert", async () => { const { transport, connection } = fakeTransport(); const session = createMeshSession(transport, testIdentity, testClock); From 4084b730da1efddc389115ec91f7898cdae84550 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 08:08:36 +0100 Subject: [PATCH 4/4] chore(spec): regenerate protocol.cddl and move the extension vector into generate.ts protocol.cddl is a mechanically concatenated file (spec/generate.sh); editing transport.cddl without regenerating it left protocol.cddl stale, which CI's own CDDL Validate job catches. The peer-advert extension conformance vector was hand-added directly to frames.v1.json in the previous commit, but that file is itself generated from conformance/generate.ts's own vector definitions -- moved there so a future `pnpm run generate` doesn't overwrite or drift from it. Regenerating reproduced byte-identical output to the hand-computed vector, confirming it was correct. --- conformance/generate.ts | 11 +++++++++++ spec/protocol.cddl | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/conformance/generate.ts b/conformance/generate.ts index fc5812c..9df5c82 100644 --- a/conformance/generate.ts +++ b/conformance/generate.ts @@ -264,6 +264,17 @@ const frameVectors: Vector[] = [ }, ], }), + vector("gossip_v1_peer_advert_with_extension", { + type: "gossip", + peers: [ + { + device: deviceC, + addresses: [], + "snapshot-seconds": 1861920000, + "presence/status": "idle", + }, + ], + }), vector("candidates_v1_host_and_relayed", { type: "candidates", candidates: [ diff --git a/spec/protocol.cddl b/spec/protocol.cddl index 1e7a679..de4636c 100644 --- a/spec/protocol.cddl +++ b/spec/protocol.cddl @@ -861,6 +861,16 @@ close-frame = { type: "close", ? reason: tstr } ; treats an unrecognised key the same verifier-obligation way an unrecognised ; value in an open discriminator is treated elsewhere in this spec: ignored, ; never acted on without understanding it, never treated as an error. +; +; A key here MUST be domain-qualified as "/" (e.g. +; "presence/status", not bare "status") — see spec/CONVENTIONS.md's +; gossip-extension-namespacing convention. This is what lets two independent +; applications advertising over the same session coexist without one's +; extension silently shadowing the other's identically-named field. A key +; here MUST also never repeat one of this map's own typed field names +; (device/addresses/snapshot-seconds) — an implementation MUST reject an +; attempt to advertise under one of those names rather than let it shadow +; the real field. peer-advert = { device: device-id, addresses: [* tstr], ; "host:port" strings