wire-mesh-core 3.6.0 → 3.7.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.
@@ -780,8 +780,8 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
780
780
  }
781
781
  };
782
782
  }
783
- function createMeshSession(transport, identity, clock = { now: () => Date.now() }, reconnect = null, addresses = [], onPeerAdvert) {
784
- const { session } = createSessionCore(identity, clock, reconnect, async (address) => transport.connect(address), onPeerAdvert, addresses);
783
+ function createMeshSession(transport, identity, clock = { now: () => Date.now() }, reconnect = null, addresses = [], onPeerAdvert, onFrame) {
784
+ const { session } = createSessionCore(identity, clock, reconnect, async (address) => transport.connect(address), onPeerAdvert, addresses, onFrame);
785
785
  return session;
786
786
  }
787
787
  /** Wires an already-accepted Connection up as a full MeshSession, mirroring exactly what createMeshSession's own dial path does once a connection exists (send handshake, send self-advert, negotiate, consume frames) -- the wire-mesh#45 prerequisite agent-comms needs, since its peers both listen and dial rather than only ever dialing the way web-console's own console UI does. Reconnect does not apply here: if this connection drops, only the remote redialing and being accepted again produces a new connection, and therefore a new session -- there is nothing on this side to retry. */
@@ -81,7 +81,17 @@ export interface MeshSession {
81
81
  readonly coordinatorFrames: AsyncIterable<CoordinatorFrame>;
82
82
  connect: (address: string, localDomains: readonly string[]) => Promise<void>;
83
83
  sendPing: () => Promise<void>;
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. */
84
+ /**
85
+ * Sends a ping-frame and resolves with the round-trip time in milliseconds once the correlated pong-frame arrives.
86
+ *
87
+ * The pairing is FIFO because ping-frame carries no correlation id of its own (spec/transport.cddl): the Nth call's promise resolves against the Nth pong received after it, never matched by any other means.
88
+ *
89
+ * Rejects with a plain Error if the connection closes, and with a PingTimeoutError (from ping-round-trips.ts) when `timeoutMs` is given and no pong arrives within it, so a caller can tell an unanswered ping from a dropped connection. It never resolves a sentinel the way sendManageRequest's own timeout does, because there is no natural "no answer" value for a bare millisecond count to double as.
90
+ *
91
+ * 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, which today means relay-hub's own frame handling (wire-mesh#181). Called against a plain peer that never sends pong it hangs until `timeoutMs` if given, or forever.
92
+ *
93
+ * It exists to isolate the sender-to-hub leg of a relayed path.trace round trip: time it 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.
94
+ */
85
95
  sendPingMeasureRtt: (timeoutMs?: number) => Promise<number>;
86
96
  /** Attaches this token to every `manage-request` sent from now on. */
87
97
  setToken: (token: CapabilityToken) => void;
@@ -103,7 +113,9 @@ export declare function createMeshSession(transport: Readonly<Transport>, identi
103
113
  /** This node's own directly-reachable "host:port" candidates (wire-mesh#38), advertised in this session's self-advert so other peers can attempt a direct connection instead of always falling back to a relay. Omit (or pass none) for a caller with nothing to offer, e.g. a browser client. */
104
114
  addresses?: readonly string[],
105
115
  /** Fired for every peer-advert entry as it's applied to the directory, regardless of source -- the hook a gossip-expansion consumer (wire-mesh#187, gossip-expansion.ts's own createGossipExpansion) uses to observe newly-gossiped peers without becoming a second, competing consumer of this session's own single-reader events stream (each emitted SessionEvent wakes at most one waiter, so a second for-await loop over events would silently steal events from whichever consumer already reads it). Omit for a caller with no use for it, exactly today's behaviour. */
106
- onPeerAdvert?: (advert: PeerAdvert) => void): MeshSession;
116
+ onPeerAdvert?: (advert: PeerAdvert) => void,
117
+ /** Fired once per frame received on this session's connection, with the Connection it arrived on: a new Connection after a reconnect is how a caller notices it has to announce itself again, and a core/data reply is how it receives a log it asked a hub for. Omit for a caller with no use for it, exactly today's behaviour. */
118
+ onFrame?: (connection: Readonly<Connection>, frame: Frame) => void | Promise<void>): MeshSession;
107
119
  /** A MeshSession built over a connection that already exists (a Transport's own listen() handed it to onConnection), extended with the one thing a dial-side session can't offer: the device-id of the specific peer at the other end. Unlike createMeshSession, which may end up talking to a relay gossiping about many devices at once, an accepted connection is the agent-comms case -- exactly two peers, directly connected -- so "the peer" is well-defined here in a way it structurally isn't for the dial side. */
108
120
  export interface AcceptedMeshSession extends MeshSession {
109
121
  /** Resolves with the device-id carried by the first peer-advert this connection's remote sends -- the same self-advertisement mechanism createMeshSession's own directory already relies on for every peer, just narrowed to "the one peer this specific connection is with" rather than accumulated into a directory of possibly many. There is no transport-level authentication behind this yet (see wire-mesh#45's own createTlsTransport item): it is only as trustworthy as the remote's own gossip, exactly the same trust level the dial-side directory already has for every entry in it. */
@@ -81,7 +81,17 @@ export interface MeshSession {
81
81
  readonly coordinatorFrames: AsyncIterable<CoordinatorFrame>;
82
82
  connect: (address: string, localDomains: readonly string[]) => Promise<void>;
83
83
  sendPing: () => Promise<void>;
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. */
84
+ /**
85
+ * Sends a ping-frame and resolves with the round-trip time in milliseconds once the correlated pong-frame arrives.
86
+ *
87
+ * The pairing is FIFO because ping-frame carries no correlation id of its own (spec/transport.cddl): the Nth call's promise resolves against the Nth pong received after it, never matched by any other means.
88
+ *
89
+ * Rejects with a plain Error if the connection closes, and with a PingTimeoutError (from ping-round-trips.ts) when `timeoutMs` is given and no pong arrives within it, so a caller can tell an unanswered ping from a dropped connection. It never resolves a sentinel the way sendManageRequest's own timeout does, because there is no natural "no answer" value for a bare millisecond count to double as.
90
+ *
91
+ * 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, which today means relay-hub's own frame handling (wire-mesh#181). Called against a plain peer that never sends pong it hangs until `timeoutMs` if given, or forever.
92
+ *
93
+ * It exists to isolate the sender-to-hub leg of a relayed path.trace round trip: time it 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.
94
+ */
85
95
  sendPingMeasureRtt: (timeoutMs?: number) => Promise<number>;
86
96
  /** Attaches this token to every `manage-request` sent from now on. */
87
97
  setToken: (token: CapabilityToken) => void;
@@ -103,7 +113,9 @@ export declare function createMeshSession(transport: Readonly<Transport>, identi
103
113
  /** This node's own directly-reachable "host:port" candidates (wire-mesh#38), advertised in this session's self-advert so other peers can attempt a direct connection instead of always falling back to a relay. Omit (or pass none) for a caller with nothing to offer, e.g. a browser client. */
104
114
  addresses?: readonly string[],
105
115
  /** Fired for every peer-advert entry as it's applied to the directory, regardless of source -- the hook a gossip-expansion consumer (wire-mesh#187, gossip-expansion.ts's own createGossipExpansion) uses to observe newly-gossiped peers without becoming a second, competing consumer of this session's own single-reader events stream (each emitted SessionEvent wakes at most one waiter, so a second for-await loop over events would silently steal events from whichever consumer already reads it). Omit for a caller with no use for it, exactly today's behaviour. */
106
- onPeerAdvert?: (advert: PeerAdvert) => void): MeshSession;
116
+ onPeerAdvert?: (advert: PeerAdvert) => void,
117
+ /** Fired once per frame received on this session's connection, with the Connection it arrived on: a new Connection after a reconnect is how a caller notices it has to announce itself again, and a core/data reply is how it receives a log it asked a hub for. Omit for a caller with no use for it, exactly today's behaviour. */
118
+ onFrame?: (connection: Readonly<Connection>, frame: Frame) => void | Promise<void>): MeshSession;
107
119
  /** A MeshSession built over a connection that already exists (a Transport's own listen() handed it to onConnection), extended with the one thing a dial-side session can't offer: the device-id of the specific peer at the other end. Unlike createMeshSession, which may end up talking to a relay gossiping about many devices at once, an accepted connection is the agent-comms case -- exactly two peers, directly connected -- so "the peer" is well-defined here in a way it structurally isn't for the dial side. */
108
120
  export interface AcceptedMeshSession extends MeshSession {
109
121
  /** Resolves with the device-id carried by the first peer-advert this connection's remote sends -- the same self-advertisement mechanism createMeshSession's own directory already relies on for every peer, just narrowed to "the one peer this specific connection is with" rather than accumulated into a directory of possibly many. There is no transport-level authentication behind this yet (see wire-mesh#45's own createTlsTransport item): it is only as trustworthy as the remote's own gossip, exactly the same trust level the dial-side directory already has for every entry in it. */
@@ -779,8 +779,8 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
779
779
  }
780
780
  };
781
781
  }
