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( !/]+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)` );