y-reticulum 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-10-03
11
+
12
+ ### Added
13
+
14
+ - Optional `linkPolicy` provider option: an application-supplied callback that decides whether a peer link may carry room traffic, evaluated on both the initiator and responder sides once the remote identity is cryptographically proven (via the signed announce on the initiator side, via the signed `LINKIDENTIFY` handshake on the responder side)
15
+ - Optional `identifyTimeoutMs` provider option: how long the responder waits for the initiator's identify handshake before refusing the link (default 10 s)
16
+ - `refused` provider event, fired when a peer link was refused by the link policy, so apps can surface access requests ("peer X wants to join")
17
+ - Browser demo build (`tsdown.config.js`, `demo-src/`)
18
+
10
19
  ## [0.1.3] - 2026-09-21
11
20
 
12
21
  ## [0.1.2] - 2026-09-21
package/README.md CHANGED
@@ -90,6 +90,15 @@ new ReticulumProvider(roomName, ydoc[, opts])
90
90
  // to the 60s floor from the Reticulum spec (sub-minute intervals trigger
91
91
  // ingress rate limiting).
92
92
  announceIntervalMs: 60_000,
93
+ // Optional access control. When set, peer links must prove their identity
94
+ // (the initiator runs the signed identify handshake over the link) and pass
95
+ // the policy before any room traffic flows. Refused links are torn down and
96
+ // reported via the `refused` event. See "Access control" below.
97
+ linkPolicy: ({ remoteIdentityHash, remoteDestinationHash, initiator }) =>
98
+ allowed.has(remoteIdentityHash),
99
+ // How long the responder waits for the initiator's identify handshake
100
+ // before refusing the link. Only relevant with a `linkPolicy`.
101
+ identifyTimeoutMs: 10_000,
93
102
  }
