Wownerogue

A browser-based roguelike synchronized with crypto block timing. A run is a race
against the next block: reach the exit before the block lands, or the house wins.
The same codebase runs the solo dungeon, the social Tavern, and operator-selected
PvP and race rulesets, with separate free and paid leaderboards.

Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md
for the design, and docs/DEPLOY.md to operate an instance.
This commit is contained in:
jwinterm
2026-07-27 08:23:01 -04:00
commit fad2a695bd
578 changed files with 102281 additions and 0 deletions
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env node
'use strict';
/*
* Apply the application's normal migrations to an already-created disposable database clone.
*
* This command intentionally cannot create, drop, restore, audit, or activate anything. Its
* target is supplied only through the inherited environment; accepting command-line options
* would make it too easy to place database credentials in argv or shell history.
*/
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
const DatabaseManager = require('../db/databaseManager');
const CONFIRM_PREFIX = 'MIGRATE_DISPOSABLE_CLONE:';
const MINIMUM_RELEASE_MIGRATION = 43;
const MIGRATIONS_PATH = path.join(__dirname, '..', 'migrations');
const DATABASE_NAME = /^[a-z][a-z0-9_]{2,62}$/;
const DATABASE_USER = /^[a-z_][a-z0-9_]{0,62}$/;
const MIGRATION_FILE = /^(\d{3})_[a-z0-9][a-z0-9_-]*\.sql$/;
const ALLOWED_PRODUCT_PREFIXES = new Set(['monerogue', 'wownerogue', 'wowngeon']);
const DISPOSABLE_TOKENS = new Set(['clone', 'restore', 'canary']);
const LIVE_TOKENS = new Set([
'active', 'current', 'live', 'mainnet', 'master', 'primary', 'prd', 'prod', 'production'
]);
const LOCAL_TCP_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
const LOCAL_SOCKET_DIRS = new Set(['/run/postgresql', '/var/run/postgresql']);
class CloneMigrationRefusal extends Error {
constructor(message) {
super(message);
this.name = 'CloneMigrationRefusal';
}
}
class CloneMigrationVerificationError extends Error {
constructor(message) {
super(message);
this.name = 'CloneMigrationVerificationError';
}
}
function invariant(condition, message, ErrorType = CloneMigrationRefusal) {
if (!condition) throw new ErrorType(message);
}
function cloneMigrationConfirmation(databaseName) {
return `${CONFIRM_PREFIX}${databaseName}`;
}
function isLocalDatabaseHost(host) {
const value = String(host || '').trim();
if (LOCAL_TCP_HOSTS.has(value.toLowerCase())) return true;
return LOCAL_SOCKET_DIRS.has(value);
}
function isDisposableCloneDatabaseName(databaseName) {
const name = String(databaseName || '');
if (!DATABASE_NAME.test(name)) return false;
const tokens = name.split('_');
if (!ALLOWED_PRODUCT_PREFIXES.has(tokens[0])) return false;
if (tokens.some(token => [...LIVE_TOKENS].some(live => token.startsWith(live)))) return false;
return tokens.some(token => DISPOSABLE_TOKENS.has(token));
}
function validateCloneMigrationEnvironment(env = process.env, argv = []) {
invariant(Array.isArray(argv) && argv.length === 0,
'command-line arguments are forbidden; use a protected inherited environment');
invariant(!String(env.DATABASE_URL || '').trim(),
'DATABASE_URL is forbidden because it is ambiguous and may contain credentials');
const database = String(env.DB_NAME || '').trim();
const expectedDatabase = String(env.CLONE_MIGRATION_EXPECT_DATABASE || '').trim();
invariant(isDisposableCloneDatabaseName(database),
'DB_NAME must be a Wowngeon clone, restore, or canary name and must not look live');
invariant(expectedDatabase === database,
'CLONE_MIGRATION_EXPECT_DATABASE must exactly equal DB_NAME');
invariant(env.CLONE_MIGRATION_CONFIRM === cloneMigrationConfirmation(database),
`CLONE_MIGRATION_CONFIRM must exactly bind the disposable confirmation to ${database}`);
const host = String(env.DB_HOST || '').trim();
invariant(isLocalDatabaseHost(host),
'DB_HOST must be loopback or a standard local PostgreSQL socket directory');
const portText = String(env.DB_PORT || '').trim();
const port = Number(portText);
invariant(/^\d{1,5}$/.test(portText) && Number.isInteger(port) && port >= 1 && port <= 65535,
'DB_PORT must be an explicit TCP port from 1 through 65535');
const user = String(env.DB_USER || '').trim();
invariant(DATABASE_USER.test(user), 'DB_USER must be an explicit simple PostgreSQL role name');
return Object.freeze({ database, expectedDatabase, host, port, user });
}
function discoverMigrationManifest(migrationsPath = MIGRATIONS_PATH) {
const entries = fs.readdirSync(migrationsPath, { withFileTypes: true });
const files = entries
.filter(entry => entry.name.endsWith('.sql'))
.map(entry => {
invariant(entry.isFile(), `migration entry is not a regular file: ${entry.name}`,
CloneMigrationVerificationError);
const match = MIGRATION_FILE.exec(entry.name);
invariant(match, `migration filename is not canonical: ${entry.name}`,
CloneMigrationVerificationError);
return { filename: entry.name, ordinal: Number(match[1]) };
})
.sort((left, right) => left.filename < right.filename ? -1 : left.filename > right.filename ? 1 : 0);
invariant(files.length > 0, 'no repository migrations were found',
CloneMigrationVerificationError);
files.forEach((file, index) => {
invariant(file.ordinal === index + 1,
`repository migration sequence is not contiguous at ${file.filename}`,
CloneMigrationVerificationError);
});
const latest = files[files.length - 1];
invariant(latest.ordinal >= MINIMUM_RELEASE_MIGRATION,
`repository schema is older than required migration ${MINIMUM_RELEASE_MIGRATION}`,
CloneMigrationVerificationError);
return Object.freeze({
files: Object.freeze(files.map(file => file.filename)),
latestFilename: latest.filename,
latestOrdinal: latest.ordinal
});
}
function verifyExactMigrationLedger(rows, manifest) {
invariant(Array.isArray(rows), 'schema_migrations query did not return rows',
CloneMigrationVerificationError);
const actual = rows.map(row => String(row?.filename || ''));
invariant(actual.length === manifest.files.length,
`migration ledger count ${actual.length} does not match repository count ${manifest.files.length}`,
CloneMigrationVerificationError);
manifest.files.forEach((filename, index) => {
invariant(actual[index] === filename,
`migration ledger differs from the repository at ${filename}`,
CloneMigrationVerificationError);
});
invariant(actual[actual.length - 1] === manifest.latestFilename,
`migration ledger does not end at ${manifest.latestFilename}`,
CloneMigrationVerificationError);
return true;
}
function poolConfiguration(target, env) {
const config = {
host: target.host,
port: target.port,
database: target.database,
user: target.user,
max: 1,
idleTimeoutMillis: 5000,
connectionTimeoutMillis: 5000,
application_name: 'wowngeon-disposable-clone-migrations'
};
// A protected PGPASSFILE/PGPASSWORD can be used without this application-specific variable.
// If the protected service environment already supplies DB_PASSWORD, keep it in memory only.
if (typeof env.DB_PASSWORD === 'string' && env.DB_PASSWORD.length > 0) {
config.password = env.DB_PASSWORD;
}
return config;
}
async function readExactDatabaseName(pool) {
const client = await pool.connect();
try {
const result = await client.query('SELECT current_database() AS database_name');
invariant(result?.rows?.length === 1,
'PostgreSQL returned an ambiguous database identity',
CloneMigrationVerificationError);
return String(result.rows[0].database_name || '');
} finally {
client.release();
}
}
async function runCloneMigrations({
env = process.env,
argv = process.argv.slice(2),
poolFactory = config => new Pool(config),
DatabaseManagerClass = DatabaseManager,
migrationsPath = MIGRATIONS_PATH,
logger = console
} = {}) {
// Every filesystem and connection action occurs after all static target gates pass.
const target = validateCloneMigrationEnvironment(env, argv);
const manifest = discoverMigrationManifest(migrationsPath);
const pool = poolFactory(poolConfiguration(target, env));
try {
const actualDatabase = await readExactDatabaseName(pool);
invariant(actualDatabase === target.expectedDatabase,
'connected PostgreSQL database does not match the exact expected clone name',
CloneMigrationVerificationError);
// This is deliberately the same implementation used during normal application startup:
// lexically ordered files, schema_migrations ledger, one transaction per new file.
const manager = new DatabaseManagerClass();
manager.pool = pool;
await manager.runMigrations();
const ledger = await pool.query(
'SELECT filename FROM schema_migrations ORDER BY filename ASC'
);
verifyExactMigrationLedger(ledger?.rows, manifest);
logger.info('clone_migration_status=ok');
logger.info(`database=${target.database}`);
logger.info(`migration_count=${manifest.files.length}`);
logger.info(`latest_migration=${manifest.latestFilename}`);
return Object.freeze({ ...target, ...manifest });
} finally {
await pool.end().catch(() => {});
}
}
async function main() {
try {
await runCloneMigrations();
} catch (error) {
if (error instanceof CloneMigrationRefusal
|| error instanceof CloneMigrationVerificationError) {
console.error(`REFUSED: ${error.message}`);
} else {
// Do not echo connection objects, URLs, environment values, or server diagnostics.
console.error('FAILED: disposable clone migration did not complete; no credentials were printed');
}
process.exitCode = 1;
}
}
if (require.main === module) {
main();
}
module.exports = {
CloneMigrationRefusal,
CloneMigrationVerificationError,
MINIMUM_RELEASE_MIGRATION,
cloneMigrationConfirmation,
discoverMigrationManifest,
isDisposableCloneDatabaseName,
isLocalDatabaseHost,
poolConfiguration,
readExactDatabaseName,
runCloneMigrations,
validateCloneMigrationEnvironment,
verifyExactMigrationLedger
};
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env node
'use strict';
/**
* Non-mutating production configuration preflight.
*
* This deliberately does not connect to PostgreSQL or either RPC. It catches dangerous operator
* intent before a deploy; the server's normal startup then verifies live dependencies.
*/
require('dotenv').config({ path: process.env.DOTENV_CONFIG_PATH || undefined });
const PaymentConfigManager = require('../config/paymentConfig');
const EnvironmentValidator = require('../config/environmentValidator');
try {
const paymentConfig = new PaymentConfigManager({ logger: console });
const result = new EnvironmentValidator({ logger: console }).assertValid(paymentConfig.getConfig());
const summary = paymentConfig.summarize();
console.log('\n✅ Production configuration preflight passed');
console.log(` chain: ${process.env.CRYPTO_TYPE || 'WOW'} / ${process.env.MONERO_NETWORK || 'mainnet'}`);
console.log(` free play: ${process.env.FREE_PLAY_ENABLED === 'true' || !summary.paymentsEnabled ? 'enabled' : 'disabled'}`);
console.log(` paid modes: ${[summary.directEnabled && 'direct', summary.creditsEnabled && 'credits'].filter(Boolean).join(', ') || 'none'}`);
console.log(` payout dispatch: ${result.money.payoutsEnabled ? 'enabled' : 'disabled'}`);
process.exitCode = 0;
} catch (error) {
console.error(`\n❌ Production configuration preflight failed: ${error.message}`);
process.exitCode = 1;
}
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
require('dotenv').config({ path: '../.env' });
const { Client } = require('pg');
const DB_CONFIG = {
user: process.env.DB_USER || 'wownerogue',
password: process.env.DB_PASSWORD,
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME || 'wownerogue'
};
// Admin config to create user/db
const ADMIN_CONFIG = {
user: process.env.DB_ADMIN_USER || 'postgres',
password: process.env.DB_ADMIN_PASSWORD || 'postgres', // Try default
host: DB_CONFIG.host,
port: DB_CONFIG.port,
database: 'postgres'
};
async function setup() {
console.log('🔧 Starting database setup...');
// Try to connect as admin
const client = new Client(ADMIN_CONFIG);
try {
await client.connect();
console.log('✅ Connected as admin');
} catch (err) {
console.log('⚠️ Could not connect as admin (postgres/postgres). Trying with current user...');
// Fallback: try connecting with current user to 'postgres' db
client.end();
const fallbackClient = new Client({
...ADMIN_CONFIG,
user: process.env.USER || 'postgres',
password: '', // often passwordless for local socket
});
try {
await fallbackClient.connect();
console.log('✅ Connected as ' + (process.env.USER || 'postgres'));
// Swap client
Object.assign(client, fallbackClient);
} catch (e) {
console.error('❌ Could not connect to postgres instance. Please ensure PostgreSQL is running and you have rights to create databases.');
console.error('Error:', err.message);
process.exit(1);
}
}
try {
// 1. Create User if not exists
const userRes = await client.query(`SELECT 1 FROM pg_roles WHERE rolname=$1`, [DB_CONFIG.user]);
if (userRes.rowCount === 0) {
console.log(`👤 Creating user ${DB_CONFIG.user}...`);
await client.query(`CREATE USER "${DB_CONFIG.user}" WITH PASSWORD '${DB_CONFIG.password}' CREATEDB`);
} else {
console.log(`👤 User ${DB_CONFIG.user} already exists`);
}
// 2. Create Database if not exists
const dbRes = await client.query(`SELECT 1 FROM pg_database WHERE datname=$1`, [DB_CONFIG.database]);
if (dbRes.rowCount === 0) {
console.log(`📦 Creating database ${DB_CONFIG.database}...`);
await client.query(`CREATE DATABASE "${DB_CONFIG.database}" OWNER "${DB_CONFIG.user}"`);
} else {
console.log(`📦 Database ${DB_CONFIG.database} already exists`);
}
console.log('✅ Setup complete!');
} catch (err) {
console.error('❌ Setup failed:', err.message);
} finally {
await client.end();
}
}
async function drop() {
console.log('🔥 Dropping database...');
const client = new Client(ADMIN_CONFIG);
try {
await client.connect();
// Terminate connections first
await client.query(`
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = $1
`, [DB_CONFIG.database]);
await client.query(`DROP DATABASE IF EXISTS "${DB_CONFIG.database}"`);
console.log(`✅ Database ${DB_CONFIG.database} dropped`);
} catch (err) {
console.error('❌ Drop failed:', err.message);
} finally {
await client.end();
}
}
const action = process.argv[2];
if (action === 'drop') {
drop();
} else {
setup();
}
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env node
/**
* Non-destructive production smoke test for wownerogue.
*
* Exercises the surfaces changed by the review/NIP-98 work WITHOUT touching money:
* no paid games, no payouts, no floods. Safe to run against the live mainnet instance.
*
* Usage: node scripts/smoke.js [baseUrl] (default https://play.wowne.ro)
* Run from the src/ directory so socket.io-client / nostr-tools resolve.
*/
'use strict';
const BASE = (process.argv[2] || 'https://play.wowne.ro').replace(/\/+$/, '');
const io = require('socket.io-client');
const nt = require('nostr-tools');
let pass = 0, fail = 0, warn = 0;
const rid = () => 'smoke-' + Math.random().toString(16).slice(2, 12);
function ok(name, detail) { pass++; console.log(' \x1b[32mPASS\x1b[0m ' + name + (detail ? ': ' + detail : '')); }
function bad(name, detail) { fail++; console.log(' \x1b[31mFAIL\x1b[0m ' + name + (detail ? ': ' + detail : '')); }
function meh(name, detail) { warn++; console.log(' \x1b[33mWARN\x1b[0m ' + name + (detail ? ': ' + detail : '')); }
async function req(method, path, { body, headers } = {}) {
const res = await fetch(BASE + path, {
method,
headers: Object.assign({ 'Content-Type': 'application/json' }, headers || {}),
body: body ? JSON.stringify(body) : undefined,
});
let json = null;
try { json = await res.json(); } catch (_) { /* non-JSON */ }
return { status: res.status, json };
}
async function section(title) { console.log('\n\x1b[1m' + title + '\x1b[0m'); }
async function run() {
console.log('Smoke test → ' + BASE + '\n' + '='.repeat(50));
// ---- A. Public health / reachability -----------------------------------
await section('A. Health & reachability');
try {
const h = await req('GET', '/health');
h.status === 200 ? ok('GET /health', '200') : meh('GET /health', 'status ' + h.status);
if (h.json) {
const bh = h.json.blockHeight || h.json.block_height || (h.json.blockchain && h.json.blockchain.height);
if (bh) ok(' block height present', String(bh));
}
} catch (e) { bad('GET /health', e.message); }
try {
const s = await req('GET', '/api/stats');
s.status === 200 ? ok('GET /api/stats', 'online=' + (s.json && (s.json.online != null ? s.json.online : '?'))) : meh('GET /api/stats', 'status ' + s.status);
} catch (e) { bad('GET /api/stats', e.message); }
// ---- B. Security: token gating (BOLA fixes) ----------------------------
await section('B. Auth gating (S1/S2 + /status BOLA)');
try {
const p = await req('GET', '/api/user/' + rid() + '/payments');
p.status === 401 ? ok('GET /api/user/:id/payments without token', '401 (gated)')
: bad('GET /api/user/:id/payments without token', 'expected 401, got ' + p.status + ': endpoint NOT gated');
} catch (e) { bad('/api/user/:id/payments', e.message); }
try {
const st = await req('GET', '/api/auth/smirk/status?socketId=' + rid());
st.status === 401 ? ok('GET /api/auth/smirk/status without token', '401 (BOLA closed)')
: bad('GET /api/auth/smirk/status without token', 'expected 401, got ' + st.status + ': BOLA still open');
} catch (e) { bad('/api/auth/smirk/status', e.message); }
// ---- C. NIP-98 verify path (non-mutating: reject cases only) ------------
await section('C. NIP-98 sign-in path (reject cases, no linking)');
let challenge = null;
try {
const c = await req('POST', '/api/auth/smirk/challenge', { body: { socketId: rid() } });
if (c.status === 200 && c.json && (c.json.challenge || c.json.nonce)) {
challenge = c.json.challenge || c.json.nonce;
ok('POST /challenge', 'issued a nonce');
} else { meh('POST /challenge', 'status ' + c.status); }
} catch (e) { bad('POST /challenge', e.message); }
// malformed event -> must reject
try {
const bad1 = await req('POST', '/api/auth/smirk/verify', { body: { socketId: rid(), event: { kind: 27235 } } });
(bad1.status >= 400 && bad1.status < 500) ? ok('POST /verify malformed event', 'rejected ' + bad1.status)
: bad('POST /verify malformed event', 'expected 4xx, got ' + bad1.status);
} catch (e) { bad('POST /verify malformed', e.message); }
// validly-signed event but a challenge the server never issued -> must reject (challenge binding + crypto path reached)
try {
const sk = nt.generateSecretKey();
const evt = nt.finalizeEvent({
kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '',
tags: [['u', BASE + '/api/auth/smirk/verify'], ['method', 'POST'], ['challenge', 'never-issued-' + rid()]],
}, sk);
const r = await req('POST', '/api/auth/smirk/verify', { body: { socketId: rid(), event: evt } });
(r.status >= 400 && r.status < 500) ? ok('POST /verify valid sig + unissued challenge', 'rejected ' + r.status + ' (challenge binding enforced)')
: bad('POST /verify valid sig + unissued challenge', 'expected 4xx, got ' + r.status + ': challenge binding NOT enforced');
} catch (e) { bad('POST /verify unissued challenge', e.message); }
// ---- D. match_queue crash-DoS fix (server must survive) -----------------
await section('D. match_queue crash-DoS fix (server survives)');
await new Promise((resolve) => {
const socket = io(BASE, { transports: ['websocket', 'polling'], timeout: 8000, reconnection: false });
let done = false;
const finish = async (connected) => {
if (done) return; done = true;
try { socket.close(); } catch (_) {}
if (!connected) { meh('socket.io connect', 'could not connect (skipping crash test)'); return resolve(); }
// give the server a moment, then confirm it is still up
await new Promise((r) => setTimeout(r, 1200));
try {
const h = await req('GET', '/health');
h.status === 200 ? ok('server alive after match_queue emit', 'GET /health 200 (no crash)')
: bad('server alive after match_queue emit', '/health status ' + h.status + ': possible crash!');
} catch (e) { bad('server alive after match_queue emit', e.message + ': possible crash!'); }
resolve();
};
socket.on('connect', () => {
ok('socket.io connect', socket.id);
try { socket.emit('match_queue', { junk: true, action: 'join' }); } catch (_) {}
try { socket.emit('match_queue', 'not-even-an-object'); } catch (_) {}
finish(true);
});
socket.on('connect_error', () => finish(false));
setTimeout(() => finish(false), 9000);
});
// ---- summary -----------------------------------------------------------
console.log('\n' + '='.repeat(50));
console.log('\x1b[1mSummary:\x1b[0m ' + pass + ' pass, ' + fail + ' fail, ' + warn + ' warn');
process.exit(fail > 0 ? 1 : 0);
}
run().catch((e) => { console.error('smoke runner error:', e); process.exit(2); });
File diff suppressed because it is too large Load Diff