@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 +7 -0
- package/index.d.ts +22 -10
- package/index.js +19 -2
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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; /**
|
|
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
|
|
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
|
|
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
|
};
|