@mmstack/mesh-protocol 0.1.1 → 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/README.md CHANGED
@@ -97,6 +97,13 @@ matches a path. For richer rules, write your own `OpPolicy` with `canWrite` and
97
97
  Schema-aware validation (deriving a policy from your data model) composes on top and stays in
98
98
  your codebase, not here.
99
99
 
100
+ Two boundaries to be clear about. Policy gates writes, not reads: every member of a room sees
101
+ the whole root, so the room is the confidentiality boundary, and data with different audiences
102
+ belongs in different rooms. And because the relay folds envelopes into snapshots, it reads
103
+ plaintext; end-to-end encryption where the server sees only ciphertext is incompatible with
104
+ server-side compaction as designed. Encrypt the transport and the stored data, but treat the
105
+ relay as inside the trust boundary.
106
+
100
107
  ## Adapter recipes
101
108
 
102
109
  The relay is pure over injected sockets, so an adapter is a few lines of glue.
package/index.d.ts CHANGED
@@ -24,6 +24,12 @@ type OpEnvelope = {
24
24
  readonly hlc: Hlc;
25
25
  readonly policyVersion: number;
26
26
  readonly ops: readonly StoreOp[];
27
+ /**
28
+ * Present only on a MIGRATION envelope: the new `schemaVersion` this envelope
29
+ * establishes. The relay bumps the room's schema + epoch when it sequences one; normal writes
30
+ * omit it.
31
+ */
32
+ readonly schemaVersion?: number;
27
33
  };
28
34
  /** An envelope the relay has ordered: `seq` is the room-scoped total order. */
