dignity.js 0.5.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -3
- package/dist/dignity.cjs.js +603 -65
- package/dist/dignity.cjs.js.map +4 -4
- package/dist/dignity.esm.js +603 -65
- package/dist/dignity.esm.js.map +3 -3
- package/dist/dignity.min.js +27 -27
- package/docs/assets/dignity.esm.js +477 -51
- package/docs/index.html +346 -6
- package/docs/openapi-like.json +40 -5
- package/examples/decentralized-chess-lite.js +9 -0
- package/package.json +2 -1
- package/src/core/dignity-p2p.js +506 -20
- package/src/gossip/peer-group.js +64 -0
- package/src/index.js +13 -1
- package/src/network/in-memory-network.js +42 -0
- package/src/network/peerjs-network.js +28 -0
- package/src/persistence/indexeddb-persistence.js +2 -0
- package/src/security/message-security-service.js +10 -2
- package/src/security/sloth-vdf.js +11 -5
- package/src/signaling/websocket-signaling-provider.js +11 -2
package/src/core/dignity-p2p.js
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
|
+
const nacl = require('tweetnacl');
|
|
2
|
+
const naclUtil = require('tweetnacl-util');
|
|
1
3
|
const EventEmitter = require('../utils/event-emitter');
|
|
2
|
-
const { MessageSecurityService } = require('../security/message-security-service');
|
|
4
|
+
const { MessageSecurityService, stableStringify } = require('../security/message-security-service');
|
|
5
|
+
const {
|
|
6
|
+
DEFAULT_PEER_GROUP_OPTIONS,
|
|
7
|
+
peerGroupScope,
|
|
8
|
+
selectFanoutPeers
|
|
9
|
+
} = require('../gossip/peer-group');
|
|
10
|
+
|
|
11
|
+
function computeContentHash(data) {
|
|
12
|
+
const canonical = stableStringify(data || {});
|
|
13
|
+
const bytes = naclUtil.decodeUTF8(canonical);
|
|
14
|
+
const hash = nacl.hash(bytes);
|
|
15
|
+
const hex = Array.from(hash, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
16
|
+
return `sha512:${hex}`;
|
|
17
|
+
}
|
|
3
18
|
|
|
4
19
|
/**
|
|
5
20
|
* Core node API for replicated object collections.
|
|
@@ -17,6 +32,7 @@ const { MessageSecurityService } = require('../security/message-security-service
|
|
|
17
32
|
* - broadcastMessage(type, payload, { connectToPeers, broadcastScope })
|
|
18
33
|
* - pushRecordSnapshot(collection, id, options) — full record sync for late joiners
|
|
19
34
|
* - getRecordPeerIds(collection, id) — owner + collaborators for connectToPeers
|
|
35
|
+
* - joinPeerGroup(groupId) / publishToPeerGroup(groupId, type, payload) — scalable gossip PubSub
|
|
20
36
|
*
|
|
21
37
|
* Authorization model:
|
|
22
38
|
* - object creator is the owner
|
|
@@ -58,9 +74,29 @@ class DignityP2P extends EventEmitter {
|
|
|
58
74
|
: 45000;
|
|
59
75
|
this.discoveryRooms = new Map(); // scope -> { metadata, heartbeatIntervalMs, ttlMs, timer }
|
|
60
76
|
this.presenceByScope = new Map(); // scope -> Map(peerId -> presence)
|
|
77
|
+
this.peerGroups = new Map(); // groupId -> PeerGroup config
|
|
78
|
+
this.seenGossipIds = new Map(); // gossipId -> expiresAt
|
|
79
|
+
this.defaultPeerGroupFanout = security && typeof security.peerGroupFanout === 'number'
|
|
80
|
+
? security.peerGroupFanout
|
|
81
|
+
: DEFAULT_PEER_GROUP_OPTIONS.fanout;
|
|
82
|
+
this.defaultPeerGroupMaxActivePeers = security && typeof security.peerGroupMaxActivePeers === 'number'
|
|
83
|
+
? security.peerGroupMaxActivePeers
|
|
84
|
+
: DEFAULT_PEER_GROUP_OPTIONS.maxActivePeers;
|
|
85
|
+
this.defaultGossipMaxHops = security && typeof security.gossipMaxHops === 'number'
|
|
86
|
+
? security.gossipMaxHops
|
|
87
|
+
: DEFAULT_PEER_GROUP_OPTIONS.maxHops;
|
|
88
|
+
this.globalMaxOpenConnections = security && typeof security.globalMaxOpenConnections === 'number'
|
|
89
|
+
? security.globalMaxOpenConnections
|
|
90
|
+
: 32;
|
|
91
|
+
this.gossipIdTtlMs = security && typeof security.gossipIdTtlMs === 'number'
|
|
92
|
+
? security.gossipIdTtlMs
|
|
93
|
+
: 5 * 60 * 1000;
|
|
94
|
+
this.maxAppliedOperations = security && typeof security.maxAppliedOperations === 'number'
|
|
95
|
+
? security.maxAppliedOperations
|
|
96
|
+
: 50000;
|
|
61
97
|
|
|
62
98
|
this.state = new Map(); // collection -> Map(id -> record)
|
|
63
|
-
this.appliedOperations = new
|
|
99
|
+
this.appliedOperations = new Map(); // opId -> appliedAt
|
|
64
100
|
this.boundMessageHandler = this.handleIncomingMessage.bind(this);
|
|
65
101
|
}
|
|
66
102
|
|
|
@@ -70,6 +106,15 @@ class DignityP2P extends EventEmitter {
|
|
|
70
106
|
}
|
|
71
107
|
|
|
72
108
|
async stop() {
|
|
109
|
+
const joinedGroups = Array.from(this.peerGroups.keys());
|
|
110
|
+
for (const groupId of joinedGroups) {
|
|
111
|
+
try {
|
|
112
|
+
await this.leavePeerGroup(groupId);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
this.emit('warning', { type: 'peer-group-leave-failed', groupId, error });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
73
118
|
const joinedScopes = Array.from(this.discoveryRooms.keys());
|
|
74
119
|
for (const scope of joinedScopes) {
|
|
75
120
|
// Best effort leave announce; do not fail node shutdown if network is interrupted.
|
|
@@ -101,6 +146,8 @@ class DignityP2P extends EventEmitter {
|
|
|
101
146
|
return null;
|
|
102
147
|
}
|
|
103
148
|
|
|
149
|
+
const normalizedData = { ...(record.data || {}) };
|
|
150
|
+
|
|
104
151
|
return {
|
|
105
152
|
id: record.id,
|
|
106
153
|
ownerId: record.ownerId,
|
|
@@ -108,7 +155,8 @@ class DignityP2P extends EventEmitter {
|
|
|
108
155
|
createdAt: record.createdAt,
|
|
109
156
|
updatedAt: record.updatedAt,
|
|
110
157
|
version: record.version,
|
|
111
|
-
|
|
158
|
+
hash: record.hash || computeContentHash(normalizedData),
|
|
159
|
+
data: normalizedData
|
|
112
160
|
};
|
|
113
161
|
}
|
|
114
162
|
|
|
@@ -204,7 +252,7 @@ class DignityP2P extends EventEmitter {
|
|
|
204
252
|
ownerId: this.nodeId,
|
|
205
253
|
collaboratorIds,
|
|
206
254
|
timestamp,
|
|
207
|
-
payload: { ...data }
|
|
255
|
+
payload: { ...(data || {}) }
|
|
208
256
|
};
|
|
209
257
|
|
|
210
258
|
this.applyOperation(operation);
|
|
@@ -497,6 +545,7 @@ class DignityP2P extends EventEmitter {
|
|
|
497
545
|
const connectToPeers = securityContext.connectToPeers;
|
|
498
546
|
if (Array.isArray(connectToPeers) && connectToPeers.length > 0) {
|
|
499
547
|
await this.ensureConnectedToPeers(connectToPeers);
|
|
548
|
+
await this.enforceConnectionBudget();
|
|
500
549
|
}
|
|
501
550
|
|
|
502
551
|
const envelope = await this.securityService.secureOutgoingMessage({
|
|
@@ -505,6 +554,17 @@ class DignityP2P extends EventEmitter {
|
|
|
505
554
|
targetId: null,
|
|
506
555
|
securityContext
|
|
507
556
|
});
|
|
557
|
+
|
|
558
|
+
const fanoutPeerIds = securityContext.fanoutPeerIds;
|
|
559
|
+
if (
|
|
560
|
+
Array.isArray(fanoutPeerIds)
|
|
561
|
+
&& fanoutPeerIds.length > 0
|
|
562
|
+
&& typeof this.networkAdapter.sendToPeers === 'function'
|
|
563
|
+
) {
|
|
564
|
+
await this.networkAdapter.sendToPeers(envelope, fanoutPeerIds);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
508
568
|
await this.networkAdapter.broadcast(envelope);
|
|
509
569
|
}
|
|
510
570
|
|
|
@@ -522,9 +582,342 @@ class DignityP2P extends EventEmitter {
|
|
|
522
582
|
payload,
|
|
523
583
|
targetId
|
|
524
584
|
});
|
|
585
|
+
|
|
586
|
+
if (targetId && typeof this.networkAdapter.sendToPeers === 'function') {
|
|
587
|
+
await this.networkAdapter.sendToPeers(envelope, [targetId]);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
525
591
|
await this.networkAdapter.broadcast(envelope);
|
|
526
592
|
}
|
|
527
593
|
|
|
594
|
+
peerGroupScopeFor(groupId) {
|
|
595
|
+
return peerGroupScope(groupId);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
getPeerGroupConfig(groupId) {
|
|
599
|
+
return this.peerGroups.get(groupId) || null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
listPeerGroupMembers(groupId, options = {}) {
|
|
603
|
+
return this.listPeers(this.peerGroupScopeFor(groupId), options);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
getPeerGroupStats() {
|
|
607
|
+
const adapter = this.networkAdapter;
|
|
608
|
+
const openPeerIds = typeof adapter.listOpenPeerIds === 'function'
|
|
609
|
+
? adapter.listOpenPeerIds()
|
|
610
|
+
: [];
|
|
611
|
+
|
|
612
|
+
return {
|
|
613
|
+
joinedGroups: Array.from(this.peerGroups.keys()),
|
|
614
|
+
seenGossipCount: this.seenGossipIds.size,
|
|
615
|
+
openConnectionCount: openPeerIds.length,
|
|
616
|
+
globalMaxOpenConnections: this.globalMaxOpenConnections
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
pruneSeenGossip() {
|
|
621
|
+
const now = this.now();
|
|
622
|
+
for (const [gossipId, expiresAt] of this.seenGossipIds.entries()) {
|
|
623
|
+
if (expiresAt <= now) {
|
|
624
|
+
this.seenGossipIds.delete(gossipId);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
hasSeenGossip(gossipId) {
|
|
630
|
+
if (!gossipId) {
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
this.pruneSeenGossip();
|
|
635
|
+
return this.seenGossipIds.has(gossipId);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
markSeenGossip(gossipId) {
|
|
639
|
+
if (!gossipId) {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
this.seenGossipIds.set(gossipId, this.now() + this.gossipIdTtlMs);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
listConnectedPeerIds() {
|
|
647
|
+
if (typeof this.networkAdapter.listOpenPeerIds === 'function') {
|
|
648
|
+
return this.networkAdapter.listOpenPeerIds();
|
|
649
|
+
}
|
|
650
|
+
return [];
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
selectPeerGroupFanout(groupId, count, excludePeerIds = []) {
|
|
654
|
+
const scope = this.peerGroupScopeFor(groupId);
|
|
655
|
+
const peers = this.listPeers(scope, { includeSelf: false });
|
|
656
|
+
return selectFanoutPeers({
|
|
657
|
+
peers,
|
|
658
|
+
count,
|
|
659
|
+
excludePeerIds: [...excludePeerIds, this.nodeId],
|
|
660
|
+
connectedPeerIds: this.listConnectedPeerIds()
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async enforceConnectionBudget() {
|
|
665
|
+
const adapter = this.networkAdapter;
|
|
666
|
+
if (typeof adapter.listOpenPeerIds !== 'function' || typeof adapter.disconnectPeer !== 'function') {
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const openPeerIds = adapter.listOpenPeerIds();
|
|
671
|
+
if (openPeerIds.length <= this.globalMaxOpenConnections) {
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const excess = openPeerIds.length - this.globalMaxOpenConnections;
|
|
676
|
+
const toClose = openPeerIds.slice(0, excess);
|
|
677
|
+
for (const peerId of toClose) {
|
|
678
|
+
try {
|
|
679
|
+
await adapter.disconnectPeer(peerId);
|
|
680
|
+
} catch (error) {
|
|
681
|
+
this.emit('warning', { type: 'peer-disconnect-failed', peerId, error });
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async joinPeerGroup(groupId, options = {}) {
|
|
687
|
+
if (!groupId) {
|
|
688
|
+
throw new Error('joinPeerGroup requires groupId');
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const scope = this.peerGroupScopeFor(groupId);
|
|
692
|
+
const config = {
|
|
693
|
+
fanout: typeof options.fanout === 'number' ? options.fanout : this.defaultPeerGroupFanout,
|
|
694
|
+
maxActivePeers: typeof options.maxActivePeers === 'number'
|
|
695
|
+
? options.maxActivePeers
|
|
696
|
+
: this.defaultPeerGroupMaxActivePeers,
|
|
697
|
+
maxHops: typeof options.maxHops === 'number' ? options.maxHops : this.defaultGossipMaxHops,
|
|
698
|
+
relayEnabled: options.relayEnabled !== false
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
await this.joinDiscovery(scope, {
|
|
702
|
+
metadata: {
|
|
703
|
+
peerGroup: groupId,
|
|
704
|
+
...(options.metadata || {})
|
|
705
|
+
},
|
|
706
|
+
bootstrapPeerIds: options.bootstrapPeerIds,
|
|
707
|
+
heartbeatIntervalMs: options.heartbeatIntervalMs,
|
|
708
|
+
ttlMs: options.ttlMs
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
this.peerGroups.set(groupId, config);
|
|
712
|
+
this.emit('peergroupjoined', { groupId, config });
|
|
713
|
+
return config;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async leavePeerGroup(groupId) {
|
|
717
|
+
if (!groupId) {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const scope = this.peerGroupScopeFor(groupId);
|
|
722
|
+
await this.leaveDiscovery(scope);
|
|
723
|
+
this.peerGroups.delete(groupId);
|
|
724
|
+
this.emit('peergroupleft', { groupId });
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async publishToPeerGroup(groupId, innerMessageType, innerPayload, options = {}) {
|
|
728
|
+
if (!groupId) {
|
|
729
|
+
throw new Error('publishToPeerGroup requires groupId');
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const group = this.peerGroups.get(groupId);
|
|
733
|
+
if (!group && options.allowUnjoined !== true) {
|
|
734
|
+
throw new Error(`PeerGroup ${groupId} has not been joined`);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const fanout = typeof options.fanout === 'number'
|
|
738
|
+
? options.fanout
|
|
739
|
+
: (group ? group.fanout : this.defaultPeerGroupFanout);
|
|
740
|
+
const maxActivePeers = group ? group.maxActivePeers : this.defaultPeerGroupMaxActivePeers;
|
|
741
|
+
const maxHop = typeof options.maxHops === 'number'
|
|
742
|
+
? options.maxHops
|
|
743
|
+
: (group ? group.maxHops : this.defaultGossipMaxHops);
|
|
744
|
+
|
|
745
|
+
const fanoutPeerIds = this.selectPeerGroupFanout(groupId, fanout, [this.nodeId]);
|
|
746
|
+
if (fanoutPeerIds.length > 0) {
|
|
747
|
+
await this.ensureConnectedToPeers(fanoutPeerIds.slice(0, maxActivePeers));
|
|
748
|
+
await this.enforceConnectionBudget();
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const gossipId = options.gossipId || this.idGenerator();
|
|
752
|
+
this.markSeenGossip(gossipId);
|
|
753
|
+
|
|
754
|
+
await this.broadcastMessage('peer-group:gossip', {
|
|
755
|
+
groupId,
|
|
756
|
+
gossipId,
|
|
757
|
+
publisherId: this.nodeId,
|
|
758
|
+
hop: 0,
|
|
759
|
+
maxHop,
|
|
760
|
+
innerMessageType,
|
|
761
|
+
innerPayload
|
|
762
|
+
}, {
|
|
763
|
+
broadcastScope: this.peerGroupScopeFor(groupId),
|
|
764
|
+
fanoutPeerIds
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
return { gossipId, fanoutPeerIds };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async publishRecordToPeerGroup(groupId, collectionName, id, options = {}) {
|
|
771
|
+
const collection = this.getCollection(collectionName);
|
|
772
|
+
const raw = collection.get(id);
|
|
773
|
+
if (!raw || raw.deletedAt) {
|
|
774
|
+
throw new Error(`Object ${id} does not exist in ${collectionName}`);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const record = this.normalizeRecord(raw);
|
|
778
|
+
return this.publishToPeerGroup(groupId, 'record:snapshot', {
|
|
779
|
+
collectionName,
|
|
780
|
+
record
|
|
781
|
+
}, options);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async handlePeerGroupGossip(decrypted) {
|
|
785
|
+
const payload = decrypted.payload || {};
|
|
786
|
+
const {
|
|
787
|
+
groupId,
|
|
788
|
+
gossipId,
|
|
789
|
+
publisherId = decrypted.senderId,
|
|
790
|
+
hop = 0,
|
|
791
|
+
maxHop: payloadMaxHop,
|
|
792
|
+
innerMessageType,
|
|
793
|
+
innerPayload
|
|
794
|
+
} = payload;
|
|
795
|
+
|
|
796
|
+
if (!groupId || !innerMessageType || !gossipId) {
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (!this.peerGroups.has(groupId)) {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (this.hasSeenGossip(gossipId)) {
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
this.markSeenGossip(gossipId);
|
|
809
|
+
await this.dispatchPeerGroupInnerMessage(innerMessageType, innerPayload, {
|
|
810
|
+
groupId,
|
|
811
|
+
senderId: decrypted.senderId,
|
|
812
|
+
publisherId
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
const group = this.peerGroups.get(groupId);
|
|
816
|
+
const configuredMaxHop = group ? group.maxHops : this.defaultGossipMaxHops;
|
|
817
|
+
const maxHop = typeof payloadMaxHop === 'number'
|
|
818
|
+
? Math.min(payloadMaxHop, configuredMaxHop)
|
|
819
|
+
: configuredMaxHop;
|
|
820
|
+
|
|
821
|
+
if (!group || group.relayEnabled === false || hop >= maxHop) {
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const relayPeers = this.selectPeerGroupFanout(groupId, group.fanout, [
|
|
826
|
+
decrypted.senderId,
|
|
827
|
+
this.nodeId
|
|
828
|
+
]);
|
|
829
|
+
|
|
830
|
+
if (relayPeers.length === 0) {
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
await this.ensureConnectedToPeers(relayPeers.slice(0, group.maxActivePeers));
|
|
835
|
+
await this.enforceConnectionBudget();
|
|
836
|
+
|
|
837
|
+
await this.broadcastMessage('peer-group:gossip', {
|
|
838
|
+
groupId,
|
|
839
|
+
gossipId,
|
|
840
|
+
publisherId,
|
|
841
|
+
hop: hop + 1,
|
|
842
|
+
maxHop,
|
|
843
|
+
innerMessageType,
|
|
844
|
+
innerPayload
|
|
845
|
+
}, {
|
|
846
|
+
broadcastScope: this.peerGroupScopeFor(groupId),
|
|
847
|
+
fanoutPeerIds: relayPeers
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
normalizeGossipOperation(operation, publisherId) {
|
|
852
|
+
if (!operation || !publisherId) {
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (operation.actorId && operation.actorId !== publisherId) {
|
|
857
|
+
this.emit('warning', {
|
|
858
|
+
type: 'gossip-operation-actor-mismatch',
|
|
859
|
+
publisherId,
|
|
860
|
+
actorId: operation.actorId,
|
|
861
|
+
kind: operation.kind,
|
|
862
|
+
collection: operation.collectionName,
|
|
863
|
+
id: operation.id
|
|
864
|
+
});
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const normalized = {
|
|
869
|
+
...operation,
|
|
870
|
+
actorId: publisherId
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
if (normalized.kind === 'create') {
|
|
874
|
+
normalized.ownerId = publisherId;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
return normalized;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async dispatchPeerGroupInnerMessage(innerMessageType, innerPayload, context = {}) {
|
|
881
|
+
if (innerMessageType === 'operation') {
|
|
882
|
+
const operation = this.normalizeGossipOperation(
|
|
883
|
+
innerPayload,
|
|
884
|
+
context.publisherId || context.senderId
|
|
885
|
+
);
|
|
886
|
+
if (operation) {
|
|
887
|
+
this.applyOperation(operation);
|
|
888
|
+
}
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
if (innerMessageType === 'record:snapshot') {
|
|
893
|
+
const { collectionName, record } = innerPayload || {};
|
|
894
|
+
if (collectionName && record) {
|
|
895
|
+
const applied = this.restoreRecord(collectionName, record, {
|
|
896
|
+
rejectOnHashMismatch: true,
|
|
897
|
+
rejectOnOwnershipMismatch: true,
|
|
898
|
+
via: 'peer-group'
|
|
899
|
+
});
|
|
900
|
+
if (applied) {
|
|
901
|
+
this.emit('change', {
|
|
902
|
+
kind: 'snapshot',
|
|
903
|
+
collection: collectionName,
|
|
904
|
+
id: record.id,
|
|
905
|
+
via: 'peer-group',
|
|
906
|
+
groupId: context.groupId
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
this.emit('peergroupmessage', {
|
|
914
|
+
groupId: context.groupId,
|
|
915
|
+
senderId: context.senderId,
|
|
916
|
+
type: innerMessageType,
|
|
917
|
+
payload: innerPayload
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
528
921
|
getPresenceMap(scope) {
|
|
529
922
|
if (!this.presenceByScope.has(scope)) {
|
|
530
923
|
this.presenceByScope.set(scope, new Map());
|
|
@@ -682,8 +1075,16 @@ class DignityP2P extends EventEmitter {
|
|
|
682
1075
|
}
|
|
683
1076
|
|
|
684
1077
|
async handleIncomingMessage(message) {
|
|
685
|
-
// Backward compatibility for raw operation payloads
|
|
1078
|
+
// Backward compatibility for raw operation payloads (in-memory tests only)
|
|
686
1079
|
if (message && message.opId && message.kind) {
|
|
1080
|
+
if (this.securityService.options.enabled) {
|
|
1081
|
+
this.emit('messageignored', {
|
|
1082
|
+
reason: 'raw-operation-rejected',
|
|
1083
|
+
hint: 'Unsigned raw operations are disabled when security is enabled'
|
|
1084
|
+
});
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
687
1088
|
this.applyOperation(message);
|
|
688
1089
|
return;
|
|
689
1090
|
}
|
|
@@ -696,10 +1097,6 @@ class DignityP2P extends EventEmitter {
|
|
|
696
1097
|
return;
|
|
697
1098
|
}
|
|
698
1099
|
|
|
699
|
-
if (message && message.senderId && message.senderPublicKey) {
|
|
700
|
-
this.trustPeerPublicKey(message.senderId, message.senderPublicKey);
|
|
701
|
-
}
|
|
702
|
-
|
|
703
1100
|
let decrypted;
|
|
704
1101
|
try {
|
|
705
1102
|
decrypted = await this.securityService.decryptIncomingMessage(message);
|
|
@@ -720,6 +1117,10 @@ class DignityP2P extends EventEmitter {
|
|
|
720
1117
|
return;
|
|
721
1118
|
}
|
|
722
1119
|
|
|
1120
|
+
if (message && message.senderId && message.senderPublicKey) {
|
|
1121
|
+
this.trustPeerPublicKey(message.senderId, message.senderPublicKey);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
723
1124
|
if (decrypted.messageType === 'operation') {
|
|
724
1125
|
this.applyOperation(decrypted.payload);
|
|
725
1126
|
return;
|
|
@@ -746,21 +1147,32 @@ class DignityP2P extends EventEmitter {
|
|
|
746
1147
|
const payload = decrypted.payload || {};
|
|
747
1148
|
const scope = payload.scope || 'main';
|
|
748
1149
|
const peerId = payload.peerId || decrypted.senderId;
|
|
749
|
-
if (!peerId) {
|
|
1150
|
+
if (!peerId || peerId !== decrypted.senderId) {
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (!this.discoveryRooms.has(scope)) {
|
|
750
1155
|
return;
|
|
751
1156
|
}
|
|
752
1157
|
|
|
1158
|
+
const room = this.discoveryRooms.get(scope);
|
|
753
1159
|
const presenceMap = this.getPresenceMap(scope);
|
|
754
1160
|
const isNewPeerInScope = !presenceMap.has(peerId);
|
|
1161
|
+
const requestedTtl = typeof payload.ttlMs === 'number' ? payload.ttlMs : room.ttlMs;
|
|
1162
|
+
const ttlMs = Math.min(requestedTtl, room.ttlMs);
|
|
755
1163
|
|
|
756
1164
|
this.upsertPresence(
|
|
757
1165
|
scope,
|
|
758
1166
|
peerId,
|
|
759
1167
|
payload.metadata || {},
|
|
760
|
-
|
|
761
|
-
|
|
1168
|
+
ttlMs,
|
|
1169
|
+
this.now()
|
|
762
1170
|
);
|
|
763
1171
|
|
|
1172
|
+
if (payload.metadata && payload.metadata.publicKey) {
|
|
1173
|
+
this.trustPeerPublicKey(peerId, payload.metadata.publicKey);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
764
1176
|
// Discovery handshake: when a new peer appears in a joined scope,
|
|
765
1177
|
// send our current presence so late joiners quickly converge.
|
|
766
1178
|
if (isNewPeerInScope && peerId !== this.nodeId && this.discoveryRooms.has(scope)) {
|
|
@@ -781,6 +1193,10 @@ class DignityP2P extends EventEmitter {
|
|
|
781
1193
|
const payload = decrypted.payload || {};
|
|
782
1194
|
const scope = payload.scope || 'main';
|
|
783
1195
|
const peerId = payload.peerId || decrypted.senderId;
|
|
1196
|
+
if (!peerId || peerId !== decrypted.senderId) {
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
784
1200
|
const map = this.presenceByScope.get(scope);
|
|
785
1201
|
if (map && peerId && map.has(peerId)) {
|
|
786
1202
|
map.delete(peerId);
|
|
@@ -789,6 +1205,11 @@ class DignityP2P extends EventEmitter {
|
|
|
789
1205
|
return;
|
|
790
1206
|
}
|
|
791
1207
|
|
|
1208
|
+
if (decrypted.messageType === 'peer-group:gossip') {
|
|
1209
|
+
await this.handlePeerGroupGossip(decrypted);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
792
1213
|
this.emit('message', {
|
|
793
1214
|
senderId: decrypted.senderId,
|
|
794
1215
|
targetId: decrypted.targetId,
|
|
@@ -844,7 +1265,7 @@ class DignityP2P extends EventEmitter {
|
|
|
844
1265
|
this.emit('conflict', details);
|
|
845
1266
|
}
|
|
846
1267
|
|
|
847
|
-
restoreRecord(collectionName, record) {
|
|
1268
|
+
restoreRecord(collectionName, record, options = {}) {
|
|
848
1269
|
if (!record || !record.id) {
|
|
849
1270
|
return false;
|
|
850
1271
|
}
|
|
@@ -855,11 +1276,59 @@ class DignityP2P extends EventEmitter {
|
|
|
855
1276
|
return false;
|
|
856
1277
|
}
|
|
857
1278
|
|
|
1279
|
+
const restoredData = { ...(record.data || {}) };
|
|
1280
|
+
const computedHash = computeContentHash(restoredData);
|
|
1281
|
+
const rejectOnHashMismatch = options.rejectOnHashMismatch === true;
|
|
1282
|
+
const rejectOnOwnershipMismatch = options.rejectOnOwnershipMismatch === true;
|
|
1283
|
+
|
|
1284
|
+
if (
|
|
1285
|
+
rejectOnOwnershipMismatch
|
|
1286
|
+
&& current
|
|
1287
|
+
&& record.ownerId
|
|
1288
|
+
&& current.ownerId !== record.ownerId
|
|
1289
|
+
) {
|
|
1290
|
+
this.emit('warning', {
|
|
1291
|
+
type: 'ownership-mismatch',
|
|
1292
|
+
collection: collectionName,
|
|
1293
|
+
id: record.id,
|
|
1294
|
+
currentOwnerId: current.ownerId,
|
|
1295
|
+
advertisedOwnerId: record.ownerId,
|
|
1296
|
+
via: options.via || null
|
|
1297
|
+
});
|
|
1298
|
+
return false;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
if (!record.hash) {
|
|
1302
|
+
const warning = {
|
|
1303
|
+
type: 'content-hash-missing',
|
|
1304
|
+
collection: collectionName,
|
|
1305
|
+
id: record.id,
|
|
1306
|
+
via: options.via || null
|
|
1307
|
+
};
|
|
1308
|
+
this.emit('warning', warning);
|
|
1309
|
+
if (rejectOnHashMismatch) {
|
|
1310
|
+
return false;
|
|
1311
|
+
}
|
|
1312
|
+
} else if (record.hash !== computedHash) {
|
|
1313
|
+
this.emit('warning', {
|
|
1314
|
+
type: 'content-hash-mismatch',
|
|
1315
|
+
collection: collectionName,
|
|
1316
|
+
id: record.id,
|
|
1317
|
+
advertisedHash: record.hash,
|
|
1318
|
+
computedHash,
|
|
1319
|
+
via: options.via || null
|
|
1320
|
+
});
|
|
1321
|
+
if (rejectOnHashMismatch) {
|
|
1322
|
+
return false;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
858
1326
|
collection.set(record.id, {
|
|
859
1327
|
id: record.id,
|
|
860
1328
|
ownerId: record.ownerId,
|
|
861
1329
|
collaboratorIds: this.normalizeCollaboratorIds(record.collaboratorIds),
|
|
862
|
-
data:
|
|
1330
|
+
data: restoredData,
|
|
1331
|
+
hash: computedHash,
|
|
863
1332
|
createdAt: record.createdAt,
|
|
864
1333
|
updatedAt: record.updatedAt,
|
|
865
1334
|
deletedAt: record.deletedAt || null,
|
|
@@ -881,7 +1350,8 @@ class DignityP2P extends EventEmitter {
|
|
|
881
1350
|
id: raw.id,
|
|
882
1351
|
ownerId: raw.ownerId,
|
|
883
1352
|
collaboratorIds: Array.isArray(raw.collaboratorIds) ? [...raw.collaboratorIds] : [],
|
|
884
|
-
data: { ...raw.data },
|
|
1353
|
+
data: { ...(raw.data || {}) },
|
|
1354
|
+
hash: raw.hash || computeContentHash(raw.data || {}),
|
|
885
1355
|
createdAt: raw.createdAt,
|
|
886
1356
|
updatedAt: raw.updatedAt,
|
|
887
1357
|
deletedAt: raw.deletedAt || null,
|
|
@@ -900,6 +1370,16 @@ class DignityP2P extends EventEmitter {
|
|
|
900
1370
|
return record;
|
|
901
1371
|
}
|
|
902
1372
|
|
|
1373
|
+
pruneAppliedOperations() {
|
|
1374
|
+
while (this.appliedOperations.size > this.maxAppliedOperations) {
|
|
1375
|
+
const oldestOpId = this.appliedOperations.keys().next().value;
|
|
1376
|
+
if (!oldestOpId) {
|
|
1377
|
+
break;
|
|
1378
|
+
}
|
|
1379
|
+
this.appliedOperations.delete(oldestOpId);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
903
1383
|
applyOperation(operation) {
|
|
904
1384
|
if (!operation || !operation.opId || this.appliedOperations.has(operation.opId)) {
|
|
905
1385
|
return false;
|
|
@@ -917,14 +1397,16 @@ class DignityP2P extends EventEmitter {
|
|
|
917
1397
|
id: operation.id,
|
|
918
1398
|
ownerId: operation.ownerId,
|
|
919
1399
|
collaboratorIds: this.normalizeCollaboratorIds(operation.collaboratorIds),
|
|
920
|
-
data: { ...operation.payload },
|
|
1400
|
+
data: { ...(operation.payload || {}) },
|
|
1401
|
+
hash: computeContentHash(operation.payload || {}),
|
|
921
1402
|
createdAt: operation.timestamp,
|
|
922
1403
|
updatedAt: operation.timestamp,
|
|
923
1404
|
deletedAt: null,
|
|
924
1405
|
version: 1
|
|
925
1406
|
});
|
|
926
1407
|
|
|
927
|
-
this.appliedOperations.
|
|
1408
|
+
this.appliedOperations.set(operation.opId, this.now());
|
|
1409
|
+
this.pruneAppliedOperations();
|
|
928
1410
|
this.emit('change', { kind: 'create', collection: operation.collectionName, id: operation.id });
|
|
929
1411
|
return true;
|
|
930
1412
|
}
|
|
@@ -975,7 +1457,8 @@ class DignityP2P extends EventEmitter {
|
|
|
975
1457
|
current.updatedAt = operation.timestamp;
|
|
976
1458
|
current.version += 1;
|
|
977
1459
|
|
|
978
|
-
this.appliedOperations.
|
|
1460
|
+
this.appliedOperations.set(operation.opId, this.now());
|
|
1461
|
+
this.pruneAppliedOperations();
|
|
979
1462
|
this.emit('change', {
|
|
980
1463
|
kind: 'transfer-ownership',
|
|
981
1464
|
collection: operation.collectionName,
|
|
@@ -1008,7 +1491,8 @@ class DignityP2P extends EventEmitter {
|
|
|
1008
1491
|
current.updatedAt = operation.timestamp;
|
|
1009
1492
|
current.version += 1;
|
|
1010
1493
|
|
|
1011
|
-
this.appliedOperations.
|
|
1494
|
+
this.appliedOperations.set(operation.opId, this.now());
|
|
1495
|
+
this.pruneAppliedOperations();
|
|
1012
1496
|
this.emit('change', { kind: 'delete', collection: operation.collectionName, id: operation.id });
|
|
1013
1497
|
return true;
|
|
1014
1498
|
}
|
|
@@ -1035,6 +1519,7 @@ class DignityP2P extends EventEmitter {
|
|
|
1035
1519
|
...current.data,
|
|
1036
1520
|
...operation.payload
|
|
1037
1521
|
};
|
|
1522
|
+
current.hash = computeContentHash(current.data);
|
|
1038
1523
|
|
|
1039
1524
|
if (Array.isArray(operation.collaboratorIds) && operation.actorId === current.ownerId) {
|
|
1040
1525
|
current.collaboratorIds = this.normalizeCollaboratorIds(operation.collaboratorIds);
|
|
@@ -1043,7 +1528,8 @@ class DignityP2P extends EventEmitter {
|
|
|
1043
1528
|
current.updatedAt = operation.timestamp;
|
|
1044
1529
|
current.version += 1;
|
|
1045
1530
|
|
|
1046
|
-
this.appliedOperations.
|
|
1531
|
+
this.appliedOperations.set(operation.opId, this.now());
|
|
1532
|
+
this.pruneAppliedOperations();
|
|
1047
1533
|
this.emit('change', { kind: 'update', collection: operation.collectionName, id: operation.id });
|
|
1048
1534
|
return true;
|
|
1049
1535
|
}
|