mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-20 07:38:34 +02:00
feat: expose immutable release identity
This commit is contained in:
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user