diff --git a/.gitea/workflows/cms-content-validation.yml b/.gitea/workflows/cms-content-validation.yml new file mode 100644 index 0000000..4a1dfad --- /dev/null +++ b/.gitea/workflows/cms-content-validation.yml @@ -0,0 +1,34 @@ +name: CMS content validation + +on: + pull_request: + branches: [main] + +jobs: + validate: + name: cms-content-validation + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + env: + ASTRO_TELEMETRY_DISABLED: '1' + steps: + - name: Checkout source + uses: https://github.com/actions/checkout@v4 + - name: Use Node 22 + uses: https://github.com/actions/setup-node@v4 + with: + node-version: 22 + - name: Install locked dependencies + run: npm ci + - name: Verify Decap configuration and content boundaries + run: npm run verify:cms + - name: Build primary site + run: SITE_KEY=www npm run build:site + - name: Verify primary site + run: SITE_KEY=www npm run verify:build + - name: Build blog + run: SITE_KEY=blog npm run build:site + - name: Verify blog + run: SITE_KEY=blog npm run verify:build diff --git a/README.md b/README.md index 25ab799..be02665 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Use Node 22.12 or newer within the Node 22 release line. ```bash npm ci +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 @@ -36,6 +37,12 @@ Each selected site builds to `dist//`. The Worker configurations use only static assets and a custom 404 page. They contain no custom domains, routes, account IDs, or secrets. +`npm run verify:cms` checks the fixed Gitea branch/backend, all four Decap +collection contracts, the text-first raster-media boundary, the pinned admin +shell, and the stable secret-free pull-request status context. The live steps +remain unapplied; see the [Gitea pilot settings](docs/decap-cms-gitea-setup.md) +for the exact review checklist. + ## Preview deployment The Gitea workflows upload scoped non-production preview versions of the diff --git a/docs/decap-cms-gitea-setup.md b/docs/decap-cms-gitea-setup.md new file mode 100644 index 0000000..ffff00f --- /dev/null +++ b/docs/decap-cms-gitea-setup.md @@ -0,0 +1,185 @@ +# Decap CMS pilot: unapplied Gitea settings + +Status: repository-side review specification only. Nothing in this document +authorizes a Gitea restart, OAuth application creation, branch-protection +change, branch push, preview deployment, or merge. + +The repository implementation targets these fixed public values: + +| Setting | Value | +| --- | --- | +| Gitea repository | `SloanOps/3031023-xyz` | +| Decap write branch | `cms/pilot` | +| Gitea base URL | `https://git.sloanops.com` | +| Gitea API root | `https://git.sloanops.com/api/v1` | +| Stable CMS origin | `https://cms-pilot-3031023-xyz-www.ksloan90.workers.dev` | +| Exact OAuth redirect URI | `https://cms-pilot-3031023-xyz-www.ksloan90.workers.dev/admin/` | +| Required status context | `CMS content validation / cms-content-validation (pull_request)` | + +The predicted Workers alias must be confirmed after the first explicitly +approved preview deployment. If Cloudflare assigns a different stable origin, +update the repository configuration, CORS origin, and OAuth redirect URI +together through review before enabling login. + +## Unapplied CORS change + +The tracked Gitea service in +`/home/sloan/gitRepos/SloanOps/infrastructure/docker-compose.yml` currently has +no `[cors]` environment overrides. After separate approval, add these entries +to the existing `gitea.environment` list and redeploy only through the normal +infrastructure change process: + +```yaml +- GITEA__cors__ENABLED=true +- GITEA__cors__ALLOW_DOMAIN=https://cms-pilot-3031023-xyz-www.ksloan90.workers.dev +- GITEA__cors__METHODS=GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS +- GITEA__cors__MAX_AGE=10m +- GITEA__cors__ALLOW_CREDENTIALS=false +- GITEA__cors__HEADERS=Authorization,Content-Type +``` + +This is one exact origin, no wildcard, no cookie-bearing cross-origin request, +and only the API methods and non-safelisted request headers used by the Gitea +backend. Do not add the production domains, the journal preview, arbitrary +Workers preview hosts, or localhost. Gitea's same-origin login page continues +to use its own session cookie; Decap API calls use the OAuth bearer token. + +After an approved Gitea deployment, verify a preflight without including a +token or cookie: + +```bash +curl --request OPTIONS 'https://git.sloanops.com/api/v1/version' \ + --header 'Origin: https://cms-pilot-3031023-xyz-www.ksloan90.workers.dev' \ + --header 'Access-Control-Request-Method: GET' \ + --header 'Access-Control-Request-Headers: authorization,content-type' +``` + +The response must allow only the configured origin, requested method, and +headers. Repeat with an unrelated origin and confirm that Gitea does not grant +it cross-origin access. Also verify normal Git, web UI, API, and Actions +behavior before continuing. + +## OAuth application to create later + +Create one Gitea OAuth2 application only after the stable preview origin and +CORS response have been reviewed: + +- Application name: `3031023.xyz Decap CMS pilot`. +- Confidential client: **unchecked**. The browser client must use PKCE. +- Redirect URI: exactly + `https://cms-pilot-3031023-xyz-www.ksloan90.workers.dev/admin/`, including the + trailing slash. +- Do not add wildcards, localhost, either production hostname, or the journal + preview as additional redirects. +- Copy only the public client ID into `backend.app_id` in + `public/admin/config.yml` through a reviewed commit. +- Do not store or use a client secret in the repository, Actions, Cloudflare, + or Decap configuration. + +The CMS account still needs repository write access; the branch rules below +are the security boundary that reduces that write access to pilot content and +media paths. + +## Branch protection rules to apply together + +Create both rules before granting a CMS/client account write access or testing +login. Gitea uses only the first matching rule, so the order is material. + +### Priority 1: production `main` + +| Gitea control | Required value | +| --- | --- | +| Protected branch pattern | `main` | +| Priority | `1` (first/highest) | +| Push | Disabled for everyone; no push allowlist | +| Force push | Disabled | +| Required approvals | `1` | +| Approval allowlist | Enabled; only the named SloanOps operator user/team | +| Dismiss stale approvals | Enabled | +| Ignore stale approvals | Disabled | +| Block rejected reviews | Enabled | +| Block outstanding official review requests | Enabled | +| Block outdated pull requests | Enabled | +| Status checks | Enabled | +| Status check pattern | `CMS content validation / cms-content-validation (pull_request)` | +| Merge allowlist | Enabled; only the named SloanOps operator user/team | +| Administrators must follow protection | Enabled | +| Protection bypass allowlist | Disabled | + +Leave both file-pattern fields empty on this rule. Direct pushes are already +disabled, and executable or configuration changes must remain possible only in +a separate operator-authored, reviewed pull request. The fallback rule prevents +CMS users from placing those files on any source branch. + +This rule means every `main` change arrives through a current pull request, +passes the one secret-free job that validates the CMS contract and builds and +verifies both sites, receives a current SloanOps approval, and is merged by a +SloanOps operator. A CMS/client account must not be added to the approval, +merge, administrator, or bypass allowlists. + +### Priority 2: all-branch fallback + +| Gitea control | Required value | +| --- | --- | +| Protected branch pattern | `*` | +| Priority | `2` (after `main`) | +| Push | Disabled, except the unprotected file patterns below | +| Force push | Disabled | +| Unprotected file patterns | Exact semicolon-separated value below | +| Protected file patterns | Empty; every path omitted from the unprotected list remains protected | +| Protection bypass allowlist | Enabled only for the named SloanOps operator user/team | +| CMS/client account | Write access, but absent from every allowlist | + +Enter this exact single value in **Unprotected file patterns**: + +```text +sites/www/content/**;sites/blog/content/**;public/images/uploads/*.jpg;public/images/uploads/*.jpeg;public/images/uploads/*.png;public/images/uploads/*.webp;public/images/uploads/*.avif +``` + +Do not add a nested upload glob. The current Decap configuration writes media +directly into the flat uploads directory, and the local verifier rejects nested +media paths. Gitea file patterns are case-insensitive; CI verifies actual file +signatures and the 500 KiB limit before a preview can upload. + +Everything not listed is protected from CMS/client pushes on `cms/pilot` and +on every differently named branch, including: + +```text +.gitea/** +astro.config.mjs +package.json +package-lock.json +schemas/** +scripts/** +src/** +wrangler/** +sites.json +sites/*/site.json +public/admin/** +public/_headers +public/images/* +``` + +The final `public/images/*` line describes the effect of omission, not an +additional Gitea field value: existing approved images stay protected while +the explicitly listed `public/images/uploads/*.` paths remain writable. +Workflows, source, scripts, package files, schemas, manifests, Wrangler +configuration, `site.json` files, and the CMS application therefore cannot be +altered by choosing another branch name. + +## Verification before an editor invitation + +1. Run `npm ci`, `npm run verify:cms`, and both site build/verifier pairs. +2. Create `cms/pilot` from the reviewed baseline only after approval. +3. Apply both protection rules together and confirm `main` is priority 1 and + `*` is priority 2. +4. Use the same non-operator account intended for Decap to prove allowed + Markdown edits succeed and every protected-path/direct-`main` attempt fails. +5. Open, but do not merge, a pull request into `main`. Confirm Gitea reports + the exact required status context above and blocks merge without both its + successful result and a current SloanOps approval. +6. Confirm both branch previews remain no-index and production is unchanged. + +Do not treat a Decap UI restriction as authorization. Gitea's first-match +branch rules are the enforcement boundary, and the pull-request build is the +content/media integrity boundary. diff --git a/docs/decap-cms-pilot.md b/docs/decap-cms-pilot.md index 80bb92f..4c82763 100644 --- a/docs/decap-cms-pilot.md +++ b/docs/decap-cms-pilot.md @@ -1,9 +1,10 @@ # Decap CMS Gitea pilot -Status: planned; no CMS, OAuth application, Gitea CORS change, branch -protection, or deployment has been enabled by this document. +Status: repository implementation prepared for review; no OAuth application, +Gitea CORS change, branch protection, branch push, preview deployment, or +production merge has been performed. -Updated: 2026-09-03 +Updated: 2026-09-04 ## Purpose @@ -49,6 +50,39 @@ References: - - +The exact, unapplied Gitea CORS, OAuth, and two-rule branch-protection settings +are recorded in [Decap CMS pilot: unapplied Gitea settings](decap-cms-gitea-setup.md). + +## Repository implementation under review + +The `review/decap-cms-pilot` branch now contains the repository-side portion of +the pilot: + +- `public/admin/` provides a permanently no-index admin shell and one shared + configuration for both site builds. +- The shell pins Decap CMS `3.15.1` by exact URL and verified SHA-384 integrity + value rather than a floating release range. +- The backend is fixed to `SloanOps/3031023-xyz` and `cms/pilot`. Its public + OAuth client ID remains an explicit placeholder until the separately + approved non-confidential Gitea application exists. +- All four Markdown collections and every persisted frontmatter field are + modeled with the current Astro/Zod bounds. Markdown controls are text-first; + image embedding and general file widgets are absent. +- The team image field accepts an existing approved raster path or a bounded + upload. URL insertion and SVG paths are rejected. Builds additionally verify + the extension, raster signature, flat upload path, and 500 KiB limit. +- `.gitea/workflows/cms-content-validation.yml` runs on every pull request into + `main`, uses no secrets, and reports + `CMS content validation / cms-content-validation (pull_request)` after + checking the Decap contract and building and verifying both sites. +- The existing branch preview workflows remain in place. Local output checks + now prove preview robots rules and page metadata stay no-index and that the + admin response has a no-index header rule. + +This preparation does not make the CMS usable yet: the placeholder public +client ID, unapplied CORS setting, absent `cms/pilot` remote branch, and absent +branch rules are intentional review gates. + ## Proposed pilot architecture ```text @@ -192,8 +226,8 @@ request in Gitea. After an approved merge, synchronize the CMS branch with Allow only required methods and headers, including `Authorization` and `Content-Type`; do not use a wildcard origin. 3. Create `cms/pilot` from the reviewed repository baseline. -4. Add pinned Decap admin assets beneath `public/admin/`. Do not load a floating - major-version range from a CDN. +4. Review the pinned Decap admin assets beneath `public/admin/`. Do not replace + the exact version and integrity value with a floating range. 5. Configure the Gitea backend for `SloanOps/3031023-xyz` and branch `cms/pilot`. 6. Model every persisted frontmatter field with limits matching diff --git a/package-lock.json b/package-lock.json index d56d58d..02131bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "astro": "~7.2.0", + "js-yaml": "4.3.2", "wrangler": "^4.0.0" }, "engines": { diff --git a/package.json b/package.json index 51b7cb4..62aed0e 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "scripts": { "validate": "node scripts/validate.mjs", + "verify:cms": "npm run validate && node scripts/verify-decap.mjs", "build": "npm run validate && ASTRO_TELEMETRY_DISABLED=1 astro build", "build:site": "node scripts/build-site.mjs", "verify:build": "node scripts/verify-build.mjs", @@ -17,6 +18,7 @@ "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "astro": "~7.2.0", + "js-yaml": "4.3.2", "wrangler": "^4.0.0" } } diff --git a/public/_headers b/public/_headers index 5bf6b40..0bc0977 100644 --- a/public/_headers +++ b/public/_headers @@ -3,3 +3,7 @@ Referrer-Policy: strict-origin-when-cross-origin X-Frame-Options: SAMEORIGIN Permissions-Policy: geolocation=(), microphone=(), camera=() + +/admin/* + X-Robots-Tag: noindex, nofollow, noarchive + Cache-Control: no-store diff --git a/public/admin/config.yml b/public/admin/config.yml new file mode 100644 index 0000000..793d4fc --- /dev/null +++ b/public/admin/config.yml @@ -0,0 +1,218 @@ +backend: + name: gitea + repo: SloanOps/3031023-xyz + branch: cms/pilot + app_id: REPLACE_WITH_GITEA_OAUTH_CLIENT_ID + api_root: https://git.sloanops.com/api/v1 + base_url: https://git.sloanops.com + auth_endpoint: https://git.sloanops.com/login/oauth/authorize + +publish_mode: simple +site_url: https://cms-pilot-3031023-xyz-www.ksloan90.workers.dev +display_url: https://cms-pilot-3031023-xyz-www.ksloan90.workers.dev +show_preview_links: false + +media_folder: public/images/uploads +public_folder: /images/uploads + +slug: + encoding: ascii + clean_accents: true + sanitize_replacement: "-" + +collections: + - name: primary_faq + label: Primary site — FAQs + label_singular: FAQ + description: Answers shown on the primary Happy Tails site. Draft entries stay out of the generated site. + folder: sites/www/content/faq + create: true + delete: true + extension: md + format: frontmatter + identifier_field: question + slug: "{{slug}}" + summary: "{{question}} ({{category}})" + sortable_fields: [order, question, category, draft] + fields: + - label: Question + name: question + widget: string + pattern: ['^.{12,180}$', 'Use 12–180 characters.'] + - label: Category + name: category + widget: string + default: General + pattern: ['^.{2,48}$', 'Use 2–48 characters.'] + - label: Display order + name: order + widget: number + value_type: int + min: 0 + max: 99 + step: 1 + default: 0 + - label: Draft (excluded from generated site) + name: draft + widget: boolean + default: false + - label: Answer + name: body + widget: markdown + buttons: [bold, italic, link, heading-two, heading-three, quote, bulleted-list, numbered-list] + editor_components: [] + + - name: primary_services + label: Primary site — Services + label_singular: Service + description: Service cards shown on the primary Happy Tails site. + folder: sites/www/content/services + create: true + delete: true + extension: md + format: frontmatter + identifier_field: title + slug: "{{slug}}" + summary: "{{title}} — {{price}}" + sortable_fields: [order, title, featured, draft] + fields: + - label: Title + name: title + widget: string + pattern: ['^.{3,80}$', 'Use 3–80 characters.'] + - label: Price label + name: price + widget: string + pattern: ['^.{2,48}$', 'Use 2–48 characters.'] + - label: Included items + name: items + widget: list + min: 1 + max: 8 + field: + label: Item + name: item + widget: string + pattern: ['^.{3,80}$', 'Use 3–80 characters.'] + - label: Display order + name: order + widget: number + value_type: int + min: 0 + max: 99 + step: 1 + default: 0 + - label: Featured + name: featured + widget: boolean + default: false + - label: Draft (excluded from generated site) + name: draft + widget: boolean + default: false + - label: Description + name: body + widget: markdown + buttons: [bold, italic, link, heading-two, heading-three, quote, bulleted-list, numbered-list] + editor_components: [] + + - name: primary_team + label: Primary site — Team + label_singular: Team member + description: Team profiles shown on the primary Happy Tails site. + folder: sites/www/content/team + create: true + delete: true + extension: md + format: frontmatter + identifier_field: name + slug: "{{slug}}" + summary: "{{name}} — {{role}}" + sortable_fields: [order, name, role, draft] + fields: + - label: Name + name: name + widget: string + pattern: ['^.{3,80}$', 'Use 3–80 characters.'] + - label: Role + name: role + widget: string + pattern: ['^.{3,80}$', 'Use 3–80 characters.'] + - label: Approved raster image + name: image + widget: image + choose_url: false + allow_multiple: false + pattern: ['^/images/(?:uploads/)?[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$', 'Choose an existing approved image or a JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.'] + media_library: + config: + max_file_size: 512000 + multiple: false + - label: Display order + name: order + widget: number + value_type: int + min: 0 + max: 99 + step: 1 + default: 0 + - label: Draft (excluded from generated site) + name: draft + widget: boolean + default: false + - label: Biography + name: body + widget: markdown + buttons: [bold, italic, link, heading-two, heading-three, quote, bulleted-list, numbered-list] + editor_components: [] + + - name: journal_posts + label: Journal — Posts + label_singular: Journal post + description: Markdown posts published on the Happy Tails Journal preview. Draft entries stay out of the generated site. + folder: sites/blog/content/posts + create: true + delete: true + extension: md + format: frontmatter + identifier_field: title + slug: "{{slug}}" + summary: "{{title}} — {{pubDate}}" + sortable_fields: [pubDate, updatedDate, title, draft] + fields: + - label: Title + name: title + widget: string + pattern: ['^.{12,100}$', 'Use 12–100 characters.'] + - label: Description + name: description + widget: text + pattern: ['^[\s\S]{30,180}$', 'Use 30–180 characters.'] + - label: Publish date + name: pubDate + widget: date + format: YYYY-MM-DD + - label: Updated date + name: updatedDate + widget: date + format: YYYY-MM-DD + required: false + - label: Tags + name: tags + widget: list + min: 1 + max: 4 + field: + label: Tag + name: tag + widget: string + pattern: ['^.{2,32}$', 'Use 2–32 characters.'] + - label: Draft (excluded from generated site) + name: draft + widget: boolean + default: false + - label: Body + name: body + widget: markdown + buttons: [bold, italic, link, heading-two, heading-three, quote, bulleted-list, numbered-list] + editor_components: [] diff --git a/public/admin/index.html b/public/admin/index.html new file mode 100644 index 0000000..d6d48fd --- /dev/null +++ b/public/admin/index.html @@ -0,0 +1,20 @@ + + + + + + + + + Happy Tails preview content manager + + + + + + + diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 038aad6..8bcfe53 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -1,7 +1,7 @@ import Ajv2020 from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; import { readdir, readFile, stat } from 'node:fs/promises'; -import { join } from 'node:path'; +import { extname, join, relative, sep } from 'node:path'; const readJson = async (path) => JSON.parse(await readFile(path, 'utf8')); const ajv = new Ajv2020({ allErrors: true, strict: true }); @@ -23,8 +23,47 @@ for (const key of Object.keys(sites.sites)) { if (!siteCheck(data)) throw new Error(`${key} site data rejected: ${ajv.errorsText(siteCheck.errors)}`); } -for (const file of await readdir('public/images')) { - const info = await stat(join('public/images', file)); - if (info.size > 500 * 1024) throw new Error(`Image exceeds the 500 KiB pilot limit: ${file}`); +async function filesUnder(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...await filesUnder(path)); + else if (entry.isFile()) files.push(path); + else throw new Error(`Unsupported filesystem entry in public images: ${path}`); + } + return files; } -console.log('Intake fixtures, deployment manifest, site data, and image limits are valid.'); + +const uploadRoot = join('public', 'images', 'uploads'); +const allowedUploadExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']); +const ascii = (value) => Buffer.from(value, 'ascii'); +const startsWith = (buffer, signature) => buffer.subarray(0, signature.length).equals(signature); +const isRasterForExtension = (buffer, extension) => { + if (extension === '.jpg' || extension === '.jpeg') return startsWith(buffer, Buffer.from([0xff, 0xd8, 0xff])); + if (extension === '.png') return startsWith(buffer, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + if (extension === '.webp') return buffer.length >= 12 && buffer.subarray(0, 4).equals(ascii('RIFF')) && buffer.subarray(8, 12).equals(ascii('WEBP')); + if (extension === '.avif' && buffer.length >= 16 && buffer.subarray(4, 8).equals(ascii('ftyp'))) { + for (let offset = 8; offset + 4 <= Math.min(buffer.length, 64); offset += 4) { + const brand = buffer.subarray(offset, offset + 4).toString('ascii'); + if (brand === 'avif' || brand === 'avis') return true; + } + } + return false; +}; + +for (const path of await filesUnder('public/images')) { + const imagePath = relative('public/images', path).split(sep).join('/'); + const info = await stat(path); + if (info.size > 500 * 1024) throw new Error(`Image exceeds the 500 KiB pilot limit: ${imagePath}`); + + const uploadPath = relative(uploadRoot, path); + const isUpload = uploadPath !== '..' && !uploadPath.startsWith(`..${sep}`); + if (!isUpload) continue; + if (uploadPath.includes(sep)) throw new Error(`Nested CMS media paths are not allowed: ${imagePath}`); + const extension = extname(uploadPath).toLowerCase(); + if (!allowedUploadExtensions.has(extension)) throw new Error(`CMS media is not an allowed raster extension: ${imagePath}`); + const buffer = await readFile(path); + if (!isRasterForExtension(buffer, extension)) throw new Error(`CMS media does not match its raster extension: ${imagePath}`); +} +console.log('Intake fixtures, deployment manifest, site data, and bounded raster images are valid.'); diff --git a/scripts/verify-build.mjs b/scripts/verify-build.mjs index ce881b9..aa03904 100644 --- a/scripts/verify-build.mjs +++ b/scripts/verify-build.mjs @@ -16,6 +16,21 @@ for (const { file, html } of pages) { for (const match of html.matchAll(/(?:src|href)="(\/images\/[^"?#]+)"/g)) await access(resolve(root, `.${match[1]}`)); } +const robots = await readFile(resolve(root, 'robots.txt'), 'utf8'); +if (process.env.DEPLOY_ENV === 'production') { + if (!robots.includes('Allow: /') || !robots.includes('Sitemap: https://')) throw new Error(`${key} production robots rules are not indexable.`); +} else { + if (robots !== 'User-agent: *\nDisallow: /\n') throw new Error(`${key} preview robots rules must disallow all crawling.`); + for (const { file, html } of pages) if (!html.includes('')) throw new Error(`${file} is missing preview no-index metadata.`); +} + +await Promise.all(['admin/index.html', 'admin/config.yml', '_headers'].map((file) => access(resolve(root, file)))); +const admin = await readFile(resolve(root, 'admin/index.html'), 'utf8'); +if (!admin.includes('content="noindex,nofollow,noarchive"')) throw new Error('Admin shell is missing no-index metadata.'); +if (!admin.includes('decap-cms@3.15.1/dist/decap-cms.js')) throw new Error('Admin shell is missing the pinned Decap CMS application.'); +const headers = await readFile(resolve(root, '_headers'), 'utf8'); +if (!/\/admin\/\*[\s\S]*X-Robots-Tag: noindex, nofollow, noarchive/.test(headers)) throw new Error('Admin response headers are missing the no-index rule.'); + const home = await readFile(resolve(root, 'index.html'), 'utf8'); if (key === 'www') { for (const { file, html } of pages) if (!html.includes('href="https://blog.3031023.xyz/"')) throw new Error(`${file} is missing the Happy Tails Journal link.`); diff --git a/scripts/verify-decap.mjs b/scripts/verify-decap.mjs new file mode 100644 index 0000000..8672cc5 --- /dev/null +++ b/scripts/verify-decap.mjs @@ -0,0 +1,190 @@ +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import yaml from 'js-yaml'; + +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-pilot-3031023-xyz-www.ksloan90.workers.dev'; +const expectedBackend = { + name: 'gitea', + repo: 'SloanOps/3031023-xyz', + branch: 'cms/pilot', + 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 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: { + widget: 'image', + choose_url: false, + allow_multiple: false, + pattern: [ + '^/images/(?:uploads/)?[A-Za-z0-9][A-Za-z0-9._-]*\\.(?:jpe?g|png|webp|avif)$', + 'Choose an existing approved image or a JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.', + ], + media_library: { config: { max_file_size: 512000, multiple: false } }, + }, + 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: 'date', format: 'YYYY-MM-DD' }, + updatedDate: { widget: 'date', format: 'YYYY-MM-DD', 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).sort()), 'Decap must expose exactly the four approved collections.'); +for (const collection of config.collections) { + 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}`); +} + +function visitField(field) { + assert(field.widget !== 'file', `General file widget is forbidden: ${field.name}.`); + if (field.fields) field.fields.forEach(visitField); + if (field.field) visitField(field.field); +} +config.collections.flatMap(({ fields }) => fields).forEach(visitField); +const imageFields = config.collections.flatMap(({ fields }) => fields).filter(({ widget }) => widget === 'image'); +assert(imageFields.length === 1 && imageFields[0].name === 'image', 'Only the bounded team raster-image field may expose media selection.'); + +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)', + "image: z.string().startsWith('/images/')", + '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}`); + +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); + +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}`); +} + +console.log('Verified Decap backend, four content contracts, text-first media boundary, admin pin, and stable PR status context.');