// ── §2.7's last rule, given the CI it does not have ─────────────────────── // // `book/02-website-module.md` is explicit that "the website process never opens a // connection to a game server" is the **one boundary rule with no CI behind it**: // an outbound socket is not statically detectable the way an internal `require` // is, so in general the rule is held up by review and by understanding it. // // True of the general case, and not a reason to check nothing. A module can state // a narrower, completely decidable property about **itself**, and this one says: // the shipped server half references no networking primitive at all. Everything // it knows arrives from its own tables, which its sidecar writes. // // Adopted from the kit's acceptance run (`docs/modules/kit-acceptance.md`), where // a reader building a Rust module wrote it unprompted after reading that the rule // had no CI — and observed that for Rust in particular, which ships RCON over // WebSocket, `new WebSocket(rconUrl)` in `boot.js` is about ten lines away. // // ── NARROWED, NOT DELETED ───────────────────────────────────────────────── // // This module has a real sidecar client, so the check is narrowed to allow that // one file and keeps the rest of the tree under the ban. Talking to *the sidecar* // over HTTP is the expected shape and is not what §2.7 forbids — the rule is // about the **game server**. // // const MAY_OPEN_SOCKETS = new Set(['sidecarClient.js']) // // What that buys is a test naming the *one* file allowed to reach the network — // exactly the file a reviewer should read closely, and exactly the place a // game-server URL would appear if the rule were ever broken. The temptation on a // red run here is to add a second name; the answer is almost always to move the // call into `sidecarClient.js` instead. // // It is worth saying what this does NOT prove. `sidecarClient.js` is exempt, so // nothing here stops it being pointed at a game server's own port — it would // take a URL an operator typed. The decidable half is that no OTHER file can // reach the network at all, which is what keeps the exempt file small enough to // read. // // Scope: SHIPPED code only. `test/` and `scripts/` never run inside core's process. const test = require('node:test') const assert = require('node:assert') const fs = require('node:fs') const path = require('node:path') const SERVER_ROOT = path.resolve(__dirname, '..') const NOT_SHIPPED = new Set(['test', 'scripts', 'node_modules', 'swagger']) /** * The one shipped file allowed to reach the network. See the header. * * Kept as a set of BASENAMES rather than paths, so that moving the file does not * silently re-ban it — a rename is meant to be a conversation. */ const MAY_OPEN_SOCKETS = new Set(['sidecarClient.js']) /** Every shipped `.js` file under `server/`. */ function shippedFiles(dir = SERVER_ROOT, out = []) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.isDirectory()) { if (dir === SERVER_ROOT && NOT_SHIPPED.has(entry.name)) continue if (entry.name === 'node_modules') continue shippedFiles(path.join(dir, entry.name), out) } else if (entry.isFile() && entry.name.endsWith('.js')) { out.push(path.join(dir, entry.name)) } } return out } /** * Blank comments, so prose ABOUT the rule does not trip the rule. * * This file is itself the proof that it is needed: the paragraphs above say * "WebSocket" several times. `scripts/checkImports.js` documents hitting exactly * this on its own documentation, and it is the third time in this project's * history that a boundary check has failed on the text explaining it. * * Blanked rather than deleted, so line numbers in a failure still point at the * right line. */ function stripComments(src) { return src .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')) .replace(/^[ \t]*\/\/.*$/gm, '') } // Each is a way a Node process opens a socket. Matched as identifiers, so a // column named `websocket_url` inside a SQL string would not fire. const NETWORKING = [ /\brequire\(\s*['"](?:node:)?(?:net|tls|dgram|http|https|http2)['"]\s*\)/, /\bfrom\s+['"](?:node:)?(?:net|tls|dgram|http|https|http2)['"]/, /\brequire\(\s*['"](?:ws|socket\.io-client|undici|axios|node-fetch|got)['"]\s*\)/, /\bnew\s+WebSocket\b/, /\bfetch\s*\(/, /\bXMLHttpRequest\b/, /\bEventSource\b/, ] test('no shipped file references a networking primitive (§2.7)', () => { const offenders = [] for (const file of shippedFiles()) { if (MAY_OPEN_SOCKETS.has(path.basename(file))) continue const code = stripComments(fs.readFileSync(file, 'utf8')) for (const pattern of NETWORKING) { if (pattern.test(code)) { offenders.push(`${path.relative(SERVER_ROOT, file)} matches ${pattern}`) } } } assert.deepStrictEqual( offenders, [], 'the website process must never open a connection to a game server. If this is ' + 'your sidecar client, allow that one file rather than removing the check — see ' + `the header of this file.\n ${offenders.join('\n ')}`, ) }) test('every name on the allowlist is a file that exists and is shipped', () => { // A stale allowlist entry is a silent hole: the file it exempted was renamed, // the ban no longer covers the new name either (because the old one is still // listed and nothing matches it), and the check goes on passing. Holding the // list against the tree is what stops an exemption outliving its reason. const shipped = new Set(shippedFiles().map((f) => path.basename(f))) for (const name of MAY_OPEN_SOCKETS) { assert.ok(shipped.has(name), `${name} is allowed to open sockets but is not a shipped file`) } }) test('the check can actually fail — it is pointed at a real violation', () => { // A check that has never been shown to fail is a check nobody knows the state // of. This is the game-server dial the rule exists to stop. const violation = "const socket = new WebSocket('ws://10.0.0.5:28016/' + rconPassword)" assert.ok( NETWORKING.some((p) => p.test(stripComments(violation))), 'the guard would not have caught a direct game-server dial', ) }) test('prose describing the rule does not trip it', () => { const prose = [ '// A game shipping RCON over WebSocket means a module COULD write', "// const s = new WebSocket(url); require('net')", '// in about ten lines. It must not.', 'const x = 1', ].join('\n') for (const pattern of NETWORKING) { assert.ok(!pattern.test(stripComments(prose)), `${pattern} fired on a comment`) } })