import { readFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import yaml from 'js-yaml'; import { maxSourceImageBytes } from './image-policy.mjs'; const fail = (message) => { throw new Error(message); }; const assert = (condition, message) => { if (!condition) fail(message); }; const same = (actual, expected) => JSON.stringify(actual) === JSON.stringify(expected); function expectSubset(actual, expected, path) { assert(actual && typeof actual === 'object', `${path} must be an object.`); for (const [key, value] of Object.entries(expected)) { const currentPath = `${path}.${key}`; if (value && typeof value === 'object' && !Array.isArray(value)) expectSubset(actual[key], value, currentPath); else assert(same(actual[key], value), `${currentPath} must be ${JSON.stringify(value)}.`); } } const configPath = 'public/admin/config.yml'; const config = yaml.load(await readFile(configPath, 'utf8')); const expectedOrigin = 'https://cms-3031023-xyz-www.ksloan90.workers.dev'; const expectedBackend = { name: 'gitea', repo: 'SloanOps/3031023-xyz', branch: 'cms', api_root: 'https://git.sloanops.com/api/v1', base_url: 'https://git.sloanops.com', auth_endpoint: 'https://git.sloanops.com/login/oauth/authorize', }; expectSubset(config.backend, expectedBackend, 'backend'); assert(same(Object.keys(config.backend).sort(), [...Object.keys(expectedBackend), 'app_id'].sort()), 'The backend must not contain secret-bearing or unreviewed options.'); assert(typeof config.backend.app_id === 'string' && /^[A-Za-z0-9._-]+$/.test(config.backend.app_id), 'backend.app_id must be a non-confidential public client ID or the review placeholder.'); assert(config.publish_mode === 'simple', 'The Gitea pilot must use simple mode on its fixed review branch.'); assert(config.site_url === expectedOrigin && config.display_url === expectedOrigin, 'The CMS must use the stable primary preview origin.'); assert(config.local_backend === undefined, 'The pilot configuration must not enable an alternate local backend.'); expectSubset(config, { media_folder: 'public/images/uploads', public_folder: '/images/uploads', }, 'config'); assert(config.media_library === undefined, 'Top-level media_library is reserved for registered external libraries; bound the built-in uploader on the image field.'); const markdownField = { widget: 'markdown', buttons: ['bold', 'italic', 'link', 'heading-two', 'heading-three', 'quote', 'bulleted-list', 'numbered-list'], editor_components: [], }; const cmsImagePattern = [ '^[A-Za-z0-9][A-Za-z0-9._-]*\\.(?:jpe?g|png|webp|avif)$', 'Choose an approved JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.', ]; const optionalImageField = { widget: 'image', required: false, choose_url: false, allow_multiple: false, pattern: cmsImagePattern, media_library: { config: { max_file_size: maxSourceImageBytes, multiple: false } }, }; const imageAltField = { widget: 'string', pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.'] }; const expectedCollections = { primary_faq: { folder: 'sites/www/content/faq', identifier_field: 'question', fields: { question: { widget: 'string', pattern: ['^.{12,180}$', 'Use 12–180 characters.'] }, category: { widget: 'string', default: 'General', pattern: ['^.{2,48}$', 'Use 2–48 characters.'] }, order: { widget: 'number', value_type: 'int', min: 0, max: 99, step: 1, default: 0 }, draft: { widget: 'boolean', default: false }, body: markdownField, }, }, primary_services: { folder: 'sites/www/content/services', identifier_field: 'title', fields: { title: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] }, price: { widget: 'string', pattern: ['^.{2,48}$', 'Use 2–48 characters.'] }, items: { widget: 'list', min: 1, max: 8, field: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] } }, order: { widget: 'number', value_type: 'int', min: 0, max: 99, step: 1, default: 0 }, featured: { widget: 'boolean', default: false }, draft: { widget: 'boolean', default: false }, body: markdownField, }, }, primary_team: { folder: 'sites/www/content/team', identifier_field: 'name', fields: { name: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] }, role: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] }, image: optionalImageField, order: { widget: 'number', value_type: 'int', min: 0, max: 99, step: 1, default: 0 }, draft: { widget: 'boolean', default: false }, body: markdownField, }, }, journal_posts: { folder: 'sites/blog/content/posts', identifier_field: 'title', fields: { title: { widget: 'string', pattern: ['^.{12,100}$', 'Use 12–100 characters.'] }, description: { widget: 'text', pattern: ['^[\\s\\S]{30,180}$', 'Use 30–180 characters.'] }, pubDate: { widget: 'datetime', default: '{{now}}', date_format: 'YYYY-MM-DD', time_format: false, picker_utc: true }, updatedDate: { widget: 'datetime', date_format: 'YYYY-MM-DD', time_format: false, picker_utc: true, required: false }, tags: { widget: 'list', min: 1, max: 4, field: { widget: 'string', pattern: ['^.{2,32}$', 'Use 2–32 characters.'] } }, draft: { widget: 'boolean', default: false }, body: markdownField, }, }, }; assert(Array.isArray(config.collections), 'collections must be an array.'); assert(same(config.collections.map(({ name }) => name).sort(), [...Object.keys(expectedCollections), 'primary_page_images'].sort()), 'Decap must expose the four content collections and one fixed page-image collection.'); for (const collection of config.collections.filter(({ name }) => expectedCollections[name])) { const expected = expectedCollections[collection.name]; expectSubset(collection, { folder: expected.folder, identifier_field: expected.identifier_field, create: true, delete: true, extension: 'md', format: 'frontmatter', slug: '{{slug}}', }, `collections.${collection.name}`); const fields = Object.fromEntries(collection.fields.map((field) => [field.name, field])); assert(same(Object.keys(fields).sort(), Object.keys(expected.fields).sort()), `${collection.name} must preserve every modeled frontmatter field and body.`); for (const [fieldName, fieldContract] of Object.entries(expected.fields)) expectSubset(fields[fieldName], fieldContract, `${collection.name}.${fieldName}`); } const pageImageCollection = config.collections.find(({ name }) => name === 'primary_page_images'); assert(pageImageCollection.folder === undefined && pageImageCollection.create === undefined && pageImageCollection.delete === undefined, 'Page images must remain a fixed file collection.'); assert(Array.isArray(pageImageCollection.files) && pageImageCollection.files.length === 1, 'Page images must expose exactly one fixed record.'); const pageImageFile = pageImageCollection.files[0]; expectSubset(pageImageFile, { name: 'primary', file: 'sites/www/content/page-images/primary.md' }, 'primary_page_images.primary'); const expectedPageImageFields = { homeHeroImage: optionalImageField, homeHeroImageAlt: imageAltField, homeStoryImage: optionalImageField, homeStoryImageAlt: imageAltField, aboutHeroImage: optionalImageField, aboutHeroImageAlt: imageAltField, aboutStoryImage: optionalImageField, aboutStoryImageAlt: imageAltField, servicesHeroImage: optionalImageField, servicesHeroImageAlt: imageAltField, contactHeroImage: optionalImageField, contactHeroImageAlt: imageAltField, }; const pageImageFields = Object.fromEntries(pageImageFile.fields.map((field) => [field.name, field])); assert(same(Object.keys(pageImageFields).sort(), Object.keys(expectedPageImageFields).sort()), 'Page images must expose only the six approved image and description pairs.'); for (const [fieldName, fieldContract] of Object.entries(expectedPageImageFields)) expectSubset(pageImageFields[fieldName], fieldContract, `primary_page_images.${fieldName}`); function visitField(field) { assert(field.widget !== 'file', `General file widget is forbidden: ${field.name}.`); assert(field.widget !== 'date', `Unsupported Decap date widget is forbidden; use a date-only datetime widget: ${field.name}.`); if (field.fields) field.fields.forEach(visitField); if (field.field) visitField(field.field); } const allFields = config.collections.flatMap(({ fields, files }) => fields ?? files.flatMap((file) => file.fields)); allFields.forEach(visitField); const imageFields = allFields.filter(({ widget }) => widget === 'image'); assert(imageFields.length === 7, 'Only the team image and six fixed page-image fields may expose media selection.'); for (const field of imageFields) expectSubset(field, optionalImageField, `image field ${field.name}`); assert(new RegExp(cmsImagePattern[0]).test('example-photo.jpg'), 'Image widget validation must accept the filename Decap passes to field patterns.'); assert(!new RegExp(cmsImagePattern[0]).test('example-photo.svg'), 'Image widget validation must reject SVG filenames.'); const schema = await readFile('src/content.config.ts', 'utf8'); for (const fragment of [ 'title: z.string().min(12).max(100)', 'description: z.string().min(30).max(180)', 'updatedDate: z.coerce.date().optional()', 'tags: z.array(z.string().min(2).max(32)).min(1).max(4)', "question: z.string().min(12).max(180)", "category: z.string().min(2).max(48).default('General')", 'title: z.string().min(3).max(80)', 'price: z.string().min(2).max(48)', 'items: z.array(z.string().min(3).max(80)).min(1).max(8)', 'name: z.string().min(3).max(80)', 'role: z.string().min(3).max(80)', 'const optionalCmsImage = z.preprocess(', 'image: optionalCmsImage', 'homeHeroImage: optionalCmsImage', 'contactHeroImageAlt: z.string().min(8).max(180)', 'order: z.number().int().min(0).max(99).default(0)', 'draft: z.boolean().default(false)', 'featured: z.boolean().default(false)', ]) assert(schema.includes(fragment), `Astro schema changed; review the Decap contract for: ${fragment}`); const buildScript = await readFile('scripts/build-site.mjs', 'utf8'); assert(buildScript.includes("['scripts/optimize-images.mjs']"), 'Every site build must optimize uploaded images before deployment.'); const imageOptimizer = await readFile('scripts/optimize-images.mjs', 'utf8'); for (const fragment of ['maxBuiltImageBytes', 'maxBuiltImageDimension', '.rotate()', 'metadata.exif', 'metadata.icc', 'metadata.iptc', 'metadata.xmp']) { assert(imageOptimizer.includes(fragment), `Built-image optimization changed; review the CMS media contract for: ${fragment}`); } async function verifyContentPaths(directory) { const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { const path = join(directory, entry.name); if (entry.isDirectory()) { assert(/^[a-z0-9][a-z0-9-]*$/.test(entry.name), `Content directory is outside the Markdown slug contract: ${path}`); await verifyContentPaths(path); } else { assert(entry.isFile(), `Special content paths are outside the pilot contract: ${path}`); assert(/^[a-z0-9][a-z0-9-]*\.md$/.test(entry.name), `Content filename is outside the Markdown slug contract: ${path}`); } } } for (const directory of Object.values(expectedCollections).map(({ folder }) => folder)) await verifyContentPaths(directory); await verifyContentPaths('sites/www/content/page-images'); const admin = await readFile('public/admin/index.html', 'utf8'); assert(admin.includes('content="noindex,nofollow,noarchive"'), 'The admin shell must always carry no-index metadata.'); assert(admin.includes('href="/admin/config.yml"') && admin.includes('rel="cms-config-url"'), 'The admin shell must load the reviewed configuration.'); assert(admin.includes('https://unpkg.com/decap-cms@3.15.1/dist/decap-cms.js'), 'The admin shell must pin Decap CMS 3.15.1 exactly.'); assert(admin.includes('sha384-in6eHztHveqQ7uMZ1fDaKlDmacQLFuLH2wWrFTiymyuS8zQ5bixwL8U3AeRi8h/L'), 'The Decap bundle must retain its verified SHA-384 integrity value.'); assert(!/decap-cms@(latest|[~^*])/.test(admin), 'Floating Decap CMS versions are forbidden.'); const headers = await readFile('public/_headers', 'utf8'); assert(/\/admin\/\*[\s\S]*X-Robots-Tag: noindex, nofollow, noarchive/.test(headers), 'Admin responses must include an X-Robots-Tag no-index rule.'); const workflow = yaml.load(await readFile('.gitea/workflows/cms-content-validation.yml', 'utf8')); assert(workflow.name === 'CMS content validation', 'The validation workflow display name is part of the required status context.'); assert(workflow.on?.pull_request && same(workflow.on.pull_request.branches, ['main']), 'The validation workflow must run for every pull request into main.'); assert(workflow.on.pull_request.paths === undefined, 'The required validation workflow must not use path filters.'); assert(workflow.jobs?.validate?.name === 'cms-content-validation', 'The validation job name is part of the required status context.'); const workflowText = await readFile('.gitea/workflows/cms-content-validation.yml', 'utf8'); assert(!workflowText.includes('secrets.'), 'CMS pull-request validation must remain secret-free.'); for (const command of ['npm run verify:cms', 'SITE_KEY=www npm run build:site', 'SITE_KEY=www npm run verify:build', 'SITE_KEY=blog npm run build:site', 'SITE_KEY=blog npm run verify:build']) { assert(workflowText.includes(command), `Validation workflow is missing: ${command}`); } for (const path of [ '.gitea/workflows/cms-content-validation.yml', '.gitea/workflows/deploy.yml', '.gitea/workflows/deploy-blog.yml', '.gitea/workflows/production-candidate.yml', '.gitea/workflows/production-blog.yml', ]) { const text = await readFile(path, 'utf8'); assert(text.includes('uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4'), `${path} must pin checkout v4 to its reviewed commit.`); assert(text.includes('uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4'), `${path} must pin setup-node v4 to its reviewed commit.`); assert(/cache:\s*npm/.test(text) && /cache-dependency-path:\s*package-lock\.json/.test(text), `${path} must cache npm downloads by package-lock.json.`); assert(text.includes('npm ci --prefer-offline --no-audit --no-fund'), `${path} must retain a clean lockfile install with quiet, cache-friendly npm options.`); } console.log('Verified Decap backend, four content contracts, fixed page images, bounded media, admin pin, and stable PR status context.');