import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..' ); const pageFile = path.join(repoRoot, 'activity/room-reward/fami.html'); const cssFile = path.join(repoRoot, 'activity/room-reward/fami/fami.css'); const scriptFile = path.join(repoRoot, 'activity/room-reward/fami/fami.js'); const assetRoot = path.join( repoRoot, 'activity/room-reward/fami/assets/layers' ); const manifestFile = path.join( repoRoot, 'activity/room-reward/fami/assets/manifest.json' ); function fail(message) { throw new Error(`[fami-room-reward-layer-audit] ${message}`); } function sha256(file) { return crypto .createHash('sha256') .update(fs.readFileSync(file)) .digest('hex'); } for (const file of [pageFile, cssFile, scriptFile, manifestFile]) { if (!fs.existsSync(file)) fail(`missing required file: ${path.relative(repoRoot, file)}`); } const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8')); const assets = Array.isArray(manifest.assets) ? manifest.assets : []; if (!assets.length) fail('manifest has no independently traced assets'); const forbiddenNodes = new Set(manifest.policy?.forbiddenNodes || []); const manifestFiles = new Set(); for (const asset of assets) { if ( !asset.file || !asset.sha256 || !Array.isArray(asset.nodes) || !asset.nodes.length ) { fail(`incomplete manifest entry: ${JSON.stringify(asset)}`); } if (asset.nodes.some((node) => forbiddenNodes.has(node))) { fail( `aggregate/root Figma node exported as runtime asset: ${asset.file}` ); } if (!String(asset.kind || '').startsWith('single-')) { fail(`asset is not classified as a single visual layer: ${asset.file}`); } const file = path.join(assetRoot, asset.file); if (!fs.existsSync(file)) fail(`manifest asset missing: ${asset.file}`); if (sha256(file) !== asset.sha256) fail(`hash mismatch: ${asset.file}`); if (asset.pixelSize?.[0] === 1080 && asset.pixelSize?.[1] === 1985) { fail(`whole-page sized asset is forbidden: ${asset.file}`); } manifestFiles.add(asset.file); } for (const file of fs.readdirSync(assetRoot)) { if (!manifestFiles.has(file)) fail(`untracked runtime layer asset: ${file}`); } const html = fs.readFileSync(pageFile, 'utf8'); const css = fs.readFileSync(cssFile, 'utf8'); const js = fs.readFileSync(scriptFile, 'utf8'); const runtimeSource = `${html}\n${css}\n${js}`; for (const forbidden of [ 'figma-default.png', '/.audit/', '822:2775', '822:2783', ]) { if (runtimeSource.includes(forbidden)) fail(`forbidden aggregate reference: ${forbidden}`); } const referencedAssets = new Set(); for (const match of runtimeSource.matchAll( /(?:fami\/)?assets\/layers\/([\w.-]+)/g )) { referencedAssets.add(match[1]); } for (const file of referencedAssets) { if (!manifestFiles.has(file)) fail(`runtime asset is not in manifest: ${file}`); } for (const requiredDOM of [ 'id="rrTargetValue"', 'id="rrTierTable"', 'id="rrUserName"', 'id="rrContributionValue"', 'id="rrRewardValue"', ]) { if (!html.includes(requiredDOM)) fail(`dynamic DOM layer missing: ${requiredDOM}`); } if (html.includes('