dignity.js 0.5.4 → 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 +25 -3
- package/dist/dignity.cjs.js +513 -17
- package/dist/dignity.cjs.js.map +4 -4
- package/dist/dignity.esm.js +513 -17
- 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 +9 -1
- package/examples/decentralized-chess-lite.js +2 -2
- package/package.json +2 -1
- package/src/core/dignity-p2p.js +473 -16
- 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/security/message-security-service.js +10 -2
package/src/core/dignity-p2p.js
CHANGED
|
@@ -2,6 +2,11 @@ const nacl = require('tweetnacl');
|
|
|
2
2
|
const naclUtil = require('tweetnacl-util');
|
|
3
3
|
const EventEmitter = require('../utils/event-emitter');
|
|
4
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');
|
|
5
10
|
|
|
6
11
|
function computeContentHash(data) {
|
|
7
12
|
const canonical = stableStringify(data || {});
|
|
@@ -27,6 +32,7 @@ function computeContentHash(data) {
|
|
|
27
32
|
* - broadcastMessage(type, payload, { connectToPeers, broadcastScope })
|
|
28
33
|
* - pushRecordSnapshot(collection, id, options) — full record sync for late joiners
|
|
29
34
|
* - getRecordPeerIds(collection, id) — owner + collaborators for connectToPeers
|
|
35
|
+
* - joinPeerGroup(groupId) / publishToPeerGroup(groupId, type, payload) — scalable gossip PubSub
|
|
30
36
|
*
|
|
31
37
|
* Authorization model:
|
|
32
38
|
* - object creator is the owner
|
|
@@ -68,9 +74,29 @@ class DignityP2P extends EventEmitter {
|
|
|
68
74
|
: 45000;
|
|
69
75
|
this.discoveryRooms = new Map(); // scope -> { metadata, heartbeatIntervalMs, ttlMs, timer }
|
|
70
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;
|
|
71
97
|
|
|
72
98
|
this.state = new Map(); // collection -> Map(id -> record)
|
|
73
|
-
this.appliedOperations = new
|
|
99
|
+
this.appliedOperations = new Map(); // opId -> appliedAt
|
|
74
100
|
this.boundMessageHandler = this.handleIncomingMessage.bind(this);
|
|
75
101
|
}
|
|
76
102
|
|
|
@@ -80,6 +106,15 @@ class DignityP2P extends EventEmitter {
|
|
|
80
106
|
}
|
|
81
107
|
|
|
82
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
|
+
|
|
83
118
|
const joinedScopes = Array.from(this.discoveryRooms.keys());
|
|
84
119
|
for (const scope of joinedScopes) {
|
|
85
120
|
// Best effort leave announce; do not fail node shutdown if network is interrupted.
|
|
@@ -510,6 +545,7 @@ class DignityP2P extends EventEmitter {
|
|
|
510
545
|
const connectToPeers = securityContext.connectToPeers;
|
|
511
546
|
if (Array.isArray(connectToPeers) && connectToPeers.length > 0) {
|
|
512
547
|
await this.ensureConnectedToPeers(connectToPeers);
|
|
548
|
+
await this.enforceConnectionBudget();
|
|
513
549
|
}
|
|
514
550
|
|
|
515
551
|
const envelope = await this.securityService.secureOutgoingMessage({
|
|
@@ -518,6 +554,17 @@ class DignityP2P extends EventEmitter {
|
|
|
518
554
|
targetId: null,
|
|
519
555
|
securityContext
|
|
520
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
|
+
|
|
521
568
|
await this.networkAdapter.broadcast(envelope);
|
|
522
569
|
}
|
|
523
570
|
|
|
@@ -535,9 +582,342 @@ class DignityP2P extends EventEmitter {
|
|
|
535
582
|
payload,
|
|
536
583
|
targetId
|
|
537
584
|
});
|
|
585
|
+
|
|
586
|
+
if (targetId && typeof this.networkAdapter.sendToPeers === 'function') {
|
|
587
|
+
await this.networkAdapter.sendToPeers(envelope, [targetId]);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
538
591
|
await this.networkAdapter.broadcast(envelope);
|
|
539
592
|
}
|
|
540
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
|
+
|
|
541
921
|
getPresenceMap(scope) {
|
|
542
922
|
if (!this.presenceByScope.has(scope)) {
|
|
543
923
|
this.presenceByScope.set(scope, new Map());
|
|
@@ -695,8 +1075,16 @@ class DignityP2P extends EventEmitter {
|
|
|
695
1075
|
}
|
|
696
1076
|
|
|
697
1077
|
async handleIncomingMessage(message) {
|
|
698
|
-
// Backward compatibility for raw operation payloads
|
|
1078
|
+
// Backward compatibility for raw operation payloads (in-memory tests only)
|
|
699
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
|
+
|
|
700
1088
|
this.applyOperation(message);
|
|
701
1089
|
return;
|
|
702
1090
|
}
|
|
@@ -709,10 +1097,6 @@ class DignityP2P extends EventEmitter {
|
|
|
709
1097
|
return;
|
|
710
1098
|
}
|
|
711
1099
|
|
|
712
|
-
if (message && message.senderId && message.senderPublicKey) {
|
|
713
|
-
this.trustPeerPublicKey(message.senderId, message.senderPublicKey);
|
|
714
|
-
}
|
|
715
|
-
|
|
716
1100
|
let decrypted;
|
|
717
1101
|
try {
|
|
718
1102
|
decrypted = await this.securityService.decryptIncomingMessage(message);
|
|
@@ -733,6 +1117,10 @@ class DignityP2P extends EventEmitter {
|
|
|
733
1117
|
return;
|
|
734
1118
|
}
|
|
735
1119
|
|
|
1120
|
+
if (message && message.senderId && message.senderPublicKey) {
|
|
1121
|
+
this.trustPeerPublicKey(message.senderId, message.senderPublicKey);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
736
1124
|
if (decrypted.messageType === 'operation') {
|
|
737
1125
|
this.applyOperation(decrypted.payload);
|
|
738
1126
|
return;
|
|
@@ -759,21 +1147,32 @@ class DignityP2P extends EventEmitter {
|
|
|
759
1147
|
const payload = decrypted.payload || {};
|
|
760
1148
|
const scope = payload.scope || 'main';
|
|
761
1149
|
const peerId = payload.peerId || decrypted.senderId;
|
|
762
|
-
if (!peerId) {
|
|
1150
|
+
if (!peerId || peerId !== decrypted.senderId) {
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (!this.discoveryRooms.has(scope)) {
|
|
763
1155
|
return;
|
|
764
1156
|
}
|
|
765
1157
|
|
|
1158
|
+
const room = this.discoveryRooms.get(scope);
|
|
766
1159
|
const presenceMap = this.getPresenceMap(scope);
|
|
767
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);
|
|
768
1163
|
|
|
769
1164
|
this.upsertPresence(
|
|
770
1165
|
scope,
|
|
771
1166
|
peerId,
|
|
772
1167
|
payload.metadata || {},
|
|
773
|
-
|
|
774
|
-
|
|
1168
|
+
ttlMs,
|
|
1169
|
+
this.now()
|
|
775
1170
|
);
|
|
776
1171
|
|
|
1172
|
+
if (payload.metadata && payload.metadata.publicKey) {
|
|
1173
|
+
this.trustPeerPublicKey(peerId, payload.metadata.publicKey);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
777
1176
|
// Discovery handshake: when a new peer appears in a joined scope,
|
|
778
1177
|
// send our current presence so late joiners quickly converge.
|
|
779
1178
|
if (isNewPeerInScope && peerId !== this.nodeId && this.discoveryRooms.has(scope)) {
|
|
@@ -794,6 +1193,10 @@ class DignityP2P extends EventEmitter {
|
|
|
794
1193
|
const payload = decrypted.payload || {};
|
|
795
1194
|
const scope = payload.scope || 'main';
|
|
796
1195
|
const peerId = payload.peerId || decrypted.senderId;
|
|
1196
|
+
if (!peerId || peerId !== decrypted.senderId) {
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
797
1200
|
const map = this.presenceByScope.get(scope);
|
|
798
1201
|
if (map && peerId && map.has(peerId)) {
|
|
799
1202
|
map.delete(peerId);
|
|
@@ -802,6 +1205,11 @@ class DignityP2P extends EventEmitter {
|
|
|
802
1205
|
return;
|
|
803
1206
|
}
|
|
804
1207
|
|
|
1208
|
+
if (decrypted.messageType === 'peer-group:gossip') {
|
|
1209
|
+
await this.handlePeerGroupGossip(decrypted);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
805
1213
|
this.emit('message', {
|
|
806
1214
|
senderId: decrypted.senderId,
|
|
807
1215
|
targetId: decrypted.targetId,
|
|
@@ -857,7 +1265,7 @@ class DignityP2P extends EventEmitter {
|
|
|
857
1265
|
this.emit('conflict', details);
|
|
858
1266
|
}
|
|
859
1267
|
|
|
860
|
-
restoreRecord(collectionName, record) {
|
|
1268
|
+
restoreRecord(collectionName, record, options = {}) {
|
|
861
1269
|
if (!record || !record.id) {
|
|
862
1270
|
return false;
|
|
863
1271
|
}
|
|
@@ -870,14 +1278,49 @@ class DignityP2P extends EventEmitter {
|
|
|
870
1278
|
|
|
871
1279
|
const restoredData = { ...(record.data || {}) };
|
|
872
1280
|
const computedHash = computeContentHash(restoredData);
|
|
873
|
-
|
|
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) {
|
|
874
1313
|
this.emit('warning', {
|
|
875
1314
|
type: 'content-hash-mismatch',
|
|
876
1315
|
collection: collectionName,
|
|
877
1316
|
id: record.id,
|
|
878
1317
|
advertisedHash: record.hash,
|
|
879
|
-
computedHash
|
|
1318
|
+
computedHash,
|
|
1319
|
+
via: options.via || null
|
|
880
1320
|
});
|
|
1321
|
+
if (rejectOnHashMismatch) {
|
|
1322
|
+
return false;
|
|
1323
|
+
}
|
|
881
1324
|
}
|
|
882
1325
|
|
|
883
1326
|
collection.set(record.id, {
|
|
@@ -927,6 +1370,16 @@ class DignityP2P extends EventEmitter {
|
|
|
927
1370
|
return record;
|
|
928
1371
|
}
|
|
929
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
|
+
|
|
930
1383
|
applyOperation(operation) {
|
|
931
1384
|
if (!operation || !operation.opId || this.appliedOperations.has(operation.opId)) {
|
|
932
1385
|
return false;
|
|
@@ -952,7 +1405,8 @@ class DignityP2P extends EventEmitter {
|
|
|
952
1405
|
version: 1
|
|
953
1406
|
});
|
|
954
1407
|
|
|
955
|
-
this.appliedOperations.
|
|
1408
|
+
this.appliedOperations.set(operation.opId, this.now());
|
|
1409
|
+
this.pruneAppliedOperations();
|
|
956
1410
|
this.emit('change', { kind: 'create', collection: operation.collectionName, id: operation.id });
|
|
957
1411
|
return true;
|
|
958
1412
|
}
|
|
@@ -1003,7 +1457,8 @@ class DignityP2P extends EventEmitter {
|
|
|
1003
1457
|
current.updatedAt = operation.timestamp;
|
|
1004
1458
|
current.version += 1;
|
|
1005
1459
|
|
|
1006
|
-
this.appliedOperations.
|
|
1460
|
+
this.appliedOperations.set(operation.opId, this.now());
|
|
1461
|
+
this.pruneAppliedOperations();
|
|
1007
1462
|
this.emit('change', {
|
|
1008
1463
|
kind: 'transfer-ownership',
|
|
1009
1464
|
collection: operation.collectionName,
|
|
@@ -1036,7 +1491,8 @@ class DignityP2P extends EventEmitter {
|
|
|
1036
1491
|
current.updatedAt = operation.timestamp;
|
|
1037
1492
|
current.version += 1;
|
|
1038
1493
|
|
|
1039
|
-
this.appliedOperations.
|
|
1494
|
+
this.appliedOperations.set(operation.opId, this.now());
|
|
1495
|
+
this.pruneAppliedOperations();
|
|
1040
1496
|
this.emit('change', { kind: 'delete', collection: operation.collectionName, id: operation.id });
|
|
1041
1497
|
return true;
|
|
1042
1498
|
}
|
|
@@ -1072,7 +1528,8 @@ class DignityP2P extends EventEmitter {
|
|
|
1072
1528
|
current.updatedAt = operation.timestamp;
|
|
1073
1529
|
current.version += 1;
|
|
1074
1530
|
|
|
1075
|
-
this.appliedOperations.
|
|
1531
|
+
this.appliedOperations.set(operation.opId, this.now());
|
|
1532
|
+
this.pruneAppliedOperations();
|
|
1076
1533
|
this.emit('change', { kind: 'update', collection: operation.collectionName, id: operation.id });
|
|
1077
1534
|
return true;
|
|
1078
1535
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const PEER_GROUP_SCOPE_PREFIX = 'gossip:';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_PEER_GROUP_OPTIONS = {
|
|
4
|
+
fanout: 3,
|
|
5
|
+
maxActivePeers: 8,
|
|
6
|
+
maxHops: 6,
|
|
7
|
+
relayEnabled: true
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function peerGroupScope(groupId) {
|
|
11
|
+
if (!groupId) {
|
|
12
|
+
throw new Error('peerGroupScope requires groupId');
|
|
13
|
+
}
|
|
14
|
+
return `${PEER_GROUP_SCOPE_PREFIX}${groupId}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parsePeerGroupScope(scope) {
|
|
18
|
+
if (!scope || !scope.startsWith(PEER_GROUP_SCOPE_PREFIX)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return scope.slice(PEER_GROUP_SCOPE_PREFIX.length);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function shufflePeerIds(peerIds, randomFn = Math.random) {
|
|
25
|
+
const list = [...peerIds];
|
|
26
|
+
for (let i = list.length - 1; i > 0; i -= 1) {
|
|
27
|
+
const j = Math.floor(randomFn() * (i + 1));
|
|
28
|
+
[list[i], list[j]] = [list[j], list[i]];
|
|
29
|
+
}
|
|
30
|
+
return list;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function selectFanoutPeers({
|
|
34
|
+
peers,
|
|
35
|
+
count,
|
|
36
|
+
excludePeerIds = [],
|
|
37
|
+
connectedPeerIds = [],
|
|
38
|
+
randomFn = Math.random
|
|
39
|
+
}) {
|
|
40
|
+
const excluded = new Set(excludePeerIds.filter(Boolean));
|
|
41
|
+
const candidates = peers
|
|
42
|
+
.map((entry) => entry.peerId || entry)
|
|
43
|
+
.filter((peerId) => peerId && !excluded.has(peerId));
|
|
44
|
+
|
|
45
|
+
const connected = new Set(connectedPeerIds.filter(Boolean));
|
|
46
|
+
const preferred = candidates.filter((peerId) => connected.has(peerId));
|
|
47
|
+
const others = candidates.filter((peerId) => !connected.has(peerId));
|
|
48
|
+
|
|
49
|
+
const ordered = [
|
|
50
|
+
...shufflePeerIds(preferred, randomFn),
|
|
51
|
+
...shufflePeerIds(others, randomFn)
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
return ordered.slice(0, Math.max(0, count));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
PEER_GROUP_SCOPE_PREFIX,
|
|
59
|
+
DEFAULT_PEER_GROUP_OPTIONS,
|
|
60
|
+
peerGroupScope,
|
|
61
|
+
parsePeerGroupScope,
|
|
62
|
+
shufflePeerIds,
|
|
63
|
+
selectFanoutPeers
|
|
64
|
+
};
|