mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-11-14 11:03:53 +01:00
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:
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user