Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
9ac73f0
backend: import mining pool metadata from a bundled first-party file
bitcoinuniverseadmin Aug 28, 2026
e74c054
backend: publish what this deployment can serve, and refuse an incohe…
bitcoinuniverseadmin Aug 28, 2026
e19ae4a
backend: let a statistics read failure reach the route layer
bitcoinuniverseadmin Aug 28, 2026
1eb7037
frontend: give every remote read a lifecycle that ends
bitcoinuniverseadmin Aug 28, 2026
a58abe8
release: gate the cutover on the deployed configuration, not on fixtures
bitcoinuniverseadmin Aug 28, 2026
be33a11
theme: the Universe pink system, and the gates that hold its roles apart
bitcoinuniverseadmin Aug 28, 2026
2d3dc5e
protocols: let the authority decide what is readable, not the registry
bitcoinuniverseadmin Aug 28, 2026
094d5a2
fiat: leave the amount blank when there is no price, rather than show…
bitcoinuniverseadmin Aug 28, 2026
692a325
design: finish the primary action in the house style, and say why in …
bitcoinuniverseadmin Aug 28, 2026
0f7a663
ci: compare the coverage table by content, not by line ending
bitcoinuniverseadmin Aug 28, 2026
bc35956
qa: fail a page that never finishes, and check production without mocks
bitcoinuniverseadmin Aug 28, 2026
b72a9bc
docs: say what the deployment actually runs, and what a status means
bitcoinuniverseadmin Aug 28, 2026
4ca8c39
fix: give a group one visible answer, a label its place, and an error…
bitcoinuniverseadmin Aug 28, 2026
f7a8e14
backend: satisfy the unhandled-await rule the new code broke
bitcoinuniverseadmin Aug 28, 2026
9c2b526
charts: one fee rate, one colour, and a fixture large enough to see it
bitcoinuniverseadmin Aug 28, 2026
5e8a1a6
qa: prove the unfinished-page gate fires, rather than trusting it
bitcoinuniverseadmin Aug 28, 2026
64fffcd
a11y: name the icon-only controls on the graphs page
bitcoinuniverseadmin Aug 28, 2026
2954fee
ops: run the synthetic check on the host, every five minutes
bitcoinuniverseadmin Aug 28, 2026
ff9a0b1
qa: give the release identity page something to render, and state the…
bitcoinuniverseadmin Aug 28, 2026
76ec75e
release: refuse an address backend with nothing behind it
bitcoinuniverseadmin Aug 28, 2026
4356d61
design: drop the inherited page decoration, and give a bar's label it…
bitcoinuniverseadmin Aug 28, 2026
93e19b4
theme: stop spending the informational blue on decoration
bitcoinuniverseadmin Aug 28, 2026
0a61ef2
protocols: bound the registry read, and judge the waiting state on wh…
bitcoinuniverseadmin Aug 28, 2026
920a347
qa: keep harness output out of the repository
bitcoinuniverseadmin Aug 28, 2026
0a3139f
charts: say how far back the data goes when it is less than the range
bitcoinuniverseadmin Aug 28, 2026
6080691
charts: say "1 hour", and stop walking the series on every render
bitcoinuniverseadmin Aug 28, 2026
58773f9
qa: check the two modes the route matrix never opened
bitcoinuniverseadmin Aug 28, 2026
58e1d28
fix: a green at 2.37:1 that nothing could see, and the gate that let …
bitcoinuniverseadmin Aug 28, 2026
2b604e4
ops: back the database up on a schedule, and write down what it costs
bitcoinuniverseadmin Aug 28, 2026
88183aa
docs: point the rollback notes at the scheduled backup
bitcoinuniverseadmin Aug 28, 2026
9c001dc
ci: prove the call sites use the foreground pairs, not just that they…
bitcoinuniverseadmin Aug 28, 2026
ffb94dd
test: start the integration database from a known state
bitcoinuniverseadmin Aug 28, 2026
9f12cf7
ci: hold the built output to the punctuation rule, not just the source
bitcoinuniverseadmin Aug 28, 2026
5e33ed3
fix: the address page, which has been showing an error in every scree…
bitcoinuniverseadmin Aug 28, 2026
df4526b
gateway: wait out an upstream restart instead of answering 502
bitcoinuniverseadmin Aug 28, 2026
5810e13
release: leave the gateway up when its own code has not changed
bitcoinuniverseadmin Aug 28, 2026
ff476e7
docs: record what a cutover actually does to the origin
bitcoinuniverseadmin Aug 28, 2026
d701185
qa: stop the modes check racing the router
bitcoinuniverseadmin Aug 28, 2026
61db3f9
search: let the group label sit above its results instead of beside them
bitcoinuniverseadmin Aug 28, 2026
6edc89e
docs: list the gates that exist now, and say why the fixtures are par…
bitcoinuniverseadmin Aug 28, 2026
21dae53
qa: judge a page on what is on screen, and give it a real deadline
bitcoinuniverseadmin Aug 28, 2026
7007f64
qa: block on the routes this work covers, and keep reporting the rest
bitcoinuniverseadmin Aug 28, 2026
ae4ba8e
Merge the production go-state work into the pink brand system
bitcoinuniverseadmin Aug 28, 2026
20ea595
qa: fill the gaps that were failing the unfinished-page gate, and sto…
bitcoinuniverseadmin Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion .github/workflows/universe-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ jobs:
- name: Every dynamic palette carries a readable foreground
run: node scripts/universe/check-palettes.mjs

