#!/usr/bin/env node // tools/scripts/lint-room-layout.mjs // Source: 04-CONTEXT.md D-09/D-11/D-25 — structural drift guard for the // hot-reload contract locked in docs/adr/0004-room-hot-reload.md. // // Walks `apps/server/rooms/`, validates every .json against the // canonical layoutSchema's top-level keys, and verifies .sig against // the committed public-key sibling // (./keys/rebno-room-signing.ed25519.pub.pem). // // Usage: node tools/scripts/lint-room-layout.mjs // Exit: 0 clean, 1 violation. import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; import { join, basename } from 'node:path'; import { createPublicKey, verify, createHash } from 'node:crypto'; const ROOMS_DIR = 'apps/server/rooms'; const PUBKEY_PATH = process.env.ROOM_SIGNING_PUBKEY_PATH ?? './keys/rebno-room-signing.ed25519.pub.pem'; // Phase 4 / SRV-13 original schema (mvp-lobby and extracted-room layouts) const REQUIRED_KEYS_LEGACY = [ 'tile_grid', 'collision_polys', 'spawn_points', 'platform_defs', 'scripted_triggers', 'room_size', 'tile_atlas_ref', 'bg_atlas_ref', ]; // Plan 06-14 / D-29 extended SRV-13 schema (synthetic mvp-room + future converted rooms). // Identified by the presence of `room_id` at the top level. // [doc->REQ-AST-01] [doc->REQ-CLI-07] const REQUIRED_KEYS_NEW = [ 'room_id', 'version', 'width_tiles', 'height_tiles', 'tile_w', 'tile_h', 'viewport', 'tiles', 'spawn_points', ]; const ROOM_ID_RE = /^[a-z0-9-]{1,64}$/; if (!existsSync(PUBKEY_PATH)) { process.stderr.write( `lint-room-layout: pubkey ${PUBKEY_PATH} missing — generate via dev boot or set ROOM_SIGNING_PUBKEY_PATH env\n`, ); process.exit(1); } const pub = createPublicKey({ key: readFileSync(PUBKEY_PATH, 'utf-8'), format: 'pem', }); let violations = 0; function fail(msg) { process.stderr.write(`lint-room-layout: ${msg}\n`); violations++; } if (!existsSync(ROOMS_DIR)) { console.log( 'lint-room-layout: rooms dir absent — nothing to validate (acceptable for fresh checkout)', ); process.exit(0); } for (const entry of readdirSync(ROOMS_DIR)) { const room_dir = join(ROOMS_DIR, entry); if (!statSync(room_dir).isDirectory()) continue; if (!ROOM_ID_RE.test(entry)) { fail(`invalid room_id directory: ${entry}`); continue; } const jsons = readdirSync(room_dir).filter((f) => f.endsWith('.json')); if (jsons.length === 0) { fail(`${entry}: no .json files`); continue; } for (const j of jsons) { const rev = basename(j, '.json'); // BL-04 fix: rev names are AT LEAST 3 digits zero-padded; revs ≥ 1000 // keep their natural width. Tightening to /^\d{3}$/ historically caused // RoomRegistry + room-converter to silently corrupt the rev counter at // the 1000-boundary (lex sort 1000 < 999 + filter elision). if (!/^\d{3,}$/.test(rev)) { fail(`${entry}/${j}: rev must be ≥3-digit zero-padded all-numeric (got '${rev}')`); continue; } const sigPath = join(room_dir, `${rev}.sig`); if (!existsSync(sigPath)) { fail(`${entry}/${j}: companion .sig missing`); continue; } let json; try { json = readFileSync(join(room_dir, j), 'utf-8'); } catch (e) { fail(`${entry}/${j}: read failed ${e.message}`); continue; } let parsed; try { parsed = JSON.parse(json); } catch (e) { fail(`${entry}/${j}: JSON parse failed: ${e.message}`); continue; } // Choose schema based on presence of `room_id` (new SRV-13 extended shape) // vs. legacy `tile_grid` shape (mvp-lobby and Phase 4 extracted rooms). const requiredKeys = ('room_id' in parsed) ? REQUIRED_KEYS_NEW : REQUIRED_KEYS_LEGACY; for (const k of requiredKeys) { if (!(k in parsed)) fail(`${entry}/${j}: missing key '${k}'`); } const sig = readFileSync(sigPath); const payload = Buffer.concat([ Buffer.from(entry, 'utf-8'), Buffer.from(rev, 'utf-8'), createHash('sha256').update(Buffer.from(json, 'utf-8')).digest(), ]); if (!verify(null, payload, pub, sig)) { fail(`${entry}/${j}: Ed25519 signature INVALID`); } } } if (violations > 0) { process.stderr.write( `lint-room-layout: ${violations} violation(s)\n`, ); process.exit(1); } console.log('lint-room-layout: OK'); process.exit(0);