29
35
  type SeqEnvelope = OpEnvelope & {
@@ -40,7 +46,8 @@ type HelloMsg = {
40
46
  readonly origin: string;
41
47
  readonly proto: number;
42
48
  readonly policyVersion: number; /** Last room seq this client has applied — enables the delta answer on reconnect. */
43
- readonly seq?: number;
49
+ readonly seq?: number; /** The data shape this client speaks, older-than-room is rejected `schema`. */
50
+ readonly schemaVersion?: number;
44
51
  };
45
52
  type ClientEnvMsg = {
46
53
  readonly t: 'env';
@@ -60,14 +67,15 @@ type ClientSignalMsg = {
60
67
  readonly data: unknown;
61
68
  };
62
69
  type ClientMsg = HelloMsg | ClientEnvMsg | ClientPresenceMsg | ClientSignalMsg;
63
- /** The tri-state join answer (RFC §6), plus the current presence roster. */
70
+ /** The tri-state join answer, plus the current presence roster. */
64
71
  type WelcomeMsg = {
65
72
  readonly t: 'welcome';
66
73
  readonly room: string;
67
74
  readonly seq: number;
68
75
  /** Room-instance nonce: changes when a room is recreated (relay restart, DO eviction),
69
76
  * so clients know their seq watermark belongs to a dead seq space. */
70
- readonly epoch: string;
77
+ readonly epoch: string; /** The room's current data shape. */
78
+ readonly schemaVersion: number;
71
79
  readonly peers: readonly PresenceState[]; /** Origins currently in the room (membership ≠ presence) — the P2P bootstrap roster. */
72
80
  readonly members: readonly string[];
73
81
  } & ({
@@ -93,7 +101,7 @@ type ServerPresenceMsg = {
93
101
  type RejectMsg = {
94
102
  readonly t: 'reject';
95
103
  readonly room: string;
96
- readonly reason: 'proto' | 'policy-version' | 'unauthorized';
104
+ readonly reason: 'proto' | 'policy-version' | 'unauthorized' | 'schema';
97
105
  readonly expected?: number;
98
106
  };
99
107
  type EjectMsg = {
@@ -124,8 +132,7 @@ declare function applyWireOps<T>(root: T, ops: readonly StoreOp[]): T;
124
132
  //#endregion
125
133
  //#region src/lib/policy.d.ts
126
134
  /**
127
- * The principal behind a connection, as authenticated by the adapter (the relay never mints
128
- * identity — RFC §3). `kind` distinguishes non-human peers: an agent is a user (§0), just one
135
+ * The principal behind a connection, as authenticated by the adapter. `kind` distinguishes non-human peers: an agent is a user, just one
129
136
  * whose policy is usually narrower.
130
137
  */
131
138
  type PrincipalCtx = {
@@ -134,10 +141,13 @@ type PrincipalCtx = {
134
141
  readonly claims?: Readonly<Record<string, unknown>>;
135
142
  };
136
143
  /**
137
- * Pure, deterministic, versioned validation (RFC §7). Run symmetrically on emit and on
144
+ * Pure, deterministic, versioned validation. Run symmetrically on emit and on
138
145
  * apply: honest peers never emit invalid ops, so any violation observed on the wire is a
139
146
  * buggy or malicious writer — tripwire semantics eject it deterministically. Never skip an
140
147
  * op mid-log.
148
+ *
149
+ * This is a WRITE ACL, not a read ACL: every room member sees the whole root, so the room is
150
+ * the confidentiality boundary. Data with different audiences belongs in different rooms.
141
151
  */
142
152
  type OpPolicy = {
143
153
  canWrite?(ctx: PrincipalCtx, path: readonly Key[]): boolean;
@@ -190,7 +200,8 @@ type RelayOptions = {
190
200
  type RoomState = {
191
201
  readonly seq: number;
192
202
  readonly epoch: string;
193
- readonly root: unknown;
203
+ readonly root: unknown; /** The room's data shape; restored via {@link Relay.hydrate}. */
204
+ readonly schemaVersion: number;
194
205
  };
195
206
  /** A persisted room to restore via {@link Relay.hydrate}. */
196
207
  type RoomSnapshot = {
@@ -200,7 +211,8 @@ type RoomSnapshot = {
200
211
  * Restore the persisted epoch so clients reconnecting across the restart keep their seq
201
212
  * watermark and get a `delta` answer; omit to mint a fresh one (they re-snapshot instead).
202
213
  */
203
- readonly epoch?: string; /** Journal tail (ascending seq, entries at or below `seq`) enabling those delta answers. */
214
+ readonly epoch?: string; /** Restore the persisted schema version (a compacted snapshot is post-migration). */
215
+ readonly schemaVersion?: number; /** Journal tail (ascending seq, entries at or below `seq`) enabling those delta answers. */
204
216
  readonly journal?: readonly SeqEnvelope[];
205
217
  };
206
218
  type RelayConnection = {
@@ -223,7 +235,7 @@ type Relay = {
223
235
  hydrate(name: string, snapshot: RoomSnapshot): boolean;
224
236
  };
225
237
  /**
226
- * The reference relay core (op-protocol RFC §6/§7): room-scoped sequencing, journal +
238
+ * The reference relay core: room-scoped sequencing, journal +
227
239
  * snapshot compaction, the tri-state join answer, presence fan-out, and tripwire policy
228
240
  * enforcement. Pure over injected sockets — runs identically under ws, Bun, a Durable
229
241
  * Object, or an in-memory test pair. The relay never interprets ops beyond folding them
package/index.js CHANGED
@@ -60,7 +60,7 @@ function applyAt(container, path, idx, op) {
60
60
  //#region src/lib/relay.ts
61
61
  let epochCounter = 0;
62
62
  /**
63
- * The reference relay core (op-protocol RFC §6/§7): room-scoped sequencing, journal +
63
+ * The reference relay core: room-scoped sequencing, journal +
64
64
  * snapshot compaction, the tri-state join answer, presence fan-out, and tripwire policy
65
65
  * enforcement. Pure over injected sockets — runs identically under ws, Bun, a Durable
66
66
  * Object, or an in-memory test pair. The relay never interprets ops beyond folding them
@@ -85,6 +85,7 @@ function createRelay(opt = {}) {
85
85
  room = {
86
86
  seq: 0,
87
87
  epoch: `${now().toString(36)}-${(++epochCounter).toString(36)}`,
88
+ schemaVersion: 0,
88
89
  root: void 0,
89
90
  journal: [],
90
91
  members: /* @__PURE__ */ new Set(),
@@ -164,6 +165,7 @@ function createRelay(opt = {}) {
164
165
  room.seq = snapshot.seq;
165
166
  room.root = snapshot.root;
166
167
  if (snapshot.epoch !== void 0) room.epoch = snapshot.epoch;
168
+ if (snapshot.schemaVersion !== void 0) room.schemaVersion = snapshot.schemaVersion;
167
169
  if (snapshot.journal) room.journal = snapshot.journal.filter((e) => e.seq <= snapshot.seq).sort((a, b) => a.seq - b.seq).slice(-journalLimit);
168
170
  return true;
169
171
  },
@@ -215,6 +217,15 @@ function createRelay(opt = {}) {
215
217
  });
216
218
  return;
217
219
  }
220
+ if (msg.schemaVersion !== void 0 && msg.schemaVersion < room.schemaVersion) {
221
+ socket.send({
222
+ t: "reject",
223
+ room: msg.room,
224
+ reason: "schema",
225
+ expected: room.schemaVersion
226
+ });
227
+ return;
228
+ }
218
229
  for (const prior of [...room.members]) {
219
230
  if (prior.origin !== msg.origin) continue;
220
231
  room.members.delete(prior);
@@ -239,6 +250,7 @@ function createRelay(opt = {}) {
239
250
  room: msg.room,
240
251
  seq: room.seq,
241
252
  epoch: room.epoch,
253
+ schemaVersion: room.schemaVersion,
242
254
  peers,
243
255
  members: [...room.members].filter((m) => m !== member).map((m) => m.origin)
244
256
  };
@@ -312,6 +324,10 @@ function createRelay(opt = {}) {
312
324
  };
313
325
  room.journal.push(seqEnv);
314
326
  room.root = applyWireOps(room.root, env.ops);
327
+ if (env.schemaVersion !== void 0 && env.schemaVersion > room.schemaVersion) {
328
+ room.schemaVersion = env.schemaVersion;
329
+ room.epoch = `${now().toString(36)}-${(++epochCounter).toString(36)}`;
330
+ }
315
331
  if (room.journal.length > journalLimit) room.journal.shift();
316
332
  broadcast(room, {
317
333
  t: "env",
@@ -321,7 +337,8 @@ function createRelay(opt = {}) {
321
337
  opt.onCommit?.(msg.room, seqEnv, {
322
338
  seq: room.seq,
323
339
  epoch: room.epoch,
324
- root: room.root
340
+ root: room.root,
341
+ schemaVersion: room.schemaVersion
325
342
  });
326
343
  }
327
344
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmstack/mesh-protocol",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "module": "./index.js",