- name: Every filled surface declares the ink on it
run: node scripts/universe/check-fills.mjs

- name: Contrast arithmetic is correct
run: node --test scripts/universe/contrast.test.mjs

Expand All @@ -52,6 +55,9 @@ jobs:
- name: Gateway routing
run: node --test scripts/universe/gateway.test.mjs

- name: The gateway bridges an upstream restart rather than failing
run: node --test scripts/universe/gateway-restart.test.mjs

backend:
name: Backend build and test
runs-on: [self-hosted, linux-ultra]
Expand Down Expand Up @@ -83,6 +89,13 @@ jobs:
working-directory: backend
run: npm run test:ci

# The failure that shipped was a configuration one: the migrations never
# ran against the engine production uses, so nothing caught the syntax it
# rejects. These run the real backend against a real database.
- name: Migrations and repositories against a real database
working-directory: backend
run: npm run test:integration

frontend:
name: Frontend build
runs-on: [self-hosted, linux-ultra]
Expand Down Expand Up @@ -123,10 +136,17 @@ jobs:
- name: Every asset the build references is in the output
run: node scripts/universe/check-build-assets.mjs

- name: No em dash in what the build actually ships
run: node scripts/universe/check-text.mjs frontend/dist

- name: Install the visual QA harness
working-directory: scripts/universe/visual-qa
run: npm ci

- name: The unfinished-page gate fires on the states that shipped
working-directory: scripts/universe/visual-qa
run: node --test progress-gate.test.mjs

- name: Serve the built frontend
run: |
UNIVERSE_GATEWAY_PORT=8123 UNIVERSE_GATEWAY_ROOT=frontend/dist/mempool/browser node scripts/universe/gateway.mjs &
Expand All @@ -135,10 +155,17 @@ jobs:
sleep 1
done

- name: Measured contrast, accessibility, overflow, and screenshots
# The harness now fails the run when a page never finishes loading, when a
# chart panel draws nothing, or when a failure state says nothing about
# why. Those three states passed every other gate here and shipped.
- name: Measured contrast, accessibility, overflow, unfinished pages, and screenshots
working-directory: scripts/universe/visual-qa
run: node capture.mjs --base=http://127.0.0.1:8123 --out=./artifacts

- name: Forced colours and 200 percent zoom
working-directory: scripts/universe/visual-qa
run: node modes-check.mjs --base=http://127.0.0.1:8123

- name: Keep the contrast report and the route screenshots
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/universe-production-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Universe production smoke

# Checks the live origin with nothing mocked.
#
# The rest of the suite answers every request from fixtures, which is what let
# a release ship with a Charts page and a Mining dashboard in front of routes
# that answered 404. Fixtures prove how a page renders given data; only this
# proves the deployment can produce any.

on:
# After a release, and on a schedule so a dependency that fails later is
# noticed without anyone deploying.
workflow_dispatch:
inputs:
origin:
description: Origin to check
required: false
default: https://explorer.bitcoinuniverse.io
schedule:
- cron: '17 * * * *'

permissions:
contents: read

concurrency:
group: universe-production-smoke
cancel-in-progress: false

jobs:
smoke:
name: Live origin answers with real content
runs-on: [self-hosted, linux-ultra]
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '24.19.0'

- name: Every advertised feature has routes, data, and a matching release
run: node scripts/universe/synthetic-check.mjs "${{ github.event.inputs.origin || 'https://explorer.bitcoinuniverse.io' }}"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@ node_modules/

# Visual QA working output. Curated review sets are committed under docs/design.
scripts/universe/visual-qa/artifacts/
scripts/universe/visual-qa/verify*/
# Any output directory the harness is pointed at, not just the default one.
scripts/universe/visual-qa/artifacts-*/
scripts/universe/visual-qa/node_modules/
5 changes: 4 additions & 1 deletion backend/docker-compose.test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ version: "3.7"