782
- function createMeshSession(transport, identity, clock = { now: () => Date.now() }, reconnect = null, addresses = [], onPeerAdvert) {
783
- const { session } = createSessionCore(identity, clock, reconnect, async (address) => transport.connect(address), onPeerAdvert, addresses);
782
+ function createMeshSession(transport, identity, clock = { now: () => Date.now() }, reconnect = null, addresses = [], onPeerAdvert, onFrame) {
783
+ const { session } = createSessionCore(identity, clock, reconnect, async (address) => transport.connect(address), onPeerAdvert, addresses, onFrame);
784
784
  return session;
785
785
  }
786
786
  /** Wires an already-accepted Connection up as a full MeshSession, mirroring exactly what createMeshSession's own dial path does once a connection exists (send handshake, send self-advert, negotiate, consume frames) -- the wire-mesh#45 prerequisite agent-comms needs, since its peers both listen and dial rather than only ever dialing the way web-console's own console UI does. Reconnect does not apply here: if this connection drops, only the remote redialing and being accepted again produces a new connection, and therefore a new session -- there is nothing on this side to retry. */
@@ -56,13 +56,13 @@ function createNoticeBoard(options) {
56
56
  entries: [entry]
57
57
  })).ok) throw new Error(`gap rejecting entry for peer log (head ${String(fromSeq)})`);
