From 9c66483a1e37683a664bd6bdc1434da60af677c6 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 15 Aug 2026 16:02:53 -0700 Subject: [PATCH 1/9] fix(decoder): AML review round findings (7 files) Findings adjudicated in the 2026-08-15 xchain-platform review round. Every claim was re-checked against current code and every remedy was re-derived rather than applied from the finding's recommended option; each verdict then passed an adversarial verify stage before booking. Findings: #4830 test-quality: sleep-flake in xchain-decoder (6) #4855 Stale 'not part of any consensus hash' comment on getFirstBlock contradicts the 2026-08-06 #4930 DISPENSER EXPIRATION: decoder rejects values above 4294967295 that the indexer accepts, sil --- src/CryptoNetworks.js | 7 ++++- src/XChainDecoder.js | 28 +++++++++++++++---- src/db.js | 4 +-- src/sql/dispensers.sql | 2 +- test/chaos/CE08-signalHandling.chaos.js | 9 +++++- test/fuzz/harness/dispenserParsing.fuzz.js | 7 +++-- .../connectionHandling.security.test.js | 8 +++++- 7 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/CryptoNetworks.js b/src/CryptoNetworks.js index 40499c8..78a1f53 100644 --- a/src/CryptoNetworks.js +++ b/src/CryptoNetworks.js @@ -68,7 +68,12 @@ class CryptoNetworks { return coins.getCoinConfig(p.tick, p.net).chainGenesisHash || null; } - // Indexing start height (not part of any consensus hash). Unknown/regtest -> 0. + // Indexing start height, and a CONSENSUS input: coins/index.js folds firstBlock into + // consensusSubset() (REGENERATED 2026-08-06, see coins/consensus_pin.js), because the + // decoder reads it as the chain's start height, so it decides which block the action + // history begins at. A node bundling a higher value skips the actions below it and + // replays a different history while its pin verifies clean, so this is never a + // locally-tunable operational value. Unknown/regtest -> 0. static getFirstBlock(networkName){ const p = parseNetworkName(networkName); return p ? coins.getCoinConfig(p.tick, p.net).firstBlock : 0; diff --git a/src/XChainDecoder.js b/src/XChainDecoder.js index c6d167a..fdb4961 100644 --- a/src/XChainDecoder.js +++ b/src/XChainDecoder.js @@ -2913,10 +2913,24 @@ class XChainDecoder { // block loop, which then retries the same deterministic tx // forever - or truncates under a lax one, leaving the decoder // holding a dispenser the indexer never registered. - // Number.isInteger already excludes NaN and Infinity, so it + // Number.isSafeInteger already excludes NaN and Infinity, so it // subsumes the isNaN test it replaces; the default expiration is // integral by construction (block timestamp + whole days). - if (!Number.isInteger(expiration) || expiration < 0 || expiration > 4294967295) { + // + // SAFE integer, not merely integer, and no u32 ceiling. The old + // `expiration > 4294967295` reject was recognition drift: the + // indexer escrows any non-negative integer EXPIRATION into its own + // BIGINT UNSIGNED column, so a dispenser opened past year 2106 (or + // spelled 9999999999 for "never") stayed open and escrowed there + // while the decoder skipped registration, and a later coin payment + // to it was never flagged as a dispense. Number.isSafeInteger is + // the bound that actually holds: at or below it Number() round-trips + // the payload token exactly, so the decoder stores the same value + // the indexer does, and it stays far inside BIGINT UNSIGNED. + // Dropping the ceiling outright would NOT be safe - Number.isInteger + // is true for 1e300, which overflows the column and wedges the block + // loop on the same deterministic tx forever. + if (!Number.isSafeInteger(expiration) || expiration < 0) { this.parseErrors++ console.error(`Skipping dispenser in tx ${nextTransactionHash}: invalid expiration value '${decodedDataSplit[14]}'`) } else if (this.dispenserOpensForThisChain(giveCoin, getCoin)){ @@ -3040,9 +3054,13 @@ class XChainDecoder { // path writes through extendOpenDispenserExpirationBySource // into the same BIGINT UNSIGNED column, and the indexer // rejects a fractional edit EXPIRATION with the identical - // isInteger test. - if (Number.isInteger(newExpiration) && newExpiration >= 0 && - newExpiration <= 4294967295 && newExpiration > block.timestamp){ + // isInteger test, and the same SAFE-integer ceiling rather than + // a u32 one (see the create guard: a u32 reject here would + // silently decline to mirror an extend the indexer accepted, + // closing the decoder's row early on a dispenser that is still + // open and escrowed). + if (Number.isSafeInteger(newExpiration) && newExpiration >= 0 && + newExpiration > block.timestamp){ // nextBlockHeight lets the mirror also clear a soft-expiry // THIS block stamped: deleteOpenDispensers ran before this // loop, so without it the `IS NULL` filter silently skipped diff --git a/src/db.js b/src/db.js index 2be564b..b39a05c 100644 --- a/src/db.js +++ b/src/db.js @@ -474,7 +474,7 @@ class Database { } // Assert that dispensers.expiration is exactly BIGINT UNSIGNED. The DISPENSER parser - // accepts a raw unix expiration up to 4294967295 (year 2106) and xchain-indexer holds + // accepts a raw unix expiration up to Number.MAX_SAFE_INTEGER and xchain-indexer holds // the same field as BIGINT UNSIGNED, so anything narrower or signed is fleet drift the // guard exists to catch: a signed BIGINT loses nothing today but rejects nothing either, // while INT / INT UNSIGNED either fail the write under a strict sql_mode or truncate @@ -1953,7 +1953,7 @@ class Database { // expiration is a raw unix timestamp (seconds) stored as-is into a BIGINT UNSIGNED // column. It is deliberately NOT wrapped in FROM_UNIXTIME(): FROM_UNIXTIME() caps at // 2147483647 (Y2038) and returns NULL above it, which would silently drop every - // expiration in 2038–2106 even though the decoder accepts values up to 4294967295 + // expiration past 2038 even though the decoder accepts any safe-integer value // (XChainDecoder.js DISPENSER parse). Matches xchain-indexer dispensers.expiration. let connection = await this.getConnection() diff --git a/src/sql/dispensers.sql b/src/sql/dispensers.sql index 0b5219f..efff576 100644 --- a/src/sql/dispensers.sql +++ b/src/sql/dispensers.sql @@ -16,7 +16,7 @@ DROP TABLE IF EXISTS dispensers; CREATE TABLE dispensers ( tx_index BIGINT UNSIGNED, address_id BIGINT UNSIGNED, - expiration BIGINT UNSIGNED, -- unix timestamp of dispenser expiration (raw seconds; matches xchain-indexer dispensers.expiration). Stored raw, NOT via FROM_UNIXTIME: a DATETIME/FROM_UNIXTIME round-trip silently NULLs any expiration past 2038 (Y2038), yet the protocol accepts values up to 4294967295 (year 2106). + expiration BIGINT UNSIGNED, -- unix timestamp of dispenser expiration (raw seconds; matches xchain-indexer dispensers.expiration). Stored raw, NOT via FROM_UNIXTIME: a DATETIME/FROM_UNIXTIME round-trip silently NULLs any expiration past 2038 (Y2038), yet the protocol accepts any non-negative integer EXPIRATION, well past 4294967295 (year 2106). The parser bounds it at Number.MAX_SAFE_INTEGER so the value it stores round-trips exactly, matching what xchain-indexer escrows. oracle_address_id BIGINT UNSIGNED DEFAULT NULL, -- index_addresses id of the v0 ORACLE_ADDRESS (Mode B dispensers only; NULL otherwise). Recognition-only, like the rest of this table: it exists so a later DISPENSER v2 refill, whose payload names no address, can still have its PRICE v1 oracle-usage-fee output captured into transaction_outputs for the indexer to validate. Additive and nullable, so the startup drift reconciler adds it to existing databases with no migration. source_address_id BIGINT UNSIGNED DEFAULT NULL, -- index_addresses id of the DISPENSER create's SOURCE, stored ONLY when it differs from address_id (a delegated GET_ADDRESS dispenser); NULL means "same as address_id". The indexer authorises a cancel/edit when the acting SOURCE equals the dispenser SOURCE *or* its GET_ADDRESS; address_id alone records only the latter, so a delegated dispenser cancelled by its original creator matched nothing here and stayed open past the indexer's close. Additive and nullable, so the startup drift reconciler adds it to existing databases with no migration. expired_block_index BIGINT UNSIGNED DEFAULT NULL, -- NULL = open. Set to the block height that expired this dispenser instead of hard-deleting the row (soft-expire). A reorg's deleteBlockByIndex clears the mark for orphaned heights, so a dispenser expired by a now-orphaned block's (non-monotonic) timestamp is restored rather than lost. Rows are hard-purged once they are reorg-safe-deep (see DISPENSER_EXPIRE_SAFE_DEPTH in XChainDecoder.js), bounding table growth. diff --git a/test/chaos/CE08-signalHandling.chaos.js b/test/chaos/CE08-signalHandling.chaos.js index fa0ef56..f4072db 100644 --- a/test/chaos/CE08-signalHandling.chaos.js +++ b/test/chaos/CE08-signalHandling.chaos.js @@ -117,10 +117,17 @@ describe('CE-08: Signal Handling and Graceful Shutdown', function () { assert.strictEqual(decoder.lastPollAt, 0, 'heartbeat starts unset before the loop runs') - setTimeout(() => decoder.stop(), 200) + // Stop on the signal the assertions are about (the heartbeat has been stamped) + // rather than after a fixed 200ms: a loaded machine could spend that window + // before the first iteration and stop a loop that never ran. Stopping on the + // timeout branch too keeps a stuck loop a failed assertion, not a hung suite. + const stopWhenAlive = waitUntil(() => decoder.lastPollAt > 0, { interval: 5, timeout: 5000 }) + .then(() => decoder.stop(), () => decoder.stop()) + await captureConsole(async () => { await decoder.start() }) + await stopWhenAlive assert.ok(decoder.lastPollAt > 0, 'the loop must stamp lastPollAt') assert.strictEqual(decoder.isPollSilent(), false, 'a loop that just ran is not silent') diff --git a/test/fuzz/harness/dispenserParsing.fuzz.js b/test/fuzz/harness/dispenserParsing.fuzz.js index b688134..4dd405f 100644 --- a/test/fuzz/harness/dispenserParsing.fuzz.js +++ b/test/fuzz/harness/dispenserParsing.fuzz.js @@ -61,10 +61,11 @@ function parseDispenserData(decodedData) { ? DEFAULT_EXPIRATION : Number(expirationToken) - // Mirrors the create guard, which requires an INTEGER: the column is + // Mirrors the create guard, which requires a SAFE INTEGER: the column is // BIGINT UNSIGNED and the indexer rejects a fractional EXPIRATION outright. - // Number.isInteger subsumes the isNaN test it replaces. - if (!Number.isInteger(expiration) || expiration < 0 || expiration > 4294967295) { + // Number.isSafeInteger subsumes the isNaN test it replaces, and carries no + // u32 ceiling (the indexer escrows any non-negative integer EXPIRATION). + if (!Number.isSafeInteger(expiration) || expiration < 0) { return { shouldInsert: false, fields: null } } diff --git a/test/security/connectionHandling.security.test.js b/test/security/connectionHandling.security.test.js index 87a2ec2..172660d 100644 --- a/test/security/connectionHandling.security.test.js +++ b/test/security/connectionHandling.security.test.js @@ -95,7 +95,13 @@ describe('Security: Connection Handling', () => { secondAcquired = true }) - await new Promise(resolve => setTimeout(resolve, 10)) + // Poll for the waiter to enqueue rather than sleeping a fixed 10ms: the wait + // is on an observable condition, so a loaded machine cannot under-sleep it. + // Same shape as the FIFO test below. + const deadline = Date.now() + 2000 + while (db._transactionLockQueue.length < 1 && Date.now() < deadline) { + await new Promise(resolve => setImmediate(resolve)) + } assert.strictEqual(secondAcquired, false) assert.strictEqual(db._transactionLockQueue.length, 1) From c3931149c620e82e2ed2056dbc793dc4cc1175b6 Mon Sep 17 00:00:00 2001 From: J-Dog <376028+jdogresorg@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:30:25 +0000 Subject: [PATCH 2/9] fix(decoder): re-prove chain identity on the reorg tip re-read verifyReorg's mid-walk tip refresh accepted a refreshed node tip after only the chain-tier gate. A same-tier foreign endpoint (BTC-mainnet and DOGE-mainnet both report chain="main") reached via NODE_URL_FALLBACK failover could then have its block height accepted as nodeTip and drive deleteBlockByIndex over valid local blocks. Gate the tip acceptance on verifyChainGenesis() too, matching the main poll loop's dual gate: on a proven block-0 mismatch keep the call-time tip and fall through to sleep-and-retry (the recoverable direction). Adds regression coverage for the foreign-endpoint refusal and the agreeing- endpoint self-heal, and updates the wiring test to assert the dual gate. --- src/XChainDecoder.js | 14 +++- test/unit/chainIdentityGate.test.js | 18 +++-- test/unit/verifyReorgRetry.test.js | 101 ++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 5 deletions(-) diff --git a/src/XChainDecoder.js b/src/XChainDecoder.js index fdb4961..dbd744b 100644 --- a/src/XChainDecoder.js +++ b/src/XChainDecoder.js @@ -1811,7 +1811,19 @@ class XChainDecoder { if (reorgChainMismatch){ this.logError('reorg: ignoring a tip refresh from a foreign endpoint: ' + reorgChainMismatch) } else if (info && typeof info.blocks === 'number') { - nodeTip = info.blocks + // Tier agreement is not chain identity: a same-tier foreign node (BTC-mainnet + // and DOGE-mainnet both report chain="main") passes the tier gate above, so + // re-prove the chain with the genesis pin too, exactly as the block loop does + // before it trusts a refreshed tip. verifyChainGenesis() never throws and returns + // null when unpinned/unreadable/agreeing, so on anything but a PROVEN mismatch the + // tip advances as before; a proven mismatch keeps the call-time tip and falls + // through to sleep-and-retry (the recoverable direction). + const reorgGenesisMismatch = await this.verifyChainGenesis() + if (reorgGenesisMismatch){ + this.logError('reorg: ignoring a tip refresh from a foreign endpoint: ' + reorgGenesisMismatch) + } else { + nodeTip = info.blocks + } } } catch (refreshErr) { /* node unreachable; retry with the existing tip */ } await this.sleep(3000) diff --git a/test/unit/chainIdentityGate.test.js b/test/unit/chainIdentityGate.test.js index d2e3ba8..bb66622 100644 --- a/test/unit/chainIdentityGate.test.js +++ b/test/unit/chainIdentityGate.test.js @@ -122,10 +122,20 @@ describe('endpoint chain-tier identity gate @regression', function () { it("verifyReorg's tip re-read refuses a foreign endpoint instead of moving nodeTip", function () { const idx = SRC.indexOf('chainTierMismatch(this.consensusNetwork, info["chain"])'); assert.ok(idx > 0, "verifyReorg's getBlockchainInfo re-read must check the reported chain"); - const window = SRC.slice(idx, idx + 400); - assert.ok(/nodeTip = info\.blocks/.test(window), 'the guarded assignment is the one under test'); - assert.ok(/\}\s*else if/.test(window), - 'the tip assignment must be the ELSE of the mismatch branch, so a foreign tip is never taken'); + const window = SRC.slice(idx, idx + 1400); + // The tier gate cannot separate a same-tier foreign chain (BTC-mainnet and + // DOGE-mainnet both report chain="main"), so the re-read re-proves chain + // identity with the block-0 pin too before it trusts the refreshed tip, + // exactly as the block loop does. Both gates must precede the assignment. + const genesisIdx = window.indexOf('await this.verifyChainGenesis()'); + const tipIdx = window.indexOf('nodeTip = info.blocks'); + assert.ok(genesisIdx > 0, + 'the tip re-read must also re-prove chain identity via verifyChainGenesis()'); + assert.ok(tipIdx > 0, 'the guarded assignment is the one under test'); + assert.ok(genesisIdx < tipIdx, + 'verifyChainGenesis() must gate the tip assignment: a foreign block-0 keeps the call-time tip'); + assert.ok(/\}\s*else\s*\{\s*nodeTip = info\.blocks/.test(window), + 'the tip assignment must be the ELSE of the genesis-mismatch branch, so a foreign tip is never taken'); }); }); diff --git a/test/unit/verifyReorgRetry.test.js b/test/unit/verifyReorgRetry.test.js index 683c306..bea3e9a 100644 --- a/test/unit/verifyReorgRetry.test.js +++ b/test/unit/verifyReorgRetry.test.js @@ -289,6 +289,107 @@ describe('XChainDecoder.verifyReorg mid-walk tip regression', function () { 'blocks above the regressed tip are deleted via the above-tip branch after the tip refresh') }) + // The mid-walk tip refresh is the SECOND path a node tip reaches nodeTip, and + // nodeTip is exactly what the above-tip branch deletes valid local blocks against. + // The tier gate alone cannot separate a same-tier foreign chain (BTC-mainnet and + // DOGE-mainnet both report chain="main") from ours, so on a NODE_URL_FALLBACK + // failover onto a same-tier foreign endpoint the refresh must also re-prove chain + // identity with the block-0 pin, exactly as the block loop does, before it trusts + // the refreshed tip. Otherwise it accepts the foreign height and deletes valid + // local blocks against another chain's tip. + + // Our pinned block-0 hash (real BTC mainnet genesis, used only as a sample value). + const OUR_GENESIS = '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f' + // A same-tier foreign chain's block-0 hash (Dogecoin mainnet), the case the tier + // gate cannot refuse because it too reports chain="main". + const FOREIGN_GENESIS = '1a91e3dace36e2be3bf030a65679fe821aa1d6ef92e7c9902eb318182c355691' + + it('refuses a same-tier foreign endpoint tip refresh (genesis pin mismatch) and deletes no local blocks', async function () { + const decoder = new XChainDecoder( + 'bitcoin-mainnet', 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null + ) + decoder.startBlockIndex = 0 + decoder.chainGenesisHash = OUR_GENESIS + + // DB stores 100..105; call-time tip is 105. The endpoint answering the mid-walk + // refresh is a SAME-TIER FOREIGN chain: it reports chain="main" (passes the tier + // gate) and blocks=102, but its block 0 is a different chain's genesis. If that + // foreign tip were accepted, 103,104,105 would look above-tip and be deleted. + let top = 105 + const deleted = [] + let genesisReads = 0 + let sleeps = 0 + decoder.sleep = async () => { + // The refusal correctly leaves the walk stuck (it will not delete against a + // foreign tip and will not accept the foreign height). Break out deterministically + // after a few refusals by exhausting the table, then assert nothing was deleted. + if (++sleeps >= 3) { top = -1 } + } + decoder.connector = { + getBlockHash: async (h) => { + if (h === 0) { genesisReads++; return FOREIGN_GENESIS } + if (h > 102) throw new Error('Block height out of range') + return 'match' + h + }, + getBlockchainInfo: async () => ({ blocks: 102, chain: 'main' }) + } + decoder.db = { + getLastBlockIndex: async () => top, + getBlockByIndex: async (h) => (h < 0 ? null : { block_hash: 'db' + h }), + deleteBlockByIndex: async (h) => { deleted.push(h); top = h - 1 }, + insertEvent: async () => true, + isReorgHalted: async () => false, + markReorgHalted: async () => {} + } + + const result = await decoder.verifyReorg(105) + assert.strictEqual(result, true) + assert.deepStrictEqual(deleted, [], + 'a same-tier foreign endpoint tip must never drive deleteBlockByIndex over valid local blocks') + assert.ok(genesisReads >= 1, 'the tip refresh must re-prove chain identity via the block-0 pin') + assert.strictEqual(decoder.chainGenesisCheckedAt, 0, + 'a refused foreign endpoint must not count as a verified check') + }) + + it('still accepts the refreshed tip when block 0 agrees, so the self-heal happy path is unchanged', async function () { + const decoder = new XChainDecoder( + 'bitcoin-mainnet', 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null + ) + decoder.startBlockIndex = 0 + decoder.chainGenesisHash = OUR_GENESIS + decoder.sleep = async () => {} + + // Identical shape to the refusal case, but the refreshing endpoint is OURS: its + // block 0 matches the pin, so the regressed tip (102) is accepted and the orphan + // blocks above it (103,104,105) drain via the above-tip branch, exactly as before + // the genesis gate was added. + const REGRESSED_TIP = 102 + let top = 105 + const deleted = [] + const dbHash = { 105: 'db105', 104: 'db104', 103: 'db103', 102: 'match102', 101: 'match101', 100: 'match100' } + decoder.connector = { + getBlockHash: async (h) => { + if (h === 0) return OUR_GENESIS + if (h > REGRESSED_TIP) throw new Error('Block height out of range') + return 'match' + h + }, + getBlockchainInfo: async () => ({ blocks: REGRESSED_TIP, chain: 'main' }) + } + decoder.db = { + getLastBlockIndex: async () => top, + getBlockByIndex: async (h) => ({ block_hash: dbHash[h] }), + deleteBlockByIndex: async (h) => { deleted.push(h); top = h - 1 }, + insertEvent: async () => true, + isReorgHalted: async () => false, + markReorgHalted: async () => {} + } + + const result = await decoder.verifyReorg(105) + assert.strictEqual(result, true) + assert.deepStrictEqual(deleted, [105, 104, 103], + 'an agreeing endpoint still self-heals a regressed tip via the above-tip branch') + }) + it('keeps retrying (does not crash) when the node is fully unreachable', async function () { const decoder = new XChainDecoder( 'bitcoin-regtest', 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null From 35fc93c9314adc5bf367a1bfaf662243eefc36bc Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 15 Aug 2026 19:54:27 -0700 Subject: [PATCH 3/9] ci: pin sibling checkouts to the branch under test With no ref, actions/checkout fetches the sibling repo's DEFAULT branch, which D7 (2026-08-14) keeps at master (released code). Develop CI was therefore comparing develop code against master siblings, which held only while master stayed leveled with develop; the LIST-memo lane diverging develop across xchain-indexer/sdk/explorer turned every cross-repo gate red (drift-guards both directions, explorer schema canary). Same idiom as the .ci-siblings SIBLINGS_REF blocks; the venue push gate already ships siblings at the pushed branch (XC-1494). XC-1519. --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e18fe14..53629ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ jobs: uses: actions/checkout@v4 with: repository: XChain-Platform/xchain-hub + ref: ${{ github.ref == 'refs/heads/master' && 'master' || 'develop' }} ssh-key: ${{ secrets.XCHAIN_HUB_DEPLOY_KEY }} path: xchain-hub From d762ec04b0f4c460eae79339ed2a562ef1edc0c4 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 15 Aug 2026 20:12:53 -0700 Subject: [PATCH 4/9] ci: give the push gate the full GitHub job set (bin/ci-full.sh) The pre-push venue gate ran npm run ci, one of the four jobs ci.yml fans out on GitHub, so a push could gate green locally and go red upstream on drift-guards, docker-suites or coverage. bin/ci-full.sh transcribes every job's run-steps in job order, fails loud on a missing sibling or a dockerless venue instead of skipping, and reports every red tier. .ci-timeout raises the gate ceiling to 5400s (docker-suites alone is allowed 30m on GitHub). When ci.yml gains or changes a job, ci-full.sh changes in the same commit. XC-1520. --- .ci-timeout | 1 + bin/ci-full.sh | 126 +++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 .ci-timeout create mode 100755 bin/ci-full.sh diff --git a/.ci-timeout b/.ci-timeout new file mode 100644 index 0000000..bd6b502 --- /dev/null +++ b/.ci-timeout @@ -0,0 +1 @@ +5400 diff --git a/bin/ci-full.sh b/bin/ci-full.sh new file mode 100755 index 0000000..cd2cf94 --- /dev/null +++ b/bin/ci-full.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +#********************************************************************* +# +# 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. +# +#********************************************************************* + +# +# bin/ci-full.sh: run EVERY tier this repo's GitHub CI runs, in one process. +# +# .github/workflows/ci.yml fans this repo out as four parallel jobs (ci, +# drift-guards, docker-suites, coverage). The pre-push venue gate used to run +# only `npm run ci`, so a push could gate green locally and then go red on +# GitHub on a job the gate never ran (2026-08-15: exactly that, on three repos +# at once). This script IS the local twin of the workflow: every job's +# run-steps, transcribed, in job order. When ci.yml gains or changes a job, +# change this script in the same commit. +# +# Layout: siblings resolve at ../, which is both the platform monorepo +# layout and the venue gate's work/ layout (.ci-siblings ships them there). A +# sibling a GitHub job checks out is REQUIRED here: missing means fail loud, +# never skip, because GitHub will run the step this gate would be skipping. The +# coverage job clones the whole .ci-siblings roster before re-running the unit +# suite, so the roster is what need_sib demands, not just drift-guards' hub. +# +# Database: nothing here reads the venue's CI_DB_* on purpose. The two +# docker-gated tiers bring up their OWN MariaDB inside their compose fixture +# (test/{integration,e2e}/fixtures/docker-compose.test.yml) on ports 13318 and +# 13319, with fixture-local throwaway credentials that the tier's setup.js +# defaults to. Pointing them at a venue database would test the wrong server. +# +# Docker: the docker-suites job runs on a runner that has docker, and both +# tiers are useless without it, so a venue without docker FAILS here rather +# than skipping (a skip is exactly the green-locally / red-on-GitHub hole this +# script exists to close). +# +# Skipped by design: none. Every run-step of every push-triggered workflow is +# transcribed below. The actions-only steps (checkout, setup-node, the npm ci +# install, and the coverage job's sibling-clone loop) have no local twin by +# nature: the venue already ships a checkout, a node, installed modules, and +# the sibling roster, and need_sib proves the last of those. +# +# Out of scope: verify-tag.yml (push on tags v*) and audit.yml +# (schedule + workflow_dispatch + pull_request on manifest paths). Neither +# triggers on a push to develop or master, so neither belongs in a push gate. +# +set -uo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." +SELF="$(pwd)" +SIB="$(cd .. && pwd)" + +FAILED="" +run_tier() { + local name="$1"; shift + echo; echo "ci:full ===== $name =====" + if "$@"; then + echo "ci:full ----- $name PASS" + else + FAILED="$FAILED [$name]" + echo "ci:full ----- $name FAIL" + fi +} +need_sib() { + local s + for s in "$@"; do + if [ ! -d "$SIB/$s" ]; then + echo "ci:full: MISSING SIBLING $SIB/$s" >&2 + echo "ci:full: GitHub CI checks this sibling out and runs steps against it," >&2 + echo "ci:full: so skipping here would gate green on a subset. Declare it in" >&2 + echo "ci:full: .ci-siblings (venue) or clone it beside this repo (hand run)." >&2 + exit 1 + fi + done +} +need_docker() { + docker info >/dev/null 2>&1 || { + echo "ci:full: VENUE LACKS DOCKER for $1; pin a docker venue with CI_VENUES=..." >&2 + exit 1 + } +} + +need_sib xchain-encoder xchain-documentation xchain-hub xchain-indexer xchain-utxo-tracker + +# --- job: ci (XChain-Platform/.github ci-reusable.yml -> npm run ci) ------- +run_tier "ci" npm run ci + +# --- job: drift-guards ----------------------------------------------------- +# Run FROM the parent so sync-coins.sh sees the canonical + vendored pair the +# way the workflow lays them out (hub checkout beside this repo's checkout). +sync_coins_check() { (cd "$SIB" && "xchain-hub/bin/sync-coins.sh" --check --only "$(basename "$SELF")"); } +run_tier "drift: coin-registry byte-identity" sync_coins_check +run_tier "drift: coin consensus-pin conformance" node -e ' + const coins = require("./src/coins"); + for (const net of ["testnet", "regtest"]) { + const res = coins.verifyConsensusPin(net); + if (res && res.skipped) throw new Error("consensus pin unexpectedly unarmed for " + net); + } + console.log("consensus pin conformance OK (testnet, regtest)"); +' + +# --- job: docker-suites ---------------------------------------------------- +# Both tiers own their venue lifecycle inside their npm script (compose up +# --wait, mocha, down -v on any exit), so this transcribes the two run-steps +# and nothing else. The gate is docker itself, checked once before either. +need_docker "docker-suites (test:integration, test:e2e)" +run_tier "docker: integration tier (test:integration)" npm run test:integration +run_tier "docker: end-to-end tier (test:e2e)" npm run test:e2e + +# --- job: coverage --------------------------------------------------------- +run_tier "coverage ratchet (coverage:check)" npm run coverage:check + +echo +if [ -n "$FAILED" ]; then + echo "ci:full: RED tiers:$FAILED" + exit 1 +fi +echo "ci:full: all tiers green (same set GitHub CI runs)" diff --git a/package.json b/package.json index 8ab384e..a590e16 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,8 @@ "test:regression:critical": "mocha --timeout 5000 --require ./test/unit/setup.js --grep '\\[REGRESSION P0\\]' 'test/unit/**/*.test.js' 'test/security/**/*.security.test.js'", "test:regression:full": "mocha --timeout 10000 --require ./test/unit/setup.js --grep '\\[REGRESSION P[0123]\\]' 'test/unit/**/*.test.js' 'test/security/**/*.security.test.js' 'test/regression/**/*.test.js'", "test:mutation": "stryker run test/mutation/stryker.config.mjs", - "test:mutation:phase2": "stryker run test/mutation/stryker.phase2.config.mjs" + "test:mutation:phase2": "stryker run test/mutation/stryker.phase2.config.mjs", + "ci:full": "bash bin/ci-full.sh" }, "devDependencies": { "@stryker-mutator/core": "^9.6.1", From be7eabb62db41cafb73e7e1bd5094c5cec0705c5 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 15 Aug 2026 20:22:31 -0700 Subject: [PATCH 5/9] test(quality): give the boundary type a script so the survey can see it [XC-1521] The AML gap board flagged boundary as a test type existing on disk with no npm script running it. The 83 cases are ALREADY gated: they sit under test/unit/boundary/ and ci:unit's glob picks them up, confirmed by running it and finding all four boundary suites in the output. So this adds test:boundary to name the type and deliberately does NOT chain it into ci, because a ci:boundary stage would re-run 83 already-gated cases for no extra coverage. The finding is real as a naming gap and cosmetic as a coverage one, and this commit says so rather than manufacturing a stage. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index a590e16..9c37580 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "coverage:check": "c8 --check-coverage --lines 87.8 --statements 87.8 --branches 85.4 --functions 77.2 --reporter=text-summary --include 'src/**/*.js' mocha --timeout 5000 --require ./test/unit/setup.js 'test/unit/**/*.test.js' --exit", "test:smoke": "mocha --timeout 5000 --require ./test/unit/setup.js 'test/smoke/**/*.smoke.js'", "test:unit": "mocha --timeout 5000 --require ./test/unit/setup.js 'test/unit/**/*.test.js'", + "test:boundary": "mocha --timeout 5000 --require ./test/unit/setup.js 'test/unit/boundary/**/*.boundary.test.js'", "ci": "npm run ci:unit && npm run ci:security && npm run ci:smoke && npm run ci:regression && npm run ci:chaos && npm run ci:fuzz", "ci:unit": "mocha --timeout 5000 --exit --require ./test/unit/setup.js 'test/unit/**/*.test.js'", "ci:security": "mocha --timeout 10000 --exit --require ./test/security/setup.js 'test/security/**/*.security.test.js'", From 44ff0b3decad7430b73c47e8b17fdbb91805fca1 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 16 Aug 2026 09:25:28 -0700 Subject: [PATCH 6/9] fix(decoder): AML review round findings (7 files) Findings adjudicated in the 2026-08-16 xchain-platform review round. Every claim was re-checked against current code and every remedy was re-derived rather than applied from the finding's recommended option; each verdict then passed an adversarial verify stage before booking. Findings: #5061 test-quality: sleep-flake in xchain-decoder (5) #5078 Decoder migration runner lacks the backdating-frontier ordering guard the indexer runner enforces Report: claude/reports/2026-08-16_review-round-xchain-platform.md --- src/db.js | 58 +++++++++++++ test/chaos/CE01-nodeUnavailability.chaos.js | 6 +- .../chaos/CE04-midTransactionFailure.chaos.js | 6 +- test/chaos/CE05-malformedMempool.chaos.js | 5 +- test/chaos/CE08-signalHandling.chaos.js | 5 +- test/unit/migration-runner.test.js | 82 +++++++++++++++++++ test/unit/reorgHaltSurface.test.js | 14 +++- 7 files changed, 167 insertions(+), 9 deletions(-) diff --git a/src/db.js b/src/db.js index b39a05c..e83b7a7 100644 --- a/src/db.js +++ b/src/db.js @@ -420,6 +420,31 @@ class Database { continue; } + // Backdating guard: the dated-prefix check above freezes the NAMING + // convention, but nothing stopped a new file from being dated before a + // migration the fleet already applied. Lexical apply order then puts it + // in its date slot on a fresh DB and after the frontier on an aged one, + // diverging the two schemas. `frontier` is the ledger state at run start + // (appliedByName is not written during the loop, and the precondition + // baseline above deliberately does not advance it), so files applied or + // baselined by THIS run never move it and a resumed partial run is fine. + // Auto files only - see Database.backdatedFrontierViolation for why a + // deferred mode=manual file cannot be told apart from a backdated one. + // Mirrors xchain-indexer/src/db.js. + if(mode === 'auto'){ + const frontier = Database.backdatedFrontierViolation(file, appliedByName.keys()); + if(frontier){ + const msg = 'runMigrations: ' + file + ' is dated BEFORE already-applied migration ' + frontier + + ', so it would run in a different position here than on a fresh database and diverge the schema. ' + + 'Rename it with a date after ' + frontier + '.'; + // Same dual-mode contract as the checksum guard above: the operator + // path and opt-in strict mode fail closed, passive startup logs and + // proceeds so a backdated commit cannot black-start the fleet. + if(includeManual || process.env.MIGRATION_STRICT_CHECKSUM === '1') throw new Error(msg); + console.error(msg + ' Applying it anyway at this position - review manually.'); + } + } + const statements = this.splitSqlStatements(raw); // Destructive-DDL guard: the mode tag is a human declaration; this scan is // the machine check behind it. A file tagged `auto` that contains DDL able @@ -2620,4 +2645,37 @@ Database.MIGRATION_PRECONDITIONS = { }, }; +// Backdating guard for the auto-apply path, mirroring xchain-indexer/src/db.js. Apply +// order is lexical, so a migration added with a date EARLIER than one already applied +// runs in a different position on a fresh database (in its date slot) than on an aged +// one (after the frontier), and the two schemas diverge across the fleet. Given a +// pending filename and the names already in the ledger, return the offending applied +// name when the pending file sorts before the lexical maximum of them, else null. An +// empty ledger (fresh install) never trips. Pure string logic, no DB, unit-tested +// directly. +// +// Callers must pass this ONLY auto-mode files, and that restriction is the whole +// correctness argument rather than an optimization. A mode=manual file legitimately +// sits unapplied behind the frontier for as long as the operator defers it (seven of +// the nine files here are manual), so it is indistinguishable at runtime from a +// backdated one and guarding it would hard-fail `node src/migrate.js` on every aged +// fleet DB. An auto file has no such state: it applies unattended at the first startup +// that sees it, so an unapplied auto file behind the frontier is always newly backdated. +// +// Only DATED ledger names are eligible to be the frontier. No undated decoder migration +// ever shipped, so unlike the indexer this filter heals no known row; it is kept because +// an undated name sorts ABOVE every 2026-* name in ASCII ('a' 0x61 > '2' 0x32), so one +// stray row would make the frontier a garbage maximum that every ordinary new migration +// sorts below, hard-failing migrate on exactly the aged DBs this guard must not break. +Database.backdatedFrontierViolation = function(pendingName, appliedNames){ + let frontier = null; + for(const name of (appliedNames || [])){ + const n = String(name); + if(!/^\d{4}-\d{2}-\d{2}-/.test(n)) continue; + if(frontier === null || n > frontier) frontier = n; + } + if(frontier === null) return null; + return (String(pendingName) < frontier) ? frontier : null; +}; + module.exports = Database \ No newline at end of file diff --git a/test/chaos/CE01-nodeUnavailability.chaos.js b/test/chaos/CE01-nodeUnavailability.chaos.js index 609eaa3..5d6f460 100644 --- a/test/chaos/CE01-nodeUnavailability.chaos.js +++ b/test/chaos/CE01-nodeUnavailability.chaos.js @@ -32,8 +32,10 @@ describe('CE-01: Node Unavailability and Recovery', function () { mockConnector = createMockConnector() decoder.db = mockDb decoder.connector = mockConnector - // Override sleep to be fast in tests - decoder.sleep = (ms) => new Promise(r => setTimeout(r, Math.min(ms, 50))) + // Drop the retry backoff without naming a duration. setImmediate still + // yields the macrotask the poll loops need, so nothing in this suite is + // timed against a fixed sleep a loaded CI machine can overrun. + decoder.sleep = () => new Promise(r => setImmediate(r)) }) afterEach(function () { diff --git a/test/chaos/CE04-midTransactionFailure.chaos.js b/test/chaos/CE04-midTransactionFailure.chaos.js index af74960..499e007 100644 --- a/test/chaos/CE04-midTransactionFailure.chaos.js +++ b/test/chaos/CE04-midTransactionFailure.chaos.js @@ -33,8 +33,10 @@ describe('CE-04: Mid-Transaction Database Failure', function () { mockConnector = createMockConnector() decoder.db = mockDb decoder.connector = mockConnector - // Speed up sleeps in tests - decoder.sleep = (ms) => new Promise(r => setTimeout(r, Math.min(ms, 50))) + // Drop the retry backoff without naming a duration. setImmediate still + // yields the macrotask the poll loops need, so nothing in this suite is + // timed against a fixed sleep a loaded CI machine can overrun. + decoder.sleep = () => new Promise(r => setImmediate(r)) }) afterEach(function () { diff --git a/test/chaos/CE05-malformedMempool.chaos.js b/test/chaos/CE05-malformedMempool.chaos.js index 96bb90c..3a71d5a 100644 --- a/test/chaos/CE05-malformedMempool.chaos.js +++ b/test/chaos/CE05-malformedMempool.chaos.js @@ -36,7 +36,10 @@ describe('CE-05: Malformed Mempool Transaction', function () { // mock so these mempool-failure cases still exercise the DB-error handling they target. decoder.mempoolDb = mockDb decoder.connector = mockConnector - decoder.sleep = (ms) => new Promise(r => setTimeout(r, Math.min(ms, 50))) + // Drop the retry backoff without naming a duration. setImmediate still + // yields the macrotask the poll loops need, so nothing in this suite is + // timed against a fixed sleep a loaded CI machine can overrun. + decoder.sleep = () => new Promise(r => setImmediate(r)) }) afterEach(function () { diff --git a/test/chaos/CE08-signalHandling.chaos.js b/test/chaos/CE08-signalHandling.chaos.js index f4072db..2851e60 100644 --- a/test/chaos/CE08-signalHandling.chaos.js +++ b/test/chaos/CE08-signalHandling.chaos.js @@ -34,7 +34,10 @@ describe('CE-08: Signal Handling and Graceful Shutdown', function () { mockConnector = createMockConnector() decoder.db = mockDb decoder.connector = mockConnector - decoder.sleep = (ms) => new Promise(r => setTimeout(r, Math.min(ms, 50))) + // Drop the retry backoff without naming a duration. setImmediate still + // yields the macrotask the poll loops need, so nothing in this suite is + // timed against a fixed sleep a loaded CI machine can overrun. + decoder.sleep = () => new Promise(r => setImmediate(r)) }) afterEach(function () { diff --git a/test/unit/migration-runner.test.js b/test/unit/migration-runner.test.js index 669533f..4519d42 100644 --- a/test/unit/migration-runner.test.js +++ b/test/unit/migration-runner.test.js @@ -204,6 +204,88 @@ describe('Database._destructiveAutoStatement() @regression', function () { }); }); +describe('Database.backdatedFrontierViolation() @regression', function () { + + it('reports the frontier when a pending file is dated before an applied one', function () { + assert.strictEqual( + Database.backdatedFrontierViolation('2026-07-01-late-add.sql', + ['2026-06-10-a.sql', '2026-08-10-b.sql']), + '2026-08-10-b.sql'); + }); + + it('stays silent for a pending file dated after everything applied', function () { + assert.strictEqual( + Database.backdatedFrontierViolation('2026-08-11-new.sql', + ['2026-06-10-a.sql', '2026-08-10-b.sql']), + null); + }); + + it('never trips on a fresh install (empty ledger)', function () { + assert.strictEqual(Database.backdatedFrontierViolation('2026-01-01-first.sql', []), null); + assert.strictEqual(Database.backdatedFrontierViolation('2026-01-01-first.sql', null), null); + }); + + it('accepts a Map keys() iterator, which is what the apply loop passes', function () { + const applied = new Map([['2026-06-10-a.sql', 'h1'], ['2026-08-10-b.sql', 'h2']]); + assert.strictEqual( + Database.backdatedFrontierViolation('2026-07-01-late-add.sql', applied.keys()), + '2026-08-10-b.sql'); + }); + + it('compares against the MAXIMUM applied name, not the last one seen', function () { + // Ledger rows arrive in whatever order the SELECT returns them. + assert.strictEqual( + Database.backdatedFrontierViolation('2026-07-01-late-add.sql', + ['2026-08-10-b.sql', '2026-06-10-a.sql']), + '2026-08-10-b.sql'); + }); + + it('treats an equal name as applied, not backdated', function () { + assert.strictEqual( + Database.backdatedFrontierViolation('2026-08-10-b.sql', ['2026-08-10-b.sql']), + null); + }); + + // An undated ledger name sorts ABOVE every 2026-* name in ASCII ('a' 0x61 > '2' + // 0x32), so an unfiltered maximum makes the frontier a garbage value that every + // ordinary new migration sorts below. No undated decoder migration ever shipped, + // so this pins the filter rather than healing a known row. + it('ignores an undated legacy ledger row when computing the frontier', function () { + assert.strictEqual( + Database.backdatedFrontierViolation('2026-08-11-new.sql', [ + '2026-06-15-events-data-mediumtext.sql', + 'add_legacy_columns.sql', + '2026-08-10-action-data-utf8mb4.sql', + ]), + null, + 'an undated legacy row must never become the frontier'); + }); + + it('still reports a real violation when an undated legacy row is present', function () { + assert.strictEqual( + Database.backdatedFrontierViolation('2026-07-01-late-add.sql', [ + 'add_legacy_columns.sql', + '2026-08-10-action-data-utf8mb4.sql', + ]), + '2026-08-10-action-data-utf8mb4.sql', + 'the filter must narrow the frontier, not disable the guard'); + }); + + // The two shipped auto files are the live callers of this guard; a resumed partial + // run must not trip on them, because the ledger prefix a crash leaves behind always + // sorts below whatever is still pending. + it('does not trip a resumed partial run over the shipped auto files', function () { + assert.strictEqual( + Database.backdatedFrontierViolation('2026-06-15-events-data-mediumtext.sql', + ['2026-05-28-unique-index-tables.sql', '2026-06-13-dispensers-expiration-bigint.sql']), + null); + assert.strictEqual( + Database.backdatedFrontierViolation('2026-06-17-pubkeys-add-monotonic-id.sql', + ['2026-06-15-events-data-mediumtext.sql']), + null); + }); +}); + describe('committed migrations declare intent @regression', function () { const MIG_DIR = path.join(__dirname, '..', '..', 'src', 'sql', 'migrations'); let files = []; diff --git a/test/unit/reorgHaltSurface.test.js b/test/unit/reorgHaltSurface.test.js index 9520d52..9e82fde 100644 --- a/test/unit/reorgHaltSurface.test.js +++ b/test/unit/reorgHaltSurface.test.js @@ -123,18 +123,26 @@ describe('XChainDecoder latent REORG_HALT reporting', function () { it('concurrent probes collapse onto one in-flight query', async function () { const decoder = makeDecoder() let queries = 0 + // Hold the first query open on a gate this test releases, rather than on a + // fixed sleep. The window the assertion needs is "query one is still in + // flight when probes two and three arrive", and a released gate states that + // window exactly instead of betting it fits inside 10ms of wall clock. + let releaseQuery + const queryGate = new Promise(resolve => { releaseQuery = resolve }) decoder.db = { getReorgHaltMarker: async () => { queries++ - await new Promise(r => setTimeout(r, 10)) + await queryGate return { halted: false, at: null, reason: null } } } - await Promise.all([ + const probes = [ decoder.checkReorgHalt({ force: true }), decoder.checkReorgHalt({ force: true }), decoder.checkReorgHalt({ force: true }) - ]) + ] + releaseQuery() + await Promise.all(probes) assert.strictEqual(queries, 1) }) From 77eab1136ea70d6ea61149dceca0460555bdbddc Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 17 Aug 2026 09:42:18 -0700 Subject: [PATCH 7/9] ci: use the shared checkout-siblings composite action Replaces the inline sibling-checkout script with the composite action published in the organization .github repo, so one definition serves every call site instead of ten copies that had drifted apart. --- .github/workflows/ci.yml | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53629ec..f8c2469 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,24 +108,7 @@ jobs: # ratchet measures a suite the gate never ran: 1248 of 1289 unit tests ran without them. # Same roster (.ci-siblings) and same layout the shared workflow uses. - name: Check out declared sibling repositories - env: - SIBLINGS_REF: ${{ github.ref == 'refs/heads/master' && 'master' || 'develop' }} - run: | - set -euo pipefail - repos=$(sed 's/#.*//' .ci-siblings | tr -d '\r' | awk 'NF') - cd "$GITHUB_WORKSPACE/.." - for repo in $repos; do - rm -rf "$repo" - url="https://github.com/${{ github.repository_owner }}/$repo.git" - git clone --quiet --depth 1 --branch "$SIBLINGS_REF" "$url" "$repo" 2>/dev/null \ - || git clone --quiet --depth 1 "$url" "$repo" - echo "sibling $repo @ $(git -C "$repo" rev-parse --abbrev-ref HEAD) $(git -C "$repo" rev-parse --short HEAD)" - if [ -f "$repo/package.json" ]; then - ( cd "$repo" && npm ci --omit=dev --ignore-scripts --no-audit --no-fund >/dev/null 2>&1 ) \ - || ( cd "$repo" && npm install --omit=dev --ignore-scripts --no-audit --no-fund >/dev/null 2>&1 ) \ - || echo "sibling $repo: dependency install failed" - fi - done + uses: XChain-Platform/.github/actions/checkout-siblings@master # one definition for every call site; see the action for why this tracks master - name: Use Node.js 22 uses: actions/setup-node@v4 From 139e5185baf919933c0bfe63bb14bb956929a793 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 17 Aug 2026 12:39:34 -0700 Subject: [PATCH 8/9] test(security): raise the brace-expansion and js-yaml advisory floors [XC-1518] --- .../configuration/dependency-advisories.test.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test/unit/security/configuration/dependency-advisories.test.js b/test/unit/security/configuration/dependency-advisories.test.js index 383fe0d..1c5cee8 100644 --- a/test/unit/security/configuration/dependency-advisories.test.js +++ b/test/unit/security/configuration/dependency-advisories.test.js @@ -44,8 +44,11 @@ describe('Security: remediated dependency advisories @regression @tier4', functi // number of results but not their total length, so a few KB of chained brace // groups exhausts the heap and kills the process with an uncatchable OOM. // Affects <=5.0.7 across every release line, and no 1.x/2.x/3.x/4.x carries - // the patch, so every entry has to move to 5.0.8. Reaches this tree dev-only - // through minimatch (mocha, glob, stryker, test-exclude). + // the patch, so every entry has to move onto the 5.x line. Reaches this tree + // dev-only through minimatch (mocha, glob, stryker, test-exclude). The first + // patch, 5.0.8, capped the result count but still materialised the whole + // expansion before truncating it, so the same input exhausted the heap one + // step later; 5.0.9 is the floor that actually holds. // // Everything below this point is the second wave: HIGH advisories no // lockfile splice could reach, because the safe version was a real upgrade @@ -59,8 +62,10 @@ describe('Security: remediated dependency advisories @regression @tier4', functi // the rest of this list axios is a direct runtime dependency of most // services, so it is pinned in dependencies rather than through overrides. // - // js-yaml <4.3.0: merge-key ("<<") chains expand quadratically, so a small + // js-yaml <4.3.1: merge-key ("<<") chains expand quadratically, so a small // document forces unbounded CPU. Dev-only here, via mocha's config loader. + // 4.3.0 fixed the plain-mapping case and left the same blowup reachable + // through an !!omap, so the floor is 4.3.1 rather than 4.3.0. // // serialize-javascript <=7.0.4: RCE via RegExp.flags and // Date.prototype.toISOString, plus CPU exhaustion on crafted array-likes. @@ -86,14 +91,14 @@ describe('Security: remediated dependency advisories @regression @tier4', functi // rate-limit bucket than its canonical address. const advisories = [ { name: 'fast-uri', minSafe: [3, 1, 5], majorSeries: 3 }, - { name: 'brace-expansion', minSafe: [5, 0, 8], majorSeries: 5 }, + { name: 'brace-expansion', minSafe: [5, 0, 9], majorSeries: 5 }, // Coupled to the entry above: brace-expansion 5.x dropped its CommonJS // default export, so only minimatch >=10 (named `import { expand }`) // can consume it. Pinning minimatch here keeps the pair from drifting // apart into a tree that installs but throws on first glob match. { name: 'minimatch', minSafe: [10, 2, 5], majorSeries: 10 }, { name: 'axios', minSafe: [1, 18, 0], majorSeries: 1 }, - { name: 'js-yaml', minSafe: [4, 3, 0], majorSeries: 4 }, + { name: 'js-yaml', minSafe: [4, 3, 1], majorSeries: 4 }, { name: 'serialize-javascript', minSafe: [7, 0, 5], majorSeries: 7 }, { name: 'shell-quote', minSafe: [1, 9, 0], majorSeries: 1 }, { name: 'form-data', minSafe: [4, 0, 6], majorSeries: 4 }, From b912c50e241aa1f38b8a34c9ec456d27af07d877 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 17 Aug 2026 15:40:45 -0700 Subject: [PATCH 9/9] chore: scrub internal work-tracking references from comments and test labels --- test/unit/roundtripConformance.test.js | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/test/unit/roundtripConformance.test.js b/test/unit/roundtripConformance.test.js index 3b6f7ce..1e44f10 100644 --- a/test/unit/roundtripConformance.test.js +++ b/test/unit/roundtripConformance.test.js @@ -29,14 +29,9 @@ // the canonical rewrite instead, which is the one place the stored record is // deliberately NOT the input. // -// PLACEMENT (XC-1359). This lane sits in the fast gated unit tier and needs no -// database: the storage gate is a pure function of a parse result, so nothing -// here is green-by-mock from test/unit/setup.js's mariadb redirect (no src/db.js -// is constructed; the parse's pubkey-capture writes go to an explicit stub whose -// calls are asserted). Only the node RPC is faked, at the connector seam, which -// is the same seam the integration tier replaces with a real regtest node. The -// other half of the invariant, the row actually landing in MariaDB, belongs to -// the docker-gated integration tier (test/integration/opReturn.test.js). +// PLACEMENT: needs no database, since the storage gate is a pure function +// of a parse result; only the node RPC is faked, at the connector seam. +// The docker-gated integration tier covers the row landing in MariaDB. // // The fixture is authored in the sibling xchain-encoder repo (single source of // truth) and VENDORED byte-identically into test/fixtures/ here. The vendored