@xnetjs/sync 0.0.2
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/LICENSE +21 -0
- package/README.md +140 -0
- package/dist/index.d.ts +3203 -0
- package/dist/index.js +3706 -0
- package/package.json +45 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3706 @@
|
|
|
1
|
+
// src/change.ts
|
|
2
|
+
import { hashHex, sign, verify } from "@xnetjs/crypto";
|
|
3
|
+
var CURRENT_PROTOCOL_VERSION = 3;
|
|
4
|
+
function createUnsignedChange(options) {
|
|
5
|
+
const unsigned = {
|
|
6
|
+
protocolVersion: CURRENT_PROTOCOL_VERSION,
|
|
7
|
+
id: options.id,
|
|
8
|
+
type: options.type,
|
|
9
|
+
payload: options.payload,
|
|
10
|
+
parentHash: options.parentHash,
|
|
11
|
+
authorDID: options.authorDID,
|
|
12
|
+
wallTime: options.wallTime ?? Date.now(),
|
|
13
|
+
lamport: options.lamport
|
|
14
|
+
};
|
|
15
|
+
if (options.batchId !== void 0) {
|
|
16
|
+
unsigned.batchId = options.batchId;
|
|
17
|
+
unsigned.batchIndex = options.batchIndex;
|
|
18
|
+
unsigned.batchSize = options.batchSize;
|
|
19
|
+
}
|
|
20
|
+
return unsigned;
|
|
21
|
+
}
|
|
22
|
+
function createBatchId() {
|
|
23
|
+
return `batch-${crypto.randomUUID()}`;
|
|
24
|
+
}
|
|
25
|
+
function sortObjectKeys(obj) {
|
|
26
|
+
if (obj === null || typeof obj !== "object") {
|
|
27
|
+
return obj;
|
|
28
|
+
}
|
|
29
|
+
if (Array.isArray(obj)) {
|
|
30
|
+
return obj.map(sortObjectKeys);
|
|
31
|
+
}
|
|
32
|
+
const sorted = {};
|
|
33
|
+
for (const key of Object.keys(obj).sort()) {
|
|
34
|
+
sorted[key] = sortObjectKeys(obj[key]);
|
|
35
|
+
}
|
|
36
|
+
return sorted;
|
|
37
|
+
}
|
|
38
|
+
function computeChangeHash(unsigned) {
|
|
39
|
+
let toHash;
|
|
40
|
+
if (unsigned.protocolVersion === void 0 || unsigned.protocolVersion === 0) {
|
|
41
|
+
const legacy = {};
|
|
42
|
+
for (const [key, value] of Object.entries(unsigned)) {
|
|
43
|
+
if (key !== "protocolVersion") {
|
|
44
|
+
legacy[key] = value;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
toHash = legacy;
|
|
48
|
+
} else {
|
|
49
|
+
toHash = unsigned;
|
|
50
|
+
}
|
|
51
|
+
const canonical = JSON.stringify(sortObjectKeys(toHash));
|
|
52
|
+
const hashBytes = new TextEncoder().encode(canonical);
|
|
53
|
+
return `cid:blake3:${hashHex(hashBytes)}`;
|
|
54
|
+
}
|
|
55
|
+
function signChange(unsigned, signingKey) {
|
|
56
|
+
const hash4 = computeChangeHash(unsigned);
|
|
57
|
+
const hashBytes = new TextEncoder().encode(hash4);
|
|
58
|
+
const signature = sign(hashBytes, signingKey);
|
|
59
|
+
return {
|
|
60
|
+
...unsigned,
|
|
61
|
+
hash: hash4,
|
|
62
|
+
signature
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function verifyChange(change, publicKey) {
|
|
66
|
+
const version = change.protocolVersion ?? 0;
|
|
67
|
+
if (version > CURRENT_PROTOCOL_VERSION) {
|
|
68
|
+
console.warn(
|
|
69
|
+
`[xnet/sync] Change ${change.id} uses protocol version ${version}, but current version is ${CURRENT_PROTOCOL_VERSION}. Consider upgrading xNet for full compatibility.`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const hashBytes = new TextEncoder().encode(change.hash);
|
|
73
|
+
return verify(hashBytes, change.signature, publicKey);
|
|
74
|
+
}
|
|
75
|
+
function verifyChangeHash(change) {
|
|
76
|
+
const unsigned = {
|
|
77
|
+
id: change.id,
|
|
78
|
+
type: change.type,
|
|
79
|
+
payload: change.payload,
|
|
80
|
+
parentHash: change.parentHash,
|
|
81
|
+
authorDID: change.authorDID,
|
|
82
|
+
wallTime: change.wallTime,
|
|
83
|
+
lamport: change.lamport
|
|
84
|
+
};
|
|
85
|
+
if (change.protocolVersion !== void 0) {
|
|
86
|
+
unsigned.protocolVersion = change.protocolVersion;
|
|
87
|
+
}
|
|
88
|
+
if (change.batchId !== void 0) {
|
|
89
|
+
unsigned.batchId = change.batchId;
|
|
90
|
+
unsigned.batchIndex = change.batchIndex;
|
|
91
|
+
unsigned.batchSize = change.batchSize;
|
|
92
|
+
}
|
|
93
|
+
const computedHash = computeChangeHash(unsigned);
|
|
94
|
+
return computedHash === change.hash;
|
|
95
|
+
}
|
|
96
|
+
function createChangeId() {
|
|
97
|
+
return crypto.randomUUID();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/clock.ts
|
|
101
|
+
function createLamportClock(author) {
|
|
102
|
+
return { time: 0, author };
|
|
103
|
+
}
|
|
104
|
+
function tick(clock) {
|
|
105
|
+
const newTime = clock.time + 1;
|
|
106
|
+
const newClock = { ...clock, time: newTime };
|
|
107
|
+
const timestamp = { time: newTime, author: clock.author };
|
|
108
|
+
return [newClock, timestamp];
|
|
109
|
+
}
|
|
110
|
+
function receive(clock, receivedTime) {
|
|
111
|
+
return {
|
|
112
|
+
...clock,
|
|
113
|
+
time: Math.max(clock.time, receivedTime)
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function compareLamportTimestamps(a, b) {
|
|
117
|
+
if (a.time < b.time) return -1;
|
|
118
|
+
if (a.time > b.time) return 1;
|
|
119
|
+
const authorCmp = a.author.localeCompare(b.author);
|
|
120
|
+
if (authorCmp < 0) return -1;
|
|
121
|
+
if (authorCmp > 0) return 1;
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
function isBefore(a, b) {
|
|
125
|
+
return compareLamportTimestamps(a, b) === -1;
|
|
126
|
+
}
|
|
127
|
+
function isAfter(a, b) {
|
|
128
|
+
return compareLamportTimestamps(a, b) === 1;
|
|
129
|
+
}
|
|
130
|
+
function serializeTimestamp(ts) {
|
|
131
|
+
const paddedTime = ts.time.toString().padStart(16, "0");
|
|
132
|
+
return `${paddedTime}-${ts.author}`;
|
|
133
|
+
}
|
|
134
|
+
function parseTimestamp(serialized) {
|
|
135
|
+
const dashIndex = serialized.indexOf("-");
|
|
136
|
+
if (dashIndex === -1) {
|
|
137
|
+
throw new Error(`Invalid serialized timestamp: ${serialized}`);
|
|
138
|
+
}
|
|
139
|
+
const timeStr = serialized.slice(0, dashIndex);
|
|
140
|
+
const author = serialized.slice(dashIndex + 1);
|
|
141
|
+
const time = parseInt(timeStr, 10);
|
|
142
|
+
if (isNaN(time)) {
|
|
143
|
+
throw new Error(`Invalid time in timestamp: ${timeStr}`);
|
|
144
|
+
}
|
|
145
|
+
return { time, author };
|
|
146
|
+
}
|
|
147
|
+
function maxTime(timestamps) {
|
|
148
|
+
if (timestamps.length === 0) return 0;
|
|
149
|
+
return Math.max(...timestamps.map((ts) => ts.time));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/chain.ts
|
|
153
|
+
function validateChain(changes) {
|
|
154
|
+
if (changes.length === 0) {
|
|
155
|
+
return { valid: true };
|
|
156
|
+
}
|
|
157
|
+
const byHash = /* @__PURE__ */ new Map();
|
|
158
|
+
for (const change of changes) {
|
|
159
|
+
byHash.set(change.hash, change);
|
|
160
|
+
}
|
|
161
|
+
for (const change of changes) {
|
|
162
|
+
if (!verifyChangeHash(change)) {
|
|
163
|
+
return {
|
|
164
|
+
valid: false,
|
|
165
|
+
error: `Change ${change.id} has invalid hash (data may be tampered)`
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (change.parentHash !== null && !byHash.has(change.parentHash)) {
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const { hasFork, forkPoints } = detectFork(changes);
|
|
172
|
+
if (hasFork) {
|
|
173
|
+
return {
|
|
174
|
+
valid: true,
|
|
175
|
+
// Forks are valid, just need resolution
|
|
176
|
+
forkDetected: true,
|
|
177
|
+
forkPoint: forkPoints[0]
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return { valid: true };
|
|
181
|
+
}
|
|
182
|
+
function detectFork(changes) {
|
|
183
|
+
const childrenByParent = /* @__PURE__ */ new Map();
|
|
184
|
+
for (const change of changes) {
|
|
185
|
+
const children = childrenByParent.get(change.parentHash) || [];
|
|
186
|
+
children.push(change);
|
|
187
|
+
childrenByParent.set(change.parentHash, children);
|
|
188
|
+
}
|
|
189
|
+
const forkPoints = [];
|
|
190
|
+
for (const [parent, children] of childrenByParent) {
|
|
191
|
+
if (children.length > 1 && parent !== null) {
|
|
192
|
+
forkPoints.push(parent);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
hasFork: forkPoints.length > 0,
|
|
197
|
+
forkPoints
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function getChainHeads(changes) {
|
|
201
|
+
const parentHashes = new Set(
|
|
202
|
+
changes.map((c) => c.parentHash).filter((h) => h !== null)
|
|
203
|
+
);
|
|
204
|
+
return changes.filter((c) => !parentHashes.has(c.hash));
|
|
205
|
+
}
|
|
206
|
+
function getChainRoots(changes) {
|
|
207
|
+
return changes.filter((c) => c.parentHash === null);
|
|
208
|
+
}
|
|
209
|
+
function getAncestry(change, allChanges) {
|
|
210
|
+
const byHash = /* @__PURE__ */ new Map();
|
|
211
|
+
for (const c of allChanges) {
|
|
212
|
+
byHash.set(c.hash, c);
|
|
213
|
+
}
|
|
214
|
+
const ancestry = [];
|
|
215
|
+
let current = change.parentHash;
|
|
216
|
+
while (current !== null) {
|
|
217
|
+
const parent = byHash.get(current);
|
|
218
|
+
if (!parent) break;
|
|
219
|
+
ancestry.unshift(parent);
|
|
220
|
+
current = parent.parentHash;
|
|
221
|
+
}
|
|
222
|
+
return ancestry;
|
|
223
|
+
}
|
|
224
|
+
function findCommonAncestor(a, b, allChanges) {
|
|
225
|
+
const ancestryA = /* @__PURE__ */ new Set([a.hash, ...getAncestry(a, allChanges).map((c) => c.hash)]);
|
|
226
|
+
const byHash = /* @__PURE__ */ new Map();
|
|
227
|
+
for (const c of allChanges) {
|
|
228
|
+
byHash.set(c.hash, c);
|
|
229
|
+
}
|
|
230
|
+
if (ancestryA.has(b.hash)) {
|
|
231
|
+
return b;
|
|
232
|
+
}
|
|
233
|
+
let current = b.parentHash;
|
|
234
|
+
while (current !== null) {
|
|
235
|
+
if (ancestryA.has(current)) {
|
|
236
|
+
return byHash.get(current) || null;
|
|
237
|
+
}
|
|
238
|
+
const parent = byHash.get(current);
|
|
239
|
+
if (!parent) break;
|
|
240
|
+
current = parent.parentHash;
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
function getForks(changes) {
|
|
245
|
+
const { forkPoints } = detectFork(changes);
|
|
246
|
+
if (forkPoints.length === 0) return [];
|
|
247
|
+
const byHash = /* @__PURE__ */ new Map();
|
|
248
|
+
for (const c of changes) {
|
|
249
|
+
byHash.set(c.hash, c);
|
|
250
|
+
}
|
|
251
|
+
const forks = [];
|
|
252
|
+
for (const forkPoint of forkPoints) {
|
|
253
|
+
const children = changes.filter((c) => c.parentHash === forkPoint);
|
|
254
|
+
if (children.length >= 2) {
|
|
255
|
+
children.sort((a, b) => compareLamportTimestamps(a.lamport, b.lamport));
|
|
256
|
+
forks.push({
|
|
257
|
+
commonAncestor: forkPoint,
|
|
258
|
+
branch1: [children[0]],
|
|
259
|
+
branch2: children.slice(1)
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return forks;
|
|
264
|
+
}
|
|
265
|
+
function topologicalSort(changes) {
|
|
266
|
+
const byHash = /* @__PURE__ */ new Map();
|
|
267
|
+
for (const c of changes) {
|
|
268
|
+
byHash.set(c.hash, c);
|
|
269
|
+
}
|
|
270
|
+
const sorted = [];
|
|
271
|
+
const visited = /* @__PURE__ */ new Set();
|
|
272
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
273
|
+
function visit(change) {
|
|
274
|
+
if (visited.has(change.hash)) return;
|
|
275
|
+
if (visiting.has(change.hash)) {
|
|
276
|
+
throw new Error("Cycle detected in change chain");
|
|
277
|
+
}
|
|
278
|
+
visiting.add(change.hash);
|
|
279
|
+
if (change.parentHash !== null) {
|
|
280
|
+
const parent = byHash.get(change.parentHash);
|
|
281
|
+
if (parent) {
|
|
282
|
+
visit(parent);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
visiting.delete(change.hash);
|
|
286
|
+
visited.add(change.hash);
|
|
287
|
+
sorted.push(change);
|
|
288
|
+
}
|
|
289
|
+
const sortedInput = [...changes].sort((a, b) => compareLamportTimestamps(a.lamport, b.lamport));
|
|
290
|
+
for (const change of sortedInput) {
|
|
291
|
+
visit(change);
|
|
292
|
+
}
|
|
293
|
+
return sorted;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// src/features.ts
|
|
297
|
+
var FEATURES = {
|
|
298
|
+
// ─── Core Features (always present since v1) ───────────────────────────────
|
|
299
|
+
/** Basic node change synchronization */
|
|
300
|
+
"node-changes": {
|
|
301
|
+
since: 1,
|
|
302
|
+
required: true,
|
|
303
|
+
description: "Basic node change synchronization"
|
|
304
|
+
},
|
|
305
|
+
/** Yjs CRDT document synchronization */
|
|
306
|
+
"yjs-updates": {
|
|
307
|
+
since: 1,
|
|
308
|
+
required: true,
|
|
309
|
+
description: "Yjs CRDT document synchronization"
|
|
310
|
+
},
|
|
311
|
+
// ─── Protocol v1 Features ──────────────────────────────────────────────────
|
|
312
|
+
/** Cryptographically signed Yjs update envelopes */
|
|
313
|
+
"signed-yjs-envelopes": {
|
|
314
|
+
since: 1,
|
|
315
|
+
required: false,
|
|
316
|
+
description: "Cryptographically signed Yjs update envelopes",
|
|
317
|
+
requires: ["yjs-updates"]
|
|
318
|
+
},
|
|
319
|
+
/** Transaction batching for atomic multi-change operations */
|
|
320
|
+
"batch-changes": {
|
|
321
|
+
since: 1,
|
|
322
|
+
required: false,
|
|
323
|
+
description: "Transaction batching for atomic multi-change operations",
|
|
324
|
+
requires: ["node-changes"]
|
|
325
|
+
},
|
|
326
|
+
/** Lamport clock ordering for conflict resolution */
|
|
327
|
+
"lamport-ordering": {
|
|
328
|
+
since: 1,
|
|
329
|
+
required: false,
|
|
330
|
+
description: "Lamport clock ordering for conflict resolution"
|
|
331
|
+
},
|
|
332
|
+
/** Hash chain integrity verification */
|
|
333
|
+
"hash-chains": {
|
|
334
|
+
since: 1,
|
|
335
|
+
required: false,
|
|
336
|
+
description: "Hash chain integrity verification",
|
|
337
|
+
requires: ["node-changes"]
|
|
338
|
+
},
|
|
339
|
+
// ─── Protocol v2 Features ──────────────────────────────────────────────────
|
|
340
|
+
/** Schema versioning with semver */
|
|
341
|
+
"schema-versioning": {
|
|
342
|
+
since: 2,
|
|
343
|
+
required: false,
|
|
344
|
+
description: "Schema versioning with semver"
|
|
345
|
+
},
|
|
346
|
+
/** Peer capability negotiation on connect */
|
|
347
|
+
"capability-negotiation": {
|
|
348
|
+
since: 2,
|
|
349
|
+
required: false,
|
|
350
|
+
description: "Peer capability negotiation on connect"
|
|
351
|
+
},
|
|
352
|
+
/** Reputation-based peer scoring */
|
|
353
|
+
"peer-scoring": {
|
|
354
|
+
since: 2,
|
|
355
|
+
required: false,
|
|
356
|
+
description: "Reputation-based peer scoring"
|
|
357
|
+
},
|
|
358
|
+
/** Schema lens migrations for version transformations */
|
|
359
|
+
"schema-lenses": {
|
|
360
|
+
since: 2,
|
|
361
|
+
required: false,
|
|
362
|
+
description: "Schema lens migrations for version transformations",
|
|
363
|
+
requires: ["schema-versioning"]
|
|
364
|
+
},
|
|
365
|
+
/** Unknown property preservation (graceful degradation) */
|
|
366
|
+
"unknown-preservation": {
|
|
367
|
+
since: 2,
|
|
368
|
+
required: false,
|
|
369
|
+
description: "Unknown property preservation for forward compatibility"
|
|
370
|
+
},
|
|
371
|
+
// ─── Protocol v3 Features (Future) ─────────────────────────────────────────
|
|
372
|
+
/** Schema inheritance with property overrides */
|
|
373
|
+
"schema-inheritance": {
|
|
374
|
+
since: 3,
|
|
375
|
+
required: false,
|
|
376
|
+
description: "Schema inheritance with property overrides",
|
|
377
|
+
requires: ["schema-versioning"]
|
|
378
|
+
},
|
|
379
|
+
/** Cross-document federated queries */
|
|
380
|
+
"federated-queries": {
|
|
381
|
+
since: 3,
|
|
382
|
+
required: false,
|
|
383
|
+
description: "Cross-document federated queries"
|
|
384
|
+
},
|
|
385
|
+
/** Compressed change payloads */
|
|
386
|
+
"compressed-payloads": {
|
|
387
|
+
since: 3,
|
|
388
|
+
required: false,
|
|
389
|
+
description: "Compressed change payloads for bandwidth efficiency"
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
var ALL_FEATURES = Object.keys(FEATURES);
|
|
393
|
+
function getEnabledFeatures(protocolVersion) {
|
|
394
|
+
return ALL_FEATURES.filter((name) => {
|
|
395
|
+
const config = FEATURES[name];
|
|
396
|
+
return config.since <= protocolVersion;
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
function isFeatureEnabled(feature, enabledFeatures) {
|
|
400
|
+
return enabledFeatures.includes(feature);
|
|
401
|
+
}
|
|
402
|
+
function getRequiredFeatures(protocolVersion) {
|
|
403
|
+
return getEnabledFeatures(protocolVersion).filter((name) => FEATURES[name].required);
|
|
404
|
+
}
|
|
405
|
+
function getOptionalFeatures(protocolVersion) {
|
|
406
|
+
return getEnabledFeatures(protocolVersion).filter((name) => !FEATURES[name].required);
|
|
407
|
+
}
|
|
408
|
+
function getFeatureVersion(feature) {
|
|
409
|
+
return FEATURES[feature].since;
|
|
410
|
+
}
|
|
411
|
+
function isFeatureAvailable(feature, protocolVersion) {
|
|
412
|
+
return FEATURES[feature].since <= protocolVersion;
|
|
413
|
+
}
|
|
414
|
+
function getFeatureDependencies(feature) {
|
|
415
|
+
return FEATURES[feature].requires ?? [];
|
|
416
|
+
}
|
|
417
|
+
function getFeatureConflicts(feature) {
|
|
418
|
+
return FEATURES[feature].conflicts ?? [];
|
|
419
|
+
}
|
|
420
|
+
function getAllDependencies(feature, visited = /* @__PURE__ */ new Set()) {
|
|
421
|
+
if (visited.has(feature)) {
|
|
422
|
+
return [];
|
|
423
|
+
}
|
|
424
|
+
visited.add(feature);
|
|
425
|
+
const direct = getFeatureDependencies(feature);
|
|
426
|
+
const transitive = [];
|
|
427
|
+
for (const dep of direct) {
|
|
428
|
+
transitive.push(dep);
|
|
429
|
+
transitive.push(...getAllDependencies(dep, visited));
|
|
430
|
+
}
|
|
431
|
+
return [...new Set(transitive)];
|
|
432
|
+
}
|
|
433
|
+
function validateFeatureSet(features, protocolVersion = CURRENT_PROTOCOL_VERSION) {
|
|
434
|
+
const errors = [];
|
|
435
|
+
const warnings = [];
|
|
436
|
+
const featureSet = new Set(features);
|
|
437
|
+
const required = getRequiredFeatures(protocolVersion);
|
|
438
|
+
for (const req of required) {
|
|
439
|
+
if (!featureSet.has(req)) {
|
|
440
|
+
errors.push({
|
|
441
|
+
type: "missing-required",
|
|
442
|
+
feature: req,
|
|
443
|
+
message: `Required feature '${req}' is not in feature set`
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
for (const feature of features) {
|
|
448
|
+
if (!isFeatureAvailable(feature, protocolVersion)) {
|
|
449
|
+
errors.push({
|
|
450
|
+
type: "version-mismatch",
|
|
451
|
+
feature,
|
|
452
|
+
message: `Feature '${feature}' requires protocol v${FEATURES[feature].since}, but running v${protocolVersion}`
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
const deps = getFeatureDependencies(feature);
|
|
456
|
+
for (const dep of deps) {
|
|
457
|
+
if (!featureSet.has(dep)) {
|
|
458
|
+
errors.push({
|
|
459
|
+
type: "missing-dependency",
|
|
460
|
+
feature,
|
|
461
|
+
relatedFeature: dep,
|
|
462
|
+
message: `Feature '${feature}' requires '${dep}' which is not enabled`
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const conflicts = getFeatureConflicts(feature);
|
|
467
|
+
for (const conflict of conflicts) {
|
|
468
|
+
if (featureSet.has(conflict)) {
|
|
469
|
+
errors.push({
|
|
470
|
+
type: "conflict",
|
|
471
|
+
feature,
|
|
472
|
+
relatedFeature: conflict,
|
|
473
|
+
message: `Feature '${feature}' conflicts with '${conflict}'`
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
valid: errors.length === 0,
|
|
480
|
+
errors,
|
|
481
|
+
warnings
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
function intersectFeatures(local, remote) {
|
|
485
|
+
const remoteSet = new Set(remote);
|
|
486
|
+
return local.filter((f) => remoteSet.has(f));
|
|
487
|
+
}
|
|
488
|
+
function diffFeatures(a, b) {
|
|
489
|
+
const bSet = new Set(b);
|
|
490
|
+
return a.filter((f) => !bSet.has(f));
|
|
491
|
+
}
|
|
492
|
+
function addDependencies(features) {
|
|
493
|
+
const result = new Set(features);
|
|
494
|
+
for (const feature of features) {
|
|
495
|
+
const deps = getAllDependencies(feature);
|
|
496
|
+
for (const dep of deps) {
|
|
497
|
+
result.add(dep);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return [...result];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// src/negotiation.ts
|
|
504
|
+
function createLocalCapabilities(peerId, features, options) {
|
|
505
|
+
return {
|
|
506
|
+
peerId,
|
|
507
|
+
protocolVersion: CURRENT_PROTOCOL_VERSION,
|
|
508
|
+
minProtocolVersion: options?.minProtocolVersion ?? 1,
|
|
509
|
+
features: features ?? getEnabledFeatures(CURRENT_PROTOCOL_VERSION),
|
|
510
|
+
packageVersion: options?.packageVersion ?? "0.0.0",
|
|
511
|
+
schemas: options?.schemas
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
function parseCapabilities(message) {
|
|
515
|
+
if (!message || typeof message !== "object") {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
const msg = message;
|
|
519
|
+
if (typeof msg.peerId !== "string" && typeof msg.did !== "string") {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
const peerId = msg.peerId ?? msg.did;
|
|
523
|
+
const protocolVersion = typeof msg.protocolVersion === "number" ? msg.protocolVersion : CURRENT_PROTOCOL_VERSION;
|
|
524
|
+
const minProtocolVersion = typeof msg.minProtocolVersion === "number" ? msg.minProtocolVersion : 1;
|
|
525
|
+
const packageVersion = typeof msg.packageVersion === "string" ? msg.packageVersion : "0.0.0";
|
|
526
|
+
let features = [];
|
|
527
|
+
if (Array.isArray(msg.features)) {
|
|
528
|
+
const knownFeatures = getEnabledFeatures(protocolVersion);
|
|
529
|
+
features = msg.features.filter(
|
|
530
|
+
(f) => knownFeatures.includes(f)
|
|
531
|
+
);
|
|
532
|
+
} else {
|
|
533
|
+
features = getEnabledFeatures(protocolVersion);
|
|
534
|
+
}
|
|
535
|
+
const schemas = Array.isArray(msg.schemas) ? msg.schemas.filter((s) => typeof s === "string") : void 0;
|
|
536
|
+
return {
|
|
537
|
+
peerId,
|
|
538
|
+
protocolVersion,
|
|
539
|
+
minProtocolVersion,
|
|
540
|
+
features,
|
|
541
|
+
packageVersion,
|
|
542
|
+
schemas
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
var VersionNegotiator = class {
|
|
546
|
+
/**
|
|
547
|
+
* Negotiate capabilities between local and remote peers.
|
|
548
|
+
*
|
|
549
|
+
* @param local - Local peer's capabilities
|
|
550
|
+
* @param remote - Remote peer's capabilities
|
|
551
|
+
* @returns Negotiated session or failure result
|
|
552
|
+
*/
|
|
553
|
+
negotiate(local, remote) {
|
|
554
|
+
const maxVersion = Math.min(local.protocolVersion, remote.protocolVersion);
|
|
555
|
+
const minVersion = Math.max(local.minProtocolVersion, remote.minProtocolVersion);
|
|
556
|
+
if (maxVersion < minVersion) {
|
|
557
|
+
return this.createFailure(local, remote, maxVersion, minVersion);
|
|
558
|
+
}
|
|
559
|
+
const commonFeatures = intersectFeatures(local.features, remote.features);
|
|
560
|
+
const required = getRequiredFeatures(maxVersion);
|
|
561
|
+
const missingRequired = required.filter((f) => !commonFeatures.includes(f));
|
|
562
|
+
if (missingRequired.length > 0) {
|
|
563
|
+
return {
|
|
564
|
+
success: false,
|
|
565
|
+
error: "missing-required-features",
|
|
566
|
+
message: `Missing required features: ${missingRequired.join(", ")}`,
|
|
567
|
+
localVersion: local.protocolVersion,
|
|
568
|
+
remoteVersion: remote.protocolVersion,
|
|
569
|
+
suggestion: "contact-support"
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
const warnings = this.generateWarnings(local, remote, commonFeatures, maxVersion);
|
|
573
|
+
return {
|
|
574
|
+
success: true,
|
|
575
|
+
peerId: remote.peerId,
|
|
576
|
+
agreedVersion: maxVersion,
|
|
577
|
+
commonFeatures,
|
|
578
|
+
warnings,
|
|
579
|
+
canUse: (feature) => commonFeatures.includes(feature)
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Validate that a set of capabilities is well-formed.
|
|
584
|
+
*
|
|
585
|
+
* @param capabilities - Capabilities to validate
|
|
586
|
+
* @returns Validation result with errors if any
|
|
587
|
+
*/
|
|
588
|
+
validateCapabilities(capabilities) {
|
|
589
|
+
const errors = [];
|
|
590
|
+
if (!capabilities.peerId) {
|
|
591
|
+
errors.push("Missing peerId");
|
|
592
|
+
}
|
|
593
|
+
if (capabilities.protocolVersion < 1) {
|
|
594
|
+
errors.push("Protocol version must be at least 1");
|
|
595
|
+
}
|
|
596
|
+
if (capabilities.minProtocolVersion > capabilities.protocolVersion) {
|
|
597
|
+
errors.push("Minimum protocol version cannot exceed protocol version");
|
|
598
|
+
}
|
|
599
|
+
const featureResult = validateFeatureSet(capabilities.features, capabilities.protocolVersion);
|
|
600
|
+
if (!featureResult.valid) {
|
|
601
|
+
for (const error of featureResult.errors) {
|
|
602
|
+
errors.push(error.message);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
valid: errors.length === 0,
|
|
607
|
+
errors
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Create a failure result with appropriate suggestion.
|
|
612
|
+
*/
|
|
613
|
+
createFailure(local, remote, _maxVersion, _minVersion) {
|
|
614
|
+
let suggestion;
|
|
615
|
+
if (local.protocolVersion < remote.minProtocolVersion) {
|
|
616
|
+
suggestion = "upgrade-client";
|
|
617
|
+
} else if (remote.protocolVersion < local.minProtocolVersion) {
|
|
618
|
+
suggestion = "upgrade-hub";
|
|
619
|
+
} else {
|
|
620
|
+
suggestion = "upgrade-both";
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
success: false,
|
|
624
|
+
error: "incompatible-versions",
|
|
625
|
+
message: `Version mismatch: local v${local.protocolVersion} (min ${local.minProtocolVersion}), remote v${remote.protocolVersion} (min ${remote.minProtocolVersion})`,
|
|
626
|
+
localVersion: local.protocolVersion,
|
|
627
|
+
remoteVersion: remote.protocolVersion,
|
|
628
|
+
suggestion
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Generate warnings about degraded functionality.
|
|
633
|
+
*/
|
|
634
|
+
generateWarnings(local, remote, commonFeatures, agreedVersion) {
|
|
635
|
+
const warnings = [];
|
|
636
|
+
if (local.protocolVersion !== remote.protocolVersion) {
|
|
637
|
+
const olderPeer = local.protocolVersion < remote.protocolVersion ? "local" : "remote";
|
|
638
|
+
warnings.push({
|
|
639
|
+
type: "version-mismatch",
|
|
640
|
+
message: `${olderPeer === "local" ? "Local" : "Remote"} peer using older protocol v${Math.min(local.protocolVersion, remote.protocolVersion)} (agreed on v${agreedVersion})`
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
const missingFromRemote = diffFeatures(local.features, remote.features);
|
|
644
|
+
if (missingFromRemote.length > 0) {
|
|
645
|
+
warnings.push({
|
|
646
|
+
type: "degraded-features",
|
|
647
|
+
message: `Features unavailable with this peer: ${missingFromRemote.join(", ")}`,
|
|
648
|
+
affectedFeatures: missingFromRemote
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
const unknownFromRemote = remote.features.filter(
|
|
652
|
+
(f) => !local.features.includes(f)
|
|
653
|
+
);
|
|
654
|
+
if (unknownFromRemote.length > 0) {
|
|
655
|
+
warnings.push({
|
|
656
|
+
type: "unknown-features",
|
|
657
|
+
message: `Remote advertises unknown features: ${unknownFromRemote.join(", ")}`,
|
|
658
|
+
affectedFeatures: unknownFromRemote
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
return warnings;
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
var defaultNegotiator = new VersionNegotiator();
|
|
665
|
+
|
|
666
|
+
// src/provider.ts
|
|
667
|
+
var BaseSyncProvider = class {
|
|
668
|
+
_status = "disconnected";
|
|
669
|
+
_peers = /* @__PURE__ */ new Map();
|
|
670
|
+
_listeners = /* @__PURE__ */ new Map();
|
|
671
|
+
/** Local capabilities for negotiation */
|
|
672
|
+
_localCapabilities;
|
|
673
|
+
/** Version negotiator instance */
|
|
674
|
+
_negotiator;
|
|
675
|
+
/** Provider options */
|
|
676
|
+
_options;
|
|
677
|
+
constructor(options) {
|
|
678
|
+
this._options = options;
|
|
679
|
+
this._negotiator = new VersionNegotiator();
|
|
680
|
+
this._localCapabilities = createLocalCapabilities(
|
|
681
|
+
options.localDID ?? `anonymous-${Date.now()}`,
|
|
682
|
+
options.enabledFeatures,
|
|
683
|
+
{
|
|
684
|
+
packageVersion: options.packageVersion,
|
|
685
|
+
minProtocolVersion: options.minProtocolVersion
|
|
686
|
+
}
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
get status() {
|
|
690
|
+
return this._status;
|
|
691
|
+
}
|
|
692
|
+
get peers() {
|
|
693
|
+
return Array.from(this._peers.keys());
|
|
694
|
+
}
|
|
695
|
+
get peerInfo() {
|
|
696
|
+
return new Map(this._peers);
|
|
697
|
+
}
|
|
698
|
+
get localCapabilities() {
|
|
699
|
+
return this._localCapabilities;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Check if a feature can be used with a specific peer.
|
|
703
|
+
*/
|
|
704
|
+
canUseFeature(peerId, feature) {
|
|
705
|
+
const peer = this._peers.get(peerId);
|
|
706
|
+
if (!peer?.negotiatedSession) {
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
return peer.negotiatedSession.canUse(feature);
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Get the negotiated session for a peer.
|
|
713
|
+
*/
|
|
714
|
+
getNegotiatedSession(peerId) {
|
|
715
|
+
return this._peers.get(peerId)?.negotiatedSession;
|
|
716
|
+
}
|
|
717
|
+
async requestChangesFromAll(since) {
|
|
718
|
+
const allChanges = [];
|
|
719
|
+
const seenHashes = /* @__PURE__ */ new Set();
|
|
720
|
+
const promises = this.peers.map(
|
|
721
|
+
(peerId) => this.requestChanges(peerId, since).catch(() => [])
|
|
722
|
+
);
|
|
723
|
+
const results = await Promise.all(promises);
|
|
724
|
+
for (const changes of results) {
|
|
725
|
+
for (const change of changes) {
|
|
726
|
+
if (!seenHashes.has(change.hash)) {
|
|
727
|
+
seenHashes.add(change.hash);
|
|
728
|
+
allChanges.push(change);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return allChanges;
|
|
733
|
+
}
|
|
734
|
+
on(event, listener) {
|
|
735
|
+
const listeners = this._listeners.get(event) || /* @__PURE__ */ new Set();
|
|
736
|
+
listeners.add(listener);
|
|
737
|
+
this._listeners.set(event, listeners);
|
|
738
|
+
}
|
|
739
|
+
off(event, listener) {
|
|
740
|
+
const listeners = this._listeners.get(event);
|
|
741
|
+
if (listeners) {
|
|
742
|
+
listeners.delete(listener);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
once(event, listener) {
|
|
746
|
+
const onceListener = ((...args) => {
|
|
747
|
+
this.off(event, onceListener);
|
|
748
|
+
listener(...args);
|
|
749
|
+
});
|
|
750
|
+
this.on(event, onceListener);
|
|
751
|
+
}
|
|
752
|
+
emit(event, ...args) {
|
|
753
|
+
const listeners = this._listeners.get(event);
|
|
754
|
+
if (listeners) {
|
|
755
|
+
for (const listener of listeners) {
|
|
756
|
+
try {
|
|
757
|
+
listener(...args);
|
|
758
|
+
} catch (error) {
|
|
759
|
+
console.error(`Error in sync provider event listener for ${event}:`, error);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
setStatus(status) {
|
|
765
|
+
if (this._status !== status) {
|
|
766
|
+
this._status = status;
|
|
767
|
+
this.emit("status-change", status);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
addPeer(id, name) {
|
|
771
|
+
const now = Date.now();
|
|
772
|
+
this._peers.set(id, {
|
|
773
|
+
id,
|
|
774
|
+
name,
|
|
775
|
+
connectedAt: now,
|
|
776
|
+
lastSeen: now
|
|
777
|
+
});
|
|
778
|
+
this.emit("peer-connected", this._peers.get(id));
|
|
779
|
+
}
|
|
780
|
+
removePeer(id) {
|
|
781
|
+
this._peers.delete(id);
|
|
782
|
+
this.emit("peer-disconnected", id);
|
|
783
|
+
}
|
|
784
|
+
updatePeerLastSeen(id) {
|
|
785
|
+
const peer = this._peers.get(id);
|
|
786
|
+
if (peer) {
|
|
787
|
+
peer.lastSeen = Date.now();
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Negotiate capabilities with a peer.
|
|
792
|
+
* Called when a peer connects and sends their capabilities.
|
|
793
|
+
*
|
|
794
|
+
* @param peerId - The peer's ID
|
|
795
|
+
* @param remoteCapabilities - The peer's advertised capabilities
|
|
796
|
+
* @returns Negotiation result (success or failure)
|
|
797
|
+
*/
|
|
798
|
+
negotiateWithPeer(peerId, remoteCapabilities) {
|
|
799
|
+
const peer = this._peers.get(peerId);
|
|
800
|
+
if (!peer) {
|
|
801
|
+
return {
|
|
802
|
+
success: false,
|
|
803
|
+
error: "invalid-capabilities",
|
|
804
|
+
message: `Peer ${peerId} not found`,
|
|
805
|
+
localVersion: this._localCapabilities.protocolVersion,
|
|
806
|
+
remoteVersion: remoteCapabilities.protocolVersion,
|
|
807
|
+
suggestion: "contact-support"
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
peer.capabilities = remoteCapabilities;
|
|
811
|
+
const result = this._negotiator.negotiate(this._localCapabilities, remoteCapabilities);
|
|
812
|
+
if (result.success) {
|
|
813
|
+
peer.negotiatedSession = result;
|
|
814
|
+
this.emit("negotiation-complete", peerId, result);
|
|
815
|
+
if (result.warnings.length > 0) {
|
|
816
|
+
const warningMessages = result.warnings.map((w) => w.message);
|
|
817
|
+
this.emit("capability-degraded", peerId, warningMessages);
|
|
818
|
+
}
|
|
819
|
+
} else {
|
|
820
|
+
this.emit("negotiation-failed", peerId, result.message, result.suggestion);
|
|
821
|
+
if (this._options.strictVersionCheck) {
|
|
822
|
+
this.removePeer(peerId);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return result;
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Get features available with all connected peers.
|
|
829
|
+
* Returns the intersection of all negotiated feature sets.
|
|
830
|
+
*/
|
|
831
|
+
getCommonFeatures() {
|
|
832
|
+
const negotiatedPeers = Array.from(this._peers.values()).filter((p) => p.negotiatedSession);
|
|
833
|
+
if (negotiatedPeers.length === 0) {
|
|
834
|
+
return this._localCapabilities.features;
|
|
835
|
+
}
|
|
836
|
+
let common = new Set(negotiatedPeers[0].negotiatedSession.commonFeatures);
|
|
837
|
+
for (let i = 1; i < negotiatedPeers.length; i++) {
|
|
838
|
+
const peerFeatures = new Set(negotiatedPeers[i].negotiatedSession.commonFeatures);
|
|
839
|
+
common = new Set([...common].filter((f) => peerFeatures.has(f)));
|
|
840
|
+
}
|
|
841
|
+
return [...common];
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Check if a feature can be used with all connected peers.
|
|
845
|
+
*/
|
|
846
|
+
canUseFeatureWithAll(feature) {
|
|
847
|
+
return this.getCommonFeatures().includes(feature);
|
|
848
|
+
}
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
// src/yjs-envelope.ts
|
|
852
|
+
import {
|
|
853
|
+
hash,
|
|
854
|
+
sign as sign2,
|
|
855
|
+
verify as verify2,
|
|
856
|
+
hybridSign,
|
|
857
|
+
hybridVerify,
|
|
858
|
+
encodeSignature,
|
|
859
|
+
decodeSignature,
|
|
860
|
+
toBase64,
|
|
861
|
+
fromBase64,
|
|
862
|
+
DEFAULT_SECURITY_LEVEL
|
|
863
|
+
} from "@xnetjs/crypto";
|
|
864
|
+
import { parseDID } from "@xnetjs/identity";
|
|
865
|
+
function isV2Envelope(envelope) {
|
|
866
|
+
return "v" in envelope && envelope.v === 2;
|
|
867
|
+
}
|
|
868
|
+
function isV1Envelope(envelope) {
|
|
869
|
+
return !("v" in envelope);
|
|
870
|
+
}
|
|
871
|
+
function signYjsUpdateV1(update, authorDID, privateKey, clientId) {
|
|
872
|
+
const updateHash = hash(update, "blake3");
|
|
873
|
+
const signature = sign2(updateHash, privateKey);
|
|
874
|
+
return {
|
|
875
|
+
update,
|
|
876
|
+
authorDID,
|
|
877
|
+
signature,
|
|
878
|
+
timestamp: Date.now(),
|
|
879
|
+
clientId
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
function verifyYjsEnvelopeV1(envelope) {
|
|
883
|
+
try {
|
|
884
|
+
const publicKey = parseDID(envelope.authorDID);
|
|
885
|
+
const updateHash = hash(envelope.update, "blake3");
|
|
886
|
+
const valid = verify2(updateHash, envelope.signature, publicKey);
|
|
887
|
+
if (!valid) {
|
|
888
|
+
return { valid: false, reason: "invalid_signature" };
|
|
889
|
+
}
|
|
890
|
+
return { valid: true };
|
|
891
|
+
} catch {
|
|
892
|
+
return { valid: false, reason: "did_resolution_failed" };
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
function signYjsUpdateV2(update, docId, clientId, keyBundle, options = {}) {
|
|
896
|
+
const { level = DEFAULT_SECURITY_LEVEL } = options;
|
|
897
|
+
const meta = {
|
|
898
|
+
authorDID: keyBundle.identity.did,
|
|
899
|
+
clientId,
|
|
900
|
+
timestamp: Date.now(),
|
|
901
|
+
docId
|
|
902
|
+
};
|
|
903
|
+
const metaBytes = new TextEncoder().encode(JSON.stringify(meta));
|
|
904
|
+
const combined = new Uint8Array(update.length + metaBytes.length);
|
|
905
|
+
combined.set(update, 0);
|
|
906
|
+
combined.set(metaBytes, update.length);
|
|
907
|
+
const signingHash = hash(combined, "blake3");
|
|
908
|
+
const signature = hybridSign(
|
|
909
|
+
signingHash,
|
|
910
|
+
{
|
|
911
|
+
ed25519: keyBundle.signingKey,
|
|
912
|
+
mlDsa: keyBundle.pqSigningKey
|
|
913
|
+
},
|
|
914
|
+
level
|
|
915
|
+
);
|
|
916
|
+
return {
|
|
917
|
+
v: 2,
|
|
918
|
+
update,
|
|
919
|
+
meta,
|
|
920
|
+
signature
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
function signYjsUpdateBatch(updates, docId, clientId, keyBundle, options = {}) {
|
|
924
|
+
return updates.map((update) => signYjsUpdateV2(update, docId, clientId, keyBundle, options));
|
|
925
|
+
}
|
|
926
|
+
async function verifyYjsEnvelopeV2(envelope, options = {}) {
|
|
927
|
+
const { registry, expectedDocId, maxAge, minLevel = 0, policy = "strict" } = options;
|
|
928
|
+
const errors = [];
|
|
929
|
+
if (expectedDocId && envelope.meta.docId !== expectedDocId) {
|
|
930
|
+
errors.push(`Document ID mismatch: expected ${expectedDocId}, got ${envelope.meta.docId}`);
|
|
931
|
+
}
|
|
932
|
+
if (maxAge) {
|
|
933
|
+
const age = Date.now() - envelope.meta.timestamp;
|
|
934
|
+
if (age > maxAge) {
|
|
935
|
+
errors.push(`Envelope too old: ${age}ms > ${maxAge}ms`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
if (envelope.signature.level < minLevel) {
|
|
939
|
+
errors.push(
|
|
940
|
+
`Security level too low: ${envelope.signature.level} < ${minLevel} (required minimum)`
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
let ed25519PublicKey;
|
|
944
|
+
try {
|
|
945
|
+
ed25519PublicKey = parseDID(envelope.meta.authorDID);
|
|
946
|
+
} catch {
|
|
947
|
+
errors.push("Failed to parse author DID");
|
|
948
|
+
return {
|
|
949
|
+
valid: false,
|
|
950
|
+
level: envelope.signature.level,
|
|
951
|
+
errors,
|
|
952
|
+
authorDID: envelope.meta.authorDID,
|
|
953
|
+
clientId: envelope.meta.clientId
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
let pqPublicKey;
|
|
957
|
+
if (envelope.signature.level >= 1 && registry) {
|
|
958
|
+
const lookedUp = await registry.lookup(envelope.meta.authorDID);
|
|
959
|
+
pqPublicKey = lookedUp ?? void 0;
|
|
960
|
+
}
|
|
961
|
+
const metaBytes = new TextEncoder().encode(JSON.stringify(envelope.meta));
|
|
962
|
+
const combined = new Uint8Array(envelope.update.length + metaBytes.length);
|
|
963
|
+
combined.set(envelope.update, 0);
|
|
964
|
+
combined.set(metaBytes, envelope.update.length);
|
|
965
|
+
const signingHash = hash(combined, "blake3");
|
|
966
|
+
const result = hybridVerify(
|
|
967
|
+
signingHash,
|
|
968
|
+
envelope.signature,
|
|
969
|
+
{ ed25519: ed25519PublicKey, mlDsa: pqPublicKey },
|
|
970
|
+
{ minLevel, policy }
|
|
971
|
+
);
|
|
972
|
+
if (!result.valid) {
|
|
973
|
+
if (result.details.ed25519?.error) errors.push(result.details.ed25519.error);
|
|
974
|
+
if (result.details.mlDsa?.error) errors.push(result.details.mlDsa.error);
|
|
975
|
+
if (!result.details.ed25519?.error && !result.details.mlDsa?.error) {
|
|
976
|
+
errors.push("Signature verification failed");
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return {
|
|
980
|
+
valid: errors.length === 0,
|
|
981
|
+
level: envelope.signature.level,
|
|
982
|
+
errors,
|
|
983
|
+
authorDID: envelope.meta.authorDID,
|
|
984
|
+
clientId: envelope.meta.clientId
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
async function verifyYjsEnvelopeQuick(envelope, options = {}) {
|
|
988
|
+
const result = await verifyYjsEnvelopeV2(envelope, options);
|
|
989
|
+
return result.valid;
|
|
990
|
+
}
|
|
991
|
+
function serializeYjsEnvelope(envelope) {
|
|
992
|
+
return {
|
|
993
|
+
v: 2,
|
|
994
|
+
u: toBase64(envelope.update),
|
|
995
|
+
m: {
|
|
996
|
+
a: envelope.meta.authorDID,
|
|
997
|
+
c: envelope.meta.clientId,
|
|
998
|
+
t: envelope.meta.timestamp,
|
|
999
|
+
d: envelope.meta.docId
|
|
1000
|
+
},
|
|
1001
|
+
s: encodeSignature(envelope.signature)
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
function deserializeYjsEnvelope(wire) {
|
|
1005
|
+
if (wire.v !== 2) {
|
|
1006
|
+
throw new Error(`Unsupported envelope version: ${wire.v}. Expected version 2.`);
|
|
1007
|
+
}
|
|
1008
|
+
return {
|
|
1009
|
+
v: 2,
|
|
1010
|
+
update: fromBase64(wire.u),
|
|
1011
|
+
meta: {
|
|
1012
|
+
authorDID: wire.m.a,
|
|
1013
|
+
clientId: wire.m.c,
|
|
1014
|
+
timestamp: wire.m.t,
|
|
1015
|
+
docId: wire.m.d
|
|
1016
|
+
},
|
|
1017
|
+
signature: decodeSignature(wire.s)
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
function envelopeSize(envelope) {
|
|
1021
|
+
let size = envelope.update.length;
|
|
1022
|
+
size += JSON.stringify(envelope.meta).length;
|
|
1023
|
+
if (envelope.signature.ed25519) size += envelope.signature.ed25519.length;
|
|
1024
|
+
if (envelope.signature.mlDsa) size += envelope.signature.mlDsa.length;
|
|
1025
|
+
return size;
|
|
1026
|
+
}
|
|
1027
|
+
function signYjsUpdate(update, authorDIDOrDocId, privateKeyOrClientId, clientIdOrKeyBundle, options) {
|
|
1028
|
+
if (typeof privateKeyOrClientId === "number") {
|
|
1029
|
+
return signYjsUpdateV2(
|
|
1030
|
+
update,
|
|
1031
|
+
authorDIDOrDocId,
|
|
1032
|
+
privateKeyOrClientId,
|
|
1033
|
+
clientIdOrKeyBundle,
|
|
1034
|
+
options
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
return signYjsUpdateV1(
|
|
1038
|
+
update,
|
|
1039
|
+
authorDIDOrDocId,
|
|
1040
|
+
privateKeyOrClientId,
|
|
1041
|
+
clientIdOrKeyBundle
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
function verifyYjsEnvelope(envelope, options) {
|
|
1045
|
+
if (isV2Envelope(envelope)) {
|
|
1046
|
+
return verifyYjsEnvelopeV2(envelope, options);
|
|
1047
|
+
}
|
|
1048
|
+
return verifyYjsEnvelopeV1(envelope);
|
|
1049
|
+
}
|
|
1050
|
+
function hasSignedEnvelope(msg) {
|
|
1051
|
+
return typeof msg === "object" && msg !== null && "envelope" in msg && typeof msg.envelope === "object" && msg.envelope !== null && "update" in msg.envelope && ("authorDID" in msg.envelope || "meta" in msg.envelope) && ("signature" in msg.envelope || "v" in msg.envelope);
|
|
1052
|
+
}
|
|
1053
|
+
function isLegacyUpdate(msg) {
|
|
1054
|
+
return typeof msg === "object" && msg !== null && "data" in msg && msg.data instanceof Uint8Array && !("envelope" in msg);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// src/yjs-limits.ts
|
|
1058
|
+
var MAX_YJS_UPDATE_SIZE = 1048576;
|
|
1059
|
+
var MAX_YJS_UPDATES_PER_SECOND = 30;
|
|
1060
|
+
var MAX_YJS_UPDATES_PER_MINUTE = 600;
|
|
1061
|
+
var MAX_YJS_DOC_SIZE = 52428800;
|
|
1062
|
+
var YJS_SYNC_CHUNK_SIZE = 262144;
|
|
1063
|
+
var YJS_RATE_BURST_ALLOWANCE = 10;
|
|
1064
|
+
var DEFAULT_RATE_LIMITER_CONFIG = {
|
|
1065
|
+
maxPerSecond: MAX_YJS_UPDATES_PER_SECOND,
|
|
1066
|
+
maxPerMinute: MAX_YJS_UPDATES_PER_MINUTE,
|
|
1067
|
+
burstAllowance: YJS_RATE_BURST_ALLOWANCE
|
|
1068
|
+
};
|
|
1069
|
+
var YjsRateLimiter = class {
|
|
1070
|
+
secondWindows = /* @__PURE__ */ new Map();
|
|
1071
|
+
minuteWindows = /* @__PURE__ */ new Map();
|
|
1072
|
+
config;
|
|
1073
|
+
cleanupInterval = null;
|
|
1074
|
+
staleThresholdMs;
|
|
1075
|
+
constructor(options = {}) {
|
|
1076
|
+
const { staleThresholdMs, cleanupIntervalMs, ...config } = options;
|
|
1077
|
+
this.config = { ...DEFAULT_RATE_LIMITER_CONFIG, ...config };
|
|
1078
|
+
this.staleThresholdMs = staleThresholdMs ?? 2 * 60 * 1e3;
|
|
1079
|
+
const interval = cleanupIntervalMs ?? 30 * 1e3;
|
|
1080
|
+
if (interval > 0) {
|
|
1081
|
+
this.startCleanup(interval);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
/** Start periodic cleanup of stale entries. */
|
|
1085
|
+
startCleanup(intervalMs = 30 * 1e3) {
|
|
1086
|
+
this.stopCleanup();
|
|
1087
|
+
this.cleanupInterval = setInterval(() => this.cleanupStale(), intervalMs);
|
|
1088
|
+
if (typeof this.cleanupInterval === "object" && "unref" in this.cleanupInterval) {
|
|
1089
|
+
this.cleanupInterval.unref();
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/** Stop periodic cleanup. */
|
|
1093
|
+
stopCleanup() {
|
|
1094
|
+
if (this.cleanupInterval) {
|
|
1095
|
+
clearInterval(this.cleanupInterval);
|
|
1096
|
+
this.cleanupInterval = null;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
/** Remove entries whose windows have expired. Returns count removed. */
|
|
1100
|
+
cleanupStale() {
|
|
1101
|
+
const now = Date.now();
|
|
1102
|
+
const threshold = now - this.staleThresholdMs;
|
|
1103
|
+
let removed = 0;
|
|
1104
|
+
for (const [peerId, window] of this.secondWindows) {
|
|
1105
|
+
if (window.resetAt < threshold) {
|
|
1106
|
+
this.secondWindows.delete(peerId);
|
|
1107
|
+
removed++;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
for (const [peerId, window] of this.minuteWindows) {
|
|
1111
|
+
if (window.resetAt < threshold) {
|
|
1112
|
+
this.minuteWindows.delete(peerId);
|
|
1113
|
+
removed++;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
return removed;
|
|
1117
|
+
}
|
|
1118
|
+
/** Get number of tracked peers. */
|
|
1119
|
+
get peerCount() {
|
|
1120
|
+
const peers = /* @__PURE__ */ new Set([...this.secondWindows.keys(), ...this.minuteWindows.keys()]);
|
|
1121
|
+
return peers.size;
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Check if a peer can send another update.
|
|
1125
|
+
*
|
|
1126
|
+
* @param peerId - Peer identifier
|
|
1127
|
+
* @returns true if allowed, false if rate-limited
|
|
1128
|
+
*/
|
|
1129
|
+
allow(peerId) {
|
|
1130
|
+
const now = Date.now();
|
|
1131
|
+
const sec = this.secondWindows.get(peerId);
|
|
1132
|
+
if (!sec || now >= sec.resetAt) {
|
|
1133
|
+
this.secondWindows.set(peerId, { count: 1, resetAt: now + 1e3 });
|
|
1134
|
+
} else {
|
|
1135
|
+
sec.count++;
|
|
1136
|
+
if (sec.count > this.config.maxPerSecond + this.config.burstAllowance) {
|
|
1137
|
+
return false;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
const min = this.minuteWindows.get(peerId);
|
|
1141
|
+
if (!min || now >= min.resetAt) {
|
|
1142
|
+
this.minuteWindows.set(peerId, { count: 1, resetAt: now + 6e4 });
|
|
1143
|
+
} else {
|
|
1144
|
+
min.count++;
|
|
1145
|
+
if (min.count > this.config.maxPerMinute) {
|
|
1146
|
+
return false;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
return true;
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Get current rate info for a peer (for debugging/monitoring).
|
|
1153
|
+
*/
|
|
1154
|
+
getInfo(peerId) {
|
|
1155
|
+
const sec = this.secondWindows.get(peerId);
|
|
1156
|
+
const min = this.minuteWindows.get(peerId);
|
|
1157
|
+
if (!sec && !min) return void 0;
|
|
1158
|
+
const now = Date.now();
|
|
1159
|
+
return {
|
|
1160
|
+
perSecond: sec && now < sec.resetAt ? sec.count : 0,
|
|
1161
|
+
perMinute: min && now < min.resetAt ? min.count : 0
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Reset state for a disconnected peer.
|
|
1166
|
+
*/
|
|
1167
|
+
remove(peerId) {
|
|
1168
|
+
this.secondWindows.delete(peerId);
|
|
1169
|
+
this.minuteWindows.delete(peerId);
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Clear all state.
|
|
1173
|
+
*/
|
|
1174
|
+
clear() {
|
|
1175
|
+
this.secondWindows.clear();
|
|
1176
|
+
this.minuteWindows.clear();
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Stop cleanup and clear all state.
|
|
1180
|
+
*/
|
|
1181
|
+
destroy() {
|
|
1182
|
+
this.stopCleanup();
|
|
1183
|
+
this.clear();
|
|
1184
|
+
}
|
|
1185
|
+
};
|
|
1186
|
+
function isUpdateTooLarge(update, maxSize = MAX_YJS_UPDATE_SIZE) {
|
|
1187
|
+
return update.length > maxSize;
|
|
1188
|
+
}
|
|
1189
|
+
function isDocumentTooLarge(state, maxSize = MAX_YJS_DOC_SIZE) {
|
|
1190
|
+
return state.length > maxSize;
|
|
1191
|
+
}
|
|
1192
|
+
function calculateChunkCount(totalSize, chunkSize = YJS_SYNC_CHUNK_SIZE) {
|
|
1193
|
+
return Math.ceil(totalSize / chunkSize);
|
|
1194
|
+
}
|
|
1195
|
+
function chunkUpdate(update, chunkSize = YJS_SYNC_CHUNK_SIZE) {
|
|
1196
|
+
const chunks = [];
|
|
1197
|
+
for (let i = 0; i < update.length; i += chunkSize) {
|
|
1198
|
+
chunks.push(update.slice(i, Math.min(i + chunkSize, update.length)));
|
|
1199
|
+
}
|
|
1200
|
+
return chunks;
|
|
1201
|
+
}
|
|
1202
|
+
function reassembleChunks(chunks) {
|
|
1203
|
+
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
1204
|
+
const result = new Uint8Array(totalLength);
|
|
1205
|
+
let offset = 0;
|
|
1206
|
+
for (const chunk of chunks) {
|
|
1207
|
+
result.set(chunk, offset);
|
|
1208
|
+
offset += chunk.length;
|
|
1209
|
+
}
|
|
1210
|
+
return result;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// src/yjs-integrity.ts
|
|
1214
|
+
import { hashHex as hashHex2 } from "@xnetjs/crypto";
|
|
1215
|
+
function hashYjsState(state) {
|
|
1216
|
+
return hashHex2(state, "blake3");
|
|
1217
|
+
}
|
|
1218
|
+
function verifyYjsStateIntegrity(state, expectedHash) {
|
|
1219
|
+
return hashYjsState(state) === expectedHash;
|
|
1220
|
+
}
|
|
1221
|
+
var YjsIntegrityError = class extends Error {
|
|
1222
|
+
constructor(docId, expectedHash, actualHash) {
|
|
1223
|
+
super(
|
|
1224
|
+
`Yjs state corrupted for doc ${docId}: expected ${expectedHash.slice(0, 16)}..., got ${actualHash.slice(0, 16)}...`
|
|
1225
|
+
);
|
|
1226
|
+
this.docId = docId;
|
|
1227
|
+
this.expectedHash = expectedHash;
|
|
1228
|
+
this.actualHash = actualHash;
|
|
1229
|
+
this.name = "YjsIntegrityError";
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
function createPersistedDocState(state, updateCount = 0) {
|
|
1233
|
+
return {
|
|
1234
|
+
state,
|
|
1235
|
+
hash: hashYjsState(state),
|
|
1236
|
+
persistedAt: Date.now(),
|
|
1237
|
+
updateCount
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
function verifyPersistedDocState(docId, record) {
|
|
1241
|
+
const actualHash = hashYjsState(record.state);
|
|
1242
|
+
if (actualHash !== record.hash) {
|
|
1243
|
+
throw new YjsIntegrityError(docId, record.hash, actualHash);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
function loadVerifiedState(docId, record) {
|
|
1247
|
+
if (!record.hash) {
|
|
1248
|
+
return record.state;
|
|
1249
|
+
}
|
|
1250
|
+
if (!verifyYjsStateIntegrity(record.state, record.hash)) {
|
|
1251
|
+
throw new YjsIntegrityError(docId, record.hash, hashYjsState(record.state));
|
|
1252
|
+
}
|
|
1253
|
+
return record.state;
|
|
1254
|
+
}
|
|
1255
|
+
var COMPACTION_UPDATE_THRESHOLD = 100;
|
|
1256
|
+
var COMPACTION_TIME_THRESHOLD = 36e5;
|
|
1257
|
+
function shouldCompact(updateCount, persistedAt) {
|
|
1258
|
+
if (updateCount >= COMPACTION_UPDATE_THRESHOLD) {
|
|
1259
|
+
return true;
|
|
1260
|
+
}
|
|
1261
|
+
if (Date.now() - persistedAt > COMPACTION_TIME_THRESHOLD) {
|
|
1262
|
+
return true;
|
|
1263
|
+
}
|
|
1264
|
+
return false;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// src/yjs-peer-scoring.ts
|
|
1268
|
+
var DEFAULT_YJS_SCORING_CONFIG = {
|
|
1269
|
+
penalties: {
|
|
1270
|
+
invalidSignature: 30,
|
|
1271
|
+
oversizedUpdate: 10,
|
|
1272
|
+
rateExceeded: 5,
|
|
1273
|
+
unsignedUpdate: 20,
|
|
1274
|
+
unattestedClientId: 15,
|
|
1275
|
+
unauthorizedUpdate: 20
|
|
1276
|
+
},
|
|
1277
|
+
thresholds: {
|
|
1278
|
+
warn: 50,
|
|
1279
|
+
throttle: 30,
|
|
1280
|
+
block: 10
|
|
1281
|
+
},
|
|
1282
|
+
recoveryRate: 1,
|
|
1283
|
+
instantBlockAfter: 3
|
|
1284
|
+
};
|
|
1285
|
+
var YjsPeerScorer = class {
|
|
1286
|
+
metrics = /* @__PURE__ */ new Map();
|
|
1287
|
+
scores = /* @__PURE__ */ new Map();
|
|
1288
|
+
config;
|
|
1289
|
+
telemetry;
|
|
1290
|
+
constructor(config) {
|
|
1291
|
+
this.config = {
|
|
1292
|
+
penalties: { ...DEFAULT_YJS_SCORING_CONFIG.penalties, ...config?.penalties },
|
|
1293
|
+
thresholds: { ...DEFAULT_YJS_SCORING_CONFIG.thresholds, ...config?.thresholds },
|
|
1294
|
+
recoveryRate: config?.recoveryRate ?? DEFAULT_YJS_SCORING_CONFIG.recoveryRate,
|
|
1295
|
+
instantBlockAfter: config?.instantBlockAfter ?? DEFAULT_YJS_SCORING_CONFIG.instantBlockAfter,
|
|
1296
|
+
telemetry: config?.telemetry
|
|
1297
|
+
};
|
|
1298
|
+
this.telemetry = config?.telemetry;
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Record a violation for a peer.
|
|
1302
|
+
*
|
|
1303
|
+
* @param peerId - Peer identifier
|
|
1304
|
+
* @param reason - Type of violation
|
|
1305
|
+
* @returns Action to take based on new score
|
|
1306
|
+
*/
|
|
1307
|
+
penalize(peerId, reason) {
|
|
1308
|
+
const metrics = this.getOrCreateMetrics(peerId);
|
|
1309
|
+
const penalty = this.config.penalties[reason];
|
|
1310
|
+
switch (reason) {
|
|
1311
|
+
case "invalidSignature":
|
|
1312
|
+
metrics.invalidSignatures++;
|
|
1313
|
+
this.telemetry?.reportSecurityEvent("sync.yjs.invalid_signature", "high");
|
|
1314
|
+
if (metrics.invalidSignatures >= this.config.instantBlockAfter) {
|
|
1315
|
+
this.scores.set(peerId, 0);
|
|
1316
|
+
this.telemetry?.reportSecurityEvent("sync.yjs.peer_auto_blocked", "critical");
|
|
1317
|
+
this.telemetry?.reportUsage("sync.yjs.peer_action.block", 1);
|
|
1318
|
+
return "block";
|
|
1319
|
+
}
|
|
1320
|
+
break;
|
|
1321
|
+
case "oversizedUpdate":
|
|
1322
|
+
metrics.oversizedUpdates++;
|
|
1323
|
+
this.telemetry?.reportSecurityEvent("sync.yjs.oversized_update", "medium");
|
|
1324
|
+
break;
|
|
1325
|
+
case "rateExceeded":
|
|
1326
|
+
metrics.rateExceeded++;
|
|
1327
|
+
this.telemetry?.reportSecurityEvent("sync.yjs.rate_exceeded", "medium");
|
|
1328
|
+
break;
|
|
1329
|
+
case "unsignedUpdate":
|
|
1330
|
+
metrics.unsignedUpdates++;
|
|
1331
|
+
this.telemetry?.reportSecurityEvent("sync.yjs.unsigned_update", "high");
|
|
1332
|
+
break;
|
|
1333
|
+
case "unattestedClientId":
|
|
1334
|
+
metrics.unattestedClientIds++;
|
|
1335
|
+
this.telemetry?.reportSecurityEvent("sync.yjs.unattested_client_id", "medium");
|
|
1336
|
+
break;
|
|
1337
|
+
case "unauthorizedUpdate":
|
|
1338
|
+
metrics.unauthorizedUpdates++;
|
|
1339
|
+
this.telemetry?.reportSecurityEvent("sync.yjs.unauthorized_update", "high");
|
|
1340
|
+
break;
|
|
1341
|
+
}
|
|
1342
|
+
metrics.lastViolation = Date.now();
|
|
1343
|
+
const currentScore = this.scores.get(peerId) ?? 100;
|
|
1344
|
+
const newScore = Math.max(0, currentScore - penalty);
|
|
1345
|
+
this.scores.set(peerId, newScore);
|
|
1346
|
+
const action = this.getAction(newScore);
|
|
1347
|
+
if (action !== "allow") {
|
|
1348
|
+
this.telemetry?.reportUsage(`sync.yjs.peer_action.${action}`, 1);
|
|
1349
|
+
}
|
|
1350
|
+
return action;
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Record a valid update (for ratio tracking + score recovery consideration).
|
|
1354
|
+
*/
|
|
1355
|
+
recordValid(peerId) {
|
|
1356
|
+
const metrics = this.getOrCreateMetrics(peerId);
|
|
1357
|
+
metrics.validUpdates++;
|
|
1358
|
+
this.telemetry?.reportUsage("sync.yjs.valid_update", 1);
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Get current score for a peer.
|
|
1362
|
+
* New peers start at 100.
|
|
1363
|
+
*/
|
|
1364
|
+
getScore(peerId) {
|
|
1365
|
+
return this.scores.get(peerId) ?? 100;
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Get action for a given score.
|
|
1369
|
+
*/
|
|
1370
|
+
getAction(score) {
|
|
1371
|
+
if (score <= this.config.thresholds.block) return "block";
|
|
1372
|
+
if (score <= this.config.thresholds.throttle) return "throttle";
|
|
1373
|
+
if (score <= this.config.thresholds.warn) return "warn";
|
|
1374
|
+
return "allow";
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Get current action for a peer.
|
|
1378
|
+
*/
|
|
1379
|
+
getPeerAction(peerId) {
|
|
1380
|
+
return this.getAction(this.getScore(peerId));
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* Recover scores over time (called periodically).
|
|
1384
|
+
* Only recovers if no recent violations.
|
|
1385
|
+
*
|
|
1386
|
+
* @param recoveryWindow - Time in ms that must pass without violations (default: 60s)
|
|
1387
|
+
*/
|
|
1388
|
+
tick(recoveryWindow = 6e4) {
|
|
1389
|
+
const now = Date.now();
|
|
1390
|
+
for (const [peerId, score] of this.scores) {
|
|
1391
|
+
if (score >= 100) continue;
|
|
1392
|
+
const metrics = this.metrics.get(peerId);
|
|
1393
|
+
if (!metrics) continue;
|
|
1394
|
+
if (now - metrics.lastViolation > recoveryWindow) {
|
|
1395
|
+
const newScore = Math.min(100, score + this.config.recoveryRate);
|
|
1396
|
+
this.scores.set(peerId, newScore);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Get metrics for a peer (for debugging/monitoring).
|
|
1402
|
+
*/
|
|
1403
|
+
getMetrics(peerId) {
|
|
1404
|
+
return this.metrics.get(peerId);
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Get all peer IDs with metrics.
|
|
1408
|
+
*/
|
|
1409
|
+
getAllPeerIds() {
|
|
1410
|
+
return Array.from(this.metrics.keys());
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Get all metrics (for monitoring endpoint).
|
|
1414
|
+
*/
|
|
1415
|
+
getAllMetrics() {
|
|
1416
|
+
return new Map(this.metrics);
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Remove all state for a disconnected peer.
|
|
1420
|
+
*/
|
|
1421
|
+
remove(peerId) {
|
|
1422
|
+
this.metrics.delete(peerId);
|
|
1423
|
+
this.scores.delete(peerId);
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* Clear all state.
|
|
1427
|
+
*/
|
|
1428
|
+
clear() {
|
|
1429
|
+
this.metrics.clear();
|
|
1430
|
+
this.scores.clear();
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Get violation ratio for a peer.
|
|
1434
|
+
* Returns the ratio of violations to total operations.
|
|
1435
|
+
*/
|
|
1436
|
+
getViolationRatio(peerId) {
|
|
1437
|
+
const metrics = this.metrics.get(peerId);
|
|
1438
|
+
if (!metrics) return 0;
|
|
1439
|
+
const violations = metrics.invalidSignatures + metrics.oversizedUpdates + metrics.rateExceeded + metrics.unsignedUpdates + metrics.unattestedClientIds + metrics.unauthorizedUpdates;
|
|
1440
|
+
const total = violations + metrics.validUpdates;
|
|
1441
|
+
if (total === 0) return 0;
|
|
1442
|
+
return violations / total;
|
|
1443
|
+
}
|
|
1444
|
+
getOrCreateMetrics(peerId) {
|
|
1445
|
+
let m = this.metrics.get(peerId);
|
|
1446
|
+
if (!m) {
|
|
1447
|
+
m = {
|
|
1448
|
+
invalidSignatures: 0,
|
|
1449
|
+
oversizedUpdates: 0,
|
|
1450
|
+
rateExceeded: 0,
|
|
1451
|
+
unsignedUpdates: 0,
|
|
1452
|
+
unattestedClientIds: 0,
|
|
1453
|
+
unauthorizedUpdates: 0,
|
|
1454
|
+
validUpdates: 0,
|
|
1455
|
+
firstSeen: Date.now(),
|
|
1456
|
+
lastViolation: 0
|
|
1457
|
+
};
|
|
1458
|
+
this.metrics.set(peerId, m);
|
|
1459
|
+
}
|
|
1460
|
+
return m;
|
|
1461
|
+
}
|
|
1462
|
+
};
|
|
1463
|
+
|
|
1464
|
+
// src/yjs-authorization.ts
|
|
1465
|
+
import {
|
|
1466
|
+
base64ToBytes,
|
|
1467
|
+
bytesToBase64,
|
|
1468
|
+
constantTimeEqual,
|
|
1469
|
+
decrypt,
|
|
1470
|
+
encrypt,
|
|
1471
|
+
hash as hash2
|
|
1472
|
+
} from "@xnetjs/crypto";
|
|
1473
|
+
var YjsStateIntegrityError = class extends Error {
|
|
1474
|
+
constructor(message = "Y.Doc state hash mismatch") {
|
|
1475
|
+
super(message);
|
|
1476
|
+
this.name = "YjsStateIntegrityError";
|
|
1477
|
+
}
|
|
1478
|
+
};
|
|
1479
|
+
function encryptYjsState(state, nodeId, contentKey, options = {}) {
|
|
1480
|
+
const encrypted = encrypt(state, contentKey);
|
|
1481
|
+
return {
|
|
1482
|
+
nodeId,
|
|
1483
|
+
version: 1,
|
|
1484
|
+
encryptedState: encrypted.ciphertext,
|
|
1485
|
+
nonce: encrypted.nonce,
|
|
1486
|
+
stateVector: options.stateVector ?? new Uint8Array(0),
|
|
1487
|
+
stateHash: hash2(state, "blake3"),
|
|
1488
|
+
checkpointedAt: options.checkpointedAt ?? Date.now(),
|
|
1489
|
+
updatesSinceCheckpoint: options.updatesSinceCheckpoint ?? 0
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
function decryptYjsState(encrypted, contentKey) {
|
|
1493
|
+
let state;
|
|
1494
|
+
try {
|
|
1495
|
+
state = decrypt(
|
|
1496
|
+
{
|
|
1497
|
+
nonce: encrypted.nonce,
|
|
1498
|
+
ciphertext: encrypted.encryptedState
|
|
1499
|
+
},
|
|
1500
|
+
contentKey
|
|
1501
|
+
);
|
|
1502
|
+
} catch {
|
|
1503
|
+
throw new YjsStateIntegrityError("Y.Doc state failed decryption or integrity check");
|
|
1504
|
+
}
|
|
1505
|
+
const computedHash = hash2(state, "blake3");
|
|
1506
|
+
if (!constantTimeEqual(computedHash, encrypted.stateHash)) {
|
|
1507
|
+
throw new YjsStateIntegrityError();
|
|
1508
|
+
}
|
|
1509
|
+
return state;
|
|
1510
|
+
}
|
|
1511
|
+
function serializeEncryptedYjsState(state) {
|
|
1512
|
+
const payload = {
|
|
1513
|
+
nodeId: state.nodeId,
|
|
1514
|
+
version: 1,
|
|
1515
|
+
encryptedState: bytesToBase64(state.encryptedState),
|
|
1516
|
+
nonce: bytesToBase64(state.nonce),
|
|
1517
|
+
stateVector: bytesToBase64(state.stateVector),
|
|
1518
|
+
stateHash: bytesToBase64(state.stateHash),
|
|
1519
|
+
checkpointedAt: state.checkpointedAt,
|
|
1520
|
+
updatesSinceCheckpoint: state.updatesSinceCheckpoint
|
|
1521
|
+
};
|
|
1522
|
+
return new TextEncoder().encode(JSON.stringify(payload));
|
|
1523
|
+
}
|
|
1524
|
+
function deserializeEncryptedYjsState(bytes) {
|
|
1525
|
+
const decoded = JSON.parse(new TextDecoder().decode(bytes));
|
|
1526
|
+
if (!decoded || decoded.version !== 1 || typeof decoded.nodeId !== "string" || typeof decoded.encryptedState !== "string" || typeof decoded.nonce !== "string" || typeof decoded.stateVector !== "string" || typeof decoded.stateHash !== "string" || typeof decoded.checkpointedAt !== "number" || typeof decoded.updatesSinceCheckpoint !== "number") {
|
|
1527
|
+
throw new Error("Invalid EncryptedYjsState payload");
|
|
1528
|
+
}
|
|
1529
|
+
return {
|
|
1530
|
+
nodeId: decoded.nodeId,
|
|
1531
|
+
version: 1,
|
|
1532
|
+
encryptedState: base64ToBytes(decoded.encryptedState),
|
|
1533
|
+
nonce: base64ToBytes(decoded.nonce),
|
|
1534
|
+
stateVector: base64ToBytes(decoded.stateVector),
|
|
1535
|
+
stateHash: base64ToBytes(decoded.stateHash),
|
|
1536
|
+
checkpointedAt: decoded.checkpointedAt,
|
|
1537
|
+
updatesSinceCheckpoint: decoded.updatesSinceCheckpoint
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
var YjsAuthGate = class _YjsAuthGate {
|
|
1541
|
+
constructor(evaluator, nodeId, options = {}) {
|
|
1542
|
+
this.evaluator = evaluator;
|
|
1543
|
+
this.nodeId = nodeId;
|
|
1544
|
+
this.cacheTTL = options.cacheTTL ?? _YjsAuthGate.DEFAULT_CACHE_TTL;
|
|
1545
|
+
this.now = options.now ?? Date.now;
|
|
1546
|
+
}
|
|
1547
|
+
static DEFAULT_CACHE_TTL = 3e4;
|
|
1548
|
+
peerAuthCache = /* @__PURE__ */ new Map();
|
|
1549
|
+
cacheTTL;
|
|
1550
|
+
now;
|
|
1551
|
+
async canApplyUpdate(envelope) {
|
|
1552
|
+
const authorDID = envelope.meta.authorDID;
|
|
1553
|
+
const cached = this.peerAuthCache.get(authorDID);
|
|
1554
|
+
if (cached && cached.expiresAt > this.now()) {
|
|
1555
|
+
return { allowed: cached.allowed, authorDID, cached: true };
|
|
1556
|
+
}
|
|
1557
|
+
const input = {
|
|
1558
|
+
subject: authorDID,
|
|
1559
|
+
action: "write",
|
|
1560
|
+
nodeId: this.nodeId
|
|
1561
|
+
};
|
|
1562
|
+
const decision = await this.evaluator.can(input);
|
|
1563
|
+
this.peerAuthCache.set(authorDID, {
|
|
1564
|
+
allowed: decision.allowed,
|
|
1565
|
+
expiresAt: this.now() + this.cacheTTL
|
|
1566
|
+
});
|
|
1567
|
+
return { allowed: decision.allowed, authorDID, cached: false };
|
|
1568
|
+
}
|
|
1569
|
+
invalidatePeer(did) {
|
|
1570
|
+
this.peerAuthCache.delete(did);
|
|
1571
|
+
}
|
|
1572
|
+
invalidateAll() {
|
|
1573
|
+
this.peerAuthCache.clear();
|
|
1574
|
+
}
|
|
1575
|
+
};
|
|
1576
|
+
var YjsCheckpointer = class {
|
|
1577
|
+
maxUpdates;
|
|
1578
|
+
maxAgeMs;
|
|
1579
|
+
now;
|
|
1580
|
+
constructor(options = {}) {
|
|
1581
|
+
this.maxUpdates = options.maxUpdates ?? 100;
|
|
1582
|
+
this.maxAgeMs = options.maxAgeMs ?? 60 * 60 * 1e3;
|
|
1583
|
+
this.now = options.now ?? Date.now;
|
|
1584
|
+
}
|
|
1585
|
+
shouldCheckpoint(state) {
|
|
1586
|
+
return state.updatesSinceCheckpoint >= this.maxUpdates || this.now() - state.checkpointedAt >= this.maxAgeMs;
|
|
1587
|
+
}
|
|
1588
|
+
checkpoint(input) {
|
|
1589
|
+
return encryptYjsState(input.state, input.nodeId, input.contentKey, {
|
|
1590
|
+
stateVector: input.stateVector,
|
|
1591
|
+
checkpointedAt: this.now(),
|
|
1592
|
+
updatesSinceCheckpoint: 0
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
};
|
|
1596
|
+
function toEncryptedData(state) {
|
|
1597
|
+
return {
|
|
1598
|
+
nonce: state.nonce,
|
|
1599
|
+
ciphertext: state.encryptedState
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// src/yjs-authorized-sync.ts
|
|
1604
|
+
import { generateContentKey, wrapKeyForRecipient } from "@xnetjs/crypto";
|
|
1605
|
+
var GRANT_SCHEMA_ID = "xnet://xnet.fyi/Grant";
|
|
1606
|
+
var AuthorizedSyncManager = class {
|
|
1607
|
+
options;
|
|
1608
|
+
rooms = /* @__PURE__ */ new Map();
|
|
1609
|
+
constructor(options) {
|
|
1610
|
+
this.options = options;
|
|
1611
|
+
}
|
|
1612
|
+
async acquire(nodeId, mode = "write") {
|
|
1613
|
+
const decision = await this.options.evaluator.can({
|
|
1614
|
+
subject: this.options.authorDID,
|
|
1615
|
+
action: mode === "write" ? "write" : "read",
|
|
1616
|
+
nodeId
|
|
1617
|
+
});
|
|
1618
|
+
if (!decision.allowed) {
|
|
1619
|
+
throw new AuthorizedYjsError("PERMISSION_DENIED", decision);
|
|
1620
|
+
}
|
|
1621
|
+
const contentKey = await this.options.keyProvider.getOrUnwrap(nodeId);
|
|
1622
|
+
const doc = this.options.ydoc.createDoc(nodeId);
|
|
1623
|
+
const encryptedBytes = await this.options.adapter.getDocumentContent(nodeId);
|
|
1624
|
+
if (encryptedBytes) {
|
|
1625
|
+
const encrypted = deserializeEncryptedYjsState(encryptedBytes);
|
|
1626
|
+
const state = decryptYjsState(encrypted, contentKey);
|
|
1627
|
+
this.options.ydoc.applyUpdate(doc, state, "bootstrap");
|
|
1628
|
+
}
|
|
1629
|
+
let room;
|
|
1630
|
+
if (mode === "write") {
|
|
1631
|
+
room = this.joinRoom(nodeId, doc, contentKey);
|
|
1632
|
+
}
|
|
1633
|
+
return {
|
|
1634
|
+
doc,
|
|
1635
|
+
nodeId,
|
|
1636
|
+
mode,
|
|
1637
|
+
contentKey,
|
|
1638
|
+
room,
|
|
1639
|
+
release: () => this.release(nodeId)
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
release(nodeId) {
|
|
1643
|
+
const room = this.rooms.get(nodeId);
|
|
1644
|
+
if (!room) {
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
room.unsubscribe?.();
|
|
1648
|
+
this.rooms.delete(nodeId);
|
|
1649
|
+
}
|
|
1650
|
+
joinRoom(nodeId, doc, contentKey) {
|
|
1651
|
+
const existing = this.rooms.get(nodeId);
|
|
1652
|
+
if (existing) {
|
|
1653
|
+
return existing;
|
|
1654
|
+
}
|
|
1655
|
+
const room = {
|
|
1656
|
+
nodeId,
|
|
1657
|
+
doc,
|
|
1658
|
+
contentKey,
|
|
1659
|
+
authGate: new YjsAuthGate(this.options.evaluator, nodeId),
|
|
1660
|
+
authorizedPeers: /* @__PURE__ */ new Set([this.options.authorDID])
|
|
1661
|
+
};
|
|
1662
|
+
room.unsubscribe = this.wireRevocationEvents(room);
|
|
1663
|
+
this.rooms.set(nodeId, room);
|
|
1664
|
+
return room;
|
|
1665
|
+
}
|
|
1666
|
+
wireRevocationEvents(room) {
|
|
1667
|
+
return this.options.store.subscribe((event) => {
|
|
1668
|
+
const grantNode = event.node;
|
|
1669
|
+
if (!grantNode || grantNode.schemaId !== GRANT_SCHEMA_ID) {
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
const resource = grantNode.properties?.resource;
|
|
1673
|
+
if (typeof resource !== "string" || resource !== room.nodeId) {
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
const revokedAt = grantNode.properties?.revokedAt;
|
|
1677
|
+
const grantee = grantNode.properties?.grantee;
|
|
1678
|
+
if (typeof revokedAt !== "number" || revokedAt <= 0 || !isDid(grantee)) {
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
room.authGate.invalidatePeer(grantee);
|
|
1682
|
+
if (!room.authorizedPeers.has(grantee)) {
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
void this.handlePeerRevocation(room, grantee);
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
async handlePeerRevocation(room, revokedDid) {
|
|
1689
|
+
room.authorizedPeers.delete(revokedDid);
|
|
1690
|
+
room.doc.emit?.("peer:kicked", [revokedDid]);
|
|
1691
|
+
const newContentKey = generateContentKey();
|
|
1692
|
+
const encrypted = encryptYjsState(
|
|
1693
|
+
this.options.ydoc.encodeStateAsUpdate(room.doc),
|
|
1694
|
+
room.nodeId,
|
|
1695
|
+
newContentKey,
|
|
1696
|
+
{
|
|
1697
|
+
stateVector: this.options.ydoc.encodeStateVector(room.doc)
|
|
1698
|
+
}
|
|
1699
|
+
);
|
|
1700
|
+
await this.options.adapter.setDocumentContent(
|
|
1701
|
+
room.nodeId,
|
|
1702
|
+
serializeEncryptedYjsState(encrypted)
|
|
1703
|
+
);
|
|
1704
|
+
const recipients = [...room.authorizedPeers];
|
|
1705
|
+
let wrappedKeys = {};
|
|
1706
|
+
if (this.options.publicKeyResolver && recipients.length > 0) {
|
|
1707
|
+
const publicKeys = await this.options.publicKeyResolver.resolveBatch(recipients);
|
|
1708
|
+
wrappedKeys = {};
|
|
1709
|
+
for (const [did, pubKey] of publicKeys) {
|
|
1710
|
+
wrappedKeys[did] = wrapKeyForRecipient(newContentKey, pubKey);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
await this.options.onRotateContentKey?.({
|
|
1714
|
+
nodeId: room.nodeId,
|
|
1715
|
+
recipients,
|
|
1716
|
+
wrappedKeys,
|
|
1717
|
+
contentKey: newContentKey
|
|
1718
|
+
});
|
|
1719
|
+
room.contentKey = newContentKey;
|
|
1720
|
+
room.doc.emit?.("key:rotated", [room.nodeId]);
|
|
1721
|
+
}
|
|
1722
|
+
};
|
|
1723
|
+
var AuthorizedYjsSyncProvider = class {
|
|
1724
|
+
nodeId;
|
|
1725
|
+
doc;
|
|
1726
|
+
ydoc;
|
|
1727
|
+
authGate;
|
|
1728
|
+
peerScorer;
|
|
1729
|
+
rateLimiter;
|
|
1730
|
+
verifyEnvelope;
|
|
1731
|
+
onRejected;
|
|
1732
|
+
constructor(options) {
|
|
1733
|
+
this.nodeId = options.nodeId;
|
|
1734
|
+
this.doc = options.doc;
|
|
1735
|
+
this.ydoc = options.ydoc;
|
|
1736
|
+
this.authGate = options.authGate;
|
|
1737
|
+
this.peerScorer = options.peerScorer ?? new YjsPeerScorer();
|
|
1738
|
+
this.rateLimiter = options.rateLimiter;
|
|
1739
|
+
this.verifyEnvelope = options.verifyEnvelope ?? verifyYjsEnvelopeV2;
|
|
1740
|
+
this.onRejected = options.onRejected;
|
|
1741
|
+
}
|
|
1742
|
+
async handleRemoteUpdate(envelope) {
|
|
1743
|
+
const peerId = envelope.meta.authorDID;
|
|
1744
|
+
if (this.rateLimiter && !this.rateLimiter.allow(peerId)) {
|
|
1745
|
+
this.peerScorer.penalize(peerId, "rateExceeded");
|
|
1746
|
+
this.onRejected?.({ peerId, reason: "rate-exceeded" });
|
|
1747
|
+
return false;
|
|
1748
|
+
}
|
|
1749
|
+
const sigResult = await this.verifyEnvelope(envelope);
|
|
1750
|
+
if (!sigResult.valid) {
|
|
1751
|
+
this.peerScorer.penalize(peerId, "invalidSignature");
|
|
1752
|
+
this.onRejected?.({ peerId, reason: "invalid-signature" });
|
|
1753
|
+
return false;
|
|
1754
|
+
}
|
|
1755
|
+
const authResult = await this.authGate.canApplyUpdate(envelope);
|
|
1756
|
+
if (!authResult.allowed) {
|
|
1757
|
+
this.peerScorer.penalize(peerId, "unauthorizedUpdate");
|
|
1758
|
+
this.onRejected?.({ peerId, reason: "unauthorized" });
|
|
1759
|
+
return false;
|
|
1760
|
+
}
|
|
1761
|
+
if (envelope.meta.docId !== this.nodeId) {
|
|
1762
|
+
this.peerScorer.penalize(peerId, "invalidSignature");
|
|
1763
|
+
this.onRejected?.({ peerId, reason: "invalid-signature" });
|
|
1764
|
+
return false;
|
|
1765
|
+
}
|
|
1766
|
+
this.ydoc.applyUpdate(this.doc, envelope.update, "remote");
|
|
1767
|
+
this.peerScorer.recordValid(peerId);
|
|
1768
|
+
return true;
|
|
1769
|
+
}
|
|
1770
|
+
};
|
|
1771
|
+
var AuthorizedYjsError = class extends Error {
|
|
1772
|
+
code;
|
|
1773
|
+
decision;
|
|
1774
|
+
constructor(code, decision) {
|
|
1775
|
+
super(`Yjs authorization denied for '${decision.action}' on '${decision.resource}'`);
|
|
1776
|
+
this.name = "AuthorizedYjsError";
|
|
1777
|
+
this.code = code;
|
|
1778
|
+
this.decision = decision;
|
|
1779
|
+
}
|
|
1780
|
+
};
|
|
1781
|
+
function isDid(value) {
|
|
1782
|
+
return typeof value === "string" && value.startsWith("did:key:");
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
// src/clientid-attestation.ts
|
|
1786
|
+
import {
|
|
1787
|
+
hash as hash3,
|
|
1788
|
+
sign as sign3,
|
|
1789
|
+
verify as verify3,
|
|
1790
|
+
hybridSign as hybridSign2,
|
|
1791
|
+
hybridVerify as hybridVerify2,
|
|
1792
|
+
encodeSignature as encodeSignature2,
|
|
1793
|
+
decodeSignature as decodeSignature2,
|
|
1794
|
+
DEFAULT_SECURITY_LEVEL as DEFAULT_SECURITY_LEVEL2
|
|
1795
|
+
} from "@xnetjs/crypto";
|
|
1796
|
+
import { parseDID as parseDID2 } from "@xnetjs/identity";
|
|
1797
|
+
function isV2Attestation(attestation) {
|
|
1798
|
+
return "v" in attestation && attestation.v === 2;
|
|
1799
|
+
}
|
|
1800
|
+
function isV1Attestation(attestation) {
|
|
1801
|
+
return !("v" in attestation);
|
|
1802
|
+
}
|
|
1803
|
+
function attestationPayloadV1(clientId, did, room, expiresAt) {
|
|
1804
|
+
const text = `clientid-bind:${clientId}:${did}:${room}:${expiresAt}`;
|
|
1805
|
+
return new TextEncoder().encode(text);
|
|
1806
|
+
}
|
|
1807
|
+
function createClientIdAttestationV1(clientId, did, privateKey, room, ttlSeconds = 3600) {
|
|
1808
|
+
const expiresAt = Math.floor(Date.now() / 1e3) + ttlSeconds;
|
|
1809
|
+
const payload = attestationPayloadV1(clientId, did, room, expiresAt);
|
|
1810
|
+
const payloadHash = hash3(payload, "blake3");
|
|
1811
|
+
const signature = sign3(payloadHash, privateKey);
|
|
1812
|
+
return { clientId, did, signature, expiresAt, room };
|
|
1813
|
+
}
|
|
1814
|
+
function verifyClientIdAttestationV1(attestation) {
|
|
1815
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1816
|
+
if (attestation.expiresAt < now) {
|
|
1817
|
+
return { valid: false, reason: "expired" };
|
|
1818
|
+
}
|
|
1819
|
+
try {
|
|
1820
|
+
const publicKey = parseDID2(attestation.did);
|
|
1821
|
+
const payload = attestationPayloadV1(
|
|
1822
|
+
attestation.clientId,
|
|
1823
|
+
attestation.did,
|
|
1824
|
+
attestation.room,
|
|
1825
|
+
attestation.expiresAt
|
|
1826
|
+
);
|
|
1827
|
+
const payloadHash = hash3(payload, "blake3");
|
|
1828
|
+
const valid = verify3(payloadHash, attestation.signature, publicKey);
|
|
1829
|
+
if (!valid) {
|
|
1830
|
+
return { valid: false, reason: "invalid_signature" };
|
|
1831
|
+
}
|
|
1832
|
+
return { valid: true };
|
|
1833
|
+
} catch {
|
|
1834
|
+
return { valid: false, reason: "did_resolution_failed" };
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
function createClientIdAttestationV2(clientId, room, keyBundle, options = {}) {
|
|
1838
|
+
const { expiresInMs, level = DEFAULT_SECURITY_LEVEL2 } = options;
|
|
1839
|
+
const timestamp = Date.now();
|
|
1840
|
+
const expiresAt = expiresInMs ? timestamp + expiresInMs : void 0;
|
|
1841
|
+
const payload = {
|
|
1842
|
+
v: 2,
|
|
1843
|
+
clientId,
|
|
1844
|
+
did: keyBundle.identity.did,
|
|
1845
|
+
timestamp,
|
|
1846
|
+
expiresAt,
|
|
1847
|
+
room
|
|
1848
|
+
};
|
|
1849
|
+
const payloadBytes = new TextEncoder().encode(JSON.stringify(payload));
|
|
1850
|
+
const payloadHash = hash3(payloadBytes, "blake3");
|
|
1851
|
+
const signature = hybridSign2(
|
|
1852
|
+
payloadHash,
|
|
1853
|
+
{
|
|
1854
|
+
ed25519: keyBundle.signingKey,
|
|
1855
|
+
mlDsa: keyBundle.pqSigningKey
|
|
1856
|
+
},
|
|
1857
|
+
level
|
|
1858
|
+
);
|
|
1859
|
+
return {
|
|
1860
|
+
v: 2,
|
|
1861
|
+
clientId,
|
|
1862
|
+
did: keyBundle.identity.did,
|
|
1863
|
+
timestamp,
|
|
1864
|
+
expiresAt,
|
|
1865
|
+
room,
|
|
1866
|
+
signature
|
|
1867
|
+
};
|
|
1868
|
+
}
|
|
1869
|
+
async function verifyClientIdAttestationV2(attestation, options = {}) {
|
|
1870
|
+
const { registry, minLevel = 0 } = options;
|
|
1871
|
+
const errors = [];
|
|
1872
|
+
const expired = attestation.expiresAt !== void 0 && Date.now() > attestation.expiresAt;
|
|
1873
|
+
if (expired) {
|
|
1874
|
+
errors.push("Attestation has expired");
|
|
1875
|
+
}
|
|
1876
|
+
if (attestation.signature.level < minLevel) {
|
|
1877
|
+
errors.push(
|
|
1878
|
+
`Security level too low: ${attestation.signature.level} < ${minLevel} (required minimum)`
|
|
1879
|
+
);
|
|
1880
|
+
}
|
|
1881
|
+
let ed25519PublicKey;
|
|
1882
|
+
try {
|
|
1883
|
+
ed25519PublicKey = parseDID2(attestation.did);
|
|
1884
|
+
} catch {
|
|
1885
|
+
errors.push("Failed to parse DID");
|
|
1886
|
+
return {
|
|
1887
|
+
valid: false,
|
|
1888
|
+
expired,
|
|
1889
|
+
level: attestation.signature.level,
|
|
1890
|
+
errors
|
|
1891
|
+
};
|
|
1892
|
+
}
|
|
1893
|
+
let pqPublicKey;
|
|
1894
|
+
if (attestation.signature.level >= 1 && registry) {
|
|
1895
|
+
const lookedUp = await registry.lookup(attestation.did);
|
|
1896
|
+
pqPublicKey = lookedUp ?? void 0;
|
|
1897
|
+
}
|
|
1898
|
+
const payload = {
|
|
1899
|
+
v: 2,
|
|
1900
|
+
clientId: attestation.clientId,
|
|
1901
|
+
did: attestation.did,
|
|
1902
|
+
timestamp: attestation.timestamp,
|
|
1903
|
+
expiresAt: attestation.expiresAt,
|
|
1904
|
+
room: attestation.room
|
|
1905
|
+
};
|
|
1906
|
+
const payloadBytes = new TextEncoder().encode(JSON.stringify(payload));
|
|
1907
|
+
const payloadHash = hash3(payloadBytes, "blake3");
|
|
1908
|
+
const result = hybridVerify2(
|
|
1909
|
+
payloadHash,
|
|
1910
|
+
attestation.signature,
|
|
1911
|
+
{ ed25519: ed25519PublicKey, mlDsa: pqPublicKey },
|
|
1912
|
+
{ minLevel }
|
|
1913
|
+
);
|
|
1914
|
+
if (!result.valid) {
|
|
1915
|
+
if (result.details.ed25519?.error) errors.push(result.details.ed25519.error);
|
|
1916
|
+
if (result.details.mlDsa?.error) errors.push(result.details.mlDsa.error);
|
|
1917
|
+
if (!result.details.ed25519?.error && !result.details.mlDsa?.error) {
|
|
1918
|
+
errors.push("Signature verification failed");
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
return {
|
|
1922
|
+
valid: errors.length === 0,
|
|
1923
|
+
expired,
|
|
1924
|
+
level: attestation.signature.level,
|
|
1925
|
+
errors
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
function serializeClientIdAttestation(attestation) {
|
|
1929
|
+
return {
|
|
1930
|
+
v: 2,
|
|
1931
|
+
c: attestation.clientId,
|
|
1932
|
+
d: attestation.did,
|
|
1933
|
+
t: attestation.timestamp,
|
|
1934
|
+
e: attestation.expiresAt,
|
|
1935
|
+
r: attestation.room,
|
|
1936
|
+
s: encodeSignature2(attestation.signature)
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
function deserializeClientIdAttestation(wire) {
|
|
1940
|
+
if (wire.v !== 2) {
|
|
1941
|
+
throw new Error(`Unsupported attestation version: ${wire.v}. Expected version 2.`);
|
|
1942
|
+
}
|
|
1943
|
+
return {
|
|
1944
|
+
v: 2,
|
|
1945
|
+
clientId: wire.c,
|
|
1946
|
+
did: wire.d,
|
|
1947
|
+
timestamp: wire.t,
|
|
1948
|
+
expiresAt: wire.e,
|
|
1949
|
+
room: wire.r,
|
|
1950
|
+
signature: decodeSignature2(wire.s)
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
function createClientIdAttestation(clientId, didOrRoom, privateKeyOrKeyBundle, roomOrOptions, ttlSeconds) {
|
|
1954
|
+
if (typeof privateKeyOrKeyBundle === "object" && "identity" in privateKeyOrKeyBundle && "signingKey" in privateKeyOrKeyBundle) {
|
|
1955
|
+
return createClientIdAttestationV2(
|
|
1956
|
+
clientId,
|
|
1957
|
+
didOrRoom,
|
|
1958
|
+
// This is room in V2
|
|
1959
|
+
privateKeyOrKeyBundle,
|
|
1960
|
+
roomOrOptions
|
|
1961
|
+
);
|
|
1962
|
+
}
|
|
1963
|
+
return createClientIdAttestationV1(
|
|
1964
|
+
clientId,
|
|
1965
|
+
didOrRoom,
|
|
1966
|
+
// This is did in V1
|
|
1967
|
+
privateKeyOrKeyBundle,
|
|
1968
|
+
roomOrOptions,
|
|
1969
|
+
ttlSeconds
|
|
1970
|
+
);
|
|
1971
|
+
}
|
|
1972
|
+
function verifyClientIdAttestation(attestation, options) {
|
|
1973
|
+
if (isV2Attestation(attestation)) {
|
|
1974
|
+
return verifyClientIdAttestationV2(attestation, options);
|
|
1975
|
+
}
|
|
1976
|
+
return verifyClientIdAttestationV1(attestation);
|
|
1977
|
+
}
|
|
1978
|
+
var ClientIdMapImpl = class {
|
|
1979
|
+
byClientId = /* @__PURE__ */ new Map();
|
|
1980
|
+
byDid = /* @__PURE__ */ new Map();
|
|
1981
|
+
getOwner(clientId) {
|
|
1982
|
+
const entry = this.byClientId.get(clientId);
|
|
1983
|
+
if (!entry) return void 0;
|
|
1984
|
+
if (entry.expiresAt < Math.floor(Date.now() / 1e3)) {
|
|
1985
|
+
this.byClientId.delete(clientId);
|
|
1986
|
+
this.byDid.delete(entry.did);
|
|
1987
|
+
return void 0;
|
|
1988
|
+
}
|
|
1989
|
+
return entry.did;
|
|
1990
|
+
}
|
|
1991
|
+
getClientId(did) {
|
|
1992
|
+
const entry = this.byDid.get(did);
|
|
1993
|
+
if (!entry) return void 0;
|
|
1994
|
+
if (entry.expiresAt < Math.floor(Date.now() / 1e3)) {
|
|
1995
|
+
this.byDid.delete(did);
|
|
1996
|
+
this.byClientId.delete(entry.clientId);
|
|
1997
|
+
return void 0;
|
|
1998
|
+
}
|
|
1999
|
+
return entry.clientId;
|
|
2000
|
+
}
|
|
2001
|
+
register(attestation) {
|
|
2002
|
+
const did = isV2Attestation(attestation) ? attestation.did : attestation.did;
|
|
2003
|
+
const expiresAt = isV2Attestation(attestation) ? attestation.expiresAt ? Math.floor(attestation.expiresAt / 1e3) : Math.floor(Date.now() / 1e3) + 3600 : attestation.expiresAt;
|
|
2004
|
+
const prev = this.byDid.get(did);
|
|
2005
|
+
if (prev) {
|
|
2006
|
+
this.byClientId.delete(prev.clientId);
|
|
2007
|
+
}
|
|
2008
|
+
const prevDid = this.byClientId.get(attestation.clientId);
|
|
2009
|
+
if (prevDid) {
|
|
2010
|
+
this.byDid.delete(prevDid.did);
|
|
2011
|
+
}
|
|
2012
|
+
this.byClientId.set(attestation.clientId, { did, expiresAt });
|
|
2013
|
+
this.byDid.set(did, { clientId: attestation.clientId, expiresAt });
|
|
2014
|
+
}
|
|
2015
|
+
cleanup() {
|
|
2016
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
2017
|
+
for (const [clientId, entry] of this.byClientId) {
|
|
2018
|
+
if (entry.expiresAt < now) {
|
|
2019
|
+
this.byClientId.delete(clientId);
|
|
2020
|
+
this.byDid.delete(entry.did);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
getAll() {
|
|
2025
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
2026
|
+
return Array.from(this.byClientId.entries()).filter(([_, entry]) => entry.expiresAt > now).map(([clientId, entry]) => ({
|
|
2027
|
+
clientId,
|
|
2028
|
+
did: entry.did,
|
|
2029
|
+
expiresAt: entry.expiresAt
|
|
2030
|
+
}));
|
|
2031
|
+
}
|
|
2032
|
+
has(clientId) {
|
|
2033
|
+
return this.getOwner(clientId) !== void 0;
|
|
2034
|
+
}
|
|
2035
|
+
size() {
|
|
2036
|
+
return this.byClientId.size;
|
|
2037
|
+
}
|
|
2038
|
+
/**
|
|
2039
|
+
* Get the count of non-expired entries without mutating state.
|
|
2040
|
+
* This is useful for accurate counts but slower than size().
|
|
2041
|
+
*/
|
|
2042
|
+
activeCount() {
|
|
2043
|
+
const now = Date.now();
|
|
2044
|
+
let count = 0;
|
|
2045
|
+
for (const entry of this.byClientId.values()) {
|
|
2046
|
+
if (entry.expiresAt > now) {
|
|
2047
|
+
count++;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
return count;
|
|
2051
|
+
}
|
|
2052
|
+
/**
|
|
2053
|
+
* Clear all bindings.
|
|
2054
|
+
*/
|
|
2055
|
+
clear() {
|
|
2056
|
+
this.byClientId.clear();
|
|
2057
|
+
this.byDid.clear();
|
|
2058
|
+
}
|
|
2059
|
+
};
|
|
2060
|
+
function validateClientIdOwnership(clientId, authorDID, map) {
|
|
2061
|
+
const owner = map.getOwner(clientId);
|
|
2062
|
+
if (owner === void 0) {
|
|
2063
|
+
return true;
|
|
2064
|
+
}
|
|
2065
|
+
return owner === authorDID;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
// src/yjs-change.ts
|
|
2069
|
+
var YJS_CHANGE_TYPE = "yjs-update";
|
|
2070
|
+
function createUnsignedYjsChange(options) {
|
|
2071
|
+
const payload = {
|
|
2072
|
+
nodeId: options.nodeId,
|
|
2073
|
+
update: options.update,
|
|
2074
|
+
clientId: options.clientId,
|
|
2075
|
+
updateCount: options.updateCount
|
|
2076
|
+
};
|
|
2077
|
+
return createUnsignedChange({
|
|
2078
|
+
id: createChangeId(),
|
|
2079
|
+
type: YJS_CHANGE_TYPE,
|
|
2080
|
+
payload,
|
|
2081
|
+
parentHash: options.parentHash,
|
|
2082
|
+
authorDID: options.authorDID,
|
|
2083
|
+
lamport: options.lamport,
|
|
2084
|
+
wallTime: options.wallTime
|
|
2085
|
+
});
|
|
2086
|
+
}
|
|
2087
|
+
function createYjsChange(options) {
|
|
2088
|
+
const unsigned = createUnsignedYjsChange(options);
|
|
2089
|
+
return signChange(unsigned, options.privateKey);
|
|
2090
|
+
}
|
|
2091
|
+
function isYjsChange(change) {
|
|
2092
|
+
return change.type === YJS_CHANGE_TYPE && typeof change.payload === "object" && change.payload !== null && "update" in change.payload && "clientId" in change.payload && "nodeId" in change.payload;
|
|
2093
|
+
}
|
|
2094
|
+
function isNodeChange(change) {
|
|
2095
|
+
return change.type !== YJS_CHANGE_TYPE && typeof change.payload === "object" && change.payload !== null && "nodeId" in change.payload && !("update" in change.payload);
|
|
2096
|
+
}
|
|
2097
|
+
function getChangeNodeId(change) {
|
|
2098
|
+
if (typeof change.payload === "object" && change.payload !== null && "nodeId" in change.payload) {
|
|
2099
|
+
return change.payload.nodeId;
|
|
2100
|
+
}
|
|
2101
|
+
return void 0;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
// src/yjs-batcher.ts
|
|
2105
|
+
var DEFAULT_BATCHER_CONFIG = {
|
|
2106
|
+
batchWindowMs: 2e3,
|
|
2107
|
+
maxBatchSize: 50,
|
|
2108
|
+
flushOnParagraph: true
|
|
2109
|
+
};
|
|
2110
|
+
function throwMergeNotProvided() {
|
|
2111
|
+
throw new Error(
|
|
2112
|
+
"[YjsBatcher] mergeUpdates function is required. Pass Y.mergeUpdates from the yjs package."
|
|
2113
|
+
);
|
|
2114
|
+
}
|
|
2115
|
+
var YjsBatcher = class {
|
|
2116
|
+
pendingUpdates = [];
|
|
2117
|
+
flushTimer = null;
|
|
2118
|
+
config;
|
|
2119
|
+
onFlush;
|
|
2120
|
+
mergeUpdates;
|
|
2121
|
+
destroyed = false;
|
|
2122
|
+
/**
|
|
2123
|
+
* Create a new YjsBatcher.
|
|
2124
|
+
*
|
|
2125
|
+
* @param onFlush - Callback invoked when a batch is flushed
|
|
2126
|
+
* @param config - Optional configuration overrides
|
|
2127
|
+
* @param mergeUpdates - Function to merge updates (required - use Y.mergeUpdates from yjs)
|
|
2128
|
+
*/
|
|
2129
|
+
constructor(onFlush, config, mergeUpdates) {
|
|
2130
|
+
this.onFlush = onFlush;
|
|
2131
|
+
this.config = {
|
|
2132
|
+
...DEFAULT_BATCHER_CONFIG,
|
|
2133
|
+
...config
|
|
2134
|
+
};
|
|
2135
|
+
this.mergeUpdates = mergeUpdates ?? throwMergeNotProvided;
|
|
2136
|
+
}
|
|
2137
|
+
/**
|
|
2138
|
+
* Add an update to the current batch.
|
|
2139
|
+
*
|
|
2140
|
+
* @param update - The Yjs update bytes
|
|
2141
|
+
* @param isParagraphBreak - Whether this update is a paragraph break (Enter key)
|
|
2142
|
+
*/
|
|
2143
|
+
add(update, isParagraphBreak = false) {
|
|
2144
|
+
if (this.destroyed) {
|
|
2145
|
+
console.warn("[YjsBatcher] Attempted to add update after destroy");
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
this.pendingUpdates.push(update);
|
|
2149
|
+
if (this.pendingUpdates.length >= this.config.maxBatchSize) {
|
|
2150
|
+
this.flush();
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
if (isParagraphBreak && this.config.flushOnParagraph) {
|
|
2154
|
+
this.flush();
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
this.resetTimer();
|
|
2158
|
+
}
|
|
2159
|
+
/**
|
|
2160
|
+
* Force flush the current batch.
|
|
2161
|
+
* Safe to call even if there are no pending updates.
|
|
2162
|
+
*/
|
|
2163
|
+
flush() {
|
|
2164
|
+
if (this.flushTimer) {
|
|
2165
|
+
clearTimeout(this.flushTimer);
|
|
2166
|
+
this.flushTimer = null;
|
|
2167
|
+
}
|
|
2168
|
+
if (this.pendingUpdates.length === 0) {
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
const merged = this.mergeUpdates(this.pendingUpdates);
|
|
2172
|
+
const updateCount = this.pendingUpdates.length;
|
|
2173
|
+
this.pendingUpdates = [];
|
|
2174
|
+
try {
|
|
2175
|
+
this.onFlush(merged, updateCount);
|
|
2176
|
+
} catch (err) {
|
|
2177
|
+
console.error("[YjsBatcher] Error in flush callback:", err);
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
/**
|
|
2181
|
+
* Check if there are pending updates.
|
|
2182
|
+
*/
|
|
2183
|
+
hasPending() {
|
|
2184
|
+
return this.pendingUpdates.length > 0;
|
|
2185
|
+
}
|
|
2186
|
+
/**
|
|
2187
|
+
* Get the number of pending updates.
|
|
2188
|
+
*/
|
|
2189
|
+
pendingCount() {
|
|
2190
|
+
return this.pendingUpdates.length;
|
|
2191
|
+
}
|
|
2192
|
+
/**
|
|
2193
|
+
* Destroy the batcher. Flushes any remaining updates.
|
|
2194
|
+
* After destroy, add() calls will be ignored.
|
|
2195
|
+
*/
|
|
2196
|
+
destroy() {
|
|
2197
|
+
if (this.destroyed) return;
|
|
2198
|
+
this.destroyed = true;
|
|
2199
|
+
this.flush();
|
|
2200
|
+
}
|
|
2201
|
+
/**
|
|
2202
|
+
* Check if the batcher has been destroyed.
|
|
2203
|
+
*/
|
|
2204
|
+
isDestroyed() {
|
|
2205
|
+
return this.destroyed;
|
|
2206
|
+
}
|
|
2207
|
+
/**
|
|
2208
|
+
* Reset or start the flush timer.
|
|
2209
|
+
*/
|
|
2210
|
+
resetTimer() {
|
|
2211
|
+
if (this.flushTimer) {
|
|
2212
|
+
clearTimeout(this.flushTimer);
|
|
2213
|
+
}
|
|
2214
|
+
this.flushTimer = setTimeout(() => {
|
|
2215
|
+
this.flushTimer = null;
|
|
2216
|
+
this.flush();
|
|
2217
|
+
}, this.config.batchWindowMs);
|
|
2218
|
+
}
|
|
2219
|
+
};
|
|
2220
|
+
|
|
2221
|
+
// src/serializers/v1.ts
|
|
2222
|
+
function encodeBase64(data) {
|
|
2223
|
+
if (typeof btoa === "function") {
|
|
2224
|
+
let binary = "";
|
|
2225
|
+
for (let i = 0; i < data.length; i++) {
|
|
2226
|
+
binary += String.fromCharCode(data[i]);
|
|
2227
|
+
}
|
|
2228
|
+
return btoa(binary);
|
|
2229
|
+
}
|
|
2230
|
+
return Buffer.from(data).toString("base64");
|
|
2231
|
+
}
|
|
2232
|
+
function decodeBase64(str) {
|
|
2233
|
+
if (typeof atob === "function") {
|
|
2234
|
+
const binary = atob(str);
|
|
2235
|
+
const bytes = new Uint8Array(binary.length);
|
|
2236
|
+
for (let i = 0; i < binary.length; i++) {
|
|
2237
|
+
bytes[i] = binary.charCodeAt(i);
|
|
2238
|
+
}
|
|
2239
|
+
return bytes;
|
|
2240
|
+
}
|
|
2241
|
+
return new Uint8Array(Buffer.from(str, "base64"));
|
|
2242
|
+
}
|
|
2243
|
+
var V1Serializer = class {
|
|
2244
|
+
version = 1;
|
|
2245
|
+
name = "V1 JSON Serializer";
|
|
2246
|
+
serialize(change) {
|
|
2247
|
+
const wire = {
|
|
2248
|
+
id: change.id,
|
|
2249
|
+
type: change.type,
|
|
2250
|
+
payload: change.payload,
|
|
2251
|
+
hash: change.hash,
|
|
2252
|
+
parentHash: change.parentHash,
|
|
2253
|
+
authorDID: change.authorDID,
|
|
2254
|
+
signature: encodeBase64(change.signature),
|
|
2255
|
+
wallTime: change.wallTime,
|
|
2256
|
+
lamport: { time: change.lamport.time, author: change.lamport.author }
|
|
2257
|
+
};
|
|
2258
|
+
if (change.protocolVersion !== void 0) {
|
|
2259
|
+
wire.protocolVersion = change.protocolVersion;
|
|
2260
|
+
}
|
|
2261
|
+
if (change.batchId !== void 0) {
|
|
2262
|
+
wire.batchId = change.batchId;
|
|
2263
|
+
wire.batchIndex = change.batchIndex;
|
|
2264
|
+
wire.batchSize = change.batchSize;
|
|
2265
|
+
}
|
|
2266
|
+
return wire;
|
|
2267
|
+
}
|
|
2268
|
+
deserialize(data) {
|
|
2269
|
+
try {
|
|
2270
|
+
let wire;
|
|
2271
|
+
if (data instanceof Uint8Array) {
|
|
2272
|
+
const json = new TextDecoder().decode(data);
|
|
2273
|
+
wire = JSON.parse(json);
|
|
2274
|
+
} else {
|
|
2275
|
+
wire = data;
|
|
2276
|
+
}
|
|
2277
|
+
if (!wire.id || !wire.type || !wire.hash || !wire.authorDID || !wire.signature) {
|
|
2278
|
+
return {
|
|
2279
|
+
success: false,
|
|
2280
|
+
error: "Missing required fields in V1 change",
|
|
2281
|
+
rawData: data
|
|
2282
|
+
};
|
|
2283
|
+
}
|
|
2284
|
+
if (!wire.lamport || typeof wire.lamport.time !== "number" || !wire.lamport.author) {
|
|
2285
|
+
return {
|
|
2286
|
+
success: false,
|
|
2287
|
+
error: "Invalid or missing lamport timestamp",
|
|
2288
|
+
rawData: data
|
|
2289
|
+
};
|
|
2290
|
+
}
|
|
2291
|
+
const change = {
|
|
2292
|
+
id: wire.id,
|
|
2293
|
+
type: wire.type,
|
|
2294
|
+
payload: wire.payload,
|
|
2295
|
+
hash: wire.hash,
|
|
2296
|
+
parentHash: wire.parentHash,
|
|
2297
|
+
authorDID: wire.authorDID,
|
|
2298
|
+
signature: decodeBase64(wire.signature),
|
|
2299
|
+
wallTime: wire.wallTime,
|
|
2300
|
+
lamport: {
|
|
2301
|
+
time: wire.lamport.time,
|
|
2302
|
+
author: wire.lamport.author
|
|
2303
|
+
}
|
|
2304
|
+
};
|
|
2305
|
+
if (wire.protocolVersion !== void 0) {
|
|
2306
|
+
change.protocolVersion = wire.protocolVersion;
|
|
2307
|
+
}
|
|
2308
|
+
if (wire.batchId !== void 0) {
|
|
2309
|
+
change.batchId = wire.batchId;
|
|
2310
|
+
change.batchIndex = wire.batchIndex;
|
|
2311
|
+
change.batchSize = wire.batchSize;
|
|
2312
|
+
}
|
|
2313
|
+
return { success: true, change };
|
|
2314
|
+
} catch (err) {
|
|
2315
|
+
return {
|
|
2316
|
+
success: false,
|
|
2317
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2318
|
+
rawData: data
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
canDeserialize(data) {
|
|
2323
|
+
if (!data || typeof data !== "object") {
|
|
2324
|
+
return false;
|
|
2325
|
+
}
|
|
2326
|
+
const obj = data;
|
|
2327
|
+
if (obj.protocolVersion !== void 0 && obj.protocolVersion !== 1) {
|
|
2328
|
+
return false;
|
|
2329
|
+
}
|
|
2330
|
+
return typeof obj.id === "string" && typeof obj.type === "string" && typeof obj.hash === "string" && typeof obj.signature === "string";
|
|
2331
|
+
}
|
|
2332
|
+
};
|
|
2333
|
+
var v1Serializer = new V1Serializer();
|
|
2334
|
+
|
|
2335
|
+
// src/serializers/v2.ts
|
|
2336
|
+
function encodeBase642(data) {
|
|
2337
|
+
if (typeof btoa === "function") {
|
|
2338
|
+
let binary = "";
|
|
2339
|
+
for (let i = 0; i < data.length; i++) {
|
|
2340
|
+
binary += String.fromCharCode(data[i]);
|
|
2341
|
+
}
|
|
2342
|
+
return btoa(binary);
|
|
2343
|
+
}
|
|
2344
|
+
return Buffer.from(data).toString("base64");
|
|
2345
|
+
}
|
|
2346
|
+
function decodeBase642(str) {
|
|
2347
|
+
if (typeof atob === "function") {
|
|
2348
|
+
const binary = atob(str);
|
|
2349
|
+
const bytes = new Uint8Array(binary.length);
|
|
2350
|
+
for (let i = 0; i < binary.length; i++) {
|
|
2351
|
+
bytes[i] = binary.charCodeAt(i);
|
|
2352
|
+
}
|
|
2353
|
+
return bytes;
|
|
2354
|
+
}
|
|
2355
|
+
return new Uint8Array(Buffer.from(str, "base64"));
|
|
2356
|
+
}
|
|
2357
|
+
var V2Serializer = class {
|
|
2358
|
+
version = 2;
|
|
2359
|
+
name = "V2 Compact Serializer";
|
|
2360
|
+
serialize(change) {
|
|
2361
|
+
const wire = {
|
|
2362
|
+
v: 2,
|
|
2363
|
+
i: change.id,
|
|
2364
|
+
t: change.type,
|
|
2365
|
+
p: change.payload,
|
|
2366
|
+
h: change.hash,
|
|
2367
|
+
ph: change.parentHash,
|
|
2368
|
+
a: change.authorDID,
|
|
2369
|
+
s: encodeBase642(change.signature),
|
|
2370
|
+
w: change.wallTime,
|
|
2371
|
+
l: { t: change.lamport.time, a: change.lamport.author }
|
|
2372
|
+
};
|
|
2373
|
+
if (change.batchId !== void 0) {
|
|
2374
|
+
wire.bi = change.batchId;
|
|
2375
|
+
wire.bx = change.batchIndex;
|
|
2376
|
+
wire.bs = change.batchSize;
|
|
2377
|
+
}
|
|
2378
|
+
return wire;
|
|
2379
|
+
}
|
|
2380
|
+
deserialize(data) {
|
|
2381
|
+
try {
|
|
2382
|
+
let wire;
|
|
2383
|
+
if (data instanceof Uint8Array) {
|
|
2384
|
+
const json = new TextDecoder().decode(data);
|
|
2385
|
+
wire = JSON.parse(json);
|
|
2386
|
+
} else {
|
|
2387
|
+
wire = data;
|
|
2388
|
+
}
|
|
2389
|
+
if (wire.v !== 2) {
|
|
2390
|
+
return {
|
|
2391
|
+
success: false,
|
|
2392
|
+
error: `Expected v2 format, got v${wire.v}`,
|
|
2393
|
+
rawData: data
|
|
2394
|
+
};
|
|
2395
|
+
}
|
|
2396
|
+
if (!wire.i || !wire.t || !wire.h || !wire.a || !wire.s) {
|
|
2397
|
+
return {
|
|
2398
|
+
success: false,
|
|
2399
|
+
error: "Missing required fields in V2 change",
|
|
2400
|
+
rawData: data
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
if (!wire.l || typeof wire.l.t !== "number" || !wire.l.a) {
|
|
2404
|
+
return {
|
|
2405
|
+
success: false,
|
|
2406
|
+
error: "Invalid or missing lamport timestamp",
|
|
2407
|
+
rawData: data
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
const change = {
|
|
2411
|
+
protocolVersion: 2,
|
|
2412
|
+
id: wire.i,
|
|
2413
|
+
type: wire.t,
|
|
2414
|
+
payload: wire.p,
|
|
2415
|
+
hash: wire.h,
|
|
2416
|
+
parentHash: wire.ph,
|
|
2417
|
+
authorDID: wire.a,
|
|
2418
|
+
signature: decodeBase642(wire.s),
|
|
2419
|
+
wallTime: wire.w,
|
|
2420
|
+
lamport: {
|
|
2421
|
+
time: wire.l.t,
|
|
2422
|
+
author: wire.l.a
|
|
2423
|
+
}
|
|
2424
|
+
};
|
|
2425
|
+
if (wire.bi !== void 0) {
|
|
2426
|
+
change.batchId = wire.bi;
|
|
2427
|
+
change.batchIndex = wire.bx;
|
|
2428
|
+
change.batchSize = wire.bs;
|
|
2429
|
+
}
|
|
2430
|
+
return { success: true, change };
|
|
2431
|
+
} catch (err) {
|
|
2432
|
+
return {
|
|
2433
|
+
success: false,
|
|
2434
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2435
|
+
rawData: data
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
canDeserialize(data) {
|
|
2440
|
+
if (!data || typeof data !== "object") {
|
|
2441
|
+
return false;
|
|
2442
|
+
}
|
|
2443
|
+
const obj = data;
|
|
2444
|
+
return obj.v === 2 && typeof obj.i === "string" && typeof obj.t === "string";
|
|
2445
|
+
}
|
|
2446
|
+
/**
|
|
2447
|
+
* Add schema version to a payload.
|
|
2448
|
+
* Used when serializing node changes with schema versioning.
|
|
2449
|
+
*/
|
|
2450
|
+
static addSchemaVersion(payload, schemaVersion) {
|
|
2451
|
+
return { ...payload, _sv: schemaVersion };
|
|
2452
|
+
}
|
|
2453
|
+
/**
|
|
2454
|
+
* Extract schema version from a payload.
|
|
2455
|
+
*/
|
|
2456
|
+
static getSchemaVersion(payload) {
|
|
2457
|
+
if (payload && typeof payload === "object") {
|
|
2458
|
+
const p = payload;
|
|
2459
|
+
return p._sv;
|
|
2460
|
+
}
|
|
2461
|
+
return void 0;
|
|
2462
|
+
}
|
|
2463
|
+
};
|
|
2464
|
+
var v2Serializer = new V2Serializer();
|
|
2465
|
+
|
|
2466
|
+
// src/serializers/v3.ts
|
|
2467
|
+
import {
|
|
2468
|
+
encodeSignature as encodeSignature3,
|
|
2469
|
+
decodeSignature as decodeSignature3
|
|
2470
|
+
} from "@xnetjs/crypto";
|
|
2471
|
+
function isUnifiedSignature(sig) {
|
|
2472
|
+
if (!sig || typeof sig !== "object") return false;
|
|
2473
|
+
const s = sig;
|
|
2474
|
+
if (typeof s.level !== "number") return false;
|
|
2475
|
+
if (s.level < 0 || s.level > 2) return false;
|
|
2476
|
+
return true;
|
|
2477
|
+
}
|
|
2478
|
+
function legacyToUnifiedSignature(sig) {
|
|
2479
|
+
return {
|
|
2480
|
+
level: 0,
|
|
2481
|
+
ed25519: sig
|
|
2482
|
+
};
|
|
2483
|
+
}
|
|
2484
|
+
var V3Serializer = class {
|
|
2485
|
+
version = 3;
|
|
2486
|
+
name = "V3 Multi-Level Crypto Serializer";
|
|
2487
|
+
serialize(change) {
|
|
2488
|
+
let sig;
|
|
2489
|
+
if (isUnifiedSignature(change.signature)) {
|
|
2490
|
+
sig = encodeSignature3(change.signature);
|
|
2491
|
+
} else if (change.signature instanceof Uint8Array) {
|
|
2492
|
+
sig = encodeSignature3(legacyToUnifiedSignature(change.signature));
|
|
2493
|
+
} else {
|
|
2494
|
+
throw new Error("Invalid signature type in change");
|
|
2495
|
+
}
|
|
2496
|
+
const wire = {
|
|
2497
|
+
v: 3,
|
|
2498
|
+
i: change.id,
|
|
2499
|
+
t: change.type,
|
|
2500
|
+
p: change.payload,
|
|
2501
|
+
h: change.hash,
|
|
2502
|
+
ph: change.parentHash,
|
|
2503
|
+
a: change.authorDID,
|
|
2504
|
+
sig,
|
|
2505
|
+
w: change.wallTime,
|
|
2506
|
+
l: { t: change.lamport.time, a: change.lamport.author }
|
|
2507
|
+
};
|
|
2508
|
+
if (change.batchId !== void 0) {
|
|
2509
|
+
wire.bi = change.batchId;
|
|
2510
|
+
wire.bx = change.batchIndex;
|
|
2511
|
+
wire.bs = change.batchSize;
|
|
2512
|
+
}
|
|
2513
|
+
return wire;
|
|
2514
|
+
}
|
|
2515
|
+
deserialize(data) {
|
|
2516
|
+
try {
|
|
2517
|
+
let wire;
|
|
2518
|
+
if (data instanceof Uint8Array) {
|
|
2519
|
+
const json = new TextDecoder().decode(data);
|
|
2520
|
+
wire = JSON.parse(json);
|
|
2521
|
+
} else {
|
|
2522
|
+
wire = data;
|
|
2523
|
+
}
|
|
2524
|
+
if (wire.v !== 3) {
|
|
2525
|
+
return {
|
|
2526
|
+
success: false,
|
|
2527
|
+
error: `Expected v3 format, got v${wire.v}. Clear your database and start fresh.`,
|
|
2528
|
+
rawData: data
|
|
2529
|
+
};
|
|
2530
|
+
}
|
|
2531
|
+
if (!wire.i || !wire.t || !wire.h || !wire.a || !wire.sig) {
|
|
2532
|
+
return {
|
|
2533
|
+
success: false,
|
|
2534
|
+
error: "Missing required fields in V3 change",
|
|
2535
|
+
rawData: data
|
|
2536
|
+
};
|
|
2537
|
+
}
|
|
2538
|
+
if (typeof wire.sig !== "object" || typeof wire.sig.l !== "number") {
|
|
2539
|
+
return {
|
|
2540
|
+
success: false,
|
|
2541
|
+
error: "Invalid signature format in V3 change",
|
|
2542
|
+
rawData: data
|
|
2543
|
+
};
|
|
2544
|
+
}
|
|
2545
|
+
if (!wire.l || typeof wire.l.t !== "number" || !wire.l.a) {
|
|
2546
|
+
return {
|
|
2547
|
+
success: false,
|
|
2548
|
+
error: "Invalid or missing lamport timestamp",
|
|
2549
|
+
rawData: data
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
const signature = decodeSignature3(wire.sig);
|
|
2553
|
+
const change = {
|
|
2554
|
+
protocolVersion: 3,
|
|
2555
|
+
id: wire.i,
|
|
2556
|
+
type: wire.t,
|
|
2557
|
+
payload: wire.p,
|
|
2558
|
+
hash: wire.h,
|
|
2559
|
+
parentHash: wire.ph,
|
|
2560
|
+
authorDID: wire.a,
|
|
2561
|
+
signature,
|
|
2562
|
+
// Type cast for compatibility
|
|
2563
|
+
wallTime: wire.w,
|
|
2564
|
+
lamport: {
|
|
2565
|
+
time: wire.l.t,
|
|
2566
|
+
author: wire.l.a
|
|
2567
|
+
}
|
|
2568
|
+
};
|
|
2569
|
+
if (wire.bi !== void 0) {
|
|
2570
|
+
change.batchId = wire.bi;
|
|
2571
|
+
change.batchIndex = wire.bx;
|
|
2572
|
+
change.batchSize = wire.bs;
|
|
2573
|
+
}
|
|
2574
|
+
return { success: true, change };
|
|
2575
|
+
} catch (err) {
|
|
2576
|
+
return {
|
|
2577
|
+
success: false,
|
|
2578
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2579
|
+
rawData: data
|
|
2580
|
+
};
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
canDeserialize(data) {
|
|
2584
|
+
if (!data || typeof data !== "object") {
|
|
2585
|
+
return false;
|
|
2586
|
+
}
|
|
2587
|
+
const obj = data;
|
|
2588
|
+
return obj.v === 3 && typeof obj.i === "string" && typeof obj.t === "string" && typeof obj.sig === "object" && obj.sig !== null;
|
|
2589
|
+
}
|
|
2590
|
+
/**
|
|
2591
|
+
* Add schema version to a payload.
|
|
2592
|
+
*/
|
|
2593
|
+
static addSchemaVersion(payload, schemaVersion) {
|
|
2594
|
+
return { ...payload, _sv: schemaVersion };
|
|
2595
|
+
}
|
|
2596
|
+
/**
|
|
2597
|
+
* Extract schema version from a payload.
|
|
2598
|
+
*/
|
|
2599
|
+
static getSchemaVersion(payload) {
|
|
2600
|
+
if (payload && typeof payload === "object") {
|
|
2601
|
+
const p = payload;
|
|
2602
|
+
return p._sv;
|
|
2603
|
+
}
|
|
2604
|
+
return void 0;
|
|
2605
|
+
}
|
|
2606
|
+
/**
|
|
2607
|
+
* Get the security level of a serialized change.
|
|
2608
|
+
*/
|
|
2609
|
+
static getSecurityLevel(wire) {
|
|
2610
|
+
return wire.sig.l;
|
|
2611
|
+
}
|
|
2612
|
+
};
|
|
2613
|
+
var v3Serializer = new V3Serializer();
|
|
2614
|
+
|
|
2615
|
+
// src/serializers/index.ts
|
|
2616
|
+
var DefaultSerializerRegistry = class {
|
|
2617
|
+
serializers = /* @__PURE__ */ new Map();
|
|
2618
|
+
defaultVersion;
|
|
2619
|
+
constructor(defaultVersion = CURRENT_PROTOCOL_VERSION) {
|
|
2620
|
+
this.defaultVersion = defaultVersion;
|
|
2621
|
+
this.register(v1Serializer);
|
|
2622
|
+
this.register(v2Serializer);
|
|
2623
|
+
this.register(v3Serializer);
|
|
2624
|
+
}
|
|
2625
|
+
get(version) {
|
|
2626
|
+
return this.serializers.get(version);
|
|
2627
|
+
}
|
|
2628
|
+
getDefault() {
|
|
2629
|
+
const serializer = this.serializers.get(this.defaultVersion);
|
|
2630
|
+
if (!serializer) {
|
|
2631
|
+
throw new Error(`No serializer registered for default version ${this.defaultVersion}`);
|
|
2632
|
+
}
|
|
2633
|
+
return serializer;
|
|
2634
|
+
}
|
|
2635
|
+
register(serializer) {
|
|
2636
|
+
this.serializers.set(serializer.version, serializer);
|
|
2637
|
+
}
|
|
2638
|
+
getVersions() {
|
|
2639
|
+
return [...this.serializers.keys()].sort((a, b) => a - b);
|
|
2640
|
+
}
|
|
2641
|
+
detect(data) {
|
|
2642
|
+
const versions = this.getVersions().reverse();
|
|
2643
|
+
for (const version of versions) {
|
|
2644
|
+
const serializer = this.serializers.get(version);
|
|
2645
|
+
if (serializer.canDeserialize(data)) {
|
|
2646
|
+
return serializer;
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
return void 0;
|
|
2650
|
+
}
|
|
2651
|
+
};
|
|
2652
|
+
var serializerRegistry = new DefaultSerializerRegistry();
|
|
2653
|
+
function getSerializer(version) {
|
|
2654
|
+
return serializerRegistry.get(version);
|
|
2655
|
+
}
|
|
2656
|
+
function getDefaultSerializer() {
|
|
2657
|
+
return serializerRegistry.getDefault();
|
|
2658
|
+
}
|
|
2659
|
+
function autoDeserialize(data) {
|
|
2660
|
+
const serializer = serializerRegistry.detect(data);
|
|
2661
|
+
if (!serializer) {
|
|
2662
|
+
return {
|
|
2663
|
+
success: false,
|
|
2664
|
+
error: "Unable to detect serializer format",
|
|
2665
|
+
rawData: data
|
|
2666
|
+
};
|
|
2667
|
+
}
|
|
2668
|
+
return serializer.deserialize(data);
|
|
2669
|
+
}
|
|
2670
|
+
function autoSerialize(change) {
|
|
2671
|
+
const version = change.protocolVersion ?? 1;
|
|
2672
|
+
const serializer = serializerRegistry.get(version) ?? serializerRegistry.getDefault();
|
|
2673
|
+
return serializer.serialize(change);
|
|
2674
|
+
}
|
|
2675
|
+
function createSerializerRegistry(defaultVersion = CURRENT_PROTOCOL_VERSION, serializers) {
|
|
2676
|
+
const registry = new DefaultSerializerRegistry(defaultVersion);
|
|
2677
|
+
if (serializers) {
|
|
2678
|
+
for (const serializer of serializers) {
|
|
2679
|
+
registry.register(serializer);
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
return registry;
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
// src/handlers/index.ts
|
|
2686
|
+
var ChangeHandlerRegistry = class {
|
|
2687
|
+
handlers = /* @__PURE__ */ new Map();
|
|
2688
|
+
unknownTypeListeners = [];
|
|
2689
|
+
invalidChangeListeners = [];
|
|
2690
|
+
/**
|
|
2691
|
+
* Register a handler for a change type and version range.
|
|
2692
|
+
*/
|
|
2693
|
+
register(handler) {
|
|
2694
|
+
const existing = this.handlers.get(handler.type) ?? [];
|
|
2695
|
+
existing.push(handler);
|
|
2696
|
+
existing.sort((a, b) => b.maxVersion - a.maxVersion);
|
|
2697
|
+
this.handlers.set(handler.type, existing);
|
|
2698
|
+
}
|
|
2699
|
+
/**
|
|
2700
|
+
* Unregister all handlers for a type.
|
|
2701
|
+
*/
|
|
2702
|
+
unregister(type) {
|
|
2703
|
+
return this.handlers.delete(type);
|
|
2704
|
+
}
|
|
2705
|
+
/**
|
|
2706
|
+
* Get the appropriate handler for a change.
|
|
2707
|
+
* Returns null if no handler can process this change.
|
|
2708
|
+
*/
|
|
2709
|
+
getHandler(change) {
|
|
2710
|
+
const handlers = this.handlers.get(change.type);
|
|
2711
|
+
if (!handlers || handlers.length === 0) {
|
|
2712
|
+
return null;
|
|
2713
|
+
}
|
|
2714
|
+
const version = change.protocolVersion ?? 0;
|
|
2715
|
+
for (const handler of handlers) {
|
|
2716
|
+
if (version >= handler.minVersion && version <= handler.maxVersion) {
|
|
2717
|
+
if (handler.canHandle(change)) {
|
|
2718
|
+
return handler;
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
for (const handler of handlers) {
|
|
2723
|
+
if (handler.maxVersion === Infinity && handler.minVersion <= version) {
|
|
2724
|
+
if (handler.canHandle(change)) {
|
|
2725
|
+
return handler;
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
return null;
|
|
2730
|
+
}
|
|
2731
|
+
/**
|
|
2732
|
+
* Check if any handler can process this change.
|
|
2733
|
+
*/
|
|
2734
|
+
canProcess(change) {
|
|
2735
|
+
return this.getHandler(change) !== null;
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2738
|
+
* Process a change using the appropriate handler.
|
|
2739
|
+
*/
|
|
2740
|
+
async process(change, context) {
|
|
2741
|
+
const handler = this.getHandler(change);
|
|
2742
|
+
if (!handler) {
|
|
2743
|
+
await context.storeUnknown(change);
|
|
2744
|
+
context.emit("unknownChangeType", { change });
|
|
2745
|
+
this.notifyUnknownType(change);
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
const validation = handler.validate(change);
|
|
2749
|
+
if (!validation.valid) {
|
|
2750
|
+
context.emit("invalidChange", { change, errors: validation.errors });
|
|
2751
|
+
this.notifyInvalidChange(change, validation.errors);
|
|
2752
|
+
return;
|
|
2753
|
+
}
|
|
2754
|
+
await handler.process(change, context);
|
|
2755
|
+
}
|
|
2756
|
+
/**
|
|
2757
|
+
* Get all registered handler types.
|
|
2758
|
+
*/
|
|
2759
|
+
getTypes() {
|
|
2760
|
+
return Array.from(this.handlers.keys());
|
|
2761
|
+
}
|
|
2762
|
+
/**
|
|
2763
|
+
* Get handlers for a specific type.
|
|
2764
|
+
*/
|
|
2765
|
+
getHandlersForType(type) {
|
|
2766
|
+
return this.handlers.get(type) ?? [];
|
|
2767
|
+
}
|
|
2768
|
+
/**
|
|
2769
|
+
* Subscribe to unknown change type events.
|
|
2770
|
+
*/
|
|
2771
|
+
onUnknownType(listener) {
|
|
2772
|
+
this.unknownTypeListeners.push(listener);
|
|
2773
|
+
return () => {
|
|
2774
|
+
const idx = this.unknownTypeListeners.indexOf(listener);
|
|
2775
|
+
if (idx !== -1) this.unknownTypeListeners.splice(idx, 1);
|
|
2776
|
+
};
|
|
2777
|
+
}
|
|
2778
|
+
/**
|
|
2779
|
+
* Subscribe to invalid change events.
|
|
2780
|
+
*/
|
|
2781
|
+
onInvalidChange(listener) {
|
|
2782
|
+
this.invalidChangeListeners.push(listener);
|
|
2783
|
+
return () => {
|
|
2784
|
+
const idx = this.invalidChangeListeners.indexOf(listener);
|
|
2785
|
+
if (idx !== -1) this.invalidChangeListeners.splice(idx, 1);
|
|
2786
|
+
};
|
|
2787
|
+
}
|
|
2788
|
+
/**
|
|
2789
|
+
* Clear all registered handlers.
|
|
2790
|
+
*/
|
|
2791
|
+
clear() {
|
|
2792
|
+
this.handlers.clear();
|
|
2793
|
+
}
|
|
2794
|
+
// ─── Private Methods ─────────────────────────────────────────────────────
|
|
2795
|
+
notifyUnknownType(change) {
|
|
2796
|
+
for (const listener of this.unknownTypeListeners) {
|
|
2797
|
+
try {
|
|
2798
|
+
listener(change);
|
|
2799
|
+
} catch (err) {
|
|
2800
|
+
console.error("[ChangeHandlerRegistry] Error in unknownType listener:", err);
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
notifyInvalidChange(change, errors) {
|
|
2805
|
+
for (const listener of this.invalidChangeListeners) {
|
|
2806
|
+
try {
|
|
2807
|
+
listener(change, errors);
|
|
2808
|
+
} catch (err) {
|
|
2809
|
+
console.error("[ChangeHandlerRegistry] Error in invalidChange listener:", err);
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
};
|
|
2814
|
+
var changeHandlerRegistry = new ChangeHandlerRegistry();
|
|
2815
|
+
function createHandler(type, process, validate) {
|
|
2816
|
+
return {
|
|
2817
|
+
type,
|
|
2818
|
+
minVersion: 0,
|
|
2819
|
+
maxVersion: Infinity,
|
|
2820
|
+
canHandle: () => true,
|
|
2821
|
+
process,
|
|
2822
|
+
validate: validate ?? (() => ({ valid: true, errors: [] }))
|
|
2823
|
+
};
|
|
2824
|
+
}
|
|
2825
|
+
function createVersionedHandler(type, minVersion, maxVersion, process, validate) {
|
|
2826
|
+
return {
|
|
2827
|
+
type,
|
|
2828
|
+
minVersion,
|
|
2829
|
+
maxVersion,
|
|
2830
|
+
canHandle: () => true,
|
|
2831
|
+
process,
|
|
2832
|
+
validate: validate ?? (() => ({ valid: true, errors: [] }))
|
|
2833
|
+
};
|
|
2834
|
+
}
|
|
2835
|
+
function createTestContext(overrides) {
|
|
2836
|
+
return {
|
|
2837
|
+
storeUnknown: async () => {
|
|
2838
|
+
},
|
|
2839
|
+
emit: () => {
|
|
2840
|
+
},
|
|
2841
|
+
authorDID: "did:key:test",
|
|
2842
|
+
...overrides
|
|
2843
|
+
};
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
// src/integrity.ts
|
|
2847
|
+
async function verifyIntegrity(changes, options = {}) {
|
|
2848
|
+
const startTime = Date.now();
|
|
2849
|
+
const issues = [];
|
|
2850
|
+
let valid = 0;
|
|
2851
|
+
const changeById = /* @__PURE__ */ new Map();
|
|
2852
|
+
const changeByHash = /* @__PURE__ */ new Map();
|
|
2853
|
+
const duplicateIds = /* @__PURE__ */ new Set();
|
|
2854
|
+
for (const change of changes) {
|
|
2855
|
+
if (changeById.has(change.id)) {
|
|
2856
|
+
duplicateIds.add(change.id);
|
|
2857
|
+
} else {
|
|
2858
|
+
changeById.set(change.id, change);
|
|
2859
|
+
}
|
|
2860
|
+
changeByHash.set(change.hash, change);
|
|
2861
|
+
}
|
|
2862
|
+
for (const id of duplicateIds) {
|
|
2863
|
+
issues.push({
|
|
2864
|
+
changeId: id,
|
|
2865
|
+
type: "duplicate-id",
|
|
2866
|
+
details: `Duplicate change ID found: ${id}`,
|
|
2867
|
+
severity: "error",
|
|
2868
|
+
repairAction: {
|
|
2869
|
+
type: "remove-duplicate",
|
|
2870
|
+
description: "Remove duplicate changes, keeping the most recent",
|
|
2871
|
+
automatic: false
|
|
2872
|
+
}
|
|
2873
|
+
});
|
|
2874
|
+
}
|
|
2875
|
+
const now = Date.now();
|
|
2876
|
+
const maxFuture = options.maxFutureTimestamp ?? 6e4;
|
|
2877
|
+
for (let i = 0; i < changes.length; i++) {
|
|
2878
|
+
const change = changes[i];
|
|
2879
|
+
if (options.onProgress) {
|
|
2880
|
+
options.onProgress(i + 1, changes.length);
|
|
2881
|
+
}
|
|
2882
|
+
let hasIssue = false;
|
|
2883
|
+
if (duplicateIds.has(change.id)) {
|
|
2884
|
+
continue;
|
|
2885
|
+
}
|
|
2886
|
+
if (!options.skipHashes) {
|
|
2887
|
+
const hashValid = verifyChangeHash(change);
|
|
2888
|
+
if (!hashValid) {
|
|
2889
|
+
issues.push({
|
|
2890
|
+
changeId: change.id,
|
|
2891
|
+
type: "hash-mismatch",
|
|
2892
|
+
details: `Hash mismatch: stored hash does not match computed hash`,
|
|
2893
|
+
severity: "error",
|
|
2894
|
+
repairAction: {
|
|
2895
|
+
type: "recompute-hash",
|
|
2896
|
+
description: "Recompute and update the hash",
|
|
2897
|
+
automatic: true
|
|
2898
|
+
}
|
|
2899
|
+
});
|
|
2900
|
+
hasIssue = true;
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
if (!options.skipSignatures && !hasIssue) {
|
|
2904
|
+
if (!change.signature || change.signature.length === 0) {
|
|
2905
|
+
issues.push({
|
|
2906
|
+
changeId: change.id,
|
|
2907
|
+
type: "signature-invalid",
|
|
2908
|
+
details: "Signature is missing or empty",
|
|
2909
|
+
severity: "error"
|
|
2910
|
+
// No automatic repair - would need original signing key
|
|
2911
|
+
});
|
|
2912
|
+
hasIssue = true;
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
if (!options.skipChain && change.parentHash !== null) {
|
|
2916
|
+
if (!changeByHash.has(change.parentHash)) {
|
|
2917
|
+
issues.push({
|
|
2918
|
+
changeId: change.id,
|
|
2919
|
+
type: "missing-parent",
|
|
2920
|
+
details: `Parent hash ${change.parentHash} not found`,
|
|
2921
|
+
severity: "warning",
|
|
2922
|
+
repairAction: {
|
|
2923
|
+
type: "request-from-peers",
|
|
2924
|
+
description: "Request the missing parent from connected peers",
|
|
2925
|
+
automatic: true
|
|
2926
|
+
}
|
|
2927
|
+
});
|
|
2928
|
+
hasIssue = true;
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
if (change.lamport.time < 0) {
|
|
2932
|
+
issues.push({
|
|
2933
|
+
changeId: change.id,
|
|
2934
|
+
type: "invalid-lamport",
|
|
2935
|
+
details: `Invalid Lamport time: ${change.lamport.time}`,
|
|
2936
|
+
severity: "error"
|
|
2937
|
+
});
|
|
2938
|
+
hasIssue = true;
|
|
2939
|
+
}
|
|
2940
|
+
if (change.wallTime > now + maxFuture) {
|
|
2941
|
+
issues.push({
|
|
2942
|
+
changeId: change.id,
|
|
2943
|
+
type: "future-timestamp",
|
|
2944
|
+
details: `Wall time is in the future: ${new Date(change.wallTime).toISOString()}`,
|
|
2945
|
+
severity: "warning"
|
|
2946
|
+
});
|
|
2947
|
+
hasIssue = true;
|
|
2948
|
+
}
|
|
2949
|
+
if (!hasIssue) {
|
|
2950
|
+
valid++;
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
const byType = {
|
|
2954
|
+
"hash-mismatch": 0,
|
|
2955
|
+
"signature-invalid": 0,
|
|
2956
|
+
"chain-broken": 0,
|
|
2957
|
+
"missing-parent": 0,
|
|
2958
|
+
"duplicate-id": 0,
|
|
2959
|
+
"invalid-lamport": 0,
|
|
2960
|
+
"future-timestamp": 0
|
|
2961
|
+
};
|
|
2962
|
+
let errors = 0;
|
|
2963
|
+
let warnings = 0;
|
|
2964
|
+
for (const issue of issues) {
|
|
2965
|
+
byType[issue.type]++;
|
|
2966
|
+
if (issue.severity === "error") {
|
|
2967
|
+
errors++;
|
|
2968
|
+
} else {
|
|
2969
|
+
warnings++;
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
const repairable = issues.every(
|
|
2973
|
+
(issue) => issue.repairAction !== void 0 && issue.repairAction.type !== "none"
|
|
2974
|
+
);
|
|
2975
|
+
return {
|
|
2976
|
+
checked: changes.length,
|
|
2977
|
+
valid,
|
|
2978
|
+
issues,
|
|
2979
|
+
repairable,
|
|
2980
|
+
summary: {
|
|
2981
|
+
errors,
|
|
2982
|
+
warnings,
|
|
2983
|
+
byType
|
|
2984
|
+
},
|
|
2985
|
+
durationMs: Date.now() - startTime
|
|
2986
|
+
};
|
|
2987
|
+
}
|
|
2988
|
+
async function quickIntegrityCheck(changes) {
|
|
2989
|
+
return verifyIntegrity(changes, {
|
|
2990
|
+
skipSignatures: true,
|
|
2991
|
+
skipChain: false
|
|
2992
|
+
});
|
|
2993
|
+
}
|
|
2994
|
+
async function verifySingleChange(change, options = {}) {
|
|
2995
|
+
const report = await verifyIntegrity([change], options);
|
|
2996
|
+
return {
|
|
2997
|
+
valid: report.valid === 1,
|
|
2998
|
+
issues: report.issues
|
|
2999
|
+
};
|
|
3000
|
+
}
|
|
3001
|
+
function findOrphans(changes) {
|
|
3002
|
+
const hashSet = new Set(changes.map((c) => c.hash));
|
|
3003
|
+
return changes.filter((c) => c.parentHash !== null && !hashSet.has(c.parentHash));
|
|
3004
|
+
}
|
|
3005
|
+
function findRoots(changes) {
|
|
3006
|
+
return changes.filter((c) => c.parentHash === null);
|
|
3007
|
+
}
|
|
3008
|
+
function findHeads(changes) {
|
|
3009
|
+
const parentHashes = new Set(
|
|
3010
|
+
changes.filter((c) => c.parentHash !== null).map((c) => c.parentHash)
|
|
3011
|
+
);
|
|
3012
|
+
return changes.filter((c) => !parentHashes.has(c.hash));
|
|
3013
|
+
}
|
|
3014
|
+
function getChainDepth(changes) {
|
|
3015
|
+
if (changes.length === 0) return 0;
|
|
3016
|
+
const childMap = /* @__PURE__ */ new Map();
|
|
3017
|
+
for (const change of changes) {
|
|
3018
|
+
const children = childMap.get(change.parentHash) ?? [];
|
|
3019
|
+
children.push(change);
|
|
3020
|
+
childMap.set(change.parentHash, children);
|
|
3021
|
+
}
|
|
3022
|
+
function getDepth(parentHash) {
|
|
3023
|
+
const children = childMap.get(parentHash) ?? [];
|
|
3024
|
+
if (children.length === 0) return 0;
|
|
3025
|
+
return 1 + Math.max(...children.map((c) => getDepth(c.hash)));
|
|
3026
|
+
}
|
|
3027
|
+
return getDepth(null);
|
|
3028
|
+
}
|
|
3029
|
+
async function attemptRepair(changes, issues) {
|
|
3030
|
+
const changeMap = new Map(changes.map((c) => [c.id, { ...c }]));
|
|
3031
|
+
const remainingIssues = [];
|
|
3032
|
+
let repairCount = 0;
|
|
3033
|
+
for (const issue of issues) {
|
|
3034
|
+
if (!issue.repairAction?.automatic) {
|
|
3035
|
+
remainingIssues.push(issue);
|
|
3036
|
+
continue;
|
|
3037
|
+
}
|
|
3038
|
+
const change = changeMap.get(issue.changeId);
|
|
3039
|
+
if (!change) {
|
|
3040
|
+
remainingIssues.push(issue);
|
|
3041
|
+
continue;
|
|
3042
|
+
}
|
|
3043
|
+
switch (issue.repairAction.type) {
|
|
3044
|
+
case "recompute-hash":
|
|
3045
|
+
change.hash = await computeChangeHash(change);
|
|
3046
|
+
repairCount++;
|
|
3047
|
+
break;
|
|
3048
|
+
case "mark-orphan":
|
|
3049
|
+
repairCount++;
|
|
3050
|
+
break;
|
|
3051
|
+
default:
|
|
3052
|
+
remainingIssues.push(issue);
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
return {
|
|
3056
|
+
repaired: Array.from(changeMap.values()),
|
|
3057
|
+
remainingIssues,
|
|
3058
|
+
repairCount
|
|
3059
|
+
};
|
|
3060
|
+
}
|
|
3061
|
+
function formatIntegrityReport(report) {
|
|
3062
|
+
const lines = [];
|
|
3063
|
+
lines.push(`Integrity Report`);
|
|
3064
|
+
lines.push(`================`);
|
|
3065
|
+
lines.push(`Checked: ${report.checked} changes`);
|
|
3066
|
+
lines.push(`Valid: ${report.valid} (${Math.round(report.valid / report.checked * 100)}%)`);
|
|
3067
|
+
lines.push(`Duration: ${report.durationMs}ms`);
|
|
3068
|
+
lines.push("");
|
|
3069
|
+
if (report.issues.length === 0) {
|
|
3070
|
+
lines.push("No issues found.");
|
|
3071
|
+
} else {
|
|
3072
|
+
lines.push(
|
|
3073
|
+
`Issues: ${report.issues.length} (${report.summary.errors} errors, ${report.summary.warnings} warnings)`
|
|
3074
|
+
);
|
|
3075
|
+
lines.push("");
|
|
3076
|
+
for (const [type, count] of Object.entries(report.summary.byType)) {
|
|
3077
|
+
if (count > 0) {
|
|
3078
|
+
lines.push(` ${type}: ${count}`);
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
lines.push("");
|
|
3082
|
+
lines.push(`Repairable: ${report.repairable ? "Yes" : "No"}`);
|
|
3083
|
+
}
|
|
3084
|
+
return lines.join("\n");
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
// src/deprecation.ts
|
|
3088
|
+
var DEPRECATIONS = [
|
|
3089
|
+
// Protocol deprecations
|
|
3090
|
+
{
|
|
3091
|
+
type: "protocol",
|
|
3092
|
+
subject: "Protocol v0 (unsigned changes)",
|
|
3093
|
+
description: "Protocol v0 changes without signatures are deprecated",
|
|
3094
|
+
deprecatedIn: "0.5.0",
|
|
3095
|
+
removedIn: "1.0.0",
|
|
3096
|
+
alternative: "Protocol v1 with signed changes",
|
|
3097
|
+
migrationGuide: "/docs/migrations/protocol-v0-to-v1",
|
|
3098
|
+
deprecatedDate: "2026-01-15"
|
|
3099
|
+
},
|
|
3100
|
+
{
|
|
3101
|
+
type: "protocol",
|
|
3102
|
+
subject: "Legacy Yjs updates",
|
|
3103
|
+
description: "Unsigned Yjs updates are deprecated",
|
|
3104
|
+
deprecatedIn: "0.5.0",
|
|
3105
|
+
removedIn: "1.0.0",
|
|
3106
|
+
alternative: "Signed Yjs envelopes",
|
|
3107
|
+
migrationGuide: "/docs/migrations/yjs-signed-envelopes"
|
|
3108
|
+
}
|
|
3109
|
+
// Schema deprecations (examples for future use)
|
|
3110
|
+
// {
|
|
3111
|
+
// type: 'schema',
|
|
3112
|
+
// subject: 'xnet://xnet.fyi/Task@1.0.0',
|
|
3113
|
+
// description: 'Task v1.0.0 schema is deprecated',
|
|
3114
|
+
// deprecatedIn: '0.6.0',
|
|
3115
|
+
// alternative: 'xnet://xnet.fyi/Task@2.0.0',
|
|
3116
|
+
// migrationGuide: '/docs/migrations/task-v1-to-v2'
|
|
3117
|
+
// },
|
|
3118
|
+
// Feature deprecations (examples for future use)
|
|
3119
|
+
// {
|
|
3120
|
+
// type: 'feature',
|
|
3121
|
+
// subject: 'legacy-auth',
|
|
3122
|
+
// description: 'Legacy authentication method is deprecated',
|
|
3123
|
+
// deprecatedIn: '0.7.0',
|
|
3124
|
+
// removedIn: '1.0.0',
|
|
3125
|
+
// alternative: 'did-auth',
|
|
3126
|
+
// migrationGuide: '/docs/migrations/legacy-auth-to-did'
|
|
3127
|
+
// }
|
|
3128
|
+
];
|
|
3129
|
+
function checkDeprecations(context) {
|
|
3130
|
+
const warnings = [];
|
|
3131
|
+
const now = /* @__PURE__ */ new Date();
|
|
3132
|
+
for (const notice of DEPRECATIONS) {
|
|
3133
|
+
let triggered = false;
|
|
3134
|
+
if (notice.type === "protocol") {
|
|
3135
|
+
if (notice.subject.includes("v0") && (context.protocolVersion ?? 0) < 1) {
|
|
3136
|
+
triggered = true;
|
|
3137
|
+
}
|
|
3138
|
+
if (notice.subject.includes("Legacy Yjs") && (context.protocolVersion ?? 0) < 1) {
|
|
3139
|
+
triggered = true;
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
if (notice.type === "schema" && context.schemas) {
|
|
3143
|
+
if (context.schemas.includes(notice.subject)) {
|
|
3144
|
+
triggered = true;
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
if (notice.type === "feature" && context.features) {
|
|
3148
|
+
if (context.features.includes(notice.subject)) {
|
|
3149
|
+
triggered = true;
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
if (triggered) {
|
|
3153
|
+
const warning = createWarning(notice, now);
|
|
3154
|
+
warnings.push(warning);
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
return warnings;
|
|
3158
|
+
}
|
|
3159
|
+
function createWarning(notice, now) {
|
|
3160
|
+
let daysUntilRemoval;
|
|
3161
|
+
if (notice.sunsetDate) {
|
|
3162
|
+
const sunset = new Date(notice.sunsetDate);
|
|
3163
|
+
const diff = sunset.getTime() - now.getTime();
|
|
3164
|
+
daysUntilRemoval = Math.ceil(diff / (1e3 * 60 * 60 * 24));
|
|
3165
|
+
}
|
|
3166
|
+
const isPastRemoval = notice.removedIn !== void 0 && daysUntilRemoval !== void 0 && daysUntilRemoval < 0;
|
|
3167
|
+
const message = isPastRemoval ? `REMOVED: ${notice.subject} was removed in v${notice.removedIn}.` : `DEPRECATED: ${notice.subject} is deprecated since v${notice.deprecatedIn}.`;
|
|
3168
|
+
let action = "";
|
|
3169
|
+
if (notice.alternative) {
|
|
3170
|
+
action = `Migrate to ${notice.alternative}.`;
|
|
3171
|
+
}
|
|
3172
|
+
if (notice.removedIn && !isPastRemoval) {
|
|
3173
|
+
action += ` Will be removed in v${notice.removedIn}.`;
|
|
3174
|
+
}
|
|
3175
|
+
if (notice.migrationGuide) {
|
|
3176
|
+
action += ` See ${notice.migrationGuide}`;
|
|
3177
|
+
}
|
|
3178
|
+
return {
|
|
3179
|
+
notice,
|
|
3180
|
+
message,
|
|
3181
|
+
action: action.trim(),
|
|
3182
|
+
severity: isPastRemoval ? "error" : "warning",
|
|
3183
|
+
daysUntilRemoval: daysUntilRemoval !== void 0 && daysUntilRemoval >= 0 ? daysUntilRemoval : void 0
|
|
3184
|
+
};
|
|
3185
|
+
}
|
|
3186
|
+
var DEPRECATION_POLICY = {
|
|
3187
|
+
/** Minimum time between deprecation and removal */
|
|
3188
|
+
minimumDeprecationPeriodDays: 180,
|
|
3189
|
+
/** Whether to log warnings to console */
|
|
3190
|
+
logWarnings: true,
|
|
3191
|
+
/** Whether to throw errors for removed functionality */
|
|
3192
|
+
strictMode: false,
|
|
3193
|
+
/** Console logger for warnings */
|
|
3194
|
+
logger: console.warn
|
|
3195
|
+
};
|
|
3196
|
+
function configureDeprecationPolicy(options) {
|
|
3197
|
+
Object.assign(DEPRECATION_POLICY, options);
|
|
3198
|
+
}
|
|
3199
|
+
var loggedDeprecations = /* @__PURE__ */ new Set();
|
|
3200
|
+
function logDeprecation(warning) {
|
|
3201
|
+
if (!DEPRECATION_POLICY.logWarnings) return;
|
|
3202
|
+
const key = `${warning.notice.type}:${warning.notice.subject}`;
|
|
3203
|
+
if (loggedDeprecations.has(key)) return;
|
|
3204
|
+
loggedDeprecations.add(key);
|
|
3205
|
+
const prefix = warning.severity === "error" ? "[REMOVED]" : "[DEPRECATED]";
|
|
3206
|
+
DEPRECATION_POLICY.logger(`${prefix} ${warning.message} ${warning.action}`);
|
|
3207
|
+
}
|
|
3208
|
+
function checkAndLogDeprecations(context) {
|
|
3209
|
+
const warnings = checkDeprecations(context);
|
|
3210
|
+
for (const warning of warnings) {
|
|
3211
|
+
logDeprecation(warning);
|
|
3212
|
+
if (DEPRECATION_POLICY.strictMode && warning.severity === "error") {
|
|
3213
|
+
throw new DeprecationError(warning);
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
return warnings;
|
|
3217
|
+
}
|
|
3218
|
+
function clearLoggedDeprecations() {
|
|
3219
|
+
loggedDeprecations.clear();
|
|
3220
|
+
}
|
|
3221
|
+
function getDeprecationsByType(type) {
|
|
3222
|
+
return DEPRECATIONS.filter((d) => d.type === type);
|
|
3223
|
+
}
|
|
3224
|
+
function getDeprecation(subject) {
|
|
3225
|
+
return DEPRECATIONS.find((d) => d.subject === subject);
|
|
3226
|
+
}
|
|
3227
|
+
function isDeprecated(subject) {
|
|
3228
|
+
return DEPRECATIONS.some((d) => d.subject === subject);
|
|
3229
|
+
}
|
|
3230
|
+
function isRemoved(subject) {
|
|
3231
|
+
const notice = getDeprecation(subject);
|
|
3232
|
+
if (!notice?.removedIn) return false;
|
|
3233
|
+
return true;
|
|
3234
|
+
}
|
|
3235
|
+
function registerDeprecation(notice) {
|
|
3236
|
+
const existing = DEPRECATIONS.findIndex((d) => d.subject === notice.subject);
|
|
3237
|
+
if (existing >= 0) {
|
|
3238
|
+
DEPRECATIONS[existing] = notice;
|
|
3239
|
+
} else {
|
|
3240
|
+
DEPRECATIONS.push(notice);
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
var DeprecationError = class extends Error {
|
|
3244
|
+
constructor(warning) {
|
|
3245
|
+
super(`${warning.message} ${warning.action}`);
|
|
3246
|
+
this.warning = warning;
|
|
3247
|
+
this.name = "DeprecationError";
|
|
3248
|
+
}
|
|
3249
|
+
};
|
|
3250
|
+
function formatDeprecationReport(warnings) {
|
|
3251
|
+
if (warnings.length === 0) {
|
|
3252
|
+
return "No deprecation warnings.";
|
|
3253
|
+
}
|
|
3254
|
+
const lines = ["Deprecation Report", "==================", ""];
|
|
3255
|
+
const errors = warnings.filter((w) => w.severity === "error");
|
|
3256
|
+
const warns = warnings.filter((w) => w.severity === "warning");
|
|
3257
|
+
if (errors.length > 0) {
|
|
3258
|
+
lines.push("REMOVED (require immediate action):");
|
|
3259
|
+
for (const w of errors) {
|
|
3260
|
+
lines.push(` - ${w.notice.subject}`);
|
|
3261
|
+
lines.push(` ${w.action}`);
|
|
3262
|
+
}
|
|
3263
|
+
lines.push("");
|
|
3264
|
+
}
|
|
3265
|
+
if (warns.length > 0) {
|
|
3266
|
+
lines.push("DEPRECATED (plan to migrate):");
|
|
3267
|
+
for (const w of warns) {
|
|
3268
|
+
lines.push(` - ${w.notice.subject}`);
|
|
3269
|
+
lines.push(` ${w.action}`);
|
|
3270
|
+
if (w.daysUntilRemoval !== void 0) {
|
|
3271
|
+
lines.push(` ${w.daysUntilRemoval} days until removal`);
|
|
3272
|
+
}
|
|
3273
|
+
}
|
|
3274
|
+
lines.push("");
|
|
3275
|
+
}
|
|
3276
|
+
return lines.join("\n");
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
// src/integrity-monitor.ts
|
|
3280
|
+
var DEFAULT_INTERVAL_MS = 5 * 60 * 1e3;
|
|
3281
|
+
var DEFAULT_MIN_CHANGES = 10;
|
|
3282
|
+
function createIntegrityMonitor(config) {
|
|
3283
|
+
let currentConfig = { ...config };
|
|
3284
|
+
let intervalId = null;
|
|
3285
|
+
let isRunning = false;
|
|
3286
|
+
let isChecking = false;
|
|
3287
|
+
const stats = {
|
|
3288
|
+
checksPerformed: 0,
|
|
3289
|
+
totalIssuesFound: 0,
|
|
3290
|
+
lastCheckAt: null,
|
|
3291
|
+
lastCheckDurationMs: 0,
|
|
3292
|
+
lastReport: null,
|
|
3293
|
+
isRunning: false,
|
|
3294
|
+
isChecking: false
|
|
3295
|
+
};
|
|
3296
|
+
const log = (message) => {
|
|
3297
|
+
if (currentConfig.debug) {
|
|
3298
|
+
console.log(`[IntegrityMonitor] ${message}`);
|
|
3299
|
+
}
|
|
3300
|
+
};
|
|
3301
|
+
const runCheck = async () => {
|
|
3302
|
+
if (isChecking) {
|
|
3303
|
+
log("Check already in progress, skipping");
|
|
3304
|
+
return stats.lastReport ?? createEmptyReport();
|
|
3305
|
+
}
|
|
3306
|
+
isChecking = true;
|
|
3307
|
+
stats.isChecking = true;
|
|
3308
|
+
try {
|
|
3309
|
+
log("Starting integrity check");
|
|
3310
|
+
const startTime = Date.now();
|
|
3311
|
+
const changes = await Promise.resolve(currentConfig.getChanges());
|
|
3312
|
+
const minChanges = currentConfig.minChangesForCheck ?? DEFAULT_MIN_CHANGES;
|
|
3313
|
+
if (changes.length < minChanges) {
|
|
3314
|
+
log(`Skipping check: only ${changes.length} changes (min: ${minChanges})`);
|
|
3315
|
+
return createEmptyReport();
|
|
3316
|
+
}
|
|
3317
|
+
const report = currentConfig.quickCheck ? await quickIntegrityCheck(changes) : await verifyIntegrity(changes, currentConfig.verifyOptions);
|
|
3318
|
+
stats.checksPerformed++;
|
|
3319
|
+
stats.lastCheckAt = /* @__PURE__ */ new Date();
|
|
3320
|
+
stats.lastCheckDurationMs = Date.now() - startTime;
|
|
3321
|
+
stats.lastReport = report;
|
|
3322
|
+
stats.totalIssuesFound += report.issues.length;
|
|
3323
|
+
log(`Check complete: ${report.valid}/${report.checked} valid, ${report.issues.length} issues`);
|
|
3324
|
+
currentConfig.onCheck?.(report);
|
|
3325
|
+
if (report.issues.length > 0) {
|
|
3326
|
+
currentConfig.onIssues?.(report);
|
|
3327
|
+
}
|
|
3328
|
+
return report;
|
|
3329
|
+
} catch (error) {
|
|
3330
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
3331
|
+
log(`Check failed: ${err.message}`);
|
|
3332
|
+
currentConfig.onError?.(err);
|
|
3333
|
+
throw err;
|
|
3334
|
+
} finally {
|
|
3335
|
+
isChecking = false;
|
|
3336
|
+
stats.isChecking = false;
|
|
3337
|
+
}
|
|
3338
|
+
};
|
|
3339
|
+
const start = () => {
|
|
3340
|
+
if (isRunning) {
|
|
3341
|
+
log("Already running");
|
|
3342
|
+
return;
|
|
3343
|
+
}
|
|
3344
|
+
const intervalMs = currentConfig.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
3345
|
+
log(`Starting with interval ${intervalMs}ms`);
|
|
3346
|
+
isRunning = true;
|
|
3347
|
+
stats.isRunning = true;
|
|
3348
|
+
if (currentConfig.checkOnStart) {
|
|
3349
|
+
runCheck().catch(() => {
|
|
3350
|
+
});
|
|
3351
|
+
}
|
|
3352
|
+
intervalId = setInterval(() => {
|
|
3353
|
+
runCheck().catch(() => {
|
|
3354
|
+
});
|
|
3355
|
+
}, intervalMs);
|
|
3356
|
+
};
|
|
3357
|
+
const stop = () => {
|
|
3358
|
+
if (!isRunning) {
|
|
3359
|
+
log("Not running");
|
|
3360
|
+
return;
|
|
3361
|
+
}
|
|
3362
|
+
log("Stopping");
|
|
3363
|
+
if (intervalId !== null) {
|
|
3364
|
+
clearInterval(intervalId);
|
|
3365
|
+
intervalId = null;
|
|
3366
|
+
}
|
|
3367
|
+
isRunning = false;
|
|
3368
|
+
stats.isRunning = false;
|
|
3369
|
+
};
|
|
3370
|
+
const checkNow = async () => {
|
|
3371
|
+
return runCheck();
|
|
3372
|
+
};
|
|
3373
|
+
const getStats = () => {
|
|
3374
|
+
return { ...stats };
|
|
3375
|
+
};
|
|
3376
|
+
const configure = (newConfig) => {
|
|
3377
|
+
const wasRunning = isRunning;
|
|
3378
|
+
if (wasRunning) {
|
|
3379
|
+
stop();
|
|
3380
|
+
}
|
|
3381
|
+
currentConfig = { ...currentConfig, ...newConfig };
|
|
3382
|
+
if (wasRunning) {
|
|
3383
|
+
start();
|
|
3384
|
+
}
|
|
3385
|
+
};
|
|
3386
|
+
return {
|
|
3387
|
+
start,
|
|
3388
|
+
stop,
|
|
3389
|
+
checkNow,
|
|
3390
|
+
getStats,
|
|
3391
|
+
isRunning: () => isRunning,
|
|
3392
|
+
configure
|
|
3393
|
+
};
|
|
3394
|
+
}
|
|
3395
|
+
function createEmptyReport() {
|
|
3396
|
+
return {
|
|
3397
|
+
checked: 0,
|
|
3398
|
+
valid: 0,
|
|
3399
|
+
issues: [],
|
|
3400
|
+
repairable: true,
|
|
3401
|
+
summary: {
|
|
3402
|
+
errors: 0,
|
|
3403
|
+
warnings: 0,
|
|
3404
|
+
byType: {
|
|
3405
|
+
"hash-mismatch": 0,
|
|
3406
|
+
"signature-invalid": 0,
|
|
3407
|
+
"chain-broken": 0,
|
|
3408
|
+
"missing-parent": 0,
|
|
3409
|
+
"duplicate-id": 0,
|
|
3410
|
+
"invalid-lamport": 0,
|
|
3411
|
+
"future-timestamp": 0
|
|
3412
|
+
}
|
|
3413
|
+
},
|
|
3414
|
+
durationMs: 0
|
|
3415
|
+
};
|
|
3416
|
+
}
|
|
3417
|
+
function createReactIntegrityMonitor(options) {
|
|
3418
|
+
const { onStateChange, ...config } = options;
|
|
3419
|
+
const monitor = createIntegrityMonitor({
|
|
3420
|
+
...config,
|
|
3421
|
+
onCheck: (report) => {
|
|
3422
|
+
config.onCheck?.(report);
|
|
3423
|
+
onStateChange?.(monitor.getStats());
|
|
3424
|
+
},
|
|
3425
|
+
onError: (error) => {
|
|
3426
|
+
config.onError?.(error);
|
|
3427
|
+
onStateChange?.(monitor.getStats());
|
|
3428
|
+
}
|
|
3429
|
+
});
|
|
3430
|
+
const originalStart = monitor.start.bind(monitor);
|
|
3431
|
+
const originalStop = monitor.stop.bind(monitor);
|
|
3432
|
+
monitor.start = () => {
|
|
3433
|
+
originalStart();
|
|
3434
|
+
onStateChange?.(monitor.getStats());
|
|
3435
|
+
};
|
|
3436
|
+
monitor.stop = () => {
|
|
3437
|
+
originalStop();
|
|
3438
|
+
onStateChange?.(monitor.getStats());
|
|
3439
|
+
};
|
|
3440
|
+
return monitor;
|
|
3441
|
+
}
|
|
3442
|
+
|
|
3443
|
+
// src/security-policy.ts
|
|
3444
|
+
import { DEFAULT_SECURITY_LEVEL as DEFAULT_SECURITY_LEVEL3 } from "@xnetjs/crypto";
|
|
3445
|
+
var DEFAULT_SECURITY_POLICY = {
|
|
3446
|
+
default: DEFAULT_SECURITY_LEVEL3,
|
|
3447
|
+
overrides: {
|
|
3448
|
+
// High-frequency, ephemeral operations - fast Ed25519
|
|
3449
|
+
"cursor-update": 0,
|
|
3450
|
+
"presence-update": 0,
|
|
3451
|
+
"typing-indicator": 0,
|
|
3452
|
+
"viewport-update": 0,
|
|
3453
|
+
"awareness-update": 0
|
|
3454
|
+
// Regular operations - use default (currently Level 0)
|
|
3455
|
+
// When we upgrade to Level 1 default, these will automatically use hybrid
|
|
3456
|
+
// 'node-create': 1,
|
|
3457
|
+
// 'node-update': 1,
|
|
3458
|
+
// 'node-delete': 1,
|
|
3459
|
+
// Critical operations - can be upgraded to Level 1/2 when PQ is enabled
|
|
3460
|
+
// 'key-rotation': 2,
|
|
3461
|
+
// 'permission-grant': 1,
|
|
3462
|
+
// 'permission-revoke': 2,
|
|
3463
|
+
// 'identity-recovery': 2,
|
|
3464
|
+
}
|
|
3465
|
+
};
|
|
3466
|
+
var HYBRID_SECURITY_POLICY = {
|
|
3467
|
+
default: 1,
|
|
3468
|
+
overrides: {
|
|
3469
|
+
// High-frequency, ephemeral operations - fast Ed25519
|
|
3470
|
+
"cursor-update": 0,
|
|
3471
|
+
"presence-update": 0,
|
|
3472
|
+
"typing-indicator": 0,
|
|
3473
|
+
"viewport-update": 0,
|
|
3474
|
+
"awareness-update": 0,
|
|
3475
|
+
// Regular operations - hybrid signatures
|
|
3476
|
+
"node-create": 1,
|
|
3477
|
+
"node-update": 1,
|
|
3478
|
+
"node-delete": 1,
|
|
3479
|
+
"yjs-update": 1,
|
|
3480
|
+
"comment-add": 1,
|
|
3481
|
+
// Critical operations - maximum security
|
|
3482
|
+
"key-rotation": 2,
|
|
3483
|
+
"permission-grant": 1,
|
|
3484
|
+
"permission-revoke": 2,
|
|
3485
|
+
"identity-recovery": 2,
|
|
3486
|
+
"share-create": 1
|
|
3487
|
+
}
|
|
3488
|
+
};
|
|
3489
|
+
var MAX_SECURITY_POLICY = {
|
|
3490
|
+
default: 2,
|
|
3491
|
+
overrides: {
|
|
3492
|
+
// High-frequency, ephemeral operations - still fast
|
|
3493
|
+
"cursor-update": 0,
|
|
3494
|
+
"presence-update": 0,
|
|
3495
|
+
"typing-indicator": 0,
|
|
3496
|
+
"viewport-update": 0,
|
|
3497
|
+
"awareness-update": 0
|
|
3498
|
+
}
|
|
3499
|
+
};
|
|
3500
|
+
function getSecurityLevel(operationType, policy = DEFAULT_SECURITY_POLICY) {
|
|
3501
|
+
const override = policy.overrides[operationType];
|
|
3502
|
+
return override !== void 0 ? override : policy.default;
|
|
3503
|
+
}
|
|
3504
|
+
function isEphemeralOperation(operationType) {
|
|
3505
|
+
const ephemeralOps = [
|
|
3506
|
+
"cursor-update",
|
|
3507
|
+
"presence-update",
|
|
3508
|
+
"typing-indicator",
|
|
3509
|
+
"viewport-update",
|
|
3510
|
+
"awareness-update"
|
|
3511
|
+
];
|
|
3512
|
+
return ephemeralOps.includes(operationType);
|
|
3513
|
+
}
|
|
3514
|
+
function isCriticalOperation(operationType) {
|
|
3515
|
+
const criticalOps = ["key-rotation", "permission-revoke", "identity-recovery"];
|
|
3516
|
+
return criticalOps.includes(operationType);
|
|
3517
|
+
}
|
|
3518
|
+
function createSecurityPolicy(options = {}) {
|
|
3519
|
+
return {
|
|
3520
|
+
default: options.default ?? DEFAULT_SECURITY_LEVEL3,
|
|
3521
|
+
overrides: {
|
|
3522
|
+
...DEFAULT_SECURITY_POLICY.overrides,
|
|
3523
|
+
...options.overrides
|
|
3524
|
+
}
|
|
3525
|
+
};
|
|
3526
|
+
}
|
|
3527
|
+
function mergeSecurityPolicies(...policies) {
|
|
3528
|
+
const merged = {
|
|
3529
|
+
default: DEFAULT_SECURITY_LEVEL3,
|
|
3530
|
+
overrides: {}
|
|
3531
|
+
};
|
|
3532
|
+
for (const policy of policies) {
|
|
3533
|
+
if (policy.default !== void 0) {
|
|
3534
|
+
merged.default = policy.default;
|
|
3535
|
+
}
|
|
3536
|
+
if (policy.overrides) {
|
|
3537
|
+
merged.overrides = { ...merged.overrides, ...policy.overrides };
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
return merged;
|
|
3541
|
+
}
|
|
3542
|
+
export {
|
|
3543
|
+
ALL_FEATURES,
|
|
3544
|
+
AuthorizedSyncManager,
|
|
3545
|
+
AuthorizedYjsError,
|
|
3546
|
+
AuthorizedYjsSyncProvider,
|
|
3547
|
+
BaseSyncProvider,
|
|
3548
|
+
COMPACTION_TIME_THRESHOLD,
|
|
3549
|
+
COMPACTION_UPDATE_THRESHOLD,
|
|
3550
|
+
CURRENT_PROTOCOL_VERSION,
|
|
3551
|
+
ChangeHandlerRegistry,
|
|
3552
|
+
ClientIdMapImpl,
|
|
3553
|
+
DEFAULT_BATCHER_CONFIG,
|
|
3554
|
+
DEFAULT_RATE_LIMITER_CONFIG,
|
|
3555
|
+
DEFAULT_SECURITY_POLICY,
|
|
3556
|
+
DEFAULT_YJS_SCORING_CONFIG,
|
|
3557
|
+
DEPRECATIONS,
|
|
3558
|
+
DEPRECATION_POLICY,
|
|
3559
|
+
DeprecationError,
|
|
3560
|
+
FEATURES,
|
|
3561
|
+
HYBRID_SECURITY_POLICY,
|
|
3562
|
+
MAX_SECURITY_POLICY,
|
|
3563
|
+
MAX_YJS_DOC_SIZE,
|
|
3564
|
+
MAX_YJS_UPDATES_PER_MINUTE,
|
|
3565
|
+
MAX_YJS_UPDATES_PER_SECOND,
|
|
3566
|
+
MAX_YJS_UPDATE_SIZE,
|
|
3567
|
+
V1Serializer,
|
|
3568
|
+
V2Serializer,
|
|
3569
|
+
VersionNegotiator,
|
|
3570
|
+
YJS_CHANGE_TYPE,
|
|
3571
|
+
YJS_RATE_BURST_ALLOWANCE,
|
|
3572
|
+
YJS_SYNC_CHUNK_SIZE,
|
|
3573
|
+
YjsAuthGate,
|
|
3574
|
+
YjsBatcher,
|
|
3575
|
+
YjsCheckpointer,
|
|
3576
|
+
YjsIntegrityError,
|
|
3577
|
+
YjsPeerScorer,
|
|
3578
|
+
YjsRateLimiter,
|
|
3579
|
+
YjsStateIntegrityError,
|
|
3580
|
+
addDependencies,
|
|
3581
|
+
attemptRepair,
|
|
3582
|
+
autoDeserialize,
|
|
3583
|
+
autoSerialize,
|
|
3584
|
+
calculateChunkCount,
|
|
3585
|
+
changeHandlerRegistry,
|
|
3586
|
+
checkAndLogDeprecations,
|
|
3587
|
+
checkDeprecations,
|
|
3588
|
+
chunkUpdate,
|
|
3589
|
+
clearLoggedDeprecations,
|
|
3590
|
+
compareLamportTimestamps,
|
|
3591
|
+
computeChangeHash,
|
|
3592
|
+
configureDeprecationPolicy,
|
|
3593
|
+
createBatchId,
|
|
3594
|
+
createChangeId,
|
|
3595
|
+
createClientIdAttestation,
|
|
3596
|
+
createClientIdAttestationV1,
|
|
3597
|
+
createClientIdAttestationV2,
|
|
3598
|
+
createHandler,
|
|
3599
|
+
createIntegrityMonitor,
|
|
3600
|
+
createLamportClock,
|
|
3601
|
+
createLocalCapabilities,
|
|
3602
|
+
createPersistedDocState,
|
|
3603
|
+
createReactIntegrityMonitor,
|
|
3604
|
+
createSecurityPolicy,
|
|
3605
|
+
createSerializerRegistry,
|
|
3606
|
+
createTestContext,
|
|
3607
|
+
createUnsignedChange,
|
|
3608
|
+
createUnsignedYjsChange,
|
|
3609
|
+
createVersionedHandler,
|
|
3610
|
+
createYjsChange,
|
|
3611
|
+
decryptYjsState,
|
|
3612
|
+
defaultNegotiator,
|
|
3613
|
+
deserializeClientIdAttestation,
|
|
3614
|
+
deserializeEncryptedYjsState,
|
|
3615
|
+
deserializeYjsEnvelope,
|
|
3616
|
+
detectFork,
|
|
3617
|
+
diffFeatures,
|
|
3618
|
+
encryptYjsState,
|
|
3619
|
+
envelopeSize,
|
|
3620
|
+
findCommonAncestor,
|
|
3621
|
+
findHeads,
|
|
3622
|
+
findOrphans,
|
|
3623
|
+
findRoots,
|
|
3624
|
+
formatDeprecationReport,
|
|
3625
|
+
formatIntegrityReport,
|
|
3626
|
+
getAllDependencies,
|
|
3627
|
+
getAncestry,
|
|
3628
|
+
getChainDepth,
|
|
3629
|
+
getChainHeads,
|
|
3630
|
+
getChainRoots,
|
|
3631
|
+
getChangeNodeId,
|
|
3632
|
+
getDefaultSerializer,
|
|
3633
|
+
getDeprecation,
|
|
3634
|
+
getDeprecationsByType,
|
|
3635
|
+
getEnabledFeatures,
|
|
3636
|
+
getFeatureConflicts,
|
|
3637
|
+
getFeatureDependencies,
|
|
3638
|
+
getFeatureVersion,
|
|
3639
|
+
getForks,
|
|
3640
|
+
getOptionalFeatures,
|
|
3641
|
+
getRequiredFeatures,
|
|
3642
|
+
getSecurityLevel,
|
|
3643
|
+
getSerializer,
|
|
3644
|
+
hasSignedEnvelope,
|
|
3645
|
+
hashYjsState,
|
|
3646
|
+
intersectFeatures,
|
|
3647
|
+
isAfter,
|
|
3648
|
+
isBefore,
|
|
3649
|
+
isCriticalOperation,
|
|
3650
|
+
isDeprecated,
|
|
3651
|
+
isDocumentTooLarge,
|
|
3652
|
+
isEphemeralOperation,
|
|
3653
|
+
isFeatureAvailable,
|
|
3654
|
+
isFeatureEnabled,
|
|
3655
|
+
isLegacyUpdate,
|
|
3656
|
+
isNodeChange,
|
|
3657
|
+
isRemoved,
|
|
3658
|
+
isUpdateTooLarge,
|
|
3659
|
+
isV1Attestation,
|
|
3660
|
+
isV1Envelope,
|
|
3661
|
+
isV2Attestation,
|
|
3662
|
+
isV2Envelope,
|
|
3663
|
+
isYjsChange,
|
|
3664
|
+
loadVerifiedState,
|
|
3665
|
+
logDeprecation,
|
|
3666
|
+
maxTime,
|
|
3667
|
+
mergeSecurityPolicies,
|
|
3668
|
+
parseCapabilities,
|
|
3669
|
+
parseTimestamp,
|
|
3670
|
+
quickIntegrityCheck,
|
|
3671
|
+
reassembleChunks,
|
|
3672
|
+
receive,
|
|
3673
|
+
registerDeprecation,
|
|
3674
|
+
serializeClientIdAttestation,
|
|
3675
|
+
serializeEncryptedYjsState,
|
|
3676
|
+
serializeTimestamp,
|
|
3677
|
+
serializeYjsEnvelope,
|
|
3678
|
+
serializerRegistry,
|
|
3679
|
+
shouldCompact,
|
|
3680
|
+
signChange,
|
|
3681
|
+
signYjsUpdate,
|
|
3682
|
+
signYjsUpdateBatch,
|
|
3683
|
+
signYjsUpdateV1,
|
|
3684
|
+
signYjsUpdateV2,
|
|
3685
|
+
tick,
|
|
3686
|
+
toEncryptedData,
|
|
3687
|
+
topologicalSort,
|
|
3688
|
+
v1Serializer,
|
|
3689
|
+
v2Serializer,
|
|
3690
|
+
validateChain,
|
|
3691
|
+
validateClientIdOwnership,
|
|
3692
|
+
validateFeatureSet,
|
|
3693
|
+
verifyChange,
|
|
3694
|
+
verifyChangeHash,
|
|
3695
|
+
verifyClientIdAttestation,
|
|
3696
|
+
verifyClientIdAttestationV1,
|
|
3697
|
+
verifyClientIdAttestationV2,
|
|
3698
|
+
verifyIntegrity,
|
|
3699
|
+
verifyPersistedDocState,
|
|
3700
|
+
verifySingleChange,
|
|
3701
|
+
verifyYjsEnvelope,
|
|
3702
|
+
verifyYjsEnvelopeQuick,
|
|
3703
|
+
verifyYjsEnvelopeV1,
|
|
3704
|
+
verifyYjsEnvelopeV2,
|
|
3705
|
+
verifyYjsStateIntegrity
|
|
3706
|
+
};
|