From c3f08ed1d571a07a95186d0e70ee54b6b149616c Mon Sep 17 00:00:00 2001 From: jwinterm Date: Wed, 21 Oct 2026 13:31:50 -0400 Subject: [PATCH] fix(showcase): qualify the real free multiplayer lifecycle --- docs/DEPLOY.md | 23 +- docs/DEPLOY_INSTANCES.md | 28 +- docs/RELEASE_ARTIFACT.md | 22 +- docs/SHOWCASE_LIFECYCLE_SMOKE.md | 26 + docs/SHOWCASE_QUALIFICATION.md | 5 + scripts/deploy/build-release-artifact.sh | 2 + src/game/gameModeManager.js | 2 +- src/package.json | 1 + src/scripts/showcase-lifecycle-smoke.js | 623 +++++++++++++++++++++++ src/scripts/showcase-network-guard.js | 101 ++++ test/gameModeManager.test.js | 20 + test/matchQueue.test.js | 26 + test/releaseArtifactBuilder.test.js | 4 + test/showcaseNetworkGuard.test.js | 50 ++ 14 files changed, 904 insertions(+), 29 deletions(-) create mode 100644 docs/SHOWCASE_LIFECYCLE_SMOKE.md create mode 100644 src/scripts/showcase-lifecycle-smoke.js create mode 100644 src/scripts/showcase-network-guard.js create mode 100644 test/showcaseNetworkGuard.test.js diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 2457fd6..2d09de2 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -89,11 +89,12 @@ The fleet `playbooks/wowngeon-stage-candidate.yml` role transfers exactly one ta selected root-owned mode-0700 candidate inbox and compares its remote SHA-256 with the independently recorded literal digest. It is a one-shot custody boundary, not deployment. -The executable extraction and activation boundary lives only in the separately reviewed fleet -repository as the default-closed `wowngeon_release_activate` role. This application checkout cannot -attest which fleet revision is installed or authorize its execution. Follow the hash-pinned fleet -runbook and literal receipt contract. Do not manually extract beneath `releases/`, install -dependencies there, change `current`, restart an application, or run migrations merely because a +Such Software application activation belongs to the reviewed Fleet control plane. Its generic +`wowngeon_release_activate` role was retired on 2026-09-10; no repeatable replacement activation lane +is available. The remaining one-shot Wownerogue cutover deliberately finishes stopped with ingress +closed. This source checkout cannot authorize a live update. Follow Fleet's `playbooks/WOWNGEON.md` +and `RUNBOOK-wowngeon.md` for current capabilities. Do not manually extract beneath `releases/`, +install dependencies there, change `current`, restart an application, or run migrations because a blob has arrived. --- @@ -279,12 +280,12 @@ release artifact. Provision them separately on any instance that offers the 3D r ## Updating the Deployment Run the full suite from the clean source commit, then build and verify the runtime artifact using -[RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md). Use the fleet blob-staging playbook, then proceed only -through the hash-pinned `wowngeon_release_activate` role after its clone, wallet, accounting, drain, -predecessor, and receipt gates pass. That role, not an application service identity or a manual -shell session, builds a new immutable `/var/www//releases/`, installs the -locked graph, verifies the clone and preflight evidence, seals ownership, and atomically selects -`current`. +[RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md). Fleet blob staging and disposable-clone validation are +non-activating capabilities. The generic activation role is retired, so a new reviewed update +transaction and its exact migration, accounting, predecessor, and recovery evidence are required +before an operated instance can select a new release. In particular, test the deployed predecessor +against the candidate's post-migration schema and writes; a source revision with a similar name +cannot stand in for deployed overlay bytes. Never run `git pull`, `npm install`, or `npm audit fix` inside the active release. Keep the previous release and database backup until the new version has passed public health, WebSocket, diff --git a/docs/DEPLOY_INSTANCES.md b/docs/DEPLOY_INSTANCES.md index d11efee..10ea260 100644 --- a/docs/DEPLOY_INSTANCES.md +++ b/docs/DEPLOY_INSTANCES.md @@ -74,15 +74,25 @@ The profile also controls what `/api/game-modes` publishes: `selectPublicPaidMod emits only the profile's own paid mode, so a Wownero instance never advertises `PAID_SINGLE` and a Monerogue instance never advertises `PAID_CREDITS`. +## Operated deployment status + +The activation and wallet-promotion sequence below is historical design guidance. Fleet retired the +generic application activation and stagenet wallet promotion paths on 2026-09-10. There is no +repeatable operated-application activation lane today. Staging and clone validation remain +non-activating. Consult Fleet's `playbooks/WOWNGEON.md` and `RUNBOOK-wowngeon.md`; do not execute +manual service, configuration, or `current` changes from this document against an operated instance. +A successor requires reviewed migration and exact predecessor compatibility evidence. + ## Step 0: Stage an immutable release Build one runtime artifact, then transfer only that blob into a root-owned mode-0700 candidate inbox. Compare the host's SHA-256 against an independently recorded digest; the sidecar file that travelled with the blob is not an independent provenance source. -Blob staging, two-dump clone validation ([CLONE_MIGRATIONS.md](./CLONE_MIGRATIONS.md)), wallet -candidate preparation and promotion, and per-instance release activation are performed by the -separate, default-closed, hash-pinned fleet automation. Each operation is bound to literal hashes +Blob staging and two-dump clone validation ([CLONE_MIGRATIONS.md](./CLONE_MIGRATIONS.md)) remain +separate Fleet capabilities. Wallet promotion and general per-instance activation are retired; +the remaining wallet candidate and one-shot cutover do not provide a repeatable update path. +Every admitted operation is bound to literal hashes and exact receipts and infers no release, dump, wallet, predecessor, or instance. Manual extraction under `/var/www//releases`, manual dependency installation, manual service commands, and a hand-written `current` symlink switch are not supported substitutes. A release directory is never @@ -127,11 +137,10 @@ and mnemonic custody, an off-host recovery proof, an isolated rehearsal, exact a verification, and a separately reviewed maintenance and rollback plan. Root-level wallet templates in `scripts/deploy/` are operations source, not runtime files and not an instruction to install them. -After activation, verify: +After a separately reviewed activation, use read-only verification: ```bash -sudo systemctl restart wownerogue.service -journalctl -u wownerogue.service -n 50 --no-pager # payment system enabled, payouts disabled +systemctl is-active wownerogue.service curl -fsS localhost:3000/api/game-modes \ | jq -e '.FREE.enabled == true and .PAID_CREDITS.enabled == true and (has("PAID_SINGLE") | not)' ``` @@ -234,9 +243,10 @@ sudo -u monerogue bash -c 'set -a; . /etc/monerogue/app.env; . /etc/monerogue/wa ## Step 5: Activate the instance and close the network -The runtime artifact contains no service units. Wallet promotion and instance activation run through -the hash-pinned fleet roles after the recovery, clone-validation, protected-environment, wallet, -drain, and financial-audit gates pass. Do not copy a unit out of an application release. +The runtime artifact contains no service units. A future Fleet activation transaction must prove +recovery, clone validation, protected configuration, wallet boundaries, drain, and financial audits. +There is no current repeatable lane to execute this step. Do not copy a unit out of an application +release. Install the hash-pinned copies of `scripts/deploy/wowngeon-firewall.sh` (mode 0755 at `/usr/local/sbin/wowngeon-firewall`) and `scripts/deploy/wowngeon-firewall.service`, then set the diff --git a/docs/RELEASE_ARTIFACT.md b/docs/RELEASE_ARTIFACT.md index 900ae78..3ec1c28 100644 --- a/docs/RELEASE_ARTIFACT.md +++ b/docs/RELEASE_ARTIFACT.md @@ -14,7 +14,9 @@ Everything else in the commit is excluded on purpose: `docs/`, `test/`, the root (service units, firewall and backup jobs, the hash-pinned financial audit SQL, the wallet output splitter), environment examples, `src/sim`, `src/jest.config.js`, and the operator-only commands `src/scripts/pvp-capture.js`, `src/scripts/stagenet-financial-canary.js`, `src/scripts/setup_db.js`, -`src/scripts/smoke.js`, and `src/scripts/showcase-browser-smoke.js`. Tests and operator tooling are source and control-plane inputs; they are +`src/scripts/smoke.js`, `src/scripts/showcase-browser-smoke.js`, +`src/scripts/showcase-lifecycle-smoke.js`, and `src/scripts/showcase-network-guard.js`. +Tests and operator tooling are source and control-plane inputs; they are not production runtime files, and sharing a Git commit with the runtime does not make them part of it. @@ -121,12 +123,13 @@ activation boundary must then: validates payment and environment configuration only. It connects to neither PostgreSQL nor either RPC, so it does not replace database, daemon, or wallet health checks. -The clone runner is deliberately non-activating. Production activation uses the separately reviewed, -default-closed `wowngeon_release_activate` role in the fleet control-plane repository. Do not -substitute manual extraction, dependency installation, service commands, or a hand-written `current` -switch. That role refuses activation until the matching clone-validation receipt, wallet-promotion -receipt, drain and audit gates, literal hashes, and rollback target all satisfy the contract in the -fleet runbook `RUNBOOK-wowngeon.md`. +The clone runner is deliberately non-activating. Fleet retired `wowngeon_release_activate` on +2026-09-10, and there is no repeatable operated-application activation lane today. Its remaining +one-shot Wownerogue cutover finishes stopped with ingress closed. Consult Fleet's +`playbooks/WOWNGEON.md` and `RUNBOOK-wowngeon.md` for current capabilities. A reviewed successor must +bind the exact migration, financial, predecessor compatibility, and recovery evidence before a +live update. Do not substitute manual extraction, dependency installation, service commands, or a +hand-written `current` switch for that missing capability. ## Source and operations boundary @@ -136,7 +139,10 @@ source-tree deployment scripts nor fleet tools are copied beneath the applicatio A runtime artifact hash attests nothing about a separately delivered operations file; record and verify an independent hash for every such file in the fleet change plan. -## Activation and rollback gate +## Activation and rollback requirements + +These are requirements for a future reviewed Fleet transaction, not an executable deployment +procedure. An artifact or clone-validation receipt cannot activate an operated instance. Drain public ingress and require zero active and queued games. Take and validate fresh dumps, and run the financial audit before and after stopping only the affected app. Preserve and validate the exact diff --git a/docs/SHOWCASE_LIFECYCLE_SMOKE.md b/docs/SHOWCASE_LIFECYCLE_SMOKE.md new file mode 100644 index 0000000..4b196e2 --- /dev/null +++ b/docs/SHOWCASE_LIFECYCLE_SMOKE.md @@ -0,0 +1,26 @@ +--- +title: Local multiplayer lifecycle rehearsal +status: active +updated: 2026-10-21 +owner: Wowngeon +type: testing-guide +--- + +# Local multiplayer lifecycle rehearsal + +Run `npm run test:showcase-lifecycle` from `src/` on the Linux developer workstation. It needs the repository's installed npm dependencies, PostgreSQL `initdb` and `pg_ctl` binaries, and the cached Playwright Chromium browser. It does not install dependencies or contact production services. + +The runner starts a fresh PostgreSQL cluster using a private Unix socket under its output directory, runs the actual application and migrations with anonymous browser sessions, then opens two isolated browser contexts. It drives the real free queue through the existing simulated block source, whose normal fixture cadence is 30 seconds, completes an Escape Race through keyboard movement, and verifies the persisted zero-fee result and empty financial ledgers. Payments, payouts, wallet RPC setup, and external browser egress are disabled. + +Each run creates a new mode-0700 directory under `~/Build/wowngeon/showcase-lifecycle/`; it never overwrites earlier evidence. Pass `--output-root PATH` to choose another private output root, using only letters, digits, slash, dot, underscore, or hyphen. The run report includes screenshot paths and sanitized match metadata. Browser cookies, session tokens, profiles, wallet state, and database contents are not copied into the report. + +## Acceptance checklist + +- [ ] The actual queue, simulated-block scheduler and countdown start both clients. +- [ ] Keyboard input reaches the authoritative exit and the database records the result. +- [ ] The client returns to Tavern and financial ledgers remain empty. +- [ ] No external request, browser error or unexpected HTTP failure occurs. +- [ ] The application and disposable PostgreSQL server stop after the run. + +This is a local source rehearsal. Installed-wallet and deployed acceptance remain separate in +[Showcase qualification](SHOWCASE_QUALIFICATION.md). diff --git a/docs/SHOWCASE_QUALIFICATION.md b/docs/SHOWCASE_QUALIFICATION.md index b9729c8..83da56e 100644 --- a/docs/SHOWCASE_QUALIFICATION.md +++ b/docs/SHOWCASE_QUALIFICATION.md @@ -39,6 +39,11 @@ state hashes for the same seed. The capture imports the actual match engine but players. It does not qualify the deployed scheduler, queue UI, account restoration, or Tavern-to-match navigation. +For a fresh PostgreSQL cluster and the actual application entrypoint, run the two-browser +[local lifecycle rehearsal](SHOWCASE_LIFECYCLE_SMOKE.md). It covers the free queue, real scheduler +and countdown, keyboard-driven Escape Race, persisted result, and return to Tavern. It uses the +local simulated-block source and has no wallet or payment authority. + ## Smirk checkout contract Invoice delivery and reconnect restoration never open a wallet prompt. The user first sees the diff --git a/scripts/deploy/build-release-artifact.sh b/scripts/deploy/build-release-artifact.sh index 05ee945..6c563c7 100755 --- a/scripts/deploy/build-release-artifact.sh +++ b/scripts/deploy/build-release-artifact.sh @@ -181,6 +181,8 @@ runtime_pathspecs=( ':(exclude)src/.env*' ':(glob,exclude)src/**/.env*' ':(exclude)src/scripts/showcase-browser-smoke.js' + ':(exclude)src/scripts/showcase-network-guard.js' + ':(exclude)src/scripts/showcase-lifecycle-smoke.js' ':(exclude)src/scripts/pvp-capture.js' ':(exclude)src/scripts/stagenet-financial-canary.js' ':(exclude)src/scripts/setup_db.js' diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index 187fe17..9c9f4fb 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -662,7 +662,7 @@ class GameModeManager { _getMatchEconomies() { const out = { free: true }; - if (this.creditsModeEnabled || this.freePlayEnabled) { + if (this.creditsModeEnabled) { out.credits_prestige = true; } // Crypto admission has its own explicit switch, payout cap, ruleset contract, and the diff --git a/src/package.json b/src/package.json index 32fa874..57ac103 100644 --- a/src/package.json +++ b/src/package.json @@ -10,6 +10,7 @@ "debug": "NODE_ENV=debug node index.js", "test": "jest --runInBand", "test:showcase-browser": "node scripts/showcase-browser-smoke.js", + "test:showcase-lifecycle": "node scripts/showcase-lifecycle-smoke.js", "capture:pvp": "node scripts/pvp-capture.js", "canary:stagenet": "node scripts/stagenet-financial-canary.js", "db:migrate:clone": "node scripts/migrate-disposable-clone.js", diff --git a/src/scripts/showcase-lifecycle-smoke.js b/src/scripts/showcase-lifecycle-smoke.js new file mode 100644 index 0000000..aacbde6 --- /dev/null +++ b/src/scripts/showcase-lifecycle-smoke.js @@ -0,0 +1,623 @@ +'use strict'; + +// Real application lifecycle rehearsal. The only synthetic input is the app's existing local +// simulated-block source, at its normal cadence. Queue, scheduler, match engine, database writes, +// socket events, client controls, and results all run through production application code. +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const http = require('node:http'); +const net = require('node:net'); +const os = require('node:os'); +const path = require('node:path'); +const { promisify } = require('node:util'); +const { Client } = require('pg'); +const { chromium } = require('playwright-core'); + +const execFile = promisify(childProcess.execFile); +const ROOT = path.resolve(__dirname, '../..'); +const SRC_ROOT = path.join(ROOT, 'src'); +const APP_ENTRY = path.join(SRC_ROOT, 'index.js'); +const OUTPUT_BASE = path.join(os.homedir(), 'Build', 'wowngeon', 'showcase-lifecycle'); +const ROLES = ['mica', 'moth']; +const SOCKET_EVENTS = ['connect', 'game_mode_info', 'match_queue_joined', 'match_joined', 'match_start', 'match_tick', 'match_end']; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function requireCheck(condition, code) { + if (condition) return; + const error = new Error(code); + error.code = code; + throw error; +} + +function parseArgs(argv) { + let outputRoot = OUTPUT_BASE; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--output-root') { + assert(argv[i + 1], '--output-root requires a directory'); + outputRoot = path.resolve(argv[++i]); + } else { + throw new Error(`Unknown argument: ${argv[i]}`); + } + } + return outputRoot; +} + +function isLoopback(hostname) { + return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]'; +} + +function waitFor(predicate, timeoutMs, description, intervalMs = 50) { + const started = Date.now(); + return new Promise((resolve, reject) => { + const check = () => { + Promise.resolve().then(predicate).then(value => { + if (value) return resolve(value); + if (Date.now() - started >= timeoutMs) return reject(new Error(`Timed out waiting for ${description}`)); + setTimeout(check, intervalMs); + }, reject); + }; + check(); + }); +} + +async function getFreePort() { + const server = net.createServer(); + await new Promise((resolve, reject) => server.listen(0, '127.0.0.1', resolve).once('error', reject)); + const port = server.address().port; + await new Promise(resolve => server.close(resolve)); + return port; +} + +function cleanEnv(values) { + const env = { + PATH: process.env.PATH || '/usr/bin:/bin', + HOME: os.homedir(), + LANG: 'C.UTF-8', + LC_ALL: 'C.UTF-8', + TZ: 'UTC', + USER: os.userInfo().username, + LOGNAME: os.userInfo().username, + TMPDIR: os.tmpdir(), + ...values + }; + return env; +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'`; +} + +function binaryDirectory() { + const candidates = ['/usr/lib/postgresql/18/bin', '/usr/lib/postgresql/16/bin', '/usr/bin']; + return candidates.find(dir => fs.existsSync(path.join(dir, 'initdb')) && fs.existsSync(path.join(dir, 'pg_ctl'))); +} + +async function run(command, args, options = {}) { + return execFile(command, args, { + cwd: options.cwd || SRC_ROOT, + env: options.env || cleanEnv({}), + timeout: options.timeout || 30000, + maxBuffer: 2 * 1024 * 1024 + }); +} + +function startProcess(command, args, options = {}) { + const proc = childProcess.spawn(command, args, { + cwd: options.cwd || SRC_ROOT, + env: options.env || cleanEnv({}), + stdio: ['ignore', 'pipe', 'pipe'] + }); + let output = Buffer.alloc(0); + const retainTail = chunk => { + output = Buffer.concat([output, chunk]).subarray(-256 * 1024); + }; + let launchFailed = false; + proc.once('error', () => { launchFailed = true; }); + proc.stdout.on('data', retainTail); + proc.stderr.on('data', retainTail); + return { proc, getOutput: () => output.toString(), launchFailed: () => launchFailed }; +} + +function classifyStartupFailure(output) { + const categories = [ + ['EGRESS_GUARD_TCP_IPV4', /showcase lifecycle smoke denied network egress:TCP_IPV4/], + ['EGRESS_GUARD_TCP_IPV6', /showcase lifecycle smoke denied network egress:TCP_IPV6/], + ['EGRESS_GUARD_TCP_HOSTNAME', /showcase lifecycle smoke denied network egress:TCP_HOSTNAME/], + ['EGRESS_GUARD_TCP_EMPTY', /showcase lifecycle smoke denied network egress:TCP_EMPTY/], + ['EGRESS_GUARD_TCP', /showcase lifecycle smoke denied network egress:TCP(?:\r?\n|$)/], + ['EGRESS_GUARD_DNS', /showcase lifecycle smoke denied network egress:DNS/], + ['EGRESS_GUARD_UDP', /showcase lifecycle smoke denied network egress:UDP/], + ['APP_ENV_INVALID', /Startup aborted/], + ['APP_DATABASE_FAILED', /Database connection failed/], + ['APP_MIGRATION_FAILED', /Migration failed/], + ['APP_PAYMENT_INIT_FAILED', /Failed to initialize payment system/], + ['APP_DEPENDENCY_MISSING', /Cannot find module/], + ['APP_PORT_UNAVAILABLE', /EADDRINUSE/], + ['APP_FATAL_EXCEPTION', /uncaughtException|unhandledRejection/] + ]; + return categories.find(([, pattern]) => pattern.test(output))?.[0] || 'APPLICATION_EXITED'; +} + +function classifyHarnessFailure(error, phase) { + const sqlStateCodes = { + '42P01': 'DATABASE_TABLE_UNDEFINED', + '42703': 'DATABASE_COLUMN_UNDEFINED', + '42883': 'DATABASE_FUNCTION_UNDEFINED', + '42501': 'DATABASE_PERMISSION_DENIED', + '23514': 'DATABASE_CONSTRAINT_REJECTED' + }; + if (sqlStateCodes[error?.code]) return sqlStateCodes[error.code]; + if (typeof error?.code === 'string' && /^[0-9A-Z]{5}$/.test(error.code)) return 'DATABASE_QUERY_FAILED'; + if (typeof error?.code === 'string' && /^[A-Z0-9_]{1,64}$/.test(error.code)) return error.code; + const message = String(error?.message || ''); + const patterns = [ + ['FREE_ECONOMY_MISSING', /Free\/Pleb queue option|free economy control/], + ['PAID_ECONOMY_EXPOSED', /A paid queue appeared/], + ['QUEUE_JOIN_FAILED', /Both clients did not enter the real free queue|Queued for free/], + ['MATCH_START_TIMEOUT', /real simulated-block scheduler callback|match_start/], + ['LIVE_MATCH_STATE_INVALID', /No live match state|Unexpected live match policy|socket is absent from the live public match state/], + ['EXIT_ROUTE_UNAVAILABLE', /No walkable path from the selected player to the exit/], + ['EXIT_ROUTE_EMPTY', /Exit route unexpectedly has no movement steps/], + ['BROWSER_RUNTIME_FAILURE', /Browser runtime errors|Unexpected HTTP failures|external egress/], + ['QUEUE_UI_TIMEOUT', /matchQueueBtn|matchPanel|matchEconomies|joinMatchQueueBtn/] + ]; + return patterns.find(([, pattern]) => pattern.test(message))?.[0] + || (phase === 'queue-and-match' ? 'QUEUE_AND_MATCH_FAILED' : 'UNCLASSIFIED'); +} + +async function httpStatus(url) { + return new Promise((resolve, reject) => { + const request = http.get(url, response => { + response.resume(); + resolve(response.statusCode); + }); + request.setTimeout(1000, () => request.destroy(new Error('HTTP readiness timeout'))); + request.on('error', reject); + }); +} + +async function getJson(url) { + return new Promise((resolve, reject) => { + const request = http.get(url, response => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', chunk => { body += chunk; }); + response.on('end', () => { + try { resolve({ status: response.statusCode, body: JSON.parse(body) }); } + catch (_) { reject(new Error('Health endpoint returned invalid JSON')); } + }); + }); + request.setTimeout(1000, () => request.destroy(new Error('Health request timeout'))); + request.on('error', reject); + }); +} + +function observeSocketScript() { + return `(() => { + const eventNames = ${JSON.stringify(SOCKET_EVENTS)}; + window.__lifecycle = { socket: null, events: {}, latestState: null, starts: 0, ends: [] }; + let factory = null; + const wrap = value => { + if (typeof value !== 'function') return value; + return function(...args) { + const socket = value.apply(this, args); + window.__lifecycle.socket = socket; + eventNames.forEach(name => socket.on(name, payload => { + const log = window.__lifecycle.events[name] || (window.__lifecycle.events[name] = []); + log.push(payload); + if (log.length > 30) log.shift(); + if (name === 'match_joined' && payload && payload.state?.gameState) window.__lifecycle.latestState = payload.state.gameState; + if (name === 'match_joined' && payload && payload.gameState) window.__lifecycle.latestState = payload.gameState; + if (name === 'match_start') { + window.__lifecycle.starts += 1; + if (payload && payload.gameState) window.__lifecycle.latestState = payload.gameState; + } + if (name === 'match_tick' && payload && payload.state) window.__lifecycle.latestState = payload.state; + if (name === 'match_tick' && payload && payload.gameState) window.__lifecycle.latestState = payload.gameState; + if (name === 'match_end') window.__lifecycle.ends.push(payload); + })); + return socket; + }; + }; + Object.defineProperty(window, 'io', { + configurable: true, + get() { return factory; }, + set(value) { factory = wrap(value); } + }); + })();`; +} + +function shortestPath(state, start, goal) { + const grid = state && state.visibleTiles; + if (!Array.isArray(grid) || grid.length === 0) { + const error = new Error('Match state has no public dungeon grid'); + error.code = 'LIVE_GRID_MISSING'; + throw error; + } + if (!Array.isArray(goal) || goal.length !== 2) { + const error = new Error('Live match state does not expose the race exit'); + error.code = 'LIVE_EXIT_MISSING'; + throw error; + } + const walkable = tile => tile === "'1" || tile === "'2" || tile === '>' || tile === '$M'; + const key = (x, y) => `${x},${y}`; + const occupied = new Set((state.players || []) + .filter(player => player.id !== start.id && player.alive !== false && player.finished !== true) + .map(player => key(player.x, player.y))); + const queue = [{ x: start.x, y: start.y, path: [] }]; + const seen = new Set([key(start.x, start.y)]); + const deltas = [[0, -1], [1, 0], [0, 1], [-1, 0]]; + while (queue.length) { + const node = queue.shift(); + if (node.x === goal[0] && node.y === goal[1]) return node.path; + for (const [dx, dy] of deltas) { + const x = node.x + dx; + const y = node.y + dy; + const nextKey = key(x, y); + if (seen.has(nextKey) || occupied.has(nextKey) || !walkable(grid[y]?.[x])) continue; + seen.add(nextKey); + queue.push({ x, y, path: [...node.path, { x, y, key: ['ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowLeft'][deltas.findIndex(d => d[0] === dx && d[1] === dy)] }] }); + } + } + const error = new Error('No walkable path from the selected player to the exit'); + error.code = 'EXIT_ROUTE_UNAVAILABLE'; + throw error; +} + +async function main() { + assert(process.platform === 'linux', 'Run this lifecycle smoke on the Linux developer workstation with PostgreSQL installed'); + assert(process.getuid && process.getuid() !== 0, 'Refusing to run PostgreSQL as root'); + assert(process.env.NODE_ENV !== 'production', 'Refusing to run with NODE_ENV=production'); + assert(fs.existsSync(APP_ENTRY), 'Application entrypoint is missing from the source tree'); + assert(!fs.existsSync(path.join(SRC_ROOT, '.env')), 'The isolated source tree must not contain a dotenv file'); + + const outputRoot = parseArgs(process.argv.slice(2)); + assert(!/[^A-Za-z0-9._/-]/.test(outputRoot), 'Output root may contain only letters, digits, slash, dot, underscore, or hyphen'); + fs.mkdirSync(outputRoot, { recursive: true, mode: 0o700 }); + const outputDir = fs.mkdtempSync(path.join(outputRoot, 'run-')); + fs.chmodSync(outputDir, 0o700); + const egressGuardPath = path.join(__dirname, 'showcase-network-guard.js'); + const pgDir = path.join(outputDir, 'pgdata'); + const socketDir = path.join(outputDir, 'pgsock'); + fs.mkdirSync(socketDir, { mode: 0o700 }); + const pgBin = binaryDirectory(); + assert(pgBin, 'PostgreSQL initdb/pg_ctl binaries were not found'); + const dbPort = await getFreePort(); + const appPort = await getFreePort(); + const dbName = 'wowngeon_showcase'; + const dbUser = 'wowngeon_smoke'; + const dbEnv = cleanEnv({ PGHOST: socketDir, PGPORT: String(dbPort), PGUSER: dbUser, PGDATABASE: dbName }); + + await run(path.join(pgBin, 'initdb'), ['-D', pgDir, '--auth-local=trust', '--auth-host=reject', '--no-locale', '--encoding=UTF8', '-U', dbUser], { env: cleanEnv({}) }); + const pgCtl = path.join(pgBin, 'pg_ctl'); + const pgOptions = ['-h', '', '-k', socketDir, '-p', String(dbPort), '-c', 'listen_addresses=', '-c', 'unix_socket_permissions=0700'] + .map(shellQuote).join(' '); + let postgresNeedsStop = false; + let appProcess = null; + let browser = null; + const failures = []; + const blockedEgress = []; + const httpErrors = []; + const screenshots = {}; + const contextPages = {}; + let phase = 'postgres-init'; + let startupHealthCode = 'APPLICATION_NOT_READY'; + let completedReport = null; + let primaryFailure = null; + const cleanupFailures = []; + + try { + phase = 'postgres-start'; + // Retain cleanup intent even if pg_ctl times out after starting the server. + postgresNeedsStop = true; + await run(pgCtl, ['-D', pgDir, '-l', path.join(outputDir, 'postgres.log'), '-o', pgOptions, 'start'], { env: dbEnv }); + phase = 'postgres-create-database'; + await run(path.join(pgBin, 'createdb'), ['-h', socketDir, '-p', String(dbPort), '-U', dbUser, dbName], { env: dbEnv }); + phase = 'application-startup'; + const appEnv = cleanEnv({ + NODE_ENV: 'debug', DOTENV_CONFIG_PATH: '/dev/null', HOST: '127.0.0.1', PORT: String(appPort), + DB_HOST: socketDir, DB_PORT: String(dbPort), DB_NAME: dbName, DB_USER: dbUser, DB_PASSWORD: '', + PAYMENTS_ENABLED: 'false', PAYOUTS_ENABLED: 'false', FREE_PLAY_ENABLED: 'true', GAME_MODE: 'free', + SMIRK_ENABLED: 'false', TAVERN_ENABLED: 'true', MATCH_ENABLED: 'true', MATCH_RULESET: 'race', + MATCH_CRYPTO_RACE_ENABLED: 'false', MATCH_PAYOUTS_ENABLED: 'false', + SIMULATED_BLOCKS: 'true', BLOCK_SOURCE: 'simulated', DEBUG_HOTKEYS: 'false', + MATCH_TICK_MS: '100', MATCH_MAX_PLAYERS: '4', MATCH_COUNTDOWN_MS: '1000', + MATCH_AVAILABILITY_RECONCILE_MS: '1000', + PRIMARY_WALLET_ENDPOINT: 'http://127.0.0.1:9', RPC_ENDPOINTS: 'http://127.0.0.1:9', + PRIMARY_RPC_ENDPOINT: 'http://127.0.0.1:9', FALLBACK_RPC_ENDPOINT: 'http://127.0.0.1:9', + WOWNGEON_SHOWCASE_GUARD: '1', + WOWNGEON_SHOWCASE_PG_SOCKET: path.join(socketDir, `.s.PGSQL.${dbPort}`), + NODE_OPTIONS: `--require="${egressGuardPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` + }); + assert(appEnv.DB_PASSWORD === '' && appEnv.PAYMENTS_ENABLED === 'false' && appEnv.PAYOUTS_ENABLED === 'false', 'Financially closed local configuration was not established'); + const appChild = startProcess(process.execPath, [APP_ENTRY], { cwd: SRC_ROOT, env: appEnv }); + appProcess = appChild.proc; + await waitFor(async () => { + if (appChild.launchFailed()) { + const error = new Error('Application process could not start'); + error.code = 'APPLICATION_SPAWN_FAILED'; + throw error; + } + if (appProcess.exitCode != null || appProcess.signalCode != null) { + const error = new Error('Application ended before readiness'); + error.code = classifyStartupFailure(appChild.getOutput()); + throw error; + } + try { + const response = await getJson(`http://127.0.0.1:${appPort}/health/ready`); + const checks = response.body.checks || {}; + startupHealthCode = checks.database === 'down' ? 'DATABASE_NOT_READY' + : checks.wallet === 'up' ? 'WALLET_ENDPOINT_ACTIVE' + : checks.financialRecovery === 'pending' ? 'FINANCIAL_RECOVERY_PENDING' + : response.status === 404 ? 'READINESS_ROUTE_MISSING' + : 'APPLICATION_NOT_READY'; + if (response.status !== 200 || response.body.ready !== true) return false; + assert(response.body.checks?.database === 'up', 'Health probe did not confirm the migrated database'); + assert(response.body.checks?.wallet === 'not_required', 'Health probe indicates wallet initialization is required'); + assert(response.body.walletHealthy === false, 'Health probe indicates a wallet RPC connection'); + assert(response.body.money?.paymentsEnabled === false && response.body.money?.payoutsEnabled === false, 'Health probe does not confirm the money paths are closed'); + assert(response.body.chain?.source === 'simulated', 'Health probe does not confirm the local block fixture'); + return true; + } catch (error) { + if (/Health probe/.test(error.message)) throw error; + return false; + } + }, 60000, 'real application startup and full database migrations', 250).catch(error => { + if (!error.code) error.code = startupHealthCode; + throw error; + }); + + phase = 'browser-setup'; + browser = await chromium.launch({ headless: true }); + for (const role of ROLES) { + const context = await browser.newContext({ userAgent: `WowngeonLifecycleSmoke/${role}`, viewport: { width: 1280, height: 900 } }); + context.on('requestfailed', request => { + const url = new URL(request.url()); + if (!isLoopback(url.hostname) || Number(url.port || 80) !== appPort) blockedEgress.push(url.origin); + }); + await context.route('**/*', route => { + const url = new URL(route.request().url()); + if (url.protocol === 'data:' || url.protocol === 'blob:') return route.continue(); + if (url.protocol === 'http:' && isLoopback(url.hostname) && Number(url.port || 80) === appPort) return route.continue(); + blockedEgress.push(url.origin); + return route.abort('blockedbyclient'); + }); + await context.routeWebSocket('**/*', ws => { + const url = new URL(ws.url()); + if (url.protocol === 'ws:' && isLoopback(url.hostname) && Number(url.port || 80) === appPort) ws.connectToServer(); + else blockedEgress.push(url.origin); + }); + const page = await context.newPage(); + await page.addInitScript(observeSocketScript()); + page.on('pageerror', error => failures.push(error.message)); + page.on('response', response => { + if (response.status() >= 400) httpErrors.push({ status: response.status(), path: new URL(response.url()).pathname }); + }); + page.on('console', message => { + if (message.type() === 'error') failures.push(message.text()); + }); + contextPages[role] = { context, page }; + } + + phase = 'tavern-join'; + await Promise.all(Object.values(contextPages).map(({ page }) => page.goto(`http://127.0.0.1:${appPort}/tavern`))); + for (const { page } of Object.values(contextPages)) { + await page.locator('#joinBtn').waitFor({ state: 'visible' }); + await page.waitForFunction(() => window.__lifecycle?.socket?.connected === true); + await page.locator('#name').waitFor({ state: 'visible' }); + } + for (const [role, name] of [['mica', 'Mica'], ['moth', 'Moth']]) { + const page = contextPages[role].page; + await page.locator('#name').fill(name); + await page.locator('#joinBtn').click(); + await page.locator('#stage canvas.rk-canvas').waitFor({ state: 'visible', timeout: 10000 }); + } + await waitFor(async () => { + const ids = await Promise.all(ROLES.map(role => contextPages[role].page.evaluate(() => window.__lifecycle.socket?.id || null))); + return ids.every(Boolean); + }, 10000, 'two page-owned sockets'); + const socketIds = {}; + for (const role of ROLES) socketIds[role] = await contextPages[role].page.evaluate(() => window.__lifecycle.socket.id); + assert(socketIds.mica && socketIds.moth && socketIds.mica !== socketIds.moth, 'Fresh browser contexts did not own distinct sockets'); + + phase = 'arena-open'; + const queueReady = contextPages.mica.page; + await queueReady.locator('#matchQueueBtn').waitFor({ state: 'visible', timeout: 10000 }); + await queueReady.locator('#matchQueueBtn').click(); + await queueReady.locator('#matchPanel').waitFor({ state: 'visible' }); + await waitFor(() => queueReady.locator('#matchEconomies button').count(), 5000, 'free economy control'); + const economyControls = await queueReady.locator('#matchEconomies button').allTextContents(); + assert(economyControls.some(text => /free|pleb/i.test(text)), 'Free/Pleb queue option is absent'); + assert(!economyControls.some(text => /prestige|crypto|paid/i.test(text)), `A paid queue appeared in free-only mode: ${economyControls.join(', ')}`); + const freeOption = queueReady.locator('#matchEconomies button').filter({ hasText: /free|pleb/i }).first(); + await freeOption.click(); + phase = 'queue-join'; + const joinQueue = async role => { + const page = contextPages[role].page; + if (role !== 'mica') { + await page.locator('#matchQueueBtn').click(); + await page.locator('#matchPanel').waitFor({ state: 'visible' }); + } + await page.locator('#matchEconomies button').filter({ hasText: /free|pleb/i }).first().click(); + await page.locator('#joinMatchQueueBtn').click(); + await page.getByText(/Queued for free/i).waitFor({ timeout: 10000 }); + return page.evaluate(() => window.__lifecycle.events.match_queue_joined?.slice(-1)[0]); + }; + const queued = await Promise.all(ROLES.map(joinQueue)); + assert(queued.every(result => result?.success === true && result.economy === 'free'), 'Both clients did not enter the real free queue'); + + phase = 'match-start'; + await Promise.all(ROLES.map(role => contextPages[role].page.waitForFunction(() => + window.__lifecycle.starts > 0 && window.__lifecycle.latestState?.tick > 0 && window.__lifecycle.latestState?.matchId, + null, { timeout: 65000 }))); + const matchId = await contextPages.mica.page.evaluate(() => window.__lifecycle.latestState?.matchId); + assert(matchId, 'No live match state arrived after the real simulated-block scheduler callback'); + for (const role of ROLES) { + const state = await contextPages[role].page.evaluate(() => window.__lifecycle.latestState); + assert(state?.economy === 'free' && state.ruleset?.id === 'race', `Unexpected live match policy for ${role}`); + requireCheck(state.matchId === matchId, 'PAGE_MATCH_ID_MISMATCH'); + screenshots[`${role}-match-start.png`] = path.join(outputDir, `${role}-match-start.png`); + await contextPages[role].page.screenshot({ path: screenshots[`${role}-match-start.png`] }); + } + + // The browser socket id selects the actual entrant. Find a public path from that entrant + // to the actual exit, then use keyboard events and await server ticks after every step. + phase = 'escape-race'; + const driver = contextPages.mica.page; + let state = await driver.evaluate(() => window.__lifecycle.latestState); + if (!state || !Array.isArray(state.players)) { + const error = new Error('The live match state or entrant list is missing'); + error.code = 'LIVE_MATCH_STATE_MISSING'; + throw error; + } + const player = state.players.find(value => value.id === socketIds.mica); + if (!player) { + const error = new Error('Mica page-owned socket is absent from the live public match state'); + error.code = 'PLAYER_STATE_MISSING'; + throw error; + } + const pathToExit = shortestPath(state, player, state.exit); + if (pathToExit.length === 0) { + const error = new Error('Exit route unexpectedly has no movement steps'); + error.code = 'EXIT_ROUTE_EMPTY'; + throw error; + } + for (const step of pathToExit) { + const oldTick = state.tick; + await driver.locator('body').click({ position: { x: 5, y: 5 } }); + await driver.keyboard.press(step.key); + try { + await driver.waitForFunction(({ old, x, y }) => { + const current = window.__lifecycle.latestState; + const self = current?.players?.find(value => value.id === window.__lifecycle.socket.id); + return current?.tick > old && self?.x === x && self?.y === y; + }, { old: oldTick, x: step.x, y: step.y }, { timeout: 1500 }); + } catch (_) { + const latest = await driver.evaluate(() => window.__lifecycle.latestState); + const self = latest?.players?.find(value => value.id === window.__lifecycle.socket.id); + const ended = await driver.evaluate(() => window.__lifecycle.ends.length > 0); + if (ended || (self && (self.alive === false || self.finished === true) && self.escaped !== true)) { + const error = new Error('The controlled entrant became terminal before escaping'); + error.code = 'PLAYER_TERMINAL_DURING_ROUTE'; + throw error; + } + const error = new Error('Keyboard movement did not reach its authoritative server position'); + error.code = 'KEYBOARD_MOVE_TIMEOUT'; + throw error; + } + state = await driver.evaluate(() => window.__lifecycle.latestState); + } + phase = 'match-result'; + await driver.waitForFunction(() => window.__lifecycle.ends.length > 0, null, { timeout: 10000 }); + const result = await driver.evaluate(() => window.__lifecycle.ends.slice(-1)[0]); + assert(result && result.matchId === matchId, 'The live match did not return its result to the driving page'); + const controlledResult = result.players?.find(value => value.id === socketIds.mica); + if (!controlledResult?.escaped) { + const error = new Error('The controlled entrant reached a terminal result without escaping'); + error.code = controlledResult?.killedBy === 'monster' + ? 'PLAYER_KILLED_BY_MONSTER' + : 'PLAYER_TERMINAL_WITHOUT_ESCAPE'; + throw error; + } + phase = 'return-to-tavern'; + await driver.locator('#spectatorStage').waitFor({ state: 'hidden', timeout: 12000 }); + await driver.locator('#stage canvas.rk-canvas').waitFor({ state: 'visible', timeout: 5000 }); + screenshots['mica-result-return.png'] = path.join(outputDir, 'mica-result-return.png'); + await driver.screenshot({ path: screenshots['mica-result-return.png'] }); + + phase = 'database-result-check'; + const db = new Client({ host: socketDir, port: dbPort, database: dbName, user: dbUser }); + await db.connect(); + try { + const match = await db.query(`SELECT id, status, economy, variant, entry_fee_atomic, pot_atomic, house_fee_atomic FROM matches WHERE id = $1`, [matchId]); + requireCheck(match.rowCount === 1, 'MATCH_ROW_MISSING'); + requireCheck(match.rows[0].status === 'finished' && match.rows[0].economy === 'free' && match.rows[0].variant === 'race', 'MATCH_POLICY_MISMATCH'); + requireCheck(Number(match.rows[0].entry_fee_atomic) === 0 && Number(match.rows[0].pot_atomic) === 0 && Number(match.rows[0].house_fee_atomic) === 0, 'MATCH_MONEY_FIELDS_NONZERO'); + const entrants = await db.query(`SELECT socket_id, placement, escaped FROM match_entrants WHERE match_id = $1 ORDER BY socket_id`, [matchId]); + requireCheck(entrants.rowCount === 2, 'MATCH_ENTRANT_COUNT_MISMATCH'); + const entrantSockets = new Set(entrants.rows.map(row => row.socket_id)); + requireCheck(ROLES.every(role => entrantSockets.has(socketIds[role])), 'MATCH_ENTRANT_SOCKET_MISMATCH'); + requireCheck(entrants.rows.some(row => row.socket_id === socketIds.mica && row.escaped), 'CONTROLLED_ENTRANT_ESCAPE_NOT_PERSISTED'); + const finance = await db.query(`SELECT + (SELECT COUNT(*) FROM payments) AS payments, + (SELECT COUNT(*) FROM payouts) AS payouts, + (SELECT COUNT(*) FROM credit_transactions) AS credit_transactions, + (SELECT COUNT(*) FROM race_entry_transactions) AS race_entry_transactions`); + requireCheck(Object.values(finance.rows[0]).every(value => Number(value) === 0), 'FINANCIAL_LEDGER_NOT_EMPTY'); + const users = await db.query(`SELECT + (SELECT COUNT(DISTINCT socket_id) FROM match_entrants WHERE match_id = $1 AND socket_id = ANY($2::text[])) AS page_sockets, + (SELECT COUNT(DISTINCT user_id) FROM match_entrants WHERE match_id = $1 AND socket_id = ANY($2::text[])) AS page_users, + (SELECT COALESCE(SUM(credits), 0) FROM users) AS total_credits`, [matchId, [socketIds.mica, socketIds.moth]]); + requireCheck(Number(users.rows[0].page_sockets) === 2 && Number(users.rows[0].page_users) === 2, 'ANONYMOUS_USER_BINDING_MISMATCH'); + requireCheck(Number(users.rows[0].total_credits) === 0, 'NONZERO_USER_CREDITS'); + } finally { + await db.end(); + } + + assert(blockedEgress.length === 0, `Browser attempted external egress: ${blockedEgress.join(', ')}`); + assert(httpErrors.length === 0, `Unexpected HTTP failures: ${JSON.stringify(httpErrors)}`); + assert(failures.length === 0, `Browser runtime errors: ${failures.join(' | ')}`); + completedReport = { + result: 'passed', + fixture: { blocks: 'existing local simulated-block source at normal 30-second cadence', database: 'fresh private Unix-socket PostgreSQL cluster', wallets: 'disabled and not initialized', payments: 'disabled', payouts: 'disabled' }, + flow: ['two anonymous Tavern sessions', 'free queue', 'real scheduler countdown', 'Escape Race', 'actual keyboard path to exit', 'persisted result', 'automatic return to Tavern'], + match: { id: matchId, economy: 'free', ruleset: 'race', entrants: 2, walletOrPaymentRows: 0 }, + screenshots, + browserErrors: failures.length, + externalRequestsBlocked: blockedEgress.length, + unexpectedHttpErrors: httpErrors.length + }; + } catch (error) { + primaryFailure = { phase, code: classifyHarnessFailure(error, phase) }; + } finally { + if (browser) { + try { await browser.close(); } + catch (_) { cleanupFailures.push('BROWSER_CLOSE_FAILED'); } + } + if (appProcess && appProcess.exitCode == null && appProcess.signalCode == null) { + const stopped = () => appProcess.exitCode != null || appProcess.signalCode != null; + try { + appProcess.kill('SIGTERM'); + try { await waitFor(stopped, 15000, 'application shutdown'); } + catch (_) { + appProcess.kill('SIGKILL'); + await waitFor(stopped, 5000, 'application forced shutdown'); + } + } catch (_) { cleanupFailures.push('APPLICATION_STOP_FAILED'); } + } + if (postgresNeedsStop) { + try { + await run(pgCtl, ['-D', pgDir, '-m', 'fast', 'stop'], { env: dbEnv, timeout: 15000 }); + postgresNeedsStop = false; + } catch (_) { + cleanupFailures.push('POSTGRES_STOP_FAILED'); + // Best effort still targets only the private fixture, and never erases failure. + try { await run(pgCtl, ['-D', pgDir, '-m', 'immediate', 'stop'], { env: dbEnv, timeout: 15000 }); } + catch (_) { /* The failed report retains the cleanup cause. */ } + } + } + } + + if (primaryFailure || cleanupFailures.length > 0) { + const failure = primaryFailure || { phase: 'cleanup', code: cleanupFailures[0] }; + if (cleanupFailures.length > 0) { + process.stderr.write(`Lifecycle fixture cleanup failed (${cleanupFailures.join(',')}).\n`); + } + throw new Error(`Lifecycle smoke failed during ${failure.phase} (${failure.code}). Private run artifacts: ${outputDir}`); + } + completedReport.cleanup = { browser: 'closed', application: 'stopped', postgres: 'stopped' }; + fs.writeFileSync(path.join(outputDir, 'report.json'), `${JSON.stringify(completedReport, null, 2)}\n`, { mode: 0o600 }); + process.stdout.write(`${JSON.stringify({ result: completedReport.result, outputDir, match: completedReport.match, + cleanup: completedReport.cleanup, screenshots: Object.keys(screenshots) }, null, 2)}\n`); +} + +main().catch(error => { + const safeMessage = typeof error?.message === 'string' && /^Lifecycle smoke failed during [a-z-]+ \([A-Z0-9_]+\)\. Private run artifacts: \/[A-Za-z0-9._/-]+$/.test(error.message) + ? error.message + : 'Lifecycle smoke could not start (UNCLASSIFIED)'; + process.stderr.write(`${safeMessage}\n`); + process.exitCode = 1; +}); diff --git a/src/scripts/showcase-network-guard.js b/src/scripts/showcase-network-guard.js new file mode 100644 index 0000000..85c7e2d --- /dev/null +++ b/src/scripts/showcase-network-guard.js @@ -0,0 +1,101 @@ +'use strict'; + +// Source-only application transport guard for the isolated lifecycle rehearsal. This is not an +// operating-system network sandbox. Browser traffic has its own allowlist in the runner. +const net = require('node:net'); +const dns = require('node:dns'); +const dgram = require('node:dgram'); + +function refusal() { + return Object.assign(new Error('Showcase transport refused an unsupported or nonlocal connection.'), { + code: 'SHOWCASE_EGRESS_DENIED' + }); +} + +function connectionOptions(args) { + let first = args[0]; + // Node createConnection/TLS can pass the already-normalized [options, callback] tuple. + if (Array.isArray(first)) { + if (first.length > 2 || !first[0] || typeof first[0] !== 'object' || Array.isArray(first[0])) return null; + first = first[0]; + } + if (first && typeof first === 'object') return first; + if (typeof first === 'string' && first.startsWith('/')) return { path: first }; + if (typeof first === 'number' || (typeof first === 'string' && /^\d+$/.test(first))) { + if (args[1] !== undefined && typeof args[1] !== 'function' && typeof args[1] !== 'string') return null; + return { port: first, host: typeof args[1] === 'string' ? args[1] : undefined }; + } + return null; +} + +function loopback(host) { + return host === undefined || host === 'localhost' || host === '127.0.0.1' || host === '::1'; +} + +function allowedConnection(options, allowedSocketPath) { + if (!options || typeof options !== 'object') return false; + if (options.path !== undefined) return options.path === allowedSocketPath; + return (typeof options.port === 'number' || typeof options.port === 'string') && + /^\d+$/.test(String(options.port)) && Number(options.port) > 0 && Number(options.port) <= 65535 && + loopback(options.host) && options.lookup === undefined; +} + +function localLookup(host, options, callback) { + if (typeof options === 'function') { callback = options; options = {}; } + if (typeof callback !== 'function') throw refusal(); + if (!loopback(host)) return process.nextTick(callback, refusal()); + const family = typeof options === 'number' ? options : options?.family; + const ipv6 = host === '::1' || family === 6 || family === 'IPv6'; + const result = { address: ipv6 ? '::1' : '127.0.0.1', family: ipv6 ? 6 : 4 }; + if (options?.all) return process.nextTick(callback, null, [result]); + return process.nextTick(callback, null, result.address, result.family); +} + +function install(allowedSocketPath, dependencies = { net, dns, dgram }) { + if (typeof allowedSocketPath !== 'string' || !allowedSocketPath.startsWith('/') || + !/\/\.s\.PGSQL\.\d+$/.test(allowedSocketPath)) throw refusal(); + const modules = dependencies; + const originalConnect = modules.net.Socket.prototype.connect; + modules.net.Socket.prototype.connect = function(...args) { + if (!allowedConnection(connectionOptions(args), allowedSocketPath)) throw refusal(); + return originalConnect.apply(this, args); + }; + modules.dns.lookup = localLookup; + if (modules.dns.promises) { + modules.dns.promises.lookup = (host, options) => new Promise((resolve, reject) => { + localLookup(host, options, (error, address, family) => { + if (error) reject(error); + else resolve(options?.all ? address : { address, family }); + }); + }); + } + // resolve/reverse use native c-ares sockets and must never reach a real DNS server. + const names = ['resolve', 'resolve4', 'resolve6', 'resolveAny', 'resolveCname', 'resolveMx', + 'resolveNs', 'resolveTxt', 'resolveSrv', 'resolveNaptr', 'resolvePtr', 'resolveSoa', + 'resolveCaa', 'reverse', 'lookupService']; + for (const target of [modules.dns, modules.dns.Resolver?.prototype]) { + if (!target) continue; + for (const name of names) { + if (typeof target[name] !== 'function') continue; + target[name] = (...args) => { + const callback = args.find(value => typeof value === 'function'); + if (!callback) throw refusal(); + process.nextTick(callback, refusal()); + }; + } + } + for (const target of [modules.dns.promises, modules.dns.promises?.Resolver?.prototype]) { + if (!target) continue; + for (const name of names) { + if (typeof target[name] === 'function') target[name] = async () => { throw refusal(); }; + } + } + modules.dgram.Socket.prototype.send = () => { throw refusal(); }; +} + +if (process.env.WOWNGEON_SHOWCASE_GUARD === '1') { + if (process.env.NODE_ENV === 'production') throw refusal(); + install(process.env.WOWNGEON_SHOWCASE_PG_SOCKET); +} + +module.exports = { connectionOptions, allowedConnection, install }; diff --git a/test/gameModeManager.test.js b/test/gameModeManager.test.js index d739721..22cf349 100644 --- a/test/gameModeManager.test.js +++ b/test/gameModeManager.test.js @@ -263,6 +263,26 @@ describe('GameModeManager', () => { } } }); + + test('free-play-only match mode does not advertise credit entry', () => { + const keys = ['MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED']; + const old = Object.fromEntries(keys.map(key => [key, process.env[key]])); + try { + process.env.MATCH_CRYPTO_RACE_ENABLED = 'false'; + process.env.MATCH_PAYOUTS_ENABLED = 'false'; + gmm.paymentsEnabled = false; + gmm.freePlayEnabled = true; + gmm.creditsModeEnabled = false; + expect(gmm._getMatchEconomies()).toEqual({ free: true }); + + gmm.creditsModeEnabled = true; + expect(gmm._getMatchEconomies()).toEqual({ free: true, credits_prestige: true }); + } finally { + for (const key of keys) { + if (old[key] === undefined) delete process.env[key]; else process.env[key] = old[key]; + } + } + }); }); describe('calculatePayout', () => { diff --git a/test/matchQueue.test.js b/test/matchQueue.test.js index 040181c..38dedba 100644 --- a/test/matchQueue.test.js +++ b/test/matchQueue.test.js @@ -69,6 +69,32 @@ describe('MatchQueue', () => { delete process.env.MATCH_ENABLED; }); + test('free-play-only mode rejects Prestige before debit, while explicit credits mode queues it', async () => { + process.env.MATCH_ENABLED = 'true'; + const db = makeDb(); + let creditsModeEnabled = false; + const gameModeManager = { + freePlayEnabled: true, + get creditsModeEnabled() { return creditsModeEnabled; }, + _getMatchEconomies() { + return { free: true, ...(creditsModeEnabled ? { credits_prestige: true } : {}) }; + } + }; + const q = new MatchQueue({ db, gameModeManager }); + q.initialized = true; + + const disabled = await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'credits_prestige' }); + expect(disabled).toEqual({ success: false, reason: 'economy_disabled' }); + expect(db.queries).toHaveLength(0); + expect(q.length('credits_prestige')).toBe(0); + + creditsModeEnabled = true; + const enabled = await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'credits_prestige' }); + expect(enabled.success).toBe(true); + expect(q.length('credits_prestige')).toBe(1); + delete process.env.MATCH_ENABLED; + }); + test('free join adds to memory queue', async () => { process.env.MATCH_ENABLED = 'true'; const db = makeDb(); diff --git a/test/releaseArtifactBuilder.test.js b/test/releaseArtifactBuilder.test.js index c09a4f9..10734c9 100644 --- a/test/releaseArtifactBuilder.test.js +++ b/test/releaseArtifactBuilder.test.js @@ -41,6 +41,8 @@ function fixture() { write(root, 'src/scripts/preflight.js', "'use strict';\n"); write(root, 'src/scripts/migrate-disposable-clone.js', "'use strict';\n"); write(root, 'src/scripts/showcase-browser-smoke.js', "throw new Error('source-only browser harness');\n"); + write(root, 'src/scripts/showcase-network-guard.js', "throw new Error('source-only network guard');\n"); + write(root, 'src/scripts/showcase-lifecycle-smoke.js', "throw new Error('source-only lifecycle rehearsal');\n"); write(root, 'src/scripts/pvp-capture.js', "throw new Error('source-only ad harness');\n"); write(root, 'src/scripts/stagenet-financial-canary.js', "throw new Error('source-only transfer canary');\n"); write(root, 'src/scripts/setup_db.js', "throw new Error('source-only database helper');\n"); @@ -138,6 +140,8 @@ describe('release artifact builder', () => { expect(members.some(member => /^candidate-1\/src\/\.env(?:\.|$)/.test(member))).toBe(false); expect(members.some(member => /\/\.env(?:\..*)?\.example$/.test(member))).toBe(false); expect(members).not.toContain('candidate-1/src/scripts/showcase-browser-smoke.js'); + expect(members).not.toContain('candidate-1/src/scripts/showcase-network-guard.js'); + expect(members).not.toContain('candidate-1/src/scripts/showcase-lifecycle-smoke.js'); expect(members).not.toContain('candidate-1/src/scripts/pvp-capture.js'); expect(members).not.toContain('candidate-1/src/scripts/stagenet-financial-canary.js'); expect(members).not.toContain('candidate-1/src/scripts/setup_db.js'); diff --git a/test/showcaseNetworkGuard.test.js b/test/showcaseNetworkGuard.test.js new file mode 100644 index 0000000..ef9926f --- /dev/null +++ b/test/showcaseNetworkGuard.test.js @@ -0,0 +1,50 @@ +const { connectionOptions, allowedConnection, install } = require('../src/scripts/showcase-network-guard'); + +const SOCKET = '/private/showcase/pgsock/.s.PGSQL.5432'; +function fixture() { + const connect = jest.fn(() => 'connected'); + const send = jest.fn(); + const resolve = jest.fn(); + const modules = { + net: { Socket: { prototype: { connect } } }, + dns: { lookup: jest.fn(), resolve, reverse: resolve, + Resolver: { prototype: { resolve } }, + promises: { lookup: jest.fn(), resolve, Resolver: { prototype: { resolve } } } }, + dgram: { Socket: { prototype: { send } } } + }; + install(SOCKET, modules); + return { modules, connect, send, resolve }; +} + +test.each([ + [5432], [5432, '127.0.0.1'], [5432, '::1'], [{ port: 5432, host: 'localhost' }], + [[{ port: 5432, host: '127.0.0.1' }, () => {}]], [SOCKET], [{ path: SOCKET }] +])('admits documented local connect arguments: %j', (...args) => { + expect(allowedConnection(connectionOptions(args), SOCKET)).toBe(true); +}); + +test.each([ + [5432, '192.0.2.1'], [{ port: 443, host: 'api.example.invalid' }], + [[{ port: 443, host: '192.0.2.1' }, () => {}]], + [{ port: 443, host: 'localhost', lookup: () => {} }], + ['/private/other/.s.PGSQL.5432'], [{ path: '/private/other/.s.PGSQL.5432' }], + [[[{ port: 443, host: '192.0.2.1' }]]], [null], [{}], [false] +])('refuses unsupported or foreign transports without opening a socket: %j', (...args) => { + const { modules, connect } = fixture(); + expect(() => modules.net.Socket.prototype.connect(...args)).toThrow(expect.objectContaining({ code: 'SHOWCASE_EGRESS_DENIED' })); + expect(connect).not.toHaveBeenCalled(); +}); + +test('resolves localhost locally and refuses native DNS and UDP without calling originals', async () => { + const { modules, resolve, send } = fixture(); + await expect(modules.dns.promises.lookup('localhost')).resolves.toEqual({ address: '127.0.0.1', family: 4 }); + await expect(modules.dns.promises.lookup('localhost', { all: true, family: 6 })).resolves.toEqual([{ address: '::1', family: 6 }]); + await expect(modules.dns.promises.lookup('api.example.invalid')).rejects.toMatchObject({ code: 'SHOWCASE_EGRESS_DENIED' }); + await expect(modules.dns.promises.resolve('localhost')).rejects.toMatchObject({ code: 'SHOWCASE_EGRESS_DENIED' }); + await new Promise(done => modules.dns.resolve('localhost', error => { + expect(error.code).toBe('SHOWCASE_EGRESS_DENIED'); done(); + })); + expect(() => modules.dgram.Socket.prototype.send(Buffer.from('fixture'))).toThrow(); + expect(resolve).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); +});