From b04725e9b339784b08c80fe59869789c3645cce1 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 2 Sep 2026 07:52:59 -0700 Subject: [PATCH 1/9] fix(node): send the hub API key that validator init generated when the CLI pushes config to the hub `validator init` writes HUB_API_KEY to config/hub.local and the hub container deploys keyed from that sidecar, but the CLI only sent the key it found in .env. On a validator host provisioned per the runbook the config push that follows `install xchain-hub` was therefore keyless against a keyed hub and failed with HTTP 401, and so did every state-changing command after it. preCheck now fills the CLI's own env from the same sidecar before the hub is installed or pushed to. A host-env key still wins and nothing is ever minted, so a host with no sidecar stays keyless as before. --- src/precheck.js | 14 +++++++- src/services/ConfigService.js | 1 + test/unit/precheck.test.js | 63 +++++++++++++++++++++++++++++++++-- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/precheck.js b/src/precheck.js index 829822d..9ddc1ad 100644 --- a/src/precheck.js +++ b/src/precheck.js @@ -22,7 +22,7 @@ const { dataDir, moduleDir, tmpDir, containersFilesDir, const { db, isVerbose } = require('./state') const { redactSecrets } = require('./utils/helpers') const { checkDockerInstalledAndReachable, createDockerNetwork, checkContainerdDataRootRelocation } = require('./services/DockerService') -const { getDockerNetwork } = require('./services/ConfigService') +const { getDockerNetwork, applyHubApiKeyFromSidecar } = require('./services/ConfigService') const { checkAllRemoteVersions } = require('./services/VersionService') const { getStatus } = require('./services/StatusService') const { installHubModule, updateHub } = require('./services/HubService') @@ -163,6 +163,18 @@ async function preCheck(checkVersions = false, syncHubConfig = true, moduleRef = if (isVerbose()) console.log("Getting modules status") await getStatus(null, null, false, checkVersions) + // The CLI authenticates to the hub with the SAME credential the hub was deployed + // with. `validator init` mints that key into config/hub.local, and getDefaultConfig + // reads the sidecar when it builds the hub container's env, so the hub boots keyed; + // but HubConnector only sends what is in process.env, which dotenv fills from .env + // alone. On every validator host provisioned per the runbook that left the CLI's + // own updateconfig push keyless against a keyed hub: `install xchain-hub` started + // the hub and then failed with "HTTP 401" on its config push, and every + // state-changing command after it did the same. Same precedence as the container + // env: a host-env HUB_API_KEY still wins, the sidecar only fills an empty one, and + // this never mints (a host with no sidecar stays keyless exactly as before). + await applyHubApiKeyFromSidecar(process.env) + try { if (isVerbose()) console.log("Checking/Installing hub module") await installHubModule(moduleRef) diff --git a/src/services/ConfigService.js b/src/services/ConfigService.js index 28f6b91..07d228a 100644 --- a/src/services/ConfigService.js +++ b/src/services/ConfigService.js @@ -1269,6 +1269,7 @@ module.exports = { upsertSidecarValues, readSidecarValue, ensureHubApiKey, + applyHubApiKeyFromSidecar, filterCommandParameters, resolveArgs } diff --git a/test/unit/precheck.test.js b/test/unit/precheck.test.js index d82307b..db6183e 100644 --- a/test/unit/precheck.test.js +++ b/test/unit/precheck.test.js @@ -32,7 +32,8 @@ function loadPrecheck(overrides) { checkContainerdDataRootRelocation: sinon.stub().resolves(null), updateHub: sinon.stub().resolves(), updateExplorer: sinon.stub().resolves(), - installHubModule: sinon.stub().resolves() + installHubModule: sinon.stub().resolves(), + applyHubApiKeyFromSidecar: sinon.stub().resolves() }, overrides) const precheck = proxyquire('../../src/precheck.js', { @@ -48,7 +49,10 @@ function loadPrecheck(overrides) { createDockerNetwork: sinon.stub().resolves(), checkContainerdDataRootRelocation: stubs.checkContainerdDataRootRelocation }, - './services/ConfigService': { getDockerNetwork: () => 'xchain' }, + './services/ConfigService': { + getDockerNetwork: () => 'xchain', + applyHubApiKeyFromSidecar: stubs.applyHubApiKeyFromSidecar + }, './services/VersionService': { checkAllRemoteVersions: stubs.checkAllRemoteVersions }, './services/StatusService': { getStatus: stubs.getStatus }, './services/HubService': { installHubModule: stubs.installHubModule, updateHub: stubs.updateHub }, @@ -249,3 +253,58 @@ describe('preCheck: the hub is staged at the ref the command named', function () expect(installHubModule.firstCall.args[0]).to.equal(null) }) }) + +// `validator init` mints HUB_API_KEY into config/hub.local and the hub container +// deploys keyed from that sidecar, but HubConnector only sends process.env.HUB_API_KEY, +// which dotenv fills from .env alone. Nothing bridged the two, so on every validator +// host provisioned per the runbook the CLI's own updateconfig push was keyless against +// a keyed hub: `install xchain-hub` started the hub, then failed "HTTP 401" on the +// push, and so did every state-changing command after it (reported by a community +// testnet validator, 2026-09-02). +describe('preCheck: the CLI presents the sidecar HUB_API_KEY to the hub @regression', function () { + + const saved = process.env.HUB_API_KEY + afterEach(function () { + if (saved === undefined) delete process.env.HUB_API_KEY + else process.env.HUB_API_KEY = saved + }) + + it('hydrates process.env from the sidecar before the hub is installed or pushed to', async function () { + delete process.env.HUB_API_KEY + const applyHubApiKeyFromSidecar = sinon.stub().callsFake(async (target) => { + target.HUB_API_KEY = 'sidecar-key' + }) + const installHubModule = sinon.stub().callsFake(async () => { + expect(process.env.HUB_API_KEY).to.equal('sidecar-key') + }) + const updateHub = sinon.stub().callsFake(async () => { + expect(process.env.HUB_API_KEY).to.equal('sidecar-key') + }) + const { precheck } = loadPrecheck({ applyHubApiKeyFromSidecar, installHubModule, updateHub }) + + await precheck.preCheck(false, true) + + expect(applyHubApiKeyFromSidecar.calledOnce).to.be.true + expect(applyHubApiKeyFromSidecar.firstCall.args[0]).to.equal(process.env) + expect(installHubModule.calledOnce).to.be.true + expect(updateHub.calledOnce).to.be.true + expect(applyHubApiKeyFromSidecar.calledBefore(installHubModule)).to.be.true + }) + + it('runs the hydration through the real sidecar reader with a host-env key left untouched', async function () { + // The real reader, not a stub: host env wins, and an unset key with no + // sidecar on disk stays unset (never minted). + const cs = require('../../src/services/ConfigService') + process.env.HUB_API_KEY = 'host-env-key' + await cs.applyHubApiKeyFromSidecar(process.env) + expect(process.env.HUB_API_KEY).to.equal('host-env-key') + }) + + it('is a no-op on a host with no sidecar (a standalone install stays keyless)', async function () { + delete process.env.HUB_API_KEY + const { precheck, stubs } = loadPrecheck({}) + await precheck.preCheck(false, true) + expect(stubs.applyHubApiKeyFromSidecar.calledOnce).to.be.true + expect(process.env.HUB_API_KEY).to.equal(undefined) + }) +}) From c7d9cd06a2d826029043e3b381e88d398bbd1b27 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 1 Sep 2026 21:38:53 -0700 Subject: [PATCH 2/9] fix(node): ship hub_url in the checkpoint config block beside self_sync --- src/services/ConfigService.js | 9 ++++++ src/services/HubService.js | 17 ++++++++++- test/unit/HubService.test.js | 56 +++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/services/ConfigService.js b/src/services/ConfigService.js index 07d228a..a96754e 100644 --- a/src/services/ConfigService.js +++ b/src/services/ConfigService.js @@ -671,6 +671,15 @@ async function getDefaultConfig(module, coin, network) { // startup assertion to a warning and says nothing about whether a // local mirror should be provisioned). Emitted only when opted in, so // a deployment that never uses self-sync carries no unused hub URL. + // + // This env is no longer the mirror writer's ONLY source: the same URL + // now ships inside the checkpoint config block beside self_sync + // (HubService.buildCheckpointConfig), because these two lived on + // different delivery paths - container env written at install time + // versus the hub's config push - and opting in after the container + // existed left the explorer self-syncing with nowhere to sync from. + // Kept for the explorer's other hub reads (HubOperationalCache) and as + // the fallback for hand-written config.json deployments. if (process.env.EXPLORER_CHECKPOINT_SELF_SYNC !== undefined && process.env.EXPLORER_CHECKPOINT_SELF_SYNC !== "") { defaultValues.HUB_API_URL = process.env.HUB_API_URL || ("http://" + getDockerContainerImageName(HUB_MODULE_NAME, "", "") + ":" + defaultValues.HUB_PORT) diff --git a/src/services/HubService.js b/src/services/HubService.js index 3226827..c1b1d52 100644 --- a/src/services/HubService.js +++ b/src/services/HubService.js @@ -64,8 +64,20 @@ function buildHubModuleConfig(nextModule, defaultConfigCoinNetwork, ctx) { // defaultConfigCoinNetwork fields buildHubModuleConfig('xchain-indexer', ...) // reads above, rather than re-deriving them, to guarantee byte-identical // values instead of two independent paths that could drift apart. +// +// hub_url ships IN this block, beside self_sync, so the pairing is structural: +// whatever condition produces self_sync produces the URL with it. Emitted by +// separate conditions, an explorer opted in after its container exists is told +// to self-sync with no hub URL to sync from, warns once at startup and then +// serves the frozen mirror indefinitely. This block reaches the explorer over +// the hub's config push; HUB_API_URL is a container env ConfigService writes at +// install/recreate time from the same host env var, and the explorer falls back +// to it for hand-written config.json deployments. function buildCheckpointConfig(defaultConfigCoinNetwork) { return { + hub_url: process.env.HUB_API_URL || + ("http://" + getDockerContainerImageName(HUB_MODULE_NAME, "", "") + ":" + + defaultConfigCoinNetwork.HUB_PORT), db_host: defaultConfigCoinNetwork.INDEXER_DB_HOST, db_port: defaultConfigCoinNetwork.INDEXER_DB_PORT, user: defaultConfigCoinNetwork.INDEXER_DB_USER, @@ -363,5 +375,8 @@ async function installHubModule(branch = null) { module.exports = { updateHubOrExplorer, updateHub, - installHubModule + installHubModule, + // Exported for the unit suite: the self_sync/hub_url pairing is the whole + // point of this block and must be pinned without booting a docker install. + buildCheckpointConfig } diff --git a/test/unit/HubService.test.js b/test/unit/HubService.test.js index 5e690ff..6260649 100644 --- a/test/unit/HubService.test.js +++ b/test/unit/HubService.test.js @@ -79,3 +79,59 @@ describe('HubService.updateHub network attachment', function () { expect(attach.callCount).to.equal(2) }) }) + +// The self_sync flag and the hub URL the explorer's mirror writer follows ship +// together, from one condition. Delivered by separate conditions (this block over +// the hub config push, HUB_API_URL as a container env written at install time), an +// explorer is told to self-sync with no hub to sync from: it warns once and serves +// the frozen mirror indefinitely. +describe('HubService.buildCheckpointConfig hub endpoint', function () { + + const COIN_CONFIG = { + INDEXER_DB_HOST: 'mariadb', + INDEXER_DB_PORT: 3306, + INDEXER_DB_USER: 'xchain_indexer_bitcoin_regtest', + INDEXER_DB_PASS: 'secret', + INDEXER_DB_NAME: 'XChain_BTC_regtest', + HUB_PORT: 10000 + } + + let savedHubUrl + + beforeEach(function () { + savedHubUrl = process.env.HUB_API_URL + delete process.env.HUB_API_URL + }) + + afterEach(function () { + if (savedHubUrl === undefined) delete process.env.HUB_API_URL + else process.env.HUB_API_URL = savedHubUrl + }) + + it('emits a hub_url alongside every self_sync it advertises', function () { + const { svc } = loadHubService() + const cfg = svc.buildCheckpointConfig(COIN_CONFIG) + expect(cfg.self_sync).to.be.true + expect(cfg.hub_url).to.be.a('string').and.to.have.length.above(0) + }) + + it('defaults the endpoint to the hub container on the docker network', function () { + const { svc } = loadHubService() + expect(svc.buildCheckpointConfig(COIN_CONFIG).hub_url).to.equal('http://xchain-node-xchain-hub:10000') + }) + + it('honours an operator HUB_API_URL from the host env', function () { + process.env.HUB_API_URL = 'http://hub.internal:10000' + const { svc } = loadHubService() + expect(svc.buildCheckpointConfig(COIN_CONFIG).hub_url).to.equal('http://hub.internal:10000') + }) + + it('keeps the mirror schema and indexer credentials it already carried', function () { + const { svc } = loadHubService() + const cfg = svc.buildCheckpointConfig(COIN_CONFIG) + expect(cfg.name).to.equal('XChain_BTC_regtest_HubMirror') + expect(cfg.db_host).to.equal('mariadb') + expect(cfg.db_port).to.equal(3306) + expect(cfg.user).to.equal('xchain_indexer_bitcoin_regtest') + }) +}) From 1115be62fb1d086d672449c4478af68e5df6162e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 1 Sep 2026 23:00:48 -0700 Subject: [PATCH 3/9] fix(cli): make xchain-node rollback print the recovery path and exit 1 instead of hanging in the precheck --- src/cli.js | 28 +++++-- test/unit/cli-rollback.test.js | 142 +++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 test/unit/cli-rollback.test.js diff --git a/src/cli.js b/src/cli.js index 5596787..f76dd9f 100644 --- a/src/cli.js +++ b/src/cli.js @@ -133,6 +133,15 @@ async function parseCommand() { // operator can prepare their validator identity before any stack is up. const parentName = actionCommand.parent && actionCommand.parent.name() if (commandName === 'validator' || parentName === 'validator') return + // `rollback` is declared but unimplemented: its action only names the + // reset-and-restore recovery path and exits non-zero. Provisioning + // Docker/MariaDB/hub and taking the mutating lock to reach a two-line + // refusal is what made it read as a hang. Measured 2026-08-30 while + // repairing a regtest indexer: an operator reached for `rollback` + // mid-incident and waited ~10 minutes on a command that printed nothing. + // It stays listed in mutatingCommands above so that a real + // implementation, which would drop this early return, is serialized. + if (commandName === 'rollback') return // preCheck provisions shared containers/DB/hub (buildDatabaseModule, // ensureXchainNodeAccess, scanAndRegisterModules, installHubModule) for @@ -526,16 +535,25 @@ gate could report them, so the cron exited 0 while a consumer archive went stale program .command('rollback') - .description('Rollback XChain service to a set block_index') + .description('NOT IMPLEMENTED - prints the reset + bootstrap restore path for recovering a service to a block_index') .argument('', 'The index of the last known good block') .argument('', '(xchain-decoder, xchain-utxo-tracker, xchain-indexer, all)') .argument('', '(bitcoin, litecoin, dogecoin)') .argument('', '(mainnet, testnet, regtest)') - .action(async () => { + .action(async (blockIndex, service, chain, network) => { // Not yet implemented. Fail loudly instead of silently doing nothing, - // so operators don't believe a rollback occurred. - console.error('`rollback` is not yet implemented. To recover a service to a known-good block, use `reset` followed by a bootstrap restore.') - process.exitCode = 1 + // so operators don't believe a rollback occurred. This is reached + // during an incident, so it prints the runnable recovery path with + // the operator's own arguments already substituted in, and exits + // through process.exit(): setting process.exitCode alone left the + // process alive on whatever handles were open, which is how a + // command that had already printed its answer still looked hung. + console.error('`rollback` is not yet implemented; nothing was rolled back.') + console.error(`To recover ${service} (${chain} ${network}) to block ${blockIndex}, use reset followed by a bootstrap restore:`) + console.error(` xchain-node reset ${service} ${chain} ${network}`) + console.error(` xchain-node bootstrap restore ${service} ${chain} ${network}`) + console.error('Restore rewinds to the newest bootstrap at or before that block, then the service re-parses forward.') + return process.exit(1) }) program diff --git a/test/unit/cli-rollback.test.js b/test/unit/cli-rollback.test.js new file mode 100644 index 0000000..4a5682c --- /dev/null +++ b/test/unit/cli-rollback.test.js @@ -0,0 +1,142 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. +// +// `rollback` is declared but unimplemented. The row this covers is not the +// missing implementation, it is that reaching for it during an incident cost an +// operator ~10 minutes (measured 2026-08-30, repairing a regtest indexer): the +// preAction hook ran the full Docker/MariaDB/hub precheck and took the mutating +// lock before the action could say anything, and the action then set +// process.exitCode instead of exiting, so the process stayed alive on open +// handles. Printed nothing, returned nothing: it read as a hang. These cases pin +// the incident-path contract - no precheck, no lock, guidance on stderr, exit 1. + +const sinon = require('sinon') +const { expect } = require('chai') +const path = require('path') +const proxyquire = require('proxyquire') + +const CLI_PATH = path.join(__dirname, '..', '..', 'src', 'cli') + +// Builds the CLI with every side-effecting collaborator stubbed, and hands the +// stubs back so a test can assert which ones the command DID NOT touch. +function loadCli() { + const { Command } = require('commander') + const captured = new Command() + const stubs = { + preCheck: sinon.stub().resolves(), + acquireCommandLock: sinon.stub().returns(function release() {}), + maybeReportTelemetry: sinon.stub().resolves() + } + const cli = proxyquire(CLI_PATH, { + 'commander': { Command: function () { captured.parse = sinon.stub(); return captured } }, + './precheck': { preCheck: stubs.preCheck }, + './state': { setVerbose: sinon.stub(), db: {} }, + './utils/commandLock': { acquireCommandLock: stubs.acquireCommandLock }, + './services/TelemetryService': { maybeReportTelemetry: stubs.maybeReportTelemetry }, + './services/ConfigService': { + filterCommandParameters: sinon.stub().returns({ bitcoin: { regtest: ['xchain-indexer'] } }), + resolveArgs: sinon.stub().returns({ service: 'xchain-indexer', chain: 'bitcoin', network: 'regtest', branch: 'master' }) + }, + './operations/moduleOperations': { + installModules: sinon.stub().resolves(), + updateModules: sinon.stub().resolves({ updated: [], skipped: [] }), + uninstallModules: sinon.stub().resolves({ uninstalled: [], skipped: [] }), + recreateModules: sinon.stub().resolves({ recreated: [], skipped: [] }), + logModules: sinon.stub().resolves(), + monitorModules: sinon.stub().resolves(), + restartModules: sinon.stub().resolves(), + stopModules: sinon.stub().resolves(), + startModules: sinon.stub().resolves(), + execModules: sinon.stub().resolves(), + shellModule: sinon.stub().resolves(), + runE2ETest: sinon.stub().resolves({ logFile: '', exitCode: 0 }), + resetModules: sinon.stub().resolves(true), + listServedBootstrapCombos: sinon.stub().resolves([]) + }, + './services/StatusService': { getStatus: sinon.stub().resolves(), statusChanged: sinon.stub().resolves() } + }) + cli.parseCommand() + return { program: captured, stubs } +} + +describe('CLI `rollback` incident-path behaviour', function () { + + let exitStub, errorStub + + beforeEach(function () { + exitStub = sinon.stub(process, 'exit') + errorStub = sinon.stub(console, 'error') + }) + + afterEach(function () { sinon.restore() }) + + async function runRollback(program) { + await program.parseAsync(['rollback', '4200', 'xchain-indexer', 'bitcoin', 'regtest'], { from: 'user' }) + } + + it('exits NON-ZERO instead of leaving the process alive on open handles', async function () { + const { program } = loadCli() + await runRollback(program) + expect(exitStub.calledWith(1)).to.be.true + expect(exitStub.calledWith(0)).to.be.false + }) + + it('says it did nothing, so no operator reads the refusal as a completed rollback', async function () { + const { program } = loadCli() + await runRollback(program) + expect(errorStub.calledWithMatch(/not yet implemented/)).to.be.true + expect(errorStub.calledWithMatch(/nothing was rolled back/)).to.be.true + }) + + it('names the reset-and-restore recovery path as runnable commands', async function () { + const { program } = loadCli() + await runRollback(program) + const printed = errorStub.getCalls().map(c => c.args.join(' ')).join('\n') + expect(printed).to.match(/xchain-node reset xchain-indexer bitcoin regtest/) + expect(printed).to.match(/xchain-node bootstrap restore xchain-indexer bitcoin regtest/) + }) + + it('echoes the operator\'s own arguments back, so the guidance is copy-pasteable', async function () { + const { program } = loadCli() + await program.parseAsync(['rollback', '917', 'xchain-decoder', 'litecoin', 'testnet'], { from: 'user' }) + const printed = errorStub.getCalls().map(c => c.args.join(' ')).join('\n') + expect(printed).to.match(/block 917/) + expect(printed).to.match(/xchain-node reset xchain-decoder litecoin testnet/) + expect(printed).to.match(/xchain-node bootstrap restore xchain-decoder litecoin testnet/) + }) + + // The two waits that made it look like a hang. preCheck provisions the + // database container, hub module and module registry before any action runs; + // on a real node that is minutes, and it fails outright when Docker is down, + // which is exactly the state an operator reaches for `rollback` in. + it('does NOT run the Docker/MariaDB precheck for a command that provisions nothing', async function () { + const { program, stubs } = loadCli() + await runRollback(program) + expect(stubs.preCheck.called).to.be.false + }) + + it('does NOT take the mutating command lock, so it cannot queue behind a deploy', async function () { + const { program, stubs } = loadCli() + await runRollback(program) + expect(stubs.acquireCommandLock.called).to.be.false + }) + + // Guard the guard: the same loader must still show a real mutating command + // going through both, or the two assertions above would pass on a CLI whose + // hook never runs at all. + it('leaves the precheck and the lock in place for a command that does provision', async function () { + const { program, stubs } = loadCli() + await program.parseAsync(['reset', 'xchain-indexer', 'bitcoin', 'regtest', '--yes'], { from: 'user' }) + expect(stubs.preCheck.called).to.be.true + expect(stubs.acquireCommandLock.called).to.be.true + }) +}) From e82fb62f77c6270240a8d62dc6d80efb955bf338 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 1 Sep 2026 23:00:51 -0700 Subject: [PATCH 4/9] fix(node): resolve checkpoint self-sync once per push so every installed coin gets a checkpoint block --- src/services/HubService.js | 55 ++++++++++++- test/integration/hub-config-update.test.js | 91 ++++++++++++++++++++++ test/unit/HubService.test.js | 70 +++++++++++++++++ 3 files changed, 212 insertions(+), 4 deletions(-) diff --git a/src/services/HubService.js b/src/services/HubService.js index c1b1d52..8623ccc 100644 --- a/src/services/HubService.js +++ b/src/services/HubService.js @@ -90,6 +90,41 @@ function buildCheckpointConfig(defaultConfigCoinNetwork) { self_sync: true } } + +// Is the self-synced checkpoint mirror opted in for THIS deployment? +// +// EXPLORER_CHECKPOINT_SELF_SYNC is a host env read at command time, but the +// checkpoint blocks it produces are written per coin/network into stores that are +// only ever upserted, never reconciled. So the opt-in has to outlive the shell that +// first set it, and as a bare `process.env` read it did not: a coin installed later +// from a shell that never exported the env (a second terminal, a cron-driven update, +// an operator who sourced a different env file) got NO checkpoint block, while the +// coins installed earlier kept theirs. The explorer then serves exactly one coin's +// hub-mirrored routes as a fail-loud 500 - price_snapshots, oracle_prices, +// state_checkpoints, capability_snapshots, cross_chain_matches - while every sibling +// coin answers normally, which reads as a broken query rather than the config gap it +// is (and with ALLOW_NO_COLOCATED_HUB_DB=1 the explorer boots anyway, so nothing at +// startup says so either). Measured on a regtest venue whose LTC leg 500'd on +// /RLTC/api/price_snapshots/FINALIZED/status while RBTC and RDOGE were fine. +// +// The installed explorer container's own env is the durable record of the earlier +// opt-in: ConfigService writes HUB_API_URL into the explorer ONLY inside the same +// opt-in branch, so its presence there means self-sync was chosen for this +// deployment. Reading it back makes every later push emit the block for every +// installed coin, so the gap self-heals on the next mutation instead of needing a +// hand-edit. Tolerant by design: no explorer container, or an unreadable one, is +// simply "not opted in". +async function isCheckpointSelfSyncEnabled(deps = {}) { + const env = deps.env || process.env + if (env.EXPLORER_CHECKPOINT_SELF_SYNC !== undefined && env.EXPLORER_CHECKPOINT_SELF_SYNC !== "") return true + + const readEnv = deps.readContainerEnv || readContainerEnv + const containerEnv = await readEnv(getDockerContainerImageName(EXPLORER_MODULE_NAME, "", ""), deps) + if (!containerEnv) return false + + return (containerEnv.EXPLORER_CHECKPOINT_SELF_SYNC !== undefined && containerEnv.EXPLORER_CHECKPOINT_SELF_SYNC !== "") || + (containerEnv.HUB_API_URL !== undefined && containerEnv.HUB_API_URL !== "") +} const { db, getLastStatus, isStatusUpdated, isVerbose } = require('../state') const { sleep, redactSecrets } = require('../utils/helpers') const { getDefaultConfig, getDockerContainerImageName, getDockerNetwork } = require('./ConfigService') @@ -97,6 +132,10 @@ const { statusChanged, getStatus, getInstalledCoinsAndNetworks } = require('./St const { addContainerToNetwork } = require('./DockerService') const { cloneGit, buildAndUp } = require('./ModuleService') const { addUserPasswordToDatabase, getExternalDbConfig } = require('./DatabaseService') +// The explorer container's env is the durable record of the checkpoint self-sync +// opt-in; this reader already exists for the DB-credential drift guard and is +// tolerant of a missing container, which is exactly the posture wanted here. +const { readContainerEnv } = require('./DbCredentialDrift') const HubConnector = require('../HubConnector.js') async function updateHubOrExplorer(module) { @@ -130,6 +169,11 @@ async function updateHubOrExplorer(module) { // saved at the first-run prompt would be misreported to the hub (uuid:52c5b5f1). const externalDbCfg = EXTERNAL_DB ? await getExternalDbConfig() : null + // Resolved once per push, for the same reason: the answer is a property of the + // deployment, not of the coin/network being emitted, so every installed coin gets + // the same verdict and none is silently left without a checkpoint block. + const checkpointSelfSync = await isCheckpointSelfSyncEnabled() + if (module === "xchain-explorer") { jsonConfig["configs"] = [] jsonConfig = jsonConfig["configs"] @@ -162,9 +206,9 @@ async function updateHubOrExplorer(module) { // Row 39: advertise a self-synced checkpoint schema for this coin/ // network once an indexer is actually installed for it (the // checkpoint config needs the indexer's own DB host/port/user/pass) - // and the operator opted in. See buildCheckpointConfig above. - if (process.env.EXPLORER_CHECKPOINT_SELF_SYNC !== undefined && process.env.EXPLORER_CHECKPOINT_SELF_SYNC !== "" && - XChainService.XCHAIN_INDEXER in lastStatus[nextCoin][nextNetwork]) { + // and the operator opted in (once, at any point in this deployment's + // life: see isCheckpointSelfSyncEnabled). See buildCheckpointConfig above. + if (checkpointSelfSync && XChainService.XCHAIN_INDEXER in lastStatus[nextCoin][nextNetwork]) { const checkpointConfig = buildCheckpointConfig(defaultConfigCoinNetwork) if (module === "xchain-explorer") { nextConfigObject.checkpoint = checkpointConfig @@ -378,5 +422,8 @@ module.exports = { installHubModule, // Exported for the unit suite: the self_sync/hub_url pairing is the whole // point of this block and must be pinned without booting a docker install. - buildCheckpointConfig + buildCheckpointConfig, + // Same: the opt-in must survive a shell that never exported the env, and that + // is pinned against a stubbed container-env read rather than a live docker. + isCheckpointSelfSyncEnabled } diff --git a/test/integration/hub-config-update.test.js b/test/integration/hub-config-update.test.js index ffc2be6..927b7c7 100644 --- a/test/integration/hub-config-update.test.js +++ b/test/integration/hub-config-update.test.js @@ -377,4 +377,95 @@ describe('Integration: Hub/Explorer Config Update', function () { expect(networks).to.include('xchain-node-dogecoin-testnet') }) }) + + // The explorer hard-requires a checkpoint block per serving coin, and the push is + // what generates it. While the opt-in was read only from the host env at command + // time, a coin installed from a shell that never exported it got no block, both + // config stores being upsert-only kept the earlier coins' blocks, and the explorer + // served that one coin's hub-mirrored routes as a 500 while its siblings answered. + describe('checkpoint self-sync opt-in survives the shell that set it', function () { + + let savedOptIn + + beforeEach(function () { + savedOptIn = process.env.EXPLORER_CHECKPOINT_SELF_SYNC + delete process.env.EXPLORER_CHECKPOINT_SELF_SYNC + }) + + afterEach(function () { + if (savedOptIn === undefined) delete process.env.EXPLORER_CHECKPOINT_SELF_SYNC + else process.env.EXPLORER_CHECKPOINT_SELF_SYNC = savedOptIn + }) + + async function pushWithContainerEnv(containerEnv) { + const state = require('../../src/state') + + const hubId = TestEnv.fakeContainerId('h') + const btcId = TestEnv.fakeContainerId('b') + const ltcId = TestEnv.fakeContainerId('l') + + await env.insertModule('xchain-hub', '', '', hubId) + await env.insertModule('xchain-indexer', 'bitcoin', 'regtest', btcId) + await env.insertModule('xchain-indexer', 'litecoin', 'regtest', ltcId) + + env.writeConfigFile('bitcoin-regtest', '') + env.writeConfigFile('litecoin-regtest', '') + + state.setStatusUpdated(true) + state.setLastStatus({ + 'bitcoin': { 'regtest': { 'xchain-indexer': { container_id: btcId, status: { State: { Status: 'running' } } } } }, + 'litecoin': { 'regtest': { 'xchain-indexer': { container_id: ltcId, status: { State: { Status: 'running' } } } } } + }) + + httpCapture.when('127.0.0.1:10000').returns({ data: { result: true } }) + + const HubService = proxyquire('../../src/services/HubService', { + '../HubConnector.js': proxyquire('../../src/HubConnector', { + 'axios': httpCapture.createAxiosStub() + }), + './DbCredentialDrift': { + readContainerEnv: async () => containerEnv + }, + './DockerService': { + addContainerToNetwork: async () => true, + getStatusFromContainer: async () => ({ + State: { Status: 'running' }, + NetworkSettings: { Ports: {}, Networks: {} } + }), + stringToDockerContainerFile: async () => true + }, + './ModuleService': { + cloneGit: async () => true, + buildAndUp: async () => hubId + }, + '../utils/helpers': { + sleep: async () => {} + } + }) + + await HubService.updateHubOrExplorer('xchain-hub') + + const payloads = httpCapture.getPayloads('127.0.0.1:10000') + return payloads[payloads.length - 1].params.config + } + + it('emits a checkpoint block for EVERY installed coin when only the explorer container remembers the opt-in', async function () { + const config = await pushWithContainerEnv({ HUB_API_URL: 'http://xchain-node-xchain-hub:10000' }) + + for (const coin of ['bitcoin', 'litecoin']) { + const checkpoint = config[coin]['regtest'].checkpoint + expect(checkpoint, coin + ' checkpoint block').to.exist + expect(checkpoint.self_sync, coin + ' self_sync').to.be.true + expect(checkpoint.hub_url, coin + ' hub_url').to.be.a('string').and.to.have.length.above(0) + expect(checkpoint.name, coin + ' mirror schema').to.match(/_HubMirror$/) + } + }) + + it('emits none when neither the env nor the explorer container was ever opted in', async function () { + const config = await pushWithContainerEnv({ EXPLORER_PORT: '18080' }) + + expect(config['bitcoin']['regtest'].checkpoint).to.be.undefined + expect(config['litecoin']['regtest'].checkpoint).to.be.undefined + }) + }) }) diff --git a/test/unit/HubService.test.js b/test/unit/HubService.test.js index 6260649..fe20e9d 100644 --- a/test/unit/HubService.test.js +++ b/test/unit/HubService.test.js @@ -135,3 +135,73 @@ describe('HubService.buildCheckpointConfig hub endpoint', function () { expect(cfg.user).to.equal('xchain_indexer_bitcoin_regtest') }) }) + +// The opt-in that decides whether a coin gets a checkpoint block at all. Read bare +// off process.env, a push run from a shell that never exported the env emits no +// block - and because both config stores are upsert-only, the coins installed +// earlier keep theirs while the one installed later has none. The explorer +// then 500s that one coin's hub-mirrored routes (price_snapshots, oracle_prices, +// state_checkpoints) while every sibling coin answers normally. +describe('HubService.isCheckpointSelfSyncEnabled', function () { + + const EXPLORER_CONTAINER = 'xchain-node-xchain-explorer' + + it('is opted in when the host env carries the flag', async function () { + const { svc } = loadHubService() + const enabled = await svc.isCheckpointSelfSyncEnabled({ + env: { EXPLORER_CHECKPOINT_SELF_SYNC: '1' }, + readContainerEnv: async () => { throw new Error('must not need docker when the env says yes') } + }) + expect(enabled).to.be.true + }) + + it('stays opted in when the env is gone but the explorer container remembers', async function () { + const { svc } = loadHubService() + const seen = [] + const enabled = await svc.isCheckpointSelfSyncEnabled({ + env: {}, + readContainerEnv: async (name) => { + seen.push(name) + return { HUB_API_URL: 'http://xchain-node-xchain-hub:10000', SOMETHING_ELSE: 'x' } + } + }) + expect(enabled).to.be.true + expect(seen).to.deep.equal([EXPLORER_CONTAINER]) + }) + + it('reads the flag itself off the container when it is there', async function () { + const { svc } = loadHubService() + const enabled = await svc.isCheckpointSelfSyncEnabled({ + env: {}, + readContainerEnv: async () => ({ EXPLORER_CHECKPOINT_SELF_SYNC: '1' }) + }) + expect(enabled).to.be.true + }) + + it('is not opted in when neither the env nor the container says so', async function () { + const { svc } = loadHubService() + const enabled = await svc.isCheckpointSelfSyncEnabled({ + env: {}, + readContainerEnv: async () => ({ EXPLORER_PORT: '18080' }) + }) + expect(enabled).to.be.false + }) + + it('treats an empty env value as unset rather than as an opt-in', async function () { + const { svc } = loadHubService() + const enabled = await svc.isCheckpointSelfSyncEnabled({ + env: { EXPLORER_CHECKPOINT_SELF_SYNC: '' }, + readContainerEnv: async () => ({ HUB_API_URL: '' }) + }) + expect(enabled).to.be.false + }) + + it('is not opted in when there is no explorer container to ask', async function () { + const { svc } = loadHubService() + const enabled = await svc.isCheckpointSelfSyncEnabled({ + env: {}, + readContainerEnv: async () => null + }) + expect(enabled).to.be.false + }) +}) From 15beacec06b6bb0219663d4590593ad0e21ee48e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 2 Sep 2026 00:16:19 -0700 Subject: [PATCH 5/9] test(node): cover the module service paths the unit gate was missing --- test/unit/ModuleService.test.js | 68 ++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/test/unit/ModuleService.test.js b/test/unit/ModuleService.test.js index 7258d2b..7b23323 100644 --- a/test/unit/ModuleService.test.js +++ b/test/unit/ModuleService.test.js @@ -133,6 +133,19 @@ function loadModuleService(stubs, constantsOverride, extraProxies) { }, './ExplorerService': { installExplorerModule: sinon.stub().resolves(true) + }, + // Venue independence: buildAndUp('xchain-hub') calls the real guard, which + // shells out to `docker inspect` on the HOST running the suite. On a box + // with no hub container it returns null and every hub test passes; on a CI + // venue that happens to run a regtest hub it reads that container's live + // consensus env, finds the fixture env does not carry HUB_NETWORK or + // ORACLE_MIN_SUBMISSIONS, and refuses the deploy - failing hub tests that + // are about ports, health probes and capability mounts. Stub it here so the + // default answer is the clean one everywhere. Tests that assert ON the + // guard's wiring override this through extraProxies. + './HubConsensusEnvGuard': { + assertNoHubConsensusEnvDrift: sinon.stub().resolves([]), + isHubConsensusEnvDriftError: () => false } } if (constantsOverride) { @@ -171,16 +184,32 @@ describe('ModuleService', function () { const RealDockerService = require('../../src/services/DockerService') const realGetPublishedHostPorts = RealDockerService.getPublishedHostPorts + // Same venue independence for the hub consensus-env guard, which buildAndUp + // lazily requires for xchain-hub. Unstubbed it runs `docker inspect` against + // the host's own hub container: null (pass) on a box with no hub, a REFUSAL on + // a CI venue running a regtest hub, because the fixture env carries none of the + // consensus-shaped vars that live container was deployed with. loadModuleService + // now stubs it by default; this makes a load that forgets fail loudly and + // identically on every box instead of only on the venue that has a hub. + const RealHubConsensusEnvGuard = require('../../src/services/HubConsensusEnvGuard') + const realAssertNoHubConsensusEnvDrift = RealHubConsensusEnvGuard.assertNoHubConsensusEnvDrift + before(function () { RealDockerService.getPublishedHostPorts = async function () { throw new Error( 'unit test reached the real host-port probe: stub DockerService.getPublishedHostPorts' ) } + RealHubConsensusEnvGuard.assertNoHubConsensusEnvDrift = async function () { + throw new Error( + 'unit test reached the real hub consensus-env guard: stub HubConsensusEnvGuard.assertNoHubConsensusEnvDrift' + ) + } }) after(function () { RealDockerService.getPublishedHostPorts = realGetPublishedHostPorts + RealHubConsensusEnvGuard.assertNoHubConsensusEnvDrift = realAssertNoHubConsensusEnvDrift }) // ------------------------------------------------------------------- @@ -1532,7 +1561,14 @@ describe('ModuleService', function () { }, './VersionService': { getLocalNodeVersion: sinon.stub().resolves(null), getLocalModuleVersion: sinon.stub().resolves(null), checkRemoteNodeVersion: sinon.stub().resolves() }, './NodeService': { buildCryptoNode: sinon.stub().resolves(true), getCryptoNode: sinon.stub().resolves() }, - './ExplorerService': { installExplorerModule: sinon.stub().resolves(true) } + './ExplorerService': { installExplorerModule: sinon.stub().resolves(true) }, + // This load bypasses loadModuleService, so it needs the guard stub of + // its own; without it the test reads whatever hub container the host + // is running (see loadModuleService for the full note). + './HubConsensusEnvGuard': { + assertNoHubConsensusEnvDrift: sinon.stub().resolves([]), + isHubConsensusEnvDriftError: () => false + } }) await ms2.buildAndUp('xchain-hub', 'bitcoin', 'mainnet') // Should have a volume mount for the capability config, and it must be @@ -2423,7 +2459,13 @@ describe('ModuleService', function () { './DatabaseService': { setDatabaseParameters: sinon3.stub().resolves(), setHubDatabaseParameters: sinon3.stub().resolves() }, './VersionService': { getLocalNodeVersion: sinon3.stub().resolves(null), getLocalModuleVersion: sinon3.stub().resolves(null), checkRemoteNodeVersion: sinon3.stub().resolves() }, './NodeService': { buildCryptoNode: sinon3.stub().resolves(true), getCryptoNode: sinon3.stub().resolves() }, - './ExplorerService': { installExplorerModule: sinon3.stub().resolves(true) } + './ExplorerService': { installExplorerModule: sinon3.stub().resolves(true) }, + // Another load that bypasses loadModuleService, and it installs the + // HUB, so the guard fires: stub it here too (see loadModuleService). + './HubConsensusEnvGuard': { + assertNoHubConsensusEnvDrift: sinon3.stub().resolves([]), + isHubConsensusEnvDriftError: () => false + } }) // With inspect rejection, containerExistsByName returns false → proceeds with install const result = await ms.installModule('xchain-hub', null, null, false) @@ -2821,6 +2863,28 @@ describe('ModuleService', function () { expect(assertNoHubConsensusEnvDrift.called).to.equal(false) }) + + it('does not reach the real guard from a default harness load (venue independence)', async function () { + // Regression guard for the CI failure this row closed: a hub buildAndUp + // test that did not stub HubConsensusEnvGuard ran the real one, which + // inspects the HOST's hub container. It passed on a laptop with no hub + // and failed on the venue that had one. The file-level before() hook + // makes the real guard throw, so this passes only while loadModuleService + // supplies the stub by default. + const stubs = makeStubs() + stubs.execFile.callsFake((cmd, args, ...rest) => { + const cb = typeof rest[0] === 'function' ? rest[0] : rest[1] + if (args[0] === 'build') cb(null) + else if (args[0] === 'run') cb(null, 'a'.repeat(64) + '\n') + else cb(null) + }) + const ms = loadModuleService(stubs) + + await ms.buildAndUp('xchain-hub', null, null) + + // The real guard would have thrown; reaching here means the stub answered. + expect(stubs.execFile.called).to.equal(true) + }) }) // ----------------------------------------------------------------------- From 1fd956bd6645a398bd4788a1e1f4aa762e0287ee Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 2 Sep 2026 00:16:44 -0700 Subject: [PATCH 6/9] test(node): pin the mariadb advisory floor in the dependency gate --- .../dependency-advisories.test.js | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/test/unit/security/configuration/dependency-advisories.test.js b/test/unit/security/configuration/dependency-advisories.test.js index 1c5cee8..9d2903a 100644 --- a/test/unit/security/configuration/dependency-advisories.test.js +++ b/test/unit/security/configuration/dependency-advisories.test.js @@ -89,6 +89,23 @@ describe('Security: remediated dependency advisories @regression @tier4', functi // reach here, not dev-only: express-rate-limit and geoip-lite both parse the // client request IP, so a padded or mapped form could key a different // rate-limit bucket than its canonical address. + + // mariadb <=3.5.2 is the production DB driver every service here opens its + // pool with, not a dev-only reach. GHSA-cqhc-2h57-wpxf (HIGH) sends the + // password in the clear to a man in the middle even when the pool asked for + // `ssl: true`, because the connector falls back to a plaintext handshake + // rather than failing closed; GHSA-42r5-vhpq-m858 is the same + // cleartext-credential exposure stated as its own advisory; and + // GHSA-g5xc-5w98-jfvm is SQL injection through Buffer parameter escaping + // under the big5, gbk, sjis, cp932 and gb18030 client charsets. Measured + // exposure in this topology is nil today (no repo passes `ssl:` to a pool, + // every DB connection is host-local, and none selects one of those + // charsets), which is why the fix rode the ordinary fleet path instead of a + // hotfix. The guard is what stops a lockfile refresh, or the first service + // that does dial a remote database over TLS, from landing back inside the + // range. 3.5.3 is the patch, and it also moves the driver's own lru-cache + // onto the 11.x line, so a splice that bumps mariadb and leaves lru-cache + // at 10.4.3 has not actually installed the fixed driver. const advisories = [ { name: 'fast-uri', minSafe: [3, 1, 5], majorSeries: 3 }, { name: 'brace-expansion', minSafe: [5, 0, 9], majorSeries: 5 }, @@ -103,7 +120,8 @@ describe('Security: remediated dependency advisories @regression @tier4', functi { name: 'shell-quote', minSafe: [1, 9, 0], majorSeries: 1 }, { name: 'form-data', minSafe: [4, 0, 6], majorSeries: 4 }, { name: 'tmp', minSafe: [0, 2, 6], majorSeries: 0 }, - { name: 'ip-address', minSafe: [10, 3, 1], majorSeries: 10 } + { name: 'ip-address', minSafe: [10, 3, 1], majorSeries: 10 }, + { name: 'mariadb', minSafe: [3, 5, 3], majorSeries: 3 } ]; // Compares dotted numeric version triples without pulling in semver. @@ -201,4 +219,18 @@ describe('Security: remediated dependency advisories @regression @tier4', functi assert.ok(cmp(parse(axios.VERSION), [1, 18, 0]) >= 0, `installed axios is ${axios.VERSION}, inside the vulnerable range (fixed in 1.18.0)`); }); + + // Same reasoning as ADV-5, for the one entry in this list every service + // opens a socket with. mariadb's `exports` block hides its own + // package.json from require() and the module exports no version constant, + // so read the installed manifest off disk instead. + it('ADV-10: the installed mariadb reports a patched runtime version', function () { + if (!lockEntries('mariadb').length) return this.skip(); + const manifest = path.join(root, 'node_modules', 'mariadb', 'package.json'); + if (!fs.existsSync(manifest)) return this.skip(); + + const installed = JSON.parse(fs.readFileSync(manifest, 'utf8')).version; + assert.ok(cmp(parse(installed), [3, 5, 3]) >= 0, + `installed mariadb is ${installed}, inside the vulnerable range (fixed in 3.5.3)`); + }); }); From e4413c4daa242d06761ed7f05b008e3354f5ed55 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 2 Sep 2026 13:10:14 -0700 Subject: [PATCH 7/9] release: 0.14.0 Carrier for the consensus train. The manifest records the resolved component set; the activation this train carries is in CHANGELOG.md. --- CHANGELOG.md | 11 +++++++++++ package.json | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c781e..8ba5d06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.14.0] - 2026-09-02 + +### Fixed +- `install xchain-hub` no longer fails with HTTP 401 on a host provisioned by the runbook: the CLI now sends the hub API key that `validator init` generated. +- The checkpoint config block ships `hub_url` beside `self_sync`, so a fresh install resolves its checkpoint peer. +- Checkpoint self-sync resolves once per push, so every installed coin gets a checkpoint block instead of only the first. +- `xchain-node rollback` prints the recovery path and exits 1 rather than hanging in the precheck. + +### Activation +- This train carries a consensus change in the indexer and hub: attestation responsible-set widening activates on Bitcoin testnet at block 150780 and on regtest from genesis, and is inert on mainnet. Update every indexer and hub before that height; a node left on an older build will judge attestation responses differently once a widened one lands. + ## [0.12.3] - 2026-09-01 ### Fixed diff --git a/package.json b/package.json index 8578195..ed165c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xchain-node", - "version": "0.12.3", + "version": "0.14.0", "description": "xchain-node allows users to install, configure and run XChain platform nodes.", "license": "AGPL-3.0-or-later", "repository": { From 355b027896ef2483e3df10e786777b41d5824710 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 2 Sep 2026 16:22:21 -0700 Subject: [PATCH 8/9] release: pin the v0.14.0 component set xchain-indexer and xchain-hub move to v0.14.0 at their master merge commits; every other component keeps the tag it already carries. --- src/release-manifest.json | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/release-manifest.json b/src/release-manifest.json index c3c352e..684ebce 100644 --- a/src/release-manifest.json +++ b/src/release-manifest.json @@ -1,18 +1,20 @@ { "_comment": [ - "Pinned component set for XChain Platform v0.12.3.", + "Pinned component set for XChain Platform v0.14.0.", "Written at ceremony step 6 from the ACTUAL tagged master merge commits.", "xchain-node is the carrier and is not listed: checking out its tag IS this manifest.", - "A patch train tags only the repos it touches. This one moves the carrier and", - "xchain-hub; every other component is unchanged from v0.12.1 or earlier and keeps", - "the tag it already carries, which is what section 4 means by a version being the", - "platform version at which a component last changed. A gap is unchanged, not skipped.", + "A train tags only the repos it touches. This one moves xchain-indexer and xchain-hub", + "(the attestation responsible-set liveness ladder, flag day BTC testnet 150780) and", + "the carrier; xchain-documentation is tagged with the train but not pinned here.", + "Every other component is unchanged and keeps the tag it already carries, which is", + "what section 4 means by a version being the platform version at which a component", + "last changed. A gap is unchanged, not skipped.", "Each commit below is the master MERGE commit its tag names, and every tag is", "GPG-signed by the platform release key and reports verified against the", "tagger identity releases@xchain.io." ], - "platform_version": "0.12.3", - "released": "2026-09-01", + "platform_version": "0.14.0", + "released": "2026-09-02", "components": { "xchain-vm": { "tag": "v0.12.0", @@ -23,12 +25,12 @@ "commit": "9f333337c1362ff2651aa8877d7a5445476b5b12" }, "xchain-indexer": { - "tag": "v0.12.1", - "commit": "aee2bf21ecd6dc0fa90c26e7108dbedceb609bda" + "tag": "v0.14.0", + "commit": "e0d183eaf4011859b9c4f44c34112d61d43be28f" }, "xchain-hub": { - "tag": "v0.12.3", - "commit": "bb5b0d180b544ea08b5720b9f8416256fd07641c" + "tag": "v0.14.0", + "commit": "e7f3b9728847a3f7916d6e410bc0f727e6b2b844" }, "xchain-sync": { "tag": "v0.12.0", From d7b9c21bc1572a0ac67443b194aaa5105cfee110 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 2 Sep 2026 16:28:11 -0700 Subject: [PATCH 9/9] build: lift fast-uri and qs past their registry advisories Lockfile only. A new high advisory on fast-uri (via ajv) turned the production audit red between the v0.12.3 and v0.14.0 cuts. --- package-lock.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index b9fb991..3102c57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-node", - "version": "0.12.0", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-node", - "version": "0.12.0", + "version": "0.14.0", "license": "AGPL-3.0-or-later", "dependencies": { "@dankest-llc/xchain-sdk": "^0.11.1", @@ -2839,9 +2839,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -4412,9 +4412,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1",