58
58
  }
59
- async function readLog(peer, room) {
59
+ async function readLog(peer, room, dmRootPolicy) {
60
60
  const entries = await require_domain_data_sync.readEntries(storage, peer, 0);
61
61
  const out = [];
62
- for (const entry of entries) out.push(await readOne(entry, room));
62
+ for (const entry of entries) out.push(await readOne(entry, room, dmRootPolicy));
63
63
  return out;
64
64
  }
65
- async function readOne(entry, room) {
65
+ async function readOne(entry, room, dmRootPolicy) {
66
66
  const notice = decodeNotice(entry);
67
67
  if (notice === void 0) return {
68
68
  verified: false,
@@ -72,7 +72,8 @@ function createNoticeBoard(options) {
72
72
  identity,
73
73
  clock,
74
74
  revocation,
75
- expectedRoom: room
75
+ expectedRoom: room,
76
+ dmRootPolicy
76
77
  });
77
78
  if (!verdict.ok) return {
78
79
  verified: false,
@@ -119,8 +120,8 @@ function createNoticeBoard(options) {
119
120
  return {
120
121
  postEncryptedNotice,
121
122
  ingestPeerEntry,
122
- readOwnNotices: async (room) => readLog(identity.deviceId, room),
123
- readPeerNotices: async (peer, room) => readLog(peer, room)
123
+ readOwnNotices: async (room) => readLog(identity.deviceId, room, "either-participant"),
124
+ readPeerNotices: async (peer, room) => readLog(peer, room, "self")
124
125
  };
125
126
  }
126
127
  //#endregion
@@ -55,13 +55,13 @@ function createNoticeBoard(options) {
55
55
  entries: [entry]
56
56
  })).ok) throw new Error(`gap rejecting entry for peer log (head ${String(fromSeq)})`);
57
57
  }
