From 6a3d5f8191af6c604c1b444116f55f1ce6482bf9 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 23 Jul 2026 13:27:17 -0400 Subject: [PATCH] feat: expose immutable release identity --- src/config/releaseIdentity.js | 71 +++++++++++++++++++++++++++++++++++ src/game/gameModeManager.js | 5 +++ src/index.js | 5 +++ src/services/healthService.js | 15 ++++++++ test/gameModeManager.test.js | 10 +++++ test/healthService.test.js | 34 ++++++++++++++++- test/releaseIdentity.test.js | 64 +++++++++++++++++++++++++++++++ 7 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 src/config/releaseIdentity.js create mode 100644 test/releaseIdentity.test.js diff --git a/src/config/releaseIdentity.js b/src/config/releaseIdentity.js new file mode 100644 index 0000000..a0fcd14 --- /dev/null +++ b/src/config/releaseIdentity.js @@ -0,0 +1,71 @@ +const fs = require('fs'); +const path = require('path'); + +const RELEASE_ID_PATTERN = /^git-([0-9a-f]{12})$/; +const COMMIT_PATTERN = /^[0-9a-f]{40}$/; +const MAX_FILE_BYTES = 128; + +function readBoundedRegularFile(filePath) { + const descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW + ); + try { + const before = fs.fstatSync(descriptor); + if ( + !before.isFile() + || before.nlink !== 1 + || (before.mode & 0o222) !== 0 + || before.size < 1 + || before.size > MAX_FILE_BYTES + ) { + throw new Error('release identity file has unsafe shape'); + } + const value = fs.readFileSync(descriptor, { encoding: 'utf8' }).trim(); + const after = fs.fstatSync(descriptor); + if ( + before.dev !== after.dev + || before.ino !== after.ino + || before.size !== after.size + || before.mtimeMs !== after.mtimeMs + ) { + throw new Error('release identity file changed while it was read'); + } + return value; + } finally { + fs.closeSync(descriptor); + } +} + +function unverifiedIdentity() { + return Object.freeze({ verified: false, id: null, commit: null }); +} + +function loadReleaseIdentity(options = {}) { + const production = options.production === undefined + ? process.env.NODE_ENV === 'production' + : Boolean(options.production); + const releaseRoot = options.releaseRoot || path.resolve(__dirname, '..', '..'); + + try { + const id = readBoundedRegularFile(path.join(releaseRoot, '.release-id')); + const commit = readBoundedRegularFile(path.join(releaseRoot, '.release-commit')); + const idMatch = id.match(RELEASE_ID_PATTERN); + if (!idMatch || !COMMIT_PATTERN.test(commit) || idMatch[1] !== commit.slice(0, 12)) { + throw new Error('release identity files disagree'); + } + return Object.freeze({ verified: true, id, commit }); + } catch (error) { + if (production) { + throw new Error('production release identity is absent or invalid', { cause: error }); + } + return unverifiedIdentity(); + } +} + +module.exports = { + COMMIT_PATTERN, + RELEASE_ID_PATTERN, + loadReleaseIdentity, + readBoundedRegularFile +}; diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index 5ee56db..0f0b637 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -2416,6 +2416,11 @@ class GameModeManager { })); return { + release: this.releaseIdentity || { + verified: false, + id: null, + commit: null + }, operatedProductProfileId: getOperatedProductProfile(process.env)?.id || null, mode: this.gameMode, freePlayEnabled: !!this.freePlayEnabled, // players may choose free play even on a paid instance diff --git a/src/index.js b/src/index.js index f75a678..f4c55d6 100644 --- a/src/index.js +++ b/src/index.js @@ -34,6 +34,7 @@ const GameModeManager = require('./game/gameModeManager'); const PaymentConfigManager = require('./config/paymentConfig'); const EnvironmentValidator = require('./config/environmentValidator'); const RuntimePolicy = require('./config/runtimePolicy'); +const { loadReleaseIdentity } = require('./config/releaseIdentity'); const asyncHandler = require('./middleware/asyncHandler'); const createErrorMiddleware = require('./middleware/errorHandler'); const noStore = require('./middleware/noStore'); @@ -58,7 +59,9 @@ const { isSmirkEnabled } = require('./auth/smirkPolicy'); // Initialize payment configuration const paymentConfigManager = new PaymentConfigManager({ logger: console }); const environmentValidator = new EnvironmentValidator({ logger: console }); +let releaseIdentity; try { + releaseIdentity = loadReleaseIdentity(); environmentValidator.assertValid(paymentConfigManager.getConfig()); } catch (error) { console.error(`❌ Startup aborted: ${error.message}`); @@ -79,6 +82,7 @@ const walletRPCService = new WalletRPCService(debugManager, { minConfirmations: paymentConfigManager.getConfig().payouts.processing.confirmations }); const gameModeManager = new GameModeManager(databaseManager, walletRPCService, debugManager, paymentConfigManager); +gameModeManager.releaseIdentity = releaseIdentity; // Provide io reference so GameModeManager can emit events (e.g., credits_update) gameModeManager.io = io; @@ -473,6 +477,7 @@ function publicHealthSnapshot() { queuedGames: socketHandlers?.queueManager?.getQueueLength?.() || 0, connectedPlayers: io.sockets.sockets.size || 0, gameMode: gameModeManager.gameMode, + releaseIdentity, uptime: process.uptime() }); // Informational only: an optional accounting sink must not make gameplay readiness fail. diff --git a/src/services/healthService.js b/src/services/healthService.js index ab500c5..e634608 100644 --- a/src/services/healthService.js +++ b/src/services/healthService.js @@ -1,5 +1,19 @@ /** Build the public, non-sensitive health payload used by probes and the status UI. */ function buildPublicHealth(input = {}) { + const releaseCandidate = input.releaseIdentity; + const releaseValid = releaseCandidate?.verified === true + && /^git-[0-9a-f]{12}$/.test(releaseCandidate.id || '') + && /^[0-9a-f]{40}$/.test(releaseCandidate.commit || '') + && releaseCandidate.id.slice(4) === releaseCandidate.commit.slice(0, 12); + const release = releaseValid ? { + verified: true, + id: releaseCandidate.id, + commit: releaseCandidate.commit + } : { + verified: false, + id: null, + commit: null + }; const sanitizeIdentity = (identity, required) => { const pair = (value) => value && typeof value === 'object' ? { cryptoType: typeof value.cryptoType === 'string' ? value.cryptoType : null, @@ -73,6 +87,7 @@ function buildPublicHealth(input = {}) { paymentsEnabled, payoutsEnabled }, + release, identities: { daemon: sanitizeIdentity(daemonIdentity, daemonIdentityRequired), wallet: sanitizeIdentity(input.walletIdentity, walletIdentityRequired) diff --git a/test/gameModeManager.test.js b/test/gameModeManager.test.js index d2472d1..9602498 100644 --- a/test/gameModeManager.test.js +++ b/test/gameModeManager.test.js @@ -228,6 +228,16 @@ describe('GameModeManager', () => { } }); + test('game mode info binds Socket.IO clients to the immutable release', () => { + const release = { + verified: true, + id: `git-${'a'.repeat(12)}`, + commit: 'a'.repeat(40) + }; + gmm.releaseIdentity = release; + expect(gmm.getGameModeInfo().release).toEqual(release); + }); + test('crypto race economy is hidden when the payout master switch is off', () => { const keys = [ 'MATCH_ENABLED', 'MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED', diff --git a/test/healthService.test.js b/test/healthService.test.js index 301ad7d..c9bec4d 100644 --- a/test/healthService.test.js +++ b/test/healthService.test.js @@ -16,7 +16,39 @@ describe('public health payload', () => { status: 'ok', ready: true, checks: { database: 'up', chain: 'up', wallet: 'up' }, - money: { paymentsEnabled: true, payoutsEnabled: false } + money: { paymentsEnabled: true, payoutsEnabled: false }, + release: { verified: false, id: null, commit: null } + }); + }); + + test('publishes only a self-consistent immutable release identity', () => { + const commit = 'a'.repeat(40); + const health = buildPublicHealth({ + databaseReady: true, + blockHeight: 123, + releaseIdentity: { + verified: true, + id: `git-${commit.slice(0, 12)}`, + commit + } + }); + expect(health.release).toEqual({ + verified: true, + id: `git-${commit.slice(0, 12)}`, + commit + }); + + const mismatched = buildPublicHealth({ + releaseIdentity: { + verified: true, + id: `git-${'b'.repeat(12)}`, + commit + } + }); + expect(mismatched.release).toEqual({ + verified: false, + id: null, + commit: null }); }); diff --git a/test/releaseIdentity.test.js b/test/releaseIdentity.test.js new file mode 100644 index 0000000..88c5167 --- /dev/null +++ b/test/releaseIdentity.test.js @@ -0,0 +1,64 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + loadReleaseIdentity, + readBoundedRegularFile +} = require('../src/config/releaseIdentity'); + +describe('immutable release identity', () => { + let root; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'wowngeon-release-identity-')); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + function writeIdentity(commit = 'a'.repeat(40), id = `git-${commit.slice(0, 12)}`) { + fs.writeFileSync(path.join(root, '.release-id'), `${id}\n`, { mode: 0o444 }); + fs.writeFileSync(path.join(root, '.release-commit'), `${commit}\n`, { mode: 0o444 }); + } + + test('returns the matching artifact release ID and commit', () => { + writeIdentity(); + expect(loadReleaseIdentity({ releaseRoot: root, production: true })).toEqual({ + verified: true, + id: `git-${'a'.repeat(12)}`, + commit: 'a'.repeat(40) + }); + }); + + test('fails production closed when identity files disagree', () => { + writeIdentity('b'.repeat(40), `git-${'c'.repeat(12)}`); + expect(() => loadReleaseIdentity({ releaseRoot: root, production: true })) + .toThrow('production release identity is absent or invalid'); + }); + + test('fails production closed when either identity file is absent', () => { + expect(() => loadReleaseIdentity({ releaseRoot: root, production: true })) + .toThrow('production release identity is absent or invalid'); + }); + + test('returns an explicit unverified identity outside production', () => { + expect(loadReleaseIdentity({ releaseRoot: root, production: false })).toEqual({ + verified: false, + id: null, + commit: null + }); + }); + + test('refuses symlinks and oversized identity files', () => { + const target = path.join(root, 'target'); + fs.writeFileSync(target, 'a'.repeat(40)); + const linked = path.join(root, 'linked'); + fs.symlinkSync(target, linked); + expect(() => readBoundedRegularFile(linked)).toThrow(); + + const oversized = path.join(root, 'oversized'); + fs.writeFileSync(oversized, 'x'.repeat(129)); + expect(() => readBoundedRegularFile(oversized)).toThrow('unsafe shape'); + }); +});