273 lines
9.0 KiB
JavaScript
273 lines
9.0 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 root = path.resolve(scriptDirectory, '..');
|
|
const centersRoot = path.join(root, 'guild', 'fami');
|
|
const sharedRoot = path.join(root, 'guild', 'products', 'fami', 'centers');
|
|
const assetDirectory = path.join(sharedRoot, 'assets', 'fami');
|
|
const manifestPath = path.join(assetDirectory, 'manifest.json');
|
|
const pageNames = ['host-center', 'agency-center', 'bd-center', 'wallet'];
|
|
const languages = ['en', 'ar', 'tr', 'es'];
|
|
const keyPattern =
|
|
/["'`]((?:fami|contact_card|host_center|agency_center|bd_center|withdraw_exchange)\.[A-Za-z0-9_.-]+)["'`]/g;
|
|
|
|
function invariant(condition, message) {
|
|
if (!condition) throw new Error(message);
|
|
}
|
|
|
|
function dimensions(file, bytes) {
|
|
if (file.endsWith('.png')) {
|
|
invariant(
|
|
bytes.subarray(1, 4).toString('ascii') === 'PNG',
|
|
`${file}: invalid PNG signature`
|
|
);
|
|
return {
|
|
width: bytes.readUInt32BE(16),
|
|
height: bytes.readUInt32BE(20),
|
|
};
|
|
}
|
|
|
|
const source = bytes.toString('utf8');
|
|
const viewBox = source.match(/viewBox=["']([^"']+)["']/i)?.[1];
|
|
invariant(viewBox, `${file}: SVG viewBox is required`);
|
|
const values = viewBox.trim().split(/\s+/).map(Number);
|
|
return { width: values[2], height: values[3] };
|
|
}
|
|
|
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
const entryFrames = new Set([
|
|
'3843:5032',
|
|
'3843:5157',
|
|
'3843:5291',
|
|
'3843:5388',
|
|
'3843:5462',
|
|
'3843:5586',
|
|
]);
|
|
|
|
invariant(manifest.schemaVersion === 1, 'Unsupported manifest schema');
|
|
invariant(
|
|
manifest.figma?.fileKey === 'vaDp5wwn6AOQVlt9zk0f9r',
|
|
'Unexpected Figma file key'
|
|
);
|
|
invariant(manifest.figma?.page?.id === '1183:578', 'Unexpected Figma page');
|
|
invariant(
|
|
new Set(Object.values(manifest.figma?.entryFrames || {})).size === 6 &&
|
|
Object.values(manifest.figma.entryFrames).every((id) =>
|
|
entryFrames.has(id)
|
|
),
|
|
'Center entry-frame mapping changed'
|
|
);
|
|
invariant(
|
|
Array.isArray(manifest.assets) && manifest.assets.length === 22,
|
|
'Manifest must describe all 22 Center assets'
|
|
);
|
|
|
|
const listedFiles = new Set();
|
|
for (const asset of manifest.assets) {
|
|
invariant(
|
|
asset.file && !listedFiles.has(asset.file),
|
|
`Duplicate asset ${asset.file}`
|
|
);
|
|
listedFiles.add(asset.file);
|
|
invariant(asset.sourceNode, `${asset.file}: sourceNode is required`);
|
|
invariant(
|
|
!entryFrames.has(asset.sourceNode),
|
|
`${asset.file}: root-frame export is forbidden`
|
|
);
|
|
invariant(
|
|
asset.sourceName && asset.sourceType && asset.purpose,
|
|
`${asset.file}: source metadata is required`
|
|
);
|
|
invariant(
|
|
asset.bounds?.width > 0 && asset.bounds?.height > 0,
|
|
`${asset.file}: dimensions are required`
|
|
);
|
|
invariant(
|
|
!(asset.bounds.width >= 375 && asset.bounds.height >= 812),
|
|
`${asset.file}: full-screen aggregate assets are forbidden`
|
|
);
|
|
invariant(
|
|
Number.isInteger(asset.bytes) && asset.bytes > 0,
|
|
`${asset.file}: byte size is required`
|
|
);
|
|
invariant(
|
|
/^[a-f0-9]{64}$/.test(asset.sha256 || ''),
|
|
`${asset.file}: SHA-256 is required`
|
|
);
|
|
invariant(!asset.containsText, `${asset.file}: rendered text is forbidden`);
|
|
invariant(
|
|
!asset.containsControls,
|
|
`${asset.file}: rendered controls are forbidden`
|
|
);
|
|
invariant(
|
|
!asset.containsDynamicMedia || asset.testOnly === true,
|
|
`${asset.file}: dynamic media must be an explicit test-only fixture`
|
|
);
|
|
|
|
const assetPath = path.join(assetDirectory, asset.file);
|
|
const [bytes, info] = await Promise.all([
|
|
readFile(assetPath),
|
|
stat(assetPath),
|
|
]);
|
|
const digest = createHash('sha256').update(bytes).digest('hex');
|
|
const actualBounds = dimensions(asset.file, bytes);
|
|
invariant(info.size === asset.bytes, `${asset.file}: byte size mismatch`);
|
|
invariant(digest === asset.sha256, `${asset.file}: SHA-256 mismatch`);
|
|
invariant(
|
|
actualBounds.width === asset.bounds.width &&
|
|
actualBounds.height === asset.bounds.height,
|
|
`${asset.file}: dimensions mismatch`
|
|
);
|
|
}
|
|
|
|
const diskAssets = (await readdir(assetDirectory))
|
|
.filter((file) => file !== 'manifest.json')
|
|
.sort();
|
|
invariant(
|
|
JSON.stringify(diskAssets) === JSON.stringify([...listedFiles].sort()),
|
|
'Every Center asset must have exactly one manifest entry'
|
|
);
|
|
|
|
const pageSources = await Promise.all(
|
|
pageNames.map(async (pageName) => {
|
|
const pageRoot = path.join(centersRoot, pageName);
|
|
const [html, script, style] = await Promise.all([
|
|
readFile(path.join(pageRoot, 'index.html'), 'utf8'),
|
|
readFile(path.join(pageRoot, 'script.js'), 'utf8'),
|
|
readFile(path.join(pageRoot, 'style.css'), 'utf8'),
|
|
]);
|
|
|
|
invariant(
|
|
html.includes('data-hy-app-code="fami"'),
|
|
`${pageName}: Fami app identity is missing`
|
|
);
|
|
const contextIndex = html.indexOf(
|
|
'../../packages/core/product-context.js'
|
|
);
|
|
const productIndex = html.indexOf('../../products/fami/product.js');
|
|
const apiIndex = html.indexOf('../../../common/api.js');
|
|
invariant(
|
|
contextIndex >= 0 &&
|
|
productIndex > contextIndex &&
|
|
apiIndex > productIndex,
|
|
`${pageName}: product context must run before common/api.js`
|
|
);
|
|
invariant(
|
|
html.includes('../../products/fami/centers/fami.css') &&
|
|
html.includes('../../products/fami/centers/fami.js'),
|
|
`${pageName}: shared Fami Center runtime is missing`
|
|
);
|
|
invariant(
|
|
!/<img[^>]+src=["'][^"']*(?:host|agency|bd)-avatar\.png/i.test(
|
|
html
|
|
),
|
|
`${pageName}: a Figma sample avatar is wired into production DOM`
|
|
);
|
|
|
|
return { pageName, html, script, style };
|
|
})
|
|
);
|
|
|
|
const sharedSource = await readFile(path.join(sharedRoot, 'fami.js'), 'utf8');
|
|
const commonParamsSource = await readFile(
|
|
path.join(root, 'common', 'params.js'),
|
|
'utf8'
|
|
);
|
|
const runtimeSource = [
|
|
sharedSource,
|
|
...(await Promise.all(
|
|
['fami.css'].map((file) =>
|
|
readFile(path.join(sharedRoot, file), 'utf8')
|
|
)
|
|
)),
|
|
...pageSources.flatMap(({ html, script, style }) => [html, script, style]),
|
|
].join('\n');
|
|
|
|
for (const forbidden of [
|
|
'/gonghui/',
|
|
'/guide/fami/',
|
|
'withdraw-exchange',
|
|
'unhost-center',
|
|
'superadmin-center',
|
|
]) {
|
|
invariant(
|
|
!runtimeSource.includes(forbidden),
|
|
`Forbidden Center runtime path/reference: ${forbidden}`
|
|
);
|
|
}
|
|
|
|
invariant(
|
|
sharedSource.includes("(isMock() ? String(fallback || '').trim() : '')"),
|
|
'Figma avatar fallback must be gated by explicit mock mode'
|
|
);
|
|
invariant(
|
|
sharedSource.includes("params.get('mock') === '1'"),
|
|
'Mock mode must require an explicit query flag'
|
|
);
|
|
invariant(
|
|
commonParamsSource.includes("String(window.HyAppDefaultAppCode || '')"),
|
|
'Common params must preserve the page-level Fami app code default'
|
|
);
|
|
|
|
const pageScripts = pageSources.map(({ script }) => script).join('\n');
|
|
const statsCalls = [...pageScripts.matchAll(/\.stats\s*\(\s*/g)];
|
|
invariant(statsCalls.length === 7, 'Expected seven Center stats calls');
|
|
for (const call of statsCalls) {
|
|
invariant(
|
|
pageScripts
|
|
.slice(call.index + call[0].length)
|
|
.trimStart()
|
|
.startsWith('{'),
|
|
'Every Center stats call must receive an object query'
|
|
);
|
|
}
|
|
const walletScript = pageSources.find(
|
|
({ pageName }) => pageName === 'wallet'
|
|
).script;
|
|
invariant(
|
|
walletScript.includes('.withdrawUSDT({'),
|
|
'Wallet must use pointWallet.withdrawUSDT'
|
|
);
|
|
invariant(
|
|
!/\.withdraw\s*\(/.test(walletScript),
|
|
'Legacy pointWallet.withdraw is forbidden'
|
|
);
|
|
invariant(
|
|
walletScript.includes('saveWithdrawAddress({ usdt_trc20_address: value })'),
|
|
'Saved TRC20 address must use the current wallet contract field'
|
|
);
|
|
|
|
const usedKeys = new Set();
|
|
for (const match of runtimeSource.matchAll(keyPattern)) usedKeys.add(match[1]);
|
|
invariant(
|
|
usedKeys.size === 100,
|
|
`Expected 100 Center i18n keys, found ${usedKeys.size}`
|
|
);
|
|
for (const language of languages) {
|
|
const locale = JSON.parse(
|
|
await readFile(
|
|
path.join(
|
|
root,
|
|
'guild',
|
|
'products',
|
|
'fami',
|
|
'locales',
|
|
`${language}.json`
|
|
),
|
|
'utf8'
|
|
)
|
|
);
|
|
const missing = [...usedKeys].filter((key) => !(key in locale));
|
|
invariant(
|
|
missing.length === 0,
|
|
`${language}: missing Center locale keys: ${missing.join(', ')}`
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
`guild/fami Center audit passed (${pageNames.length} entries, ${manifest.assets.length} traced layers, ${usedKeys.size} locale keys)`
|
|
);
|