From 4dd9714b06e9f770c826480d4e8cb66d41133a30 Mon Sep 17 00:00:00 2001 From: bruinxz Date: Fri, 4 Sep 2026 17:50:13 +0800 Subject: [PATCH] fix: harden template publishing validation --- scripts/__tests__/validate-registry.test.mjs | 107 ++++++++++++++++- scripts/validate-registry.mjs | 114 ++++++++++++++----- 2 files changed, 192 insertions(+), 29 deletions(-) diff --git a/scripts/__tests__/validate-registry.test.mjs b/scripts/__tests__/validate-registry.test.mjs index b3804a3..15aeefb 100644 --- a/scripts/__tests__/validate-registry.test.mjs +++ b/scripts/__tests__/validate-registry.test.mjs @@ -1,12 +1,28 @@ import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; -import { validateSchema, validateTemplate } from '../validate-registry.mjs'; +import { BUILT_IN_PUBLISHING_CONTRACTS, validateSchema, validateTemplate } from '../validate-registry.mjs'; const here = dirname(fileURLToPath(import.meta.url)); const load = (name) => JSON.parse(readFileSync(join(here, 'fixtures', name), 'utf8')); const repoRoot = resolve(here, 'fixtures', 'repo'); +const realRepoRoot = resolve(here, '../..'); + +function publishingFixture(packageJSON, extraFiles = {}) { + const root = mkdtempSync(join(tmpdir(), 'insforge-template-validation-')); + const subdir = join(root, 'native-template'); + mkdirSync(subdir, { recursive: true }); + writeFileSync(join(subdir, 'package.json'), JSON.stringify(packageJSON)); + writeFileSync(join(subdir, 'package-lock.json'), '{"lockfileVersion":3}'); + for (const [name, body] of Object.entries(extraFiles)) { + const target = join(subdir, name); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, body); + } + return root; +} function entry(slug, overrides = {}) { return { @@ -16,6 +32,7 @@ function entry(slug, overrides = {}) { category: 'ai', framework: 'nextjs', publishingCompatibility: 'unsupported', + publishingBlockers: ['No controlled publishing path'], features: [], tags: [], cover: `assets/covers/${slug}.png`, @@ -144,6 +161,92 @@ describe('validateTemplate — filesystem', () => { ); expect(r).toEqual({ ok: true, errors: [] }); }); + + it('rejects non-native templates without an actionable blocker', async () => { + const r = await validateTemplate( + entry('good-slug', { publishingBlockers: [] }), + repoRoot, + ); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toMatch(/explain their publishing blockers/i); + }); + + it('rejects a native build profile that contradicts the framework or source', async () => { + const root = publishingFixture({ + scripts: { build: 'vite build' }, + dependencies: { react: '1', vite: '1' }, + }); + const r = await validateTemplate( + entry('native-template', { + framework: 'nextjs', + publishingCompatibility: 'native', + publishingBlockers: [], + buildProfile: 'vite-npm-v1', + cover: '', + }), + root, + { publishingOnly: true }, + ); + expect(r.ok).toBe(false); + expect(r.errors).toContain('native-template: vite-npm-v1 is incompatible with framework nextjs'); + }); + + it('rejects conflicting lockfiles, package managers, lifecycle scripts, and workspaces', async () => { + const root = publishingFixture({ + packageManager: 'yarn@1.22.0', + workspaces: ['packages/*'], + scripts: { build: 'vite build', prepare: 'husky' }, + dependencies: { react: '1', vite: '1' }, + }, { 'pnpm-lock.yaml': "lockfileVersion: '9.0'" }); + const r = await validateTemplate(entry('native-template', { + framework: 'react', publishingCompatibility: 'native', publishingBlockers: [], + buildProfile: 'vite-npm-v1', cover: '', + }), root, { publishingOnly: true }); + expect(r.errors.join('\n')).toMatch(/pnpm-lock.yaml: conflicts/); + expect(r.errors.join('\n')).toMatch(/packageManager must match/); + expect(r.errors.join('\n')).toMatch(/workspaces are not supported/); + expect(r.errors.join('\n')).toMatch(/lifecycle script prepare/); + }); + + it('rejects Next.js static profiles with runtime-only source', async () => { + const root = publishingFixture({ + scripts: { build: 'next build' }, + dependencies: { next: '1', react: '1' }, + }, { + 'next.config.ts': "export default { output: 'export' }", + 'app/api/chat/route.ts': 'export async function POST() {}', + }); + const r = await validateTemplate(entry('native-template', { + framework: 'nextjs', publishingCompatibility: 'native', publishingBlockers: [], + buildProfile: 'next-static-npm-v1', cover: '', + }), root, { publishingOnly: true }); + expect(r.errors.join('\n')).toMatch(/requires a Node runtime/); + }); + + it('rejects Next.js static profiles without output export', async () => { + const root = publishingFixture({ + scripts: { build: 'next build' }, + dependencies: { next: '1', react: '1' }, + }, { 'next.config.ts': 'export default {}' }); + const r = await validateTemplate(entry('native-template', { + framework: 'nextjs', publishingCompatibility: 'native', publishingBlockers: [], + buildProfile: 'next-static-npm-v1', cover: '', + }), root, { publishingOnly: true }); + expect(r.errors.join('\n')).toMatch(/requires output: export/); + }); + + it('validates publishing contracts for platform-provided starter entries', async () => { + expect(BUILT_IN_PUBLISHING_CONTRACTS).toEqual(expect.arrayContaining([ + expect.objectContaining({ + slug: 'todo', framework: 'nextjs', + buildProfile: 'next-static-npm-v1', publishingCompatibility: 'native', + }), + ])); + for (const contract of BUILT_IN_PUBLISHING_CONTRACTS) { + const r = await validateTemplate(contract, realRepoRoot, { publishingOnly: true }); + expect(r.errors, contract.slug).toEqual([]); + } + }); }); describe('validateTemplate — SQL', () => { diff --git a/scripts/validate-registry.mjs b/scripts/validate-registry.mjs index e3286b4..9663c9c 100644 --- a/scripts/validate-registry.mjs +++ b/scripts/validate-registry.mjs @@ -52,6 +52,13 @@ const ajv = new Ajv({ allErrors: true }); addFormats(ajv); const validate = ajv.compile(SCHEMA); +// Keep aligned with starterEntries() in insforge-platform/internal/templatecatalog/sync.go. +export const BUILT_IN_PUBLISHING_CONTRACTS = [ + { slug: 'react', framework: 'react', buildProfile: 'vite-npm-v1', publishingCompatibility: 'native', publishingBlockers: [] }, + { slug: 'todo', framework: 'nextjs', buildProfile: 'next-static-npm-v1', publishingCompatibility: 'native', publishingBlockers: [] }, + { slug: 'nextjs', framework: 'nextjs', publishingCompatibility: 'requires-node-runtime', publishingBlockers: ['Uses request-time authentication, redirects, and Server Actions'] }, +]; + // Patterns that strongly indicate a real secret was committed. // Surgical list — false positives block legit PRs. const SECRET_PATTERNS = [ @@ -66,8 +73,8 @@ const PLATFORM_AI_SOURCE_EXTENSIONS = new Set([ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.json', '.vue', '.svelte', '.py', '.go', '.java', '.kt', '.swift', '.rb', '.php', '.sh', '.yaml', '.yml', ]); -const PLATFORM_AI_IGNORED_DIRECTORIES = new Set([ - '.git', '.next', 'build', 'coverage', 'dist', 'node_modules', +const TEMPLATE_IGNORED_DIRECTORIES = new Set([ + '.git', '.next', 'build', 'coverage', 'dist', 'node_modules', 'out', ]); const PLATFORM_AI_FORBIDDEN_BINDINGS = [ { pattern: /https?:\/\/openrouter\.ai\b/i, label: 'direct OpenRouter endpoint' }, @@ -80,29 +87,39 @@ const PLATFORM_AI_FORBIDDEN_BINDINGS = [ label: 'provider API key environment variable', }, ]; +const FORBIDDEN_PUBLISHING_LIFECYCLE_SCRIPTS = [ + 'preinstall', 'install', 'postinstall', 'prepublish', 'prepublishOnly', + 'preprepare', 'prepare', 'postprepare', 'prebuild', 'postbuild', +]; +const NEXT_RUNTIME_SOURCE_PATTERN = /(['"]use server['"]|from\s+['"]next\/headers['"]|from\s+['"]server-only['"]|\b(?:cookies|headers)\s*\()/m; +const NEXT_STATIC_EXPORT_PATTERN = /output\s*:\s*['"]export['"]/m; -function platformAISourceFiles(root) { +function templateFiles(root, include = () => true) { const files = []; const visit = (dir) => { for (const item of readdirSync(dir, { withFileTypes: true })) { if (item.isDirectory()) { - if (!PLATFORM_AI_IGNORED_DIRECTORIES.has(item.name)) visit(join(dir, item.name)); + if (!TEMPLATE_IGNORED_DIRECTORIES.has(item.name)) visit(join(dir, item.name)); continue; } if (!item.isFile()) continue; - const extension = item.name.slice(item.name.lastIndexOf('.')); - if ( - (PLATFORM_AI_SOURCE_EXTENSIONS.has(extension) && item.name !== 'package-lock.json') - || item.name.startsWith('.env') - ) { - files.push(join(dir, item.name)); - } + if (include(item.name)) files.push(join(dir, item.name)); } }; visit(root); return files; } +function platformAISourceFiles(root) { + return templateFiles(root, (name) => { + const extension = name.slice(name.lastIndexOf('.')); + return ( + (PLATFORM_AI_SOURCE_EXTENSIONS.has(extension) && name !== 'package-lock.json') + || name.startsWith('.env') + ); + }); +} + function validatePlatformManagedAI(entry, subdir) { if (!entry.requiredCapabilities?.some((capability) => capability.startsWith('ai.'))) { return []; @@ -143,7 +160,7 @@ export function validateSchema(registry) { return { ok: errors.length === 0, errors }; } -export async function validateTemplate(entry, repoRoot) { +export async function validateTemplate(entry, repoRoot, { publishingOnly = false } = {}) { const errors = []; const subdir = join(repoRoot, entry.slug); if (!existsSync(subdir) || !statSync(subdir).isDirectory()) { @@ -165,27 +182,64 @@ export async function validateTemplate(entry, repoRoot) { errors.push(`${entry.slug}: publishingCompatibility is required`); } else if (entry.publishingCompatibility === 'native') { if (!entry.buildProfile) errors.push(`${entry.slug}: native templates require a controlled buildProfile`); + if (entry.publishingBlockers?.length) errors.push(`${entry.slug}: native templates cannot declare publishingBlockers`); + const framework = entry.framework?.trim().toLowerCase(); + if (entry.buildProfile?.startsWith('vite-') && !['react', 'vue', 'vite', 'vite-react', 'vite-vue', 'react-vite', 'vue-vite'].includes(framework)) { + errors.push(`${entry.slug}: ${entry.buildProfile} is incompatible with framework ${entry.framework}`); + } + if (entry.buildProfile?.startsWith('next-static-') && framework !== 'nextjs') { + errors.push(`${entry.slug}: ${entry.buildProfile} is incompatible with framework ${entry.framework}`); + } const pnpm = entry.buildProfile?.includes('-pnpm-'); const lockfile = pnpm ? 'pnpm-lock.yaml' : 'package-lock.json'; if (!existsSync(join(subdir, lockfile))) errors.push(`${entry.slug}/${lockfile}: required by ${entry.buildProfile}`); - if (pnpm && !pkg?.packageManager?.startsWith('pnpm@')) errors.push(`${entry.slug}: packageManager must pin pnpm for a pnpm build profile`); - const allowedBuildScripts = entry.buildProfile?.startsWith('vite-') - ? ['vite build', 'tsc -b && vite build', 'vue-tsc -b && vite build'] - : ['next build']; - if (!allowedBuildScripts.includes(pkg?.scripts?.build)) errors.push(`${entry.slug}: build script is not allowed by ${entry.buildProfile}`); - } else if ((entry.publishingCompatibility === 'requires-node-runtime' || entry.publishingCompatibility === 'conversion-required') && !entry.publishingBlockers?.length) { - errors.push(`${entry.slug}: non-native templates must explain their publishing blockers`); + const otherLockfile = pnpm ? 'package-lock.json' : 'pnpm-lock.yaml'; + if (existsSync(join(subdir, otherLockfile))) errors.push(`${entry.slug}/${otherLockfile}: conflicts with ${entry.buildProfile}`); + if (pkg) { + if (pnpm && !pkg.packageManager?.startsWith('pnpm@')) errors.push(`${entry.slug}: packageManager must pin pnpm for a pnpm build profile`); + if (!pnpm && pkg.packageManager && !pkg.packageManager.startsWith('npm@')) errors.push(`${entry.slug}: packageManager must match the npm build profile`); + if (pkg.workspaces != null) errors.push(`${entry.slug}: package.json workspaces are not supported by controlled publishing`); + for (const script of FORBIDDEN_PUBLISHING_LIFECYCLE_SCRIPTS) { + if (pkg.scripts?.[script]?.trim()) errors.push(`${entry.slug}: lifecycle script ${script} is not allowed by controlled publishing`); + } + const dependencies = { ...pkg.dependencies, ...pkg.devDependencies }; + if (entry.buildProfile?.startsWith('vite-') && !dependencies.vite) errors.push(`${entry.slug}: ${entry.buildProfile} requires a Vite dependency`); + if (entry.buildProfile?.startsWith('next-static-') && !dependencies.next) errors.push(`${entry.slug}: ${entry.buildProfile} requires a Next.js dependency`); + const allowedBuildScripts = entry.buildProfile?.startsWith('vite-') + ? ['vite build', 'tsc -b && vite build', 'vue-tsc -b && vite build'] + : ['next build']; + if (!allowedBuildScripts.includes(pkg.scripts?.build)) errors.push(`${entry.slug}: build script is not allowed by ${entry.buildProfile}`); + if (entry.buildProfile?.startsWith('next-static-')) { + const configs = ['next.config.js', 'next.config.mjs', 'next.config.ts'] + .filter((name) => existsSync(join(subdir, name))) + .map((name) => readFileSync(join(subdir, name), 'utf8')) + .join('\n'); + if (!NEXT_STATIC_EXPORT_PATTERN.test(configs)) errors.push(`${entry.slug}: Next.js static publishing requires output: export`); + for (const file of templateFiles(subdir)) { + const relative = file.slice(subdir.length + 1).replaceAll('\\', '/').toLowerCase(); + if (relative.includes('/app/api/') || relative.startsWith('app/api/') + || relative.includes('/pages/api/') || relative.startsWith('pages/api/') + || ['middleware.ts', 'middleware.js'].includes(relative.split('/').at(-1)) + || NEXT_RUNTIME_SOURCE_PATTERN.test(readFileSync(file, 'utf8'))) { + errors.push(`${entry.slug}/${relative}: requires a Node runtime and cannot use ${entry.buildProfile}`); + } + } + } + } + } else { + if (entry.buildProfile) errors.push(`${entry.slug}: only native templates may declare buildProfile`); + if (!entry.publishingBlockers?.length) errors.push(`${entry.slug}: non-native templates must explain their publishing blockers`); } - if (!existsSync(join(subdir, 'LICENSE'))) { + if (!publishingOnly && !existsSync(join(subdir, 'LICENSE'))) { errors.push(`${entry.slug}/LICENSE: missing`); } - if (!existsSync(join(subdir, 'README.md'))) { + if (!publishingOnly && !existsSync(join(subdir, 'README.md'))) { errors.push(`${entry.slug}/README.md: missing`); } const envPath = join(subdir, '.env.example'); - if (!existsSync(envPath)) { + if (!publishingOnly && !existsSync(envPath)) { errors.push(`${entry.slug}/.env.example: missing`); - } else { + } else if (!publishingOnly) { const text = readFileSync(envPath, 'utf8'); for (const re of SECRET_PATTERNS) { if (re.test(text)) { @@ -194,7 +248,7 @@ export async function validateTemplate(entry, repoRoot) { } } } - if (entry.cover) { + if (!publishingOnly && entry.cover) { // Reject absolute paths or any `..` segment so a hostile registry entry // can't point cover at `/etc/passwd` or `../../some-secret/file.png`. // Cover must be a relative path inside the repo. @@ -209,10 +263,10 @@ export async function validateTemplate(entry, repoRoot) { errors.push(`${entry.slug}: cover file ${entry.cover} not found`); } } - errors.push(...validatePlatformManagedAI(entry, subdir)); + if (!publishingOnly) errors.push(...validatePlatformManagedAI(entry, subdir)); // SQL parse check const migrationsDir = join(subdir, 'migrations'); - if (existsSync(migrationsDir) && statSync(migrationsDir).isDirectory()) { + if (!publishingOnly && existsSync(migrationsDir) && statSync(migrationsDir).isDirectory()) { const { default: PgQuery } = await import('pg-query-emscripten'); const pg = await PgQuery(); for (const file of readdirSync(migrationsDir)) { @@ -246,6 +300,12 @@ if (import.meta.url === `file://${process.argv[1]}`) { const r = await validateTemplate(entry, repoRoot); allErrors.push(...r.errors); } + const registrySlugs = new Set(registry.map((entry) => entry.slug)); + for (const entry of BUILT_IN_PUBLISHING_CONTRACTS) { + if (registrySlugs.has(entry.slug)) continue; + const r = await validateTemplate(entry, repoRoot, { publishingOnly: true }); + allErrors.push(...r.errors); + } } if (allErrors.length > 0) { @@ -253,6 +313,6 @@ if (import.meta.url === `file://${process.argv[1]}`) { for (const e of allErrors) console.error(` - ${e}`); process.exit(1); } else { - console.log(`Registry OK (${registry.length} templates).`); + console.log(`Registry and built-in publishing contracts OK (${registry.length + BUILT_IN_PUBLISHING_CONTRACTS.filter((entry) => !registry.some((item) => item.slug === entry.slug)).length} templates).`); } }