feat(experimental)!: multi-master one ledger (Option B)

Remove the last layer of soft centralization: a lone `--master` on empty
storage could still implicitly genesis a separate Autopass and split the
network on the same TOPIC_SEED. Genesis is now explicit (`--master
--genesis`); secondary masters pair via invite into the same dnsPass as
joiners, with a shared network manifest, split-brain diagnostics, and
writer-aware quorum.

- Genesis vs secondary master boot paths; auto-adopt manifest on upgrade
- Masters without dnsPass accept invites; masters with pass ignore them
- NETWORK_MANIFEST_FILE, P2NS_GENESIS, MASTER_LOAD_DOMAINS, MASTER_INVITE_ONLY
- core.status networkId; admin diagnostics; docs and multi-master tests

BREAKING: operators must run one genesis per network; additional masters
use `node p2ns.js --master` (not `--genesis`) on empty storage.
This commit is contained in:
Raven Scott
2026-05-29 05:34:50 -04:00
parent 9e931b1f4d
commit b1a02e2e84
19 changed files with 920 additions and 55 deletions
+42 -7
View File
@@ -4,6 +4,8 @@
const crypto = require('crypto');
const { logDebug, logError, logInfo, logWarn } = require('../infrastructure/logger');
const { resolveMasterInviteOnly } = require('../infrastructure/master-mode');
const { getLocalNetworkSummary } = require('../infrastructure/network-manifest');
const { INVITE_STATUS, METHODS } = require('./core-rpc-contract');
function newInviteId() {
@@ -36,6 +38,7 @@ function createCoreSwarmHandlers(ctx) {
getCurrentPairOperation,
setCurrentPairOperation,
setupDomainsWatcher,
shouldLoadDomains,
listDomains,
doAutoVotes,
setupListeners,
@@ -48,8 +51,8 @@ function createCoreSwarmHandlers(ctx) {
const label = meta.label || 'invite';
const ackPeerId = meta.ackPeerId || peerId;
if (isMaster) {
logWarn('Swarm', `Ignoring ${label} as this is the master`);
if (isMaster && getDnsPass()) {
logWarn('Swarm', `Ignoring ${label} — master already has dnsPass`);
return;
}
if (getDnsPass()) {
@@ -87,7 +90,14 @@ function createCoreSwarmHandlers(ctx) {
await currentDnsPass.ready();
}
setupDomainsWatcher();
const loadDomainsFile = isMaster
? (typeof shouldLoadDomains === 'function' && shouldLoadDomains())
: true;
if (loadDomainsFile) {
setupDomainsWatcher();
} else {
logInfo('Swarm', 'Skipping domains.json watcher (secondary master; set MASTER_LOAD_DOMAINS=true to enable)');
}
await listDomains();
doAutoVotes();
@@ -96,6 +106,10 @@ function createCoreSwarmHandlers(ctx) {
}
setupListeners();
if (isMaster) {
state.masterPendingPass = false;
logInfo('Swarm', 'Secondary master paired — dnsPass ready, master invite policy active');
}
state.consecutiveInviteFailures = 0;
const requestChannelInfo = channelManager.getChannelInfo(CORE_DOMAIN, 'request');
@@ -228,7 +242,14 @@ function createCoreSwarmHandlers(ctx) {
return { status: INVITE_STATUS.UNAVAILABLE, reason: 'joiner' };
}
if (!isMaster && process.env.ALLOW_ANY_WRITER_INVITES !== 'true') {
const masterInviteOnly = resolveMasterInviteOnly();
const allowAnyWriter = process.env.ALLOW_ANY_WRITER_INVITES === 'true';
if (masterInviteOnly && !isMaster) {
logWarn('Swarm', `Rejecting invite.request from ${peerId} (MASTER_INVITE_ONLY)`);
coreRpc.inviteUnavailable(peerId, 'not_master');
return { status: INVITE_STATUS.UNAVAILABLE, reason: 'not_master' };
}
if (!isMaster && !allowAnyWriter) {
logWarn('Swarm', `Rejecting invite.request from ${peerId} (not master)`);
coreRpc.inviteUnavailable(peerId, 'not_master');
return { status: INVITE_STATUS.UNAVAILABLE, reason: 'not_master' };
@@ -321,7 +342,11 @@ function createCoreSwarmHandlers(ctx) {
const pass = getDnsPass();
const { isDnsPassUsable } = require('./core');
if (pass && isDnsPassUsable(pass) && (isMaster || process.env.ALLOW_ANY_WRITER_INVITES === 'true')) {
const canRelayInvite =
pass &&
isDnsPassUsable(pass) &&
(isMaster || (!resolveMasterInviteOnly() && process.env.ALLOW_ANY_WRITER_INVITES === 'true'));
if (canRelayInvite) {
try {
const { createInvite, inviteToWire, whenDnsPassIdle } = require('./dns-pass-queue');
await whenDnsPassIdle();
@@ -391,26 +416,36 @@ function createCoreSwarmHandlers(ctx) {
const pass = getDnsPass();
const dnsPassInitialized = !!pass;
const allowAnyWriterInvites = process.env.ALLOW_ANY_WRITER_INVITES === 'true';
const masterInviteOnly = resolveMasterInviteOnly();
let canProvideInvite = false;
if (dnsPassInitialized) {
if (isMaster) {
canProvideInvite = true;
} else if (allowAnyWriterInvites) {
} else if (!masterInviteOnly && allowAnyWriterInvites) {
canProvideInvite = true;
}
}
const network = getLocalNetworkSummary();
const baseCoreKey = pass?.base?.key ? pass.base.key.toString('hex').slice(0, 16) + '...' : null;
return {
nodeType: isMaster ? 'master' : 'joiner',
dnsPassInitialized,
canProvideInvite,
allowAnyWriterInvites,
masterInviteOnly,
isProcessingInvite: isProcessingInvite(),
consecutiveInviteFailures: state.consecutiveInviteFailures || 0,
connectedPeers: connectedPeers.size,
masterQueueSize: state.pendingInviteRequests?.size || 0,
inFlightInviteHandlers: pendingInviteRequestHandlers.size,
failedInvitePeerCount: failedInvitePeers.size
failedInvitePeerCount: failedInvitePeers.size,
networkId: network.networkId,
manifestPresent: network.manifestPresent,
isGenesis: network.isGenesis,
masterPendingPass: network.masterPendingPass,
baseCoreKey
};
}
+7 -2
View File
@@ -223,9 +223,13 @@ async function updateClaimClients(domain, claimant, clients) {
}
}
// Get active peer count
// Get active peer count for quorum (writers on the network, not raw swarm connections)
function getActivePeerCount() {
return (state.connectedPeers?.size || 0) + 1; // +1 for local node
const writers = state.networkWriterPeers;
if (writers && writers.size > 0) {
return writers.size + 1; // +1 for local node
}
return (state.connectedPeers?.size || 0) + 1;
}
// Validate vote references existing claim
@@ -929,6 +933,7 @@ module.exports = {
doAutoVotes,
getAllEntries,
isDnsPassUsable,
getActivePeerCount,
autoVoteForDomain,
voteForDomain,
removeDomain,