94
103
  ```
95
104
 
@@ -100,6 +109,41 @@ The provider extends `ObservableV2` and emits:
100
109
  | `status` | `{ connected: boolean }` | the provider (dis)connects from the mesh |
101
110
  | `synced` | `{ synced: boolean }` | sync state with the peer mesh changes |
102
111
  | `peers` | `{ added: string[], removed: string[] }` | peers are discovered or drop off |
112
+ | `refused` | `{ refusals: Array<{ destinationHash: string \| null, identityHash: string \| null, initiator: boolean }> }` | a peer link was refused by the link policy |
113
+
114
+ ## Access control
115
+
116
+ Pass a `linkPolicy` to gate which peers may sync with your room. The policy is
117
+ a (possibly async) callback that receives the remote peer's
118
+ `remoteIdentityHash` (hex truncated hash of their long-term Reticulum
119
+ identity), their room `remoteDestinationHash` when known (initiator side;
120
+ `null` on the responder side, where it is only learnt after identify), and
121
+ `initiator` telling which side of the link you are. Return `true` to allow the
122
+ link, `false` to refuse it: refused links are torn down before any room traffic flows, and reported on the `refused` event.
123
+
124
+ The identity hash is cryptographically bound on both sides: on the initiator
125
+ side it comes from the peer's signed announce, on the responder side from the
126
+ signed identify handshake over the link. Peers that never identify (e.g. older
127
+ versions without ACL support) are refused after `identifyTimeoutMs` and
128
+ reported with a `null` identityHash.
129
+
130
+ Refusals make natural access requests: collect them and, when a user grants
131
+ access, add the peer's identity hash to your allow-list. The next announce
132
+ cycle connects the peers.
133
+
134
+ ```js
135
+ const granted = new Set([myIdentityHash])
136
+ const provider = new ReticulumProvider("your-room-name", ydoc, {
137
+ reticulum: rns,
138
+ identity,
139
+ linkPolicy: ({ remoteIdentityHash }) => granted.has(remoteIdentityHash),
140
+ })
141
+ provider.on("refused", ({ refusals }) => {
142
+ for (const { identityHash } of refusals) {
143
+ console.log("access request from", identityHash) // surface in your UI
144
+ }
145
+ })
146
+ ```
103
147
 
104
148
  ## License
105
149
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "y-reticulum",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Reticulum provider for Yjs",
5
5
  "keywords": [
6
6
  "reticulum",
package/src/provider.js CHANGED
@@ -37,6 +37,14 @@ import { Room } from "./room.js";
37
37
  * Cadence (ms) at which the room destination is re-announced for discovery.
38
38
  * Forwarded to `Destination.startAnnouncing`, which clamps it to the
39
39
  * §9.7 60 s floor (sub-minute intervals trigger ingress rate limiting).
40
+ * @property {import("./room.js").LinkPolicy} [linkPolicy]
41
+ * When set, peer links must prove their identity (the initiator runs the
42
+ * signed identify handshake over the link) and pass the policy before any
43
+ * room traffic flows. Refused links are torn down and reported via the
44
+ * `refused` event, which apps can use to surface access requests.
45
+ * @property {number} [identifyTimeoutMs]
46
+ * How long the responder waits for the initiator's identify handshake
47
+ * before refusing the link.
40
48
  */
41
49
 
42
50
  /**
@@ -50,6 +58,8 @@ import { Room } from "./room.js";
50
58
  * Fired when sync state with the peer mesh changes. (Phase 3.)
51
59
  * @property {(event: { added: Array<string>, removed: Array<string> }) => void} peers
52
60
  * Fired when peers are discovered or drop off.
61
+ * @property {(event: { refusals: Array<{ destinationHash: string | null, identityHash: string | null, initiator: boolean }> }) => void} refused
62
+ * Fired when a peer link was refused by the link policy.
53
63
  */
54
64
 
55
65
  /**
@@ -75,6 +85,8 @@ export class ReticulumProvider extends ObservableV2 {
75
85
  this.awareness = opts.awareness ?? new awarenessProtocol.Awareness(doc);
76
86
  this.maxConns = opts.maxConns ?? 20;
77
87
  this.announceIntervalMs = opts.announceIntervalMs ?? 60_000;
88
+ this.linkPolicy = opts.linkPolicy ?? null;
89
+ this.identifyTimeoutMs = opts.identifyTimeoutMs ?? 10_000;
78
90
 
79
91
  /** Resolved with the room destination's identity on connect(). */
80
92
  this.identityPromise = opts.identity
@@ -113,6 +125,8 @@ export class ReticulumProvider extends ObservableV2 {
113
125
  appName,
114
126
  maxConns: this.maxConns,
115
127
  announceIntervalMs: this.announceIntervalMs,
128
+ linkPolicy: this.linkPolicy,
129
+ identifyTimeoutMs: this.identifyTimeoutMs,
116
130
  callbacks: {
117
131
  onPeers: (
118
132
  /** @type {string[]} */ added,
@@ -120,6 +134,8 @@ export class ReticulumProvider extends ObservableV2 {
120
134
  ) => this.emit("peers", [{ added, removed }]),
121
135
  onSynced: (/** @type {boolean} */ synced) =>
122
136
  this.emit("synced", [{ synced }]),
137
+ onRefused: (/** @type {any[]} */ refusals) =>
138
+ this.emit("refused", [{ refusals }]),
123
139
  },
124
140
  });
125
141
  await this.room.connect();
package/src/room.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * lexicographically smaller. The other simply accepts.
14
14
  */
15
15
 
16
- import { Destination, DestType, toHex } from "@reticulum/core";
16
+ import { Destination, DestType, Identity, toHex } from "@reticulum/core";
17
17
  import * as encoding from "lib0/encoding";
18
18
  import * as awarenessProtocol from "y-protocols/awareness";
19
19
  import * as syncProtocol from "y-protocols/sync";
@@ -38,12 +38,36 @@ function bytesEqual(/** @type {Uint8Array} */ a, /** @type {Uint8Array} */ b) {
38
38
  return diff === 0;
39
39
  }
40
40
 
41
+ /**
42
+ * Context handed to a room's {@link LinkPolicy} for every inbound and
43
+ * outbound peer link.
44
+ *
45
+ * @typedef {Object} LinkPolicyContext
46
+ * @property {string} remoteIdentityHash Hex truncated hash of the remote
47
+ * peer's long-term identity. Cryptographically bound: on the initiator
48
+ * side it comes from the peer's announce, on the responder side from the
49
+ * signed identify handshake over the link.
50
+ * @property {string|null} remoteDestinationHash Hex destination hash of the
51
+ * remote room destination, when known (initiator side).
52
+ * @property {boolean} initiator Whether this side initiated the link.
53
+ */
54
+
55
+ /**
56
+ * Decides whether a peer link may carry room traffic. Called on both the
57
+ * initiator and responder sides once the remote identity is proven.
58
+ *
59
+ * @typedef {(context: LinkPolicyContext) => boolean | Promise<boolean>} LinkPolicy
60
+ */
61
+
41
62
  /**
42
63
  * @typedef {Object} RoomCallbacks
43
64
  * @property {(added: string[], removed: string[]) => void} onPeers
44
65
  * Fired whenever peers are discovered or drop off. Ids are hex link_ids.
45
66
  * @property {(synced: boolean) => void} onSynced
46
67
  * Fired when the room's overall sync state changes.
68
+ * @property {(refusals: Array<{ destinationHash: string | null, identityHash: string | null, initiator: boolean }>) => void} [onRefused]
69
+ * Fired when a peer link was refused by the link policy. Apps can use this
70
+ * to surface access requests (e.g. "peer X wants to join").
47
71
  */
48
72
 
49
73
  /**
@@ -61,6 +85,11 @@ export class Room {
61
85
  * @param {string} options.appName - Deterministic destination app-name for the room.
62
86
  * @param {number} options.maxConns
63
87
  * @param {number} options.announceIntervalMs
88
+ * @param {LinkPolicy | null} [options.linkPolicy] When set, peer links must prove
89
+ * their identity (initiator runs the identify handshake) and pass the
90
+ * policy before any room traffic flows; refused links are torn down.
91
+ * @param {number} [options.identifyTimeoutMs] How long the responder waits
92
+ * for the initiator's identify handshake before refusing.
64
93
  * @param {RoomCallbacks} options.callbacks
65
94
  */
66
95
  constructor({
@@ -71,6 +100,8 @@ export class Room {
71
100
  appName,
72
101
  maxConns,
73
102
  announceIntervalMs,
103
+ linkPolicy,
104
+ identifyTimeoutMs = 10_000,
74
105
  callbacks,
75
106
  }) {
76
107
  this.doc = doc;
@@ -80,6 +111,8 @@ export class Room {
80
111
  this.appName = appName;
81
112
  this.maxConns = maxConns;
82
113
  this.announceIntervalMs = announceIntervalMs;
114
+ this.linkPolicy = linkPolicy ?? null;
115
+ this.identifyTimeoutMs = identifyTimeoutMs;
83
116
  this.callbacks = callbacks;
84
117
 
85
118
  /** @type {import("@reticulum/core").Destination|null} */
@@ -202,7 +235,34 @@ export class Room {
202
235
  // Glare avoidance: only the lexicographically smaller destination initiates.
203
236
  if (this.myHex > remoteHex) return;
204
237
 
238
+ // De-bounce before the policy runs: announces repeat, and a slow (e.g.
239
+ // interactive) policy must not let concurrent announces each open a Link.
205
240
  this.pendingInitiates.add(remoteHex);
241
+
242
+ // Link policy (initiator side): the announce cryptographically binds the
243
+ // remote identity, so the policy can run before any link is opened.
244
+ if (this.linkPolicy) {
245
+ const initiatorIdentityHash = toHex(
246
+ await Identity.truncatedHash(detail.identity.publicKey),
247
+ );
248
+ const allowed = await this.linkPolicy({
249
+ remoteIdentityHash: initiatorIdentityHash,
250
+ remoteDestinationHash: remoteHex,
251
+ initiator: true,
252
+ });
253
+ if (!allowed) {
254
+ this.pendingInitiates.delete(remoteHex);
255
+ this.callbacks.onRefused?.([
256
+ {
257
+ destinationHash: remoteHex,
258
+ identityHash: initiatorIdentityHash,
259
+ initiator: true,
260
+ },
261
+ ]);
262
+ return;
263
+ }
264
+ }
265
+
206
266
  try {
207
267
  const out = await Destination.OUT(
208
268
  this.appName,
@@ -215,6 +275,11 @@ export class Room {
215
275
  await link.teardown();
216
276
  return;
217
277
  }
278
+ // With a policy, prove our identity to the responder before any room
279
+ // traffic: their policy cannot evaluate us until we do
280
+ if (this.linkPolicy) {
281
+ await link.identify(this.identity);
282
+ }
218
283
  this.linkedDestHexes.add(remoteHex);
219
284
  this._registerPeer(link, detail.destinationHash);
220
285
  } catch {
@@ -226,7 +291,9 @@ export class Room {
226
291
  }
227
292
 
228
293
  /**
229
- * Responder path: a peer is opening a Link to us. Accept it.
294
+ * Responder path: a peer is opening a Link to us. Accept it. With a link
295
+ * policy, the peer must prove its identity over the link (signed identify
296
+ * handshake) before the policy decides and any room traffic flows.
230
297
  * @param {Event} event
231
298
  */
232
299
  async _onLinkRequest(event) {
@@ -239,12 +306,58 @@ export class Room {
239
306
  await link.teardown();
240
307
  return;
241
308
  }
309
+ if (this.linkPolicy) {
310
+ const identityHash = await this._awaitIdentify(link);
311
+ if (!identityHash) {
312
+ // The peer never proved who they are: refuse without ceremony
313
+ await link.teardown();
314
+ this.callbacks.onRefused?.([
315
+ { destinationHash: null, identityHash: null, initiator: false },
316
+ ]);
317
+ return;
318
+ }
319
+ const allowed = await this.linkPolicy({
320
+ remoteIdentityHash: identityHash,
321
+ remoteDestinationHash: null,
322
+ initiator: false,
323
+ });
324
+ if (!allowed) {
325
+ await link.teardown();
326
+ this.callbacks.onRefused?.([
327
+ { destinationHash: null, identityHash, initiator: false },
328
+ ]);
329
+ return;
330
+ }
331
+ }
242
332
  this._registerPeer(link, null);
243
333
  } catch {
244
334
  // Handshake failed; nothing to clean up.
245
335
  }
246
336
  }
247
337
 
338
+ /**
339
+ * Waits for the initiator's signed identify handshake on this link.
340
+ *
341
+ * @param {import("@reticulum/core").Link} link
342
+ * @returns {Promise<string|null>} Hex remote identity hash, or null when
343
+ * the peer did not identify within the timeout.
344
+ */
345
+ _awaitIdentify(link) {
346
+ return new Promise((resolve) => {
347
+ const timer = setTimeout(() => {
348
+ link.removeEventListener("identify", onIdentify);
349
+ resolve(null);
350
+ }, this.identifyTimeoutMs);
351
+ const onIdentify = (/** @type {Event} */ event) => {
352
+ clearTimeout(timer);
353
+ const detail = /** @type {any} */ (event).detail;
354
+ const identity = detail?.identity;
355
+ resolve(identity ? toHex(identity.getSalt()) : null);
356
+ };
357
+ link.addEventListener("identify", onIdentify, { once: true });
358
+ });
359
+ }
360
+
248
361
  /**
249
362
  * Registers a newly active peer and kicks off the Yjs sync handshake
250
363
  * (syncStep1 + local awareness), mirroring y-webrtc's peer-on-connect path.
@@ -0,0 +1,84 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { Identity, toHex } from "@reticulum/core";
5
+ import * as Y from "yjs";
6
+ import { ReticulumProvider } from "../src/index.js";
7
+ import { nudgeAnnounce } from "./loopback.js";
8
+
9
+ const ROOM = "y-reticulum-acl-smoke";
10
+
11
+ /** Polls `cond()` every 50ms until true, rejecting after `timeoutMs`. */
12
+ function waitFor(cond, timeoutMs) {
13
+ return new Promise((resolve, reject) => {
14
+ const deadline = Date.now() + timeoutMs;
15
+ const tick = () => {
16
+ if (cond()) return resolve(undefined);
17
+ if (Date.now() >= deadline) return reject(new Error("waitFor timed out"));
18
+ setTimeout(tick, 50);
19
+ };
20
+ tick();
21
+ });
22
+ }
23
+
24
+ test("link policy gates sync: granted peers sync, refused peers do not", {
25
+ timeout: 30000,
26
+ }, async () => {
27
+ const { makeLoopback } = await import("./loopback.js");
28
+ const { rnsA, rnsB, close } = await makeLoopback();
29
+ const docA = new Y.Doc();
30
+ const docB = new Y.Doc();
31
+ const idA = await Identity.generate();
32
+ const idB = await Identity.generate();
33
+ const hashA = toHex(await Identity.truncatedHash(await idA.getPublicKey()));
34
+ const hashB = toHex(await Identity.truncatedHash(await idB.getPublicKey()));
35
+
36
+ // A grants itself (owner) and nobody else: B must be refused
37
+ const granted = new Set([hashA]);
38
+ /** @type {any[]} */
39
+ const refusals = [];
40
+ const providerA = new ReticulumProvider(ROOM, docA, {
41
+ reticulum: rnsA,
42
+ identity: idA,
43
+ linkPolicy: ({ remoteIdentityHash }) => granted.has(remoteIdentityHash),
44
+ });
45
+ providerA.on("refused", (/** @type {any} */ e) => {
46
+ refusals.push(...e.refusals);
47
+ });
48
+ const providerB = new ReticulumProvider(ROOM, docB, {
49
+ reticulum: rnsB,
50
+ identity: idB,
51
+ linkPolicy: () => true,
52
+ });
53
+
54
+ await providerA.connect();
55
+ await providerB.connect();
56
+ await nudgeAnnounce(providerA, providerB);
57
+
58
+ // B's edit must NOT reach A while B is ungranted (either direction of the
59
+ // link was refused)
60
+ docB.getMap("doc").set("intruder", "yes");
61
+ await new Promise((resolve) => setTimeout(resolve, 1500));
62
+ assert.equal(
63
+ docA.getMap("doc").get("intruder"),
64
+ undefined,
65
+ "ungranted peer's data never arrives",
66
+ );
67
+
68
+ // The owner sees the refusal with B's identity hash: a join request
69
+ await waitFor(() => refusals.some((r) => r.identityHash === hashB), 10000);
70
+
71
+ // Owner grants B; the next announce cycle connects them
72
+ granted.add(hashB);
73
+ await nudgeAnnounce(providerA, providerB);
74
+ await waitFor(() => docA.getMap("doc").get("intruder") === "yes", 15000);
75
+ assert.equal(
76
+ docA.getMap("doc").get("intruder"),
77
+ "yes",
78
+ "granted peer's data now syncs",
79
+ );
80
+
81
+ await providerA.destroy().catch(() => {});
82
+ await providerB.destroy().catch(() => {});
83
+ await close();
84
+ });