dignity.js 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +197 -0
- package/dist/dignity.cjs.js +3585 -0
- package/dist/dignity.cjs.js.map +7 -0
- package/dist/dignity.esm.js +3606 -0
- package/dist/dignity.esm.js.map +7 -0
- package/dist/dignity.min.js +1 -0
- package/docs/assets/dignity-logo.svg +54 -0
- package/docs/assets/styles.css +68 -0
- package/docs/index.html +117 -0
- package/docs/openapi-like.json +64 -0
- package/examples/decentralized-chess-lite.js +102 -0
- package/examples/decentralized-tictactoe.js +112 -0
- package/package.json +53 -0
- package/src/core/dignity-p2p.js +604 -0
- package/src/index.js +41 -0
- package/src/network/in-memory-network.js +77 -0
- package/src/security/message-security-service.js +462 -0
- package/src/security/sloth-vdf.js +83 -0
- package/src/security/vdf.js +23 -0
- package/src/signaling/create-default-signaling-pool.js +44 -0
- package/src/signaling/default-signaling-config.js +15 -0
- package/src/signaling/signaling-pool.js +75 -0
- package/src/signaling/websocket-signaling-provider.js +69 -0
- package/src/utils/event-emitter.js +38 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
const EventEmitter = require('../utils/event-emitter');
|
|
2
|
+
const { MessageSecurityService } = require('../security/message-security-service');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Core node API for replicated object collections.
|
|
6
|
+
*
|
|
7
|
+
* Interface shape is intentionally REST-like:
|
|
8
|
+
* - create(collection, data)
|
|
9
|
+
* - read(collection, id)
|
|
10
|
+
* - list(collection)
|
|
11
|
+
* - update(collection, id, patch)
|
|
12
|
+
* - remove(collection, id)
|
|
13
|
+
*
|
|
14
|
+
* Authorization model:
|
|
15
|
+
* - object creator is the owner
|
|
16
|
+
* - only owner can update or delete
|
|
17
|
+
*/
|
|
18
|
+
class DignityP2P extends EventEmitter {
|
|
19
|
+
constructor({ nodeId, networkAdapter, idGenerator, now, security } = {}) {
|
|
20
|
+
super();
|
|
21
|
+
|
|
22
|
+
if (!nodeId) {
|
|
23
|
+
throw new Error('DignityP2P requires nodeId');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!networkAdapter) {
|
|
27
|
+
throw new Error('DignityP2P requires networkAdapter');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
this.nodeId = nodeId;
|
|
31
|
+
this.networkAdapter = networkAdapter;
|
|
32
|
+
this.idGenerator = idGenerator || (() => `${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
33
|
+
this.now = now || (() => Date.now());
|
|
34
|
+
this.securityService = new MessageSecurityService({
|
|
35
|
+
nodeId: this.nodeId,
|
|
36
|
+
options: security || {},
|
|
37
|
+
now: this.now
|
|
38
|
+
});
|
|
39
|
+
this.bannedPeers = new Map();
|
|
40
|
+
this.peerBanDurationMs = security && typeof security.banDurationMs === 'number'
|
|
41
|
+
? security.banDurationMs
|
|
42
|
+
: 48 * 60 * 60 * 1000;
|
|
43
|
+
this.resolveBroadcastScope = security && typeof security.resolveBroadcastScope === 'function'
|
|
44
|
+
? security.resolveBroadcastScope
|
|
45
|
+
: (() => 'default');
|
|
46
|
+
this.defaultDiscoveryHeartbeatMs = security && typeof security.discoveryHeartbeatMs === 'number'
|
|
47
|
+
? security.discoveryHeartbeatMs
|
|
48
|
+
: 15000;
|
|
49
|
+
this.defaultPresenceTtlMs = security && typeof security.presenceTtlMs === 'number'
|
|
50
|
+
? security.presenceTtlMs
|
|
51
|
+
: 45000;
|
|
52
|
+
this.discoveryRooms = new Map(); // scope -> { metadata, heartbeatIntervalMs, ttlMs, timer }
|
|
53
|
+
this.presenceByScope = new Map(); // scope -> Map(peerId -> presence)
|
|
54
|
+
|
|
55
|
+
this.state = new Map(); // collection -> Map(id -> record)
|
|
56
|
+
this.appliedOperations = new Set();
|
|
57
|
+
this.boundMessageHandler = this.handleIncomingMessage.bind(this);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async start() {
|
|
61
|
+
this.networkAdapter.onMessage(this.boundMessageHandler);
|
|
62
|
+
await this.networkAdapter.start(this.nodeId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async stop() {
|
|
66
|
+
const joinedScopes = Array.from(this.discoveryRooms.keys());
|
|
67
|
+
for (const scope of joinedScopes) {
|
|
68
|
+
// Best effort leave announce; do not fail node shutdown if network is interrupted.
|
|
69
|
+
try {
|
|
70
|
+
await this.leaveDiscovery(scope);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
this.emit('warning', { type: 'presence-leave-failed', scope, error });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
this.networkAdapter.offMessage(this.boundMessageHandler);
|
|
77
|
+
await this.networkAdapter.stop();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getCollection(collectionName) {
|
|
81
|
+
if (!collectionName) {
|
|
82
|
+
throw new Error('collectionName is required');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!this.state.has(collectionName)) {
|
|
86
|
+
this.state.set(collectionName, new Map());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return this.state.get(collectionName);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
normalizeRecord(record) {
|
|
93
|
+
if (!record || record.deletedAt) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
id: record.id,
|
|
99
|
+
ownerId: record.ownerId,
|
|
100
|
+
createdAt: record.createdAt,
|
|
101
|
+
updatedAt: record.updatedAt,
|
|
102
|
+
version: record.version,
|
|
103
|
+
data: { ...record.data }
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async create(collectionName, data, options = {}) {
|
|
108
|
+
const collection = this.getCollection(collectionName);
|
|
109
|
+
const id = options.id || this.idGenerator();
|
|
110
|
+
|
|
111
|
+
if (collection.has(id) && !collection.get(id).deletedAt) {
|
|
112
|
+
throw new Error(`Object ${id} already exists in ${collectionName}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const timestamp = this.now();
|
|
116
|
+
const operation = {
|
|
117
|
+
opId: this.idGenerator(),
|
|
118
|
+
kind: 'create',
|
|
119
|
+
collectionName,
|
|
120
|
+
id,
|
|
121
|
+
actorId: this.nodeId,
|
|
122
|
+
ownerId: this.nodeId,
|
|
123
|
+
timestamp,
|
|
124
|
+
payload: { ...data }
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
this.applyOperation(operation);
|
|
128
|
+
await this.broadcastMessage('operation', operation, {
|
|
129
|
+
broadcastScope: options.broadcastScope || this.resolveBroadcastScope({
|
|
130
|
+
messageType: 'operation',
|
|
131
|
+
operation,
|
|
132
|
+
collectionName
|
|
133
|
+
})
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return this.read(collectionName, id);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
read(collectionName, id) {
|
|
140
|
+
const collection = this.getCollection(collectionName);
|
|
141
|
+
return this.normalizeRecord(collection.get(id));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
list(collectionName, options = {}) {
|
|
145
|
+
const collection = this.getCollection(collectionName);
|
|
146
|
+
const includeDeleted = options.includeDeleted || false;
|
|
147
|
+
|
|
148
|
+
const records = [];
|
|
149
|
+
for (const record of collection.values()) {
|
|
150
|
+
if (record.deletedAt && !includeDeleted) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (record.deletedAt && includeDeleted) {
|
|
155
|
+
records.push({
|
|
156
|
+
id: record.id,
|
|
157
|
+
ownerId: record.ownerId,
|
|
158
|
+
deletedAt: record.deletedAt,
|
|
159
|
+
version: record.version
|
|
160
|
+
});
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
records.push(this.normalizeRecord(record));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return records;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async update(collectionName, id, partialData, options = {}) {
|
|
171
|
+
const existing = this.getCollection(collectionName).get(id);
|
|
172
|
+
|
|
173
|
+
if (!existing || existing.deletedAt) {
|
|
174
|
+
throw new Error(`Object ${id} does not exist in ${collectionName}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (existing.ownerId !== this.nodeId) {
|
|
178
|
+
throw new Error(`Only owner ${existing.ownerId} can update object ${id}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const operation = {
|
|
182
|
+
opId: this.idGenerator(),
|
|
183
|
+
kind: 'update',
|
|
184
|
+
collectionName,
|
|
185
|
+
id,
|
|
186
|
+
actorId: this.nodeId,
|
|
187
|
+
timestamp: this.now(),
|
|
188
|
+
baseVersion: existing.version,
|
|
189
|
+
payload: { ...partialData }
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
this.applyOperation(operation);
|
|
193
|
+
await this.broadcastMessage('operation', operation, {
|
|
194
|
+
broadcastScope: options.broadcastScope || this.resolveBroadcastScope({
|
|
195
|
+
messageType: 'operation',
|
|
196
|
+
operation,
|
|
197
|
+
collectionName
|
|
198
|
+
})
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return this.read(collectionName, id);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async remove(collectionName, id, options = {}) {
|
|
205
|
+
const existing = this.getCollection(collectionName).get(id);
|
|
206
|
+
|
|
207
|
+
if (!existing || existing.deletedAt) {
|
|
208
|
+
throw new Error(`Object ${id} does not exist in ${collectionName}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (existing.ownerId !== this.nodeId) {
|
|
212
|
+
throw new Error(`Only owner ${existing.ownerId} can delete object ${id}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const operation = {
|
|
216
|
+
opId: this.idGenerator(),
|
|
217
|
+
kind: 'delete',
|
|
218
|
+
collectionName,
|
|
219
|
+
id,
|
|
220
|
+
actorId: this.nodeId,
|
|
221
|
+
timestamp: this.now(),
|
|
222
|
+
baseVersion: existing.version
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
this.applyOperation(operation);
|
|
226
|
+
await this.broadcastMessage('operation', operation, {
|
|
227
|
+
broadcastScope: options.broadcastScope || this.resolveBroadcastScope({
|
|
228
|
+
messageType: 'operation',
|
|
229
|
+
operation,
|
|
230
|
+
collectionName
|
|
231
|
+
})
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
registerPeerPublicKey(peerId, publicKey) {
|
|
236
|
+
this.securityService.registerPeerPublicKey(peerId, publicKey);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
getPublicKey() {
|
|
240
|
+
return this.securityService.getPublicKey();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async broadcastMessage(messageType, payload, securityContext = {}) {
|
|
244
|
+
const envelope = await this.securityService.secureOutgoingMessage({
|
|
245
|
+
messageType,
|
|
246
|
+
payload,
|
|
247
|
+
targetId: null,
|
|
248
|
+
securityContext
|
|
249
|
+
});
|
|
250
|
+
await this.networkAdapter.broadcast(envelope);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async sendDirectMessage(targetId, messageType, payload) {
|
|
254
|
+
const envelope = await this.securityService.secureOutgoingMessage({
|
|
255
|
+
messageType,
|
|
256
|
+
payload,
|
|
257
|
+
targetId
|
|
258
|
+
});
|
|
259
|
+
await this.networkAdapter.broadcast(envelope);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
getPresenceMap(scope) {
|
|
263
|
+
if (!this.presenceByScope.has(scope)) {
|
|
264
|
+
this.presenceByScope.set(scope, new Map());
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return this.presenceByScope.get(scope);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
upsertPresence(scope, peerId, metadata, ttlMs, announcedAt) {
|
|
271
|
+
const map = this.getPresenceMap(scope);
|
|
272
|
+
const existing = map.get(peerId);
|
|
273
|
+
const next = {
|
|
274
|
+
peerId,
|
|
275
|
+
scope,
|
|
276
|
+
metadata: metadata ? { ...metadata } : {},
|
|
277
|
+
lastSeenAt: announcedAt,
|
|
278
|
+
expiresAt: announcedAt + ttlMs
|
|
279
|
+
};
|
|
280
|
+
map.set(peerId, next);
|
|
281
|
+
|
|
282
|
+
if (!existing) {
|
|
283
|
+
this.emit('peerdiscovered', { scope, peerId, metadata: next.metadata });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return next;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
prunePresence(scope) {
|
|
290
|
+
const map = this.presenceByScope.get(scope);
|
|
291
|
+
if (!map) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const now = this.now();
|
|
296
|
+
for (const [peerId, entry] of map.entries()) {
|
|
297
|
+
if (entry.expiresAt <= now) {
|
|
298
|
+
map.delete(peerId);
|
|
299
|
+
this.emit('peerleft', { scope, peerId, reason: 'timeout' });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async joinDiscovery(scope = 'main', options = {}) {
|
|
305
|
+
const normalizedScope = scope || 'main';
|
|
306
|
+
const heartbeatIntervalMs = options.heartbeatIntervalMs || this.defaultDiscoveryHeartbeatMs;
|
|
307
|
+
const ttlMs = options.ttlMs || this.defaultPresenceTtlMs;
|
|
308
|
+
const metadata = options.metadata || {};
|
|
309
|
+
|
|
310
|
+
const existing = this.discoveryRooms.get(normalizedScope);
|
|
311
|
+
if (existing && existing.timer) {
|
|
312
|
+
clearInterval(existing.timer);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const timer = setInterval(() => {
|
|
316
|
+
this.announcePresence(normalizedScope).catch((error) => {
|
|
317
|
+
this.emit('warning', { type: 'presence-heartbeat-failed', scope: normalizedScope, error });
|
|
318
|
+
});
|
|
319
|
+
}, heartbeatIntervalMs);
|
|
320
|
+
|
|
321
|
+
this.discoveryRooms.set(normalizedScope, {
|
|
322
|
+
metadata,
|
|
323
|
+
heartbeatIntervalMs,
|
|
324
|
+
ttlMs,
|
|
325
|
+
timer
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
this.upsertPresence(normalizedScope, this.nodeId, metadata, ttlMs, this.now());
|
|
329
|
+
await this.announcePresence(normalizedScope);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async announcePresence(scope = 'main', metadataOverride = null) {
|
|
333
|
+
const normalizedScope = scope || 'main';
|
|
334
|
+
const room = this.discoveryRooms.get(normalizedScope);
|
|
335
|
+
if (!room) {
|
|
336
|
+
throw new Error(`Scope ${normalizedScope} has not been joined for discovery`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const metadata = metadataOverride || room.metadata || {};
|
|
340
|
+
const announcedAt = this.now();
|
|
341
|
+
this.upsertPresence(normalizedScope, this.nodeId, metadata, room.ttlMs, announcedAt);
|
|
342
|
+
|
|
343
|
+
await this.broadcastMessage(
|
|
344
|
+
'presence:announce',
|
|
345
|
+
{
|
|
346
|
+
scope: normalizedScope,
|
|
347
|
+
peerId: this.nodeId,
|
|
348
|
+
metadata,
|
|
349
|
+
ttlMs: room.ttlMs,
|
|
350
|
+
announcedAt
|
|
351
|
+
},
|
|
352
|
+
{ broadcastScope: normalizedScope }
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async leaveDiscovery(scope = 'main') {
|
|
357
|
+
const normalizedScope = scope || 'main';
|
|
358
|
+
const room = this.discoveryRooms.get(normalizedScope);
|
|
359
|
+
if (!room) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (room.timer) {
|
|
364
|
+
clearInterval(room.timer);
|
|
365
|
+
}
|
|
366
|
+
this.discoveryRooms.delete(normalizedScope);
|
|
367
|
+
|
|
368
|
+
const map = this.presenceByScope.get(normalizedScope);
|
|
369
|
+
if (map) {
|
|
370
|
+
map.delete(this.nodeId);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
await this.broadcastMessage(
|
|
374
|
+
'presence:leave',
|
|
375
|
+
{
|
|
376
|
+
scope: normalizedScope,
|
|
377
|
+
peerId: this.nodeId,
|
|
378
|
+
leftAt: this.now()
|
|
379
|
+
},
|
|
380
|
+
{ broadcastScope: normalizedScope }
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
listPeers(scope = 'main', options = {}) {
|
|
385
|
+
const normalizedScope = scope || 'main';
|
|
386
|
+
const includeSelf = options.includeSelf !== false;
|
|
387
|
+
this.prunePresence(normalizedScope);
|
|
388
|
+
|
|
389
|
+
const map = this.presenceByScope.get(normalizedScope);
|
|
390
|
+
if (!map) {
|
|
391
|
+
return [];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return Array.from(map.values())
|
|
395
|
+
.filter((entry) => includeSelf || entry.peerId !== this.nodeId)
|
|
396
|
+
.map((entry) => ({
|
|
397
|
+
peerId: entry.peerId,
|
|
398
|
+
scope: entry.scope,
|
|
399
|
+
metadata: { ...entry.metadata },
|
|
400
|
+
lastSeenAt: entry.lastSeenAt,
|
|
401
|
+
expiresAt: entry.expiresAt
|
|
402
|
+
}));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async handleIncomingMessage(message) {
|
|
406
|
+
// Backward compatibility for raw operation payloads
|
|
407
|
+
if (message && message.opId && message.kind) {
|
|
408
|
+
this.applyOperation(message);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (message && message.senderId && this.isPeerBanned(message.senderId)) {
|
|
413
|
+
this.emit('messageignored', {
|
|
414
|
+
senderId: message.senderId,
|
|
415
|
+
reason: 'peer-banned'
|
|
416
|
+
});
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let decrypted;
|
|
421
|
+
try {
|
|
422
|
+
decrypted = await this.securityService.decryptIncomingMessage(message);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
const senderId = message ? message.senderId : null;
|
|
425
|
+
if (senderId && (error.code === 'INVALID_SIGNATURE' || error.code === 'INVALID_POW')) {
|
|
426
|
+
this.banPeer(senderId, this.peerBanDurationMs, error.code);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
this.emit('securityerror', {
|
|
430
|
+
senderId,
|
|
431
|
+
error
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (!decrypted || decrypted.ignored) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (decrypted.messageType === 'operation') {
|
|
441
|
+
this.applyOperation(decrypted.payload);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (decrypted.messageType === 'presence:announce') {
|
|
446
|
+
const payload = decrypted.payload || {};
|
|
447
|
+
const scope = payload.scope || 'main';
|
|
448
|
+
const peerId = payload.peerId || decrypted.senderId;
|
|
449
|
+
if (!peerId) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const presenceMap = this.getPresenceMap(scope);
|
|
454
|
+
const isNewPeerInScope = !presenceMap.has(peerId);
|
|
455
|
+
|
|
456
|
+
this.upsertPresence(
|
|
457
|
+
scope,
|
|
458
|
+
peerId,
|
|
459
|
+
payload.metadata || {},
|
|
460
|
+
payload.ttlMs || this.defaultPresenceTtlMs,
|
|
461
|
+
payload.announcedAt || this.now()
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
// Discovery handshake: when a new peer appears in a joined scope,
|
|
465
|
+
// send our current presence so late joiners quickly converge.
|
|
466
|
+
if (isNewPeerInScope && peerId !== this.nodeId && this.discoveryRooms.has(scope)) {
|
|
467
|
+
this.announcePresence(scope).catch((error) => {
|
|
468
|
+
this.emit('warning', { type: 'presence-handshake-failed', scope, error });
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (decrypted.messageType === 'presence:leave') {
|
|
475
|
+
const payload = decrypted.payload || {};
|
|
476
|
+
const scope = payload.scope || 'main';
|
|
477
|
+
const peerId = payload.peerId || decrypted.senderId;
|
|
478
|
+
const map = this.presenceByScope.get(scope);
|
|
479
|
+
if (map && peerId && map.has(peerId)) {
|
|
480
|
+
map.delete(peerId);
|
|
481
|
+
this.emit('peerleft', { scope, peerId, reason: 'leave' });
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
this.emit('message', {
|
|
487
|
+
senderId: decrypted.senderId,
|
|
488
|
+
targetId: decrypted.targetId,
|
|
489
|
+
type: decrypted.messageType,
|
|
490
|
+
payload: decrypted.payload
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
banPeer(peerId, durationMs = this.peerBanDurationMs, reason = 'manual') {
|
|
495
|
+
if (!peerId) {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const bannedUntil = this.now() + Math.max(1, durationMs);
|
|
500
|
+
this.bannedPeers.set(peerId, {
|
|
501
|
+
peerId,
|
|
502
|
+
reason,
|
|
503
|
+
bannedAt: this.now(),
|
|
504
|
+
bannedUntil
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
this.emit('peerbanned', {
|
|
508
|
+
peerId,
|
|
509
|
+
reason,
|
|
510
|
+
bannedUntil
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
unbanPeer(peerId) {
|
|
515
|
+
this.bannedPeers.delete(peerId);
|
|
516
|
+
this.emit('peerunbanned', { peerId });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
getBanInfo(peerId) {
|
|
520
|
+
const info = this.bannedPeers.get(peerId);
|
|
521
|
+
if (!info) {
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (info.bannedUntil <= this.now()) {
|
|
526
|
+
this.bannedPeers.delete(peerId);
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return { ...info };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
isPeerBanned(peerId) {
|
|
534
|
+
return this.getBanInfo(peerId) !== null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
applyOperation(operation) {
|
|
538
|
+
if (!operation || !operation.opId || this.appliedOperations.has(operation.opId)) {
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const collection = this.getCollection(operation.collectionName);
|
|
543
|
+
const current = collection.get(operation.id);
|
|
544
|
+
|
|
545
|
+
if (operation.kind === 'create') {
|
|
546
|
+
if (current && !current.deletedAt) {
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
collection.set(operation.id, {
|
|
551
|
+
id: operation.id,
|
|
552
|
+
ownerId: operation.ownerId,
|
|
553
|
+
data: { ...operation.payload },
|
|
554
|
+
createdAt: operation.timestamp,
|
|
555
|
+
updatedAt: operation.timestamp,
|
|
556
|
+
deletedAt: null,
|
|
557
|
+
version: 1
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
this.appliedOperations.add(operation.opId);
|
|
561
|
+
this.emit('change', { kind: 'create', collection: operation.collectionName, id: operation.id });
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (!current || current.deletedAt) {
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (operation.actorId !== current.ownerId) {
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (typeof operation.baseVersion === 'number' && operation.baseVersion !== current.version) {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (operation.kind === 'update') {
|
|
578
|
+
current.data = {
|
|
579
|
+
...current.data,
|
|
580
|
+
...operation.payload
|
|
581
|
+
};
|
|
582
|
+
current.updatedAt = operation.timestamp;
|
|
583
|
+
current.version += 1;
|
|
584
|
+
|
|
585
|
+
this.appliedOperations.add(operation.opId);
|
|
586
|
+
this.emit('change', { kind: 'update', collection: operation.collectionName, id: operation.id });
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (operation.kind === 'delete') {
|
|
591
|
+
current.deletedAt = operation.timestamp;
|
|
592
|
+
current.updatedAt = operation.timestamp;
|
|
593
|
+
current.version += 1;
|
|
594
|
+
|
|
595
|
+
this.appliedOperations.add(operation.opId);
|
|
596
|
+
this.emit('change', { kind: 'delete', collection: operation.collectionName, id: operation.id });
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
module.exports = DignityP2P;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dignity.js public API.
|
|
3
|
+
*
|
|
4
|
+
* This package exposes:
|
|
5
|
+
* - `DignityP2P`: REST-like object CRUD over peer-to-peer operation replication
|
|
6
|
+
* - signaling providers and pool helpers
|
|
7
|
+
* - in-memory adapter utilities for tests and local prototyping
|
|
8
|
+
*/
|
|
9
|
+
const DignityP2P = require('./core/dignity-p2p');
|
|
10
|
+
const createDefaultSignalingPool = require('./signaling/create-default-signaling-pool');
|
|
11
|
+
const SignalingPool = require('./signaling/signaling-pool');
|
|
12
|
+
const WebSocketSignalingProvider = require('./signaling/websocket-signaling-provider');
|
|
13
|
+
const {
|
|
14
|
+
InMemoryNetworkHub,
|
|
15
|
+
InMemoryNetworkAdapter
|
|
16
|
+
} = require('./network/in-memory-network');
|
|
17
|
+
const {
|
|
18
|
+
DEFAULT_CLOUDFLARE_SIGNALING_URLS,
|
|
19
|
+
DEFAULT_SIGNALING_FALLBACK_URLS
|
|
20
|
+
} = require('./signaling/default-signaling-config');
|
|
21
|
+
const VDF = require('./security/vdf');
|
|
22
|
+
const SlothPermutation = require('./security/sloth-vdf');
|
|
23
|
+
const {
|
|
24
|
+
MessageSecurityService,
|
|
25
|
+
DEFAULT_SECURITY_OPTIONS
|
|
26
|
+
} = require('./security/message-security-service');
|
|
27
|
+
|
|
28
|
+
module.exports = {
|
|
29
|
+
DignityP2P,
|
|
30
|
+
createDefaultSignalingPool,
|
|
31
|
+
SignalingPool,
|
|
32
|
+
WebSocketSignalingProvider,
|
|
33
|
+
InMemoryNetworkHub,
|
|
34
|
+
InMemoryNetworkAdapter,
|
|
35
|
+
DEFAULT_CLOUDFLARE_SIGNALING_URLS,
|
|
36
|
+
DEFAULT_SIGNALING_FALLBACK_URLS,
|
|
37
|
+
VDF,
|
|
38
|
+
SlothPermutation,
|
|
39
|
+
MessageSecurityService,
|
|
40
|
+
DEFAULT_SECURITY_OPTIONS
|
|
41
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
class InMemoryNetworkHub {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.adapters = new Map();
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
register(adapter) {
|
|
7
|
+
this.adapters.set(adapter.nodeId, adapter);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
unregister(nodeId) {
|
|
11
|
+
this.adapters.delete(nodeId);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async broadcast(senderId, message) {
|
|
15
|
+
const deliveries = [];
|
|
16
|
+
for (const [nodeId, adapter] of this.adapters.entries()) {
|
|
17
|
+
if (nodeId !== senderId) {
|
|
18
|
+
deliveries.push(adapter.receive(message));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
await Promise.all(deliveries);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class InMemoryNetworkAdapter {
|
|
26
|
+
constructor(hub) {
|
|
27
|
+
if (!hub) {
|
|
28
|
+
throw new Error('InMemoryNetworkAdapter requires an InMemoryNetworkHub');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.hub = hub;
|
|
32
|
+
this.nodeId = null;
|
|
33
|
+
this.messageHandlers = new Set();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async start(nodeId) {
|
|
37
|
+
this.nodeId = nodeId;
|
|
38
|
+
this.hub.register(this);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async stop() {
|
|
42
|
+
if (this.nodeId) {
|
|
43
|
+
this.hub.unregister(this.nodeId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.nodeId = null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async broadcast(message) {
|
|
50
|
+
if (!this.nodeId) {
|
|
51
|
+
throw new Error('Network adapter has not been started');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await this.hub.broadcast(this.nodeId, message);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
onMessage(handler) {
|
|
58
|
+
this.messageHandlers.add(handler);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
offMessage(handler) {
|
|
62
|
+
this.messageHandlers.delete(handler);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async receive(message) {
|
|
66
|
+
const deliveries = [];
|
|
67
|
+
for (const handler of this.messageHandlers) {
|
|
68
|
+
deliveries.push(handler(message));
|
|
69
|
+
}
|
|
70
|
+
await Promise.all(deliveries);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
InMemoryNetworkHub,
|
|
76
|
+
InMemoryNetworkAdapter
|
|
77
|
+
};
|