services:
db-test:
image: mariadb:10.5.21
# Must stay the same engine and major version the Universe deployment runs.
# The migrations use MariaDB syntax that MySQL rejects outright, so a test
# database on a different engine proves nothing about the release.
image: mariadb:11.4
environment:
MYSQL_DATABASE: "mempool_test"
MYSQL_USER: "mempool_test"
Expand Down
40 changes: 33 additions & 7 deletions backend/jest.integration.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,23 +36,49 @@ module.exports = async () => {
const composeFile = path.join(__dirname, 'docker-compose.test.yml');
const dockerComposeCmd = getDockerComposeCmd();

// Start the container
execSync(`${dockerComposeCmd} -f "${composeFile}" up -d`, {
// A container left running by an earlier run is not the container this run
// asked for: compose reports it as already up and never recreates it, so a
// change of image or of engine is silently ignored and the tests measure
// whatever was there before. Tear it down, volumes included, first.
try {
execSync(`${dockerComposeCmd} -f "${composeFile}" down -v --remove-orphans`, {
stdio: 'inherit',
cwd: __dirname
});
} catch {
// Nothing to remove on a clean machine, which is the normal case.
}

// `down` only removes what compose owns. A container left behind under the
// same name by anything else still holds the name, and `up` fails on the
// conflict rather than starting the database.
try {
execSync('docker rm -f backend-db-test-1', { stdio: 'pipe' });
} catch {
// No such container, which is the normal case.
}

execSync(`${dockerComposeCmd} -f "${composeFile}" up -d --force-recreate`, {
stdio: 'inherit',
cwd: __dirname
});

// Wait for database to be ready
console.log('Waiting for database to be ready...');
let attempts = 0;
const maxAttempts = 30;
const maxAttempts = 60;

while (attempts < maxAttempts) {
try {
execSync(`${dockerComposeCmd} -f "${composeFile}" exec -T db-test mysqladmin ping -h localhost -u mempool_test -pmempool_test --silent`, {
cwd: __dirname,
stdio: 'pipe'
});
// MariaDB renamed its client binaries; either name may be the one this
// image ships, so a probe that only knows one of them reports a
// healthy server as a database that never started.
execSync(
`${dockerComposeCmd} -f "${composeFile}" exec -T db-test sh -c ` +
`"mariadb-admin ping -h localhost -u mempool_test -pmempool_test --silent ` +
`|| mysqladmin ping -h localhost -u mempool_test -pmempool_test --silent"`,
{ cwd: __dirname, stdio: 'pipe' },
);
console.log('Database is ready!');
break;
} catch (e) {
Expand Down
1 change: 1 addition & 0 deletions backend/mempool-config.sample.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"AUTOMATIC_POOLS_UPDATE": false,
"POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json",
"POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master",
"POOLS_JSON_FILE": "tasks/pools/pools-v2.json",
"POOLS_UPDATE_DELAY": 604800,
"AUDIT": false,
"RUST_GBT": true,
Expand Down
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"tsc": "./node_modules/typescript/bin/tsc -p tsconfig.build.json",
"build": "npm run tsc && npm run create-resources",
"clean": "rm -rf ./dist/ ./node_modules/ ./package/ ./rust-gbt/",
"create-resources": "cp ./src/tasks/price-feeds/mtgox-weekly.json ./dist/tasks && node dist/api/fetch-version.js",
"create-resources": "mkdir -p ./dist/tasks/pools && cp ./src/tasks/price-feeds/mtgox-weekly.json ./dist/tasks && cp ./src/tasks/pools/pools-v2.json ./dist/tasks/pools && node dist/api/fetch-version.js",
"package": "./npm_package.sh",
"package-rm-build-deps": "./npm_package_rm_build_deps.sh",
"preinstall": "cd ../rust/gbt && npm run build-release && npm run to-backend",
Expand Down
1 change: 1 addition & 0 deletions backend/src/__fixtures__/mempool-config.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"INDEXING_BLOCKS_AMOUNT": 14,
"POOLS_JSON_TREE_URL": "__MEMPOOL_POOLS_JSON_TREE_URL__",
"POOLS_JSON_URL": "__MEMPOOL_POOLS_JSON_URL__",
"POOLS_JSON_FILE": "__MEMPOOL_POOLS_JSON_FILE__",
"POOLS_UPDATE_DELAY": 604800,
"AUDIT": true,
"RUST_GBT": false,
Expand Down
61 changes: 61 additions & 0 deletions backend/src/__tests__/capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { preflightFailures, type PreflightInput } from '../api/capabilities.preflight';

/**
* The production incident these rules exist to prevent: the frontend shipped a
* Charts page and a Mining dashboard while the backend had the database off, so
* every request behind those pages answered 404 and the pages loaded forever.
*/

const coherentWithData: PreflightInput = {
statisticsEnabled: true,
databaseEnabled: true,
mempoolEnabled: true,
indexingBlocksAmount: -1,
};

const coherentWithoutData: PreflightInput = {
statisticsEnabled: false,
databaseEnabled: false,
mempoolEnabled: true,
indexingBlocksAmount: 0,
};

describe('deployment configuration preflight', () => {
it('accepts a deployment that serves statistics and mining from a database', () => {
expect(preflightFailures(coherentWithData)).toEqual([]);
});

it('accepts a deployment that serves neither, with the database off', () => {
expect(preflightFailures(coherentWithoutData)).toEqual([]);
});

it('rejects statistics without a database, because the routes would never mount', () => {
const failures = preflightFailures({ ...coherentWithData, databaseEnabled: false, indexingBlocksAmount: 0 });
expect(failures.map((failure) => failure.feature)).toContain('statistics');
});

it('rejects statistics without the mempool backend collecting them', () => {
const failures = preflightFailures({ ...coherentWithData, mempoolEnabled: false });
expect(failures.map((failure) => failure.reason.includes('MEMPOOL.ENABLED'))).toContain(true);
});

it('rejects block indexing without a database, because mining routes would never mount', () => {
const failures = preflightFailures({ ...coherentWithoutData, indexingBlocksAmount: 52560 });
expect(failures.map((failure) => failure.feature)).toContain('mining');
});

it('rejects a database that nothing uses', () => {
const failures = preflightFailures({ ...coherentWithoutData, databaseEnabled: true });
expect(failures.map((failure) => failure.feature)).toContain('database');
});

it('reports every problem at once rather than stopping at the first', () => {
const failures = preflightFailures({
statisticsEnabled: true,
databaseEnabled: false,
mempoolEnabled: false,
indexingBlocksAmount: 144,
});
expect(failures.length).toBeGreaterThanOrEqual(3);
});
});
1 change: 1 addition & 0 deletions backend/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ describe('Mempool Backend Config', () => {
STDOUT_LOG_MIN_PRIORITY: 'debug',
POOLS_JSON_TREE_URL: 'https://api.github.com/repos/mempool/mining-pools/git/trees/master',
POOLS_JSON_URL: 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json',
POOLS_JSON_FILE: 'tasks/pools/pools-v2.json',
POOLS_UPDATE_DELAY: 604800,
AUDIT: false,
RUST_GBT: true,
Expand Down
55 changes: 55 additions & 0 deletions backend/src/api/capabilities.preflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Configuration combinations that must never reach production traffic.
*
* This module deliberately imports nothing. The release gate, the unit tests,
* and the running backend all judge the same rules, and none of them has to
* open a database or start a timer to do it.
*/

/** A configuration combination that must never reach production traffic. */
export interface PreflightFailure {
readonly feature: string;
readonly reason: string;
}

/** The configuration facts the preflight rules judge. */
export interface PreflightInput {
readonly statisticsEnabled: boolean;
readonly databaseEnabled: boolean;
readonly mempoolEnabled: boolean;
readonly indexingBlocksAmount: number;
}

/**
* Returns every reason this configuration would put a broken public feature in
* front of users. An empty list means the deployment is coherent.
*/
export function preflightFailures(input: PreflightInput): PreflightFailure[] {
const failures: PreflightFailure[] = [];

if (input.statisticsEnabled && !input.databaseEnabled) {
failures.push({
feature: 'statistics',
reason: 'STATISTICS.ENABLED is true but DATABASE.ENABLED is false, so no statistics route can be served.',
});
}
if (input.statisticsEnabled && !input.mempoolEnabled) {
failures.push({
feature: 'statistics',
reason: 'STATISTICS.ENABLED is true but MEMPOOL.ENABLED is false, so nothing collects statistics.',
});
}
if (input.indexingBlocksAmount !== 0 && !input.databaseEnabled) {
failures.push({
feature: 'mining',
reason: 'INDEXING_BLOCKS_AMOUNT is set but DATABASE.ENABLED is false, so no mining route can be served.',
});
}
if (input.databaseEnabled && input.indexingBlocksAmount === 0 && !input.statisticsEnabled) {
failures.push({
feature: 'database',
reason: 'DATABASE.ENABLED is true but neither block indexing nor statistics uses it.',
});
}
return failures;
}
27 changes: 27 additions & 0 deletions backend/src/api/capabilities.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Application, Request, Response } from 'express';
import config from '../config';
import capabilities from './capabilities';
import { handleError } from '../utils/api';

/**
* Publishes what this deployment can actually serve. The frontend reads it to
* render truthful states instead of guessing from its own build-time flags,
* and the release procedure reads it to refuse an incoherent cutover.
*/
class CapabilitiesRoutes {
public initRoutes(app: Application): void {
app.get(config.MEMPOOL.API_URL_PREFIX + 'capabilities', async (req: Request, res: Response) => {
try {
const report = await capabilities.$report();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 10).toUTCString());
res.json(report);
} catch (e) {
handleError(req, res, 500, 'Failed to build the capability report');
}
});
}
}

export default new CapabilitiesRoutes();
Loading
Loading