339 lines
11 KiB
JavaScript
339 lines
11 KiB
JavaScript
import { createHash } from 'node:crypto';
|
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = path.resolve(scriptDirectory, '..');
|
|
const pageDirectory = path.join(repoRoot, 'activity', 'game-king');
|
|
const aslanDirectory = path.join(pageDirectory, 'aslan');
|
|
const layerDirectory = path.join(aslanDirectory, 'assets', 'layers');
|
|
const manifestPath = path.join(layerDirectory, 'manifest.json');
|
|
|
|
function invariant(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(`[game-king-aslan-asset-audit] ${message}`);
|
|
}
|
|
}
|
|
|
|
function pngDimensions(bytes, filename) {
|
|
invariant(
|
|
bytes.length >= 24 && bytes.toString('ascii', 1, 4) === 'PNG',
|
|
`${filename}: expected PNG signature`
|
|
);
|
|
return {
|
|
width: bytes.readUInt32BE(16),
|
|
height: bytes.readUInt32BE(20),
|
|
};
|
|
}
|
|
|
|
function jpegDimensions(bytes, filename) {
|
|
invariant(
|
|
bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8,
|
|
`${filename}: expected JPEG signature`
|
|
);
|
|
for (let offset = 2; offset + 9 < bytes.length; ) {
|
|
if (bytes[offset] !== 0xff) {
|
|
offset += 1;
|
|
continue;
|
|
}
|
|
const marker = bytes[offset + 1];
|
|
const length = bytes.readUInt16BE(offset + 2);
|
|
if (
|
|
marker >= 0xc0 &&
|
|
marker <= 0xc3 &&
|
|
length >= 7 &&
|
|
offset + length + 2 <= bytes.length
|
|
) {
|
|
return {
|
|
height: bytes.readUInt16BE(offset + 5),
|
|
width: bytes.readUInt16BE(offset + 7),
|
|
};
|
|
}
|
|
invariant(length >= 2, `${filename}: invalid JPEG segment length`);
|
|
offset += length + 2;
|
|
}
|
|
throw new Error(`[game-king-aslan-asset-audit] ${filename}: no JPEG size`);
|
|
}
|
|
|
|
function bitmapDimensions(bytes, asset) {
|
|
if (asset.file.format === 'png')
|
|
return pngDimensions(bytes, asset.filename);
|
|
if (asset.file.format === 'jpeg')
|
|
return jpegDimensions(bytes, asset.filename);
|
|
throw new Error(
|
|
`[game-king-aslan-asset-audit] ${asset.filename}: unsupported format`
|
|
);
|
|
}
|
|
|
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
invariant(manifest.schemaVersion === 1, 'unsupported manifest schema');
|
|
invariant(
|
|
manifest.figma?.fileKey === 'OTOg5cwbhVNWxkImGmzTJl',
|
|
'unexpected Figma file key'
|
|
);
|
|
const forbiddenNodes = new Set(manifest.policy?.forbiddenSourceNodes || []);
|
|
const approvedCompositeNodes = new Set(
|
|
manifest.policy?.approvedStandaloneCompositeNodes || []
|
|
);
|
|
const assets = manifest.assets || [];
|
|
invariant(assets.length > 0, 'manifest must list the exported layers');
|
|
|
|
const byFile = new Map();
|
|
for (const asset of assets) {
|
|
const isApprovedStaticComposite =
|
|
approvedCompositeNodes.has(asset.sourceNode) &&
|
|
/^approved-static-(header-composite|podium-frame)$/.test(
|
|
asset.kind || ''
|
|
);
|
|
invariant(
|
|
typeof asset.filename === 'string' &&
|
|
path.basename(asset.filename) === asset.filename,
|
|
'layer filename must be a basename'
|
|
);
|
|
invariant(
|
|
!byFile.has(asset.filename),
|
|
`${asset.filename}: duplicate asset`
|
|
);
|
|
byFile.set(asset.filename, asset);
|
|
invariant(
|
|
/^\d+:\d+$/.test(asset.sourceNode || ''),
|
|
`${asset.filename}: missing source node`
|
|
);
|
|
invariant(
|
|
!forbiddenNodes.has(asset.sourceNode),
|
|
`${asset.filename}: root frame/canvas exports are forbidden`
|
|
);
|
|
invariant(
|
|
asset.renderedDesignSize?.width > 0 &&
|
|
asset.renderedDesignSize?.height > 0,
|
|
`${asset.filename}: design size is required`
|
|
);
|
|
invariant(
|
|
/^[a-f0-9]{64}$/.test(asset.sha256 || ''),
|
|
`${asset.filename}: SHA-256 is required`
|
|
);
|
|
for (const field of [
|
|
'containsText',
|
|
'containsControls',
|
|
'containsDynamicBusinessMedia',
|
|
'mockOnly',
|
|
]) {
|
|
invariant(
|
|
typeof asset[field] === 'boolean',
|
|
`${asset.filename}: ${field} must be boolean`
|
|
);
|
|
}
|
|
invariant(
|
|
isApprovedStaticComposite || !asset.containsText,
|
|
`${asset.filename}: text must be live DOM/i18n content`
|
|
);
|
|
invariant(
|
|
!asset.containsControls,
|
|
`${asset.filename}: controls must be DOM elements`
|
|
);
|
|
if (asset.scope === 'runtime') {
|
|
invariant(
|
|
!asset.mockOnly,
|
|
`${asset.filename}: runtime layer cannot be mock-only`
|
|
);
|
|
invariant(
|
|
!asset.containsDynamicBusinessMedia,
|
|
`${asset.filename}: runtime layer cannot contain dynamic business media`
|
|
);
|
|
} else {
|
|
invariant(
|
|
asset.scope === 'fixture' &&
|
|
asset.mockOnly &&
|
|
asset.containsDynamicBusinessMedia,
|
|
`${asset.filename}: only an explicit mock fixture may contain business media`
|
|
);
|
|
}
|
|
|
|
const assetPath = path.join(layerDirectory, asset.filename);
|
|
const [bytes, info] = await Promise.all([
|
|
readFile(assetPath),
|
|
stat(assetPath),
|
|
]);
|
|
const dimensions = bitmapDimensions(bytes, asset);
|
|
invariant(
|
|
info.size === asset.file.bytes,
|
|
`${asset.filename}: byte size mismatch`
|
|
);
|
|
invariant(
|
|
dimensions.width === asset.file.pixelWidth &&
|
|
dimensions.height === asset.file.pixelHeight,
|
|
`${asset.filename}: pixel dimensions mismatch`
|
|
);
|
|
if (asset.naturalSize) {
|
|
invariant(
|
|
dimensions.width === asset.naturalSize.width &&
|
|
dimensions.height === asset.naturalSize.height,
|
|
`${asset.filename}: natural source dimensions mismatch`
|
|
);
|
|
}
|
|
invariant(
|
|
dimensions.width * dimensions.height <=
|
|
manifest.policy.maxDecorativeLayerPixels,
|
|
`${asset.filename}: bitmap is too large for an atomic layer`
|
|
);
|
|
invariant(
|
|
createHash('sha256').update(bytes).digest('hex') === asset.sha256,
|
|
`${asset.filename}: SHA-256 mismatch`
|
|
);
|
|
}
|
|
|
|
const diskFiles = new Set(
|
|
(await readdir(layerDirectory)).filter(
|
|
(filename) => filename !== 'manifest.json'
|
|
)
|
|
);
|
|
invariant(
|
|
diskFiles.size === byFile.size &&
|
|
[...diskFiles].every((filename) => byFile.has(filename)),
|
|
'every layer file must have exactly one traceable manifest record'
|
|
);
|
|
|
|
const [html, css, js, common, ...locales] = await Promise.all([
|
|
readFile(path.join(pageDirectory, 'aslan.html'), 'utf8'),
|
|
readFile(path.join(aslanDirectory, 'aslan.css'), 'utf8'),
|
|
readFile(path.join(aslanDirectory, 'aslan.js'), 'utf8'),
|
|
readFile(path.join(repoRoot, 'common_aslan', 'aslan-common.js'), 'utf8'),
|
|
...['en', 'ar', 'tr', 'es'].map((language) =>
|
|
readFile(
|
|
path.join(repoRoot, 'common', 'locales', `${language}.json`),
|
|
'utf8'
|
|
)
|
|
),
|
|
]);
|
|
const runtimeSource = `${html}\n${css}\n${js}`;
|
|
|
|
for (const asset of assets) {
|
|
invariant(
|
|
runtimeSource.includes(asset.filename),
|
|
`${asset.filename}: layer is not referenced`
|
|
);
|
|
}
|
|
for (const term of manifest.policy.forbiddenRuntimeTerms || []) {
|
|
invariant(
|
|
!runtimeSource.toLowerCase().includes(String(term).toLowerCase()),
|
|
`runtime source includes forbidden aggregate-image term: ${term}`
|
|
);
|
|
}
|
|
for (const match of runtimeSource.matchAll(
|
|
/(?:\.\/assets\/layers\/)([^"')\s]+)/g
|
|
)) {
|
|
invariant(
|
|
byFile.has(match[1]),
|
|
`${match[1]}: untraced local bitmap reference`
|
|
);
|
|
}
|
|
invariant(
|
|
html.includes('../../common/theme.css') &&
|
|
html.includes('../../common/i18n.js'),
|
|
'shared theme and i18n runtime are required'
|
|
);
|
|
invariant(
|
|
html.includes('data-hy-app-code="aslan"'),
|
|
'Aslan app identity is required'
|
|
);
|
|
invariant(
|
|
/function isMockMode\(\)[\s\S]*query\('mock'\)/.test(js) &&
|
|
/hostname === 'localhost'/.test(js) &&
|
|
/hostname === '127\.0\.0\.1'/.test(js) &&
|
|
/env === 'local'/.test(js) &&
|
|
/env === 'test'/.test(js) &&
|
|
/function buildMockStore\(\)/.test(js),
|
|
'mock data must be query-gated and restricted to local/test environments'
|
|
);
|
|
invariant(
|
|
js.indexOf('var icon = PRIZE_FALLBACK;') >
|
|
js.indexOf('function buildMockStore()'),
|
|
'fixture prize art must be confined to the mock builder'
|
|
);
|
|
for (const endpoint of [
|
|
'/activity/game/king/aslan/detail',
|
|
'/activity/game/king/aslan/me',
|
|
'/activity/game/king/aslan/draw',
|
|
'/activity/game/king/aslan/ranking',
|
|
'/activity/game/king/aslan/draw-records',
|
|
]) {
|
|
invariant(
|
|
common.includes(endpoint),
|
|
`missing common_aslan endpoint: ${endpoint}`
|
|
);
|
|
}
|
|
invariant(
|
|
common.includes("rankingType: String(rankingType || 'TYCOON')") &&
|
|
common.includes("period: String(period || 'DAILY')"),
|
|
'ranking contract must preserve TYCOON/VICTORIOUS and DAILY/OVERALL'
|
|
);
|
|
for (const dtoField of [
|
|
"'prizeImage'",
|
|
"'coinPerDraw'",
|
|
"'totalConsumed'",
|
|
"'entries'",
|
|
"'my'",
|
|
"'account'",
|
|
"'cover'",
|
|
"'sourceUrl'",
|
|
"'deliveryStatus'",
|
|
"'failureReason'",
|
|
]) {
|
|
invariant(
|
|
js.includes(dtoField),
|
|
`missing Game King backend DTO mapping: ${dtoField}`
|
|
);
|
|
}
|
|
invariant(
|
|
/Object\.assign\(\s*\{\},\s*detail \|\| \{\},\s*activity,\s*me \|\| \{\}/.test(
|
|
common
|
|
),
|
|
'detail config must be preserved when current-user fields are merged'
|
|
);
|
|
invariant(
|
|
js.includes("status === 'FAILED'") &&
|
|
js.includes("status === 'PROCESSING'") &&
|
|
js.includes("status === 'UNKNOWN'") &&
|
|
js.includes('function deliveryMessage') &&
|
|
js.includes('source.record || source.drawRecord || source'),
|
|
'draw result and record UI must surface delivery success, processing and failure states'
|
|
);
|
|
invariant(
|
|
common.includes('function nonProductionBaseURL') &&
|
|
common.includes('trustedApiHosts') &&
|
|
common.includes('if (!env) return normalizeBaseURL(PROD_API_BASE)') &&
|
|
!common.includes("query('apiBase')") &&
|
|
!common.includes("query('api_base')") &&
|
|
!common.includes("query('aslan_api_base')") &&
|
|
common.includes("throw new Error('aslan_test_api_not_configured')"),
|
|
'production API must be fixed; test/dev needs an explicit trusted build-time host'
|
|
);
|
|
invariant(
|
|
js.includes("var SETTLEMENT_BOARD_TYPE = 'TYCOON'") &&
|
|
js.includes('rankingRewards[SETTLEMENT_BOARD_TYPE]'),
|
|
'Award entry must show the single Tycoon overall settlement board'
|
|
);
|
|
|
|
const baseLocale = JSON.parse(locales[0]);
|
|
const gameKingKeys = Object.keys(baseLocale).filter((key) =>
|
|
key.startsWith('gameKing.')
|
|
);
|
|
invariant(
|
|
gameKingKeys.length >= 40,
|
|
'English game-king i18n keys are incomplete'
|
|
);
|
|
for (const localeText of locales.slice(1)) {
|
|
const locale = JSON.parse(localeText);
|
|
for (const key of gameKingKeys) {
|
|
invariant(
|
|
typeof locale[key] === 'string' && locale[key],
|
|
`${key}: locale translation missing`
|
|
);
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`[game-king-aslan-asset-audit] passed: ${assets.length} atomic layers, 4 locales, common_aslan contract`
|
|
);
|