Files
3031023-xyz-public/scripts/verify-decap.mjs
T
2026-09-04 22:00:33 -04:00

248 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.');