58
- async function readLog(peer, room) {
58
+ async function readLog(peer, room, dmRootPolicy) {
59
59
  const entries = await readEntries(storage, peer, 0);
60
60
  const out = [];
61
- for (const entry of entries) out.push(await readOne(entry, room));
61
+ for (const entry of entries) out.push(await readOne(entry, room, dmRootPolicy));
62
62
  return out;
63
63
  }
64
- async function readOne(entry, room) {
64
+ async function readOne(entry, room, dmRootPolicy) {
65
65
  const notice = decodeNotice(entry);
66
66
  if (notice === void 0) return {
67
67
  verified: false,
@@ -71,7 +71,8 @@ function createNoticeBoard(options) {
71
71
  identity,
72
72
  clock,
73
73
  revocation,
74
- expectedRoom: room
74
+ expectedRoom: room,
75
+ dmRootPolicy
75
76
  });
76
77
  if (!verdict.ok) return {
77
78
  verified: false,
@@ -118,8 +119,8 @@ function createNoticeBoard(options) {
118
119
  return {
119
120
  postEncryptedNotice,
120
121
  ingestPeerEntry,
121
- readOwnNotices: async (room) => readLog(identity.deviceId, room),
122
- readPeerNotices: async (peer, room) => readLog(peer, room)
122
+ readOwnNotices: async (room) => readLog(identity.deviceId, room, "either-participant"),
123
+ readPeerNotices: async (peer, room) => readLog(peer, room, "self")
123
124
  };
124
125
  }
125
126
  //#endregion
@@ -1,5 +1,12 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  //#region src/domain/ping-round-trips.ts
3
+ /** Why a ping was given up on: no pong arrived within the caller's `timeoutMs`. A call rejected for any other reason (the connection dropped or closed first) rejects with a plain Error, so a caller counting unanswered pings tells the two apart by this class rather than by message text. */
4
+ var PingTimeoutError = class extends Error {
5
+ constructor() {
6
+ super("timed out waiting for pong");
7
+ this.name = "PingTimeoutError";
8
+ }
9
+ };
3
10
  function createPingRoundTrips() {
4
11
  const pending = [];
5
12
  function cancel(sentAt) {
@@ -28,11 +35,12 @@ function createPingRoundTrips() {
28
35
  if (timeoutMs === void 0) return rtt;
29
36
  return Promise.race([rtt, new Promise((_resolve, reject) => {
30
37
  setTimeout(() => {
31
- if (cancel(sentAt)) reject(/* @__PURE__ */ new Error("timed out waiting for pong"));
38
+ if (cancel(sentAt)) reject(new PingTimeoutError());
32
39
  }, timeoutMs);
33
40
  })]);
34
41
  }
35
42
  };
36
43
  }
37
44
  //#endregion
45
+ exports.PingTimeoutError = PingTimeoutError;
38
46
  exports.createPingRoundTrips = createPingRoundTrips;
@@ -1,4 +1,8 @@
1
1
  //#region src/domain/ping-round-trips.d.ts
2
+ /** Why a ping was given up on: no pong arrived within the caller's `timeoutMs`. A call rejected for any other reason (the connection dropped or closed first) rejects with a plain Error, so a caller counting unanswered pings tells the two apart by this class rather than by message text. */
3
+ export declare class PingTimeoutError extends Error {
4
+ constructor();
5
+ }
2
6
  export interface PingRoundTrips {
3
7
  /** Resolves the oldest still-pending call with (nowMs - itsOwnSentAt) -- called once per pong-frame received. A no-op if nothing is pending (a stray pong with no outstanding call). */
4
8
  resolveOldest: (nowMs: number) => void;
@@ -1,4 +1,8 @@
1
1
  //#region src/domain/ping-round-trips.d.ts
2
+ /** Why a ping was given up on: no pong arrived within the caller's `timeoutMs`. A call rejected for any other reason (the connection dropped or closed first) rejects with a plain Error, so a caller counting unanswered pings tells the two apart by this class rather than by message text. */
3
+ export declare class PingTimeoutError extends Error {
4
+ constructor();
5
+ }
2
6
  export interface PingRoundTrips {
3
7
  /** Resolves the oldest still-pending call with (nowMs - itsOwnSentAt) -- called once per pong-frame received. A no-op if nothing is pending (a stray pong with no outstanding call). */
4
8
  resolveOldest: (nowMs: number) => void;
@@ -1,4 +1,11 @@
1
1
  //#region src/domain/ping-round-trips.ts
2
+ /** Why a ping was given up on: no pong arrived within the caller's `timeoutMs`. A call rejected for any other reason (the connection dropped or closed first) rejects with a plain Error, so a caller counting unanswered pings tells the two apart by this class rather than by message text. */
3
+ var PingTimeoutError = class extends Error {
4
+ constructor() {
5
+ super("timed out waiting for pong");
6
+ this.name = "PingTimeoutError";
7
+ }
8
+ };
2
9
  function createPingRoundTrips() {
3
10
  const pending = [];
4
11
  function cancel(sentAt) {
@@ -27,11 +34,11 @@ function createPingRoundTrips() {
27
34
  if (timeoutMs === void 0) return rtt;
28
35
  return Promise.race([rtt, new Promise((_resolve, reject) => {
29
36
  setTimeout(() => {
30
- if (cancel(sentAt)) reject(/* @__PURE__ */ new Error("timed out waiting for pong"));
37
+ if (cancel(sentAt)) reject(new PingTimeoutError());
31
38
  }, timeoutMs);
32
39
  })]);
33
40
  }
34
41
  };
35
42
  }
36
43
  //#endregion
37
- export { createPingRoundTrips };
44
+ export { PingTimeoutError, createPingRoundTrips };
@@ -9,7 +9,7 @@ import { a as RevocationCheck } from "../tokens-DqFu7iMX.cjs";
9
9
  */
10
10
  export declare function buildRoomRekeyCommand(keyEpoch: number, wrappedKey: Uint8Array | readonly Uint8Array[]): ManageCommand;
11
11
  /** Sends a room.rekey for roomPath. targetDevice/token forward directly to MeshSession.sendManageRequest's own identically-named parameters, exactly as sendRoomMessage's own doc comment already describes. */
12
- export declare function sendRoomRekey(session: Readonly<MeshSession>, roomPath: string, keyEpoch: number, wrappedKey: Uint8Array | readonly Uint8Array[], targetDevice?: DeviceId, token?: CapabilityToken): Promise<ManageOutcome>;
12
+ export declare function sendRoomRekey(session: Readonly<Pick<MeshSession, "sendManageRequest">>, roomPath: string, keyEpoch: number, wrappedKey: Uint8Array | readonly Uint8Array[], targetDevice?: DeviceId, token?: CapabilityToken): Promise<ManageOutcome>;
13
13
  /** One incoming, fully-verified and unwrapped room.rekey, surfaced for the domain to store (index content keys by epoch for later room-notice decryption). */
14
14
  export interface RoomRekeyEvent {
15
15
  room: string;
@@ -9,7 +9,7 @@ import { a as RevocationCheck } from "../tokens-CBiSYuzp.mjs";
9
9
  */
10
10
  export declare function buildRoomRekeyCommand(keyEpoch: number, wrappedKey: Uint8Array | readonly Uint8Array[]): ManageCommand;
11
11
  /** Sends a room.rekey for roomPath. targetDevice/token forward directly to MeshSession.sendManageRequest's own identically-named parameters, exactly as sendRoomMessage's own doc comment already describes. */
12
- export declare function sendRoomRekey(session: Readonly<MeshSession>, roomPath: string, keyEpoch: number, wrappedKey: Uint8Array | readonly Uint8Array[], targetDevice?: DeviceId, token?: CapabilityToken): Promise<ManageOutcome>;
12
+ export declare function sendRoomRekey(session: Readonly<Pick<MeshSession, "sendManageRequest">>, roomPath: string, keyEpoch: number, wrappedKey: Uint8Array | readonly Uint8Array[], targetDevice?: DeviceId, token?: CapabilityToken): Promise<ManageOutcome>;
13
13
  /** One incoming, fully-verified and unwrapped room.rekey, surfaced for the domain to store (index content keys by epoch for later room-notice decryption). */
14
14
  export interface RoomRekeyEvent {
15
15
  room: string;
@@ -102,7 +102,8 @@ async function verifyRoomNotice(notice, options) {
102
102
  clock: options.clock,
103
103
  revocation: options.revocation,
104
104
  expectedBearer: claims.poster,
105
- roomPath: claims.room
105
+ roomPath: claims.room,
106
+ ...options.dmRootPolicy !== void 0 ? { dmRootPolicy: options.dmRootPolicy } : {}
106
107
  });
107
108
  if (!tokenVerdict.ok) return {
108
109
  ok: false,
@@ -20,6 +20,10 @@ export interface VerifyRoomNoticeOptions {
20
20
  * When given, refuses any notice not claiming exactly this room -- "is this the room I actually asked to read", checked against the notice's own self-declared `room` field before any cryptographic work, so a caller scanning a mixed stream of notices can cheaply skip ones for other rooms. Independent of, and layered on top of, this function's own unconditional internal self-consistency check (the embedded token's scope.path MUST equal the notice's own `room` field regardless of whether expectedRoom is given at all) -- a notice can be internally self-consistent yet still be for a room other than the one a caller expected, and this is the option that catches that case. Mirrors verifyCapabilityToken's own optional expectedBearer for the same "also assert it matches what I expected" shape.
21
21
  */
22
22
  expectedRoom?: RoomPath;
23
+ /**
24
+ * How a DM's embedded token must be rooted, passed to verifyRoomToken (see its own `dmRootPolicy`). The default requires the root to be the verifying identity, which is right for a notice another device posted to me. A device reading its own notice holds a token rooted at the other participant, so only "either-participant" can pass there; it is safe because nothing about an own notice rests on the root rule, the device wrote it.
25
+ */
26
+ dmRootPolicy?: "self" | "either-participant";
23
27
  }
24
28
  /**
25
29
  * Verifies one room-notice (spec/room.cddl) against every obligation its own "Six verifier obligations" comment documents, including `valid-until` (obligation 6): the envelope is a well-formed COSE_Sign1 whose signature verifies against its own embedded poster-key, and poster-key is self-certifying (sha256(poster-key.public-key) equals poster); the embedded token independently passes every ordinary capability-token obligation (signature, expiry, not-before, revocation, delegations- remaining, and -- since the embedded token is itself room-scoped -- the chain-root and scope obligations spec/room.cddl's own six general verifier obligations require of any room:member token) with its bearer pinned to this notice's own `poster` field and its scope pinned to this notice's own `room` field; and, if present, `valid-until` has not yet elapsed as of the injected clock. Checks run cheapest-and-structural first, signature next, the recursive token-chain verification last, mirroring tokens.ts's own ordering discipline.
@@ -20,6 +20,10 @@ export interface VerifyRoomNoticeOptions {
20
20
  * When given, refuses any notice not claiming exactly this room -- "is this the room I actually asked to read", checked against the notice's own self-declared `room` field before any cryptographic work, so a caller scanning a mixed stream of notices can cheaply skip ones for other rooms. Independent of, and layered on top of, this function's own unconditional internal self-consistency check (the embedded token's scope.path MUST equal the notice's own `room` field regardless of whether expectedRoom is given at all) -- a notice can be internally self-consistent yet still be for a room other than the one a caller expected, and this is the option that catches that case. Mirrors verifyCapabilityToken's own optional expectedBearer for the same "also assert it matches what I expected" shape.
21
21
  */
22
22
  expectedRoom?: RoomPath;
23
+ /**
24
+ * How a DM's embedded token must be rooted, passed to verifyRoomToken (see its own `dmRootPolicy`). The default requires the root to be the verifying identity, which is right for a notice another device posted to me. A device reading its own notice holds a token rooted at the other participant, so only "either-participant" can pass there; it is safe because nothing about an own notice rests on the root rule, the device wrote it.
25
+ */
26
+ dmRootPolicy?: "self" | "either-participant";
23
27
  }
24
28
  /**
25
29
  * Verifies one room-notice (spec/room.cddl) against every obligation its own "Six verifier obligations" comment documents, including `valid-until` (obligation 6): the envelope is a well-formed COSE_Sign1 whose signature verifies against its own embedded poster-key, and poster-key is self-certifying (sha256(poster-key.public-key) equals poster); the embedded token independently passes every ordinary capability-token obligation (signature, expiry, not-before, revocation, delegations- remaining, and -- since the embedded token is itself room-scoped -- the chain-root and scope obligations spec/room.cddl's own six general verifier obligations require of any room:member token) with its bearer pinned to this notice's own `poster` field and its scope pinned to this notice's own `room` field; and, if present, `valid-until` has not yet elapsed as of the injected clock. Checks run cheapest-and-structural first, signature next, the recursive token-chain verification last, mirroring tokens.ts's own ordering discipline.
@@ -101,7 +101,8 @@ async function verifyRoomNotice(notice, options) {
101
101
  clock: options.clock,
102
102
  revocation: options.revocation,
103
103
  expectedBearer: claims.poster,
104
- roomPath: claims.room
104
+ roomPath: claims.room,
105
+ ...options.dmRootPolicy !== void 0 ? { dmRootPolicy: options.dmRootPolicy } : {}
105
106
  });
106
107
  if (!tokenVerdict.ok) return {
107
108
  ok: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wire-mesh-core",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
4
4
  "dependencies": {
5
5
  "cbor2": "2.3.0",
6
6
  "cddl.js": "1.0.1",