Add preview-only Decap CMS pilot

This commit is contained in:
Kevin
2026-09-04 00:33:03 -04:00
parent ee1010d751
commit 7eeb9e1e01
12 changed files with 759 additions and 10 deletions
@@ -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
+7
View File
@@ -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/<site-key>/`. 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
+185
View File
@@ -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/*.<raster>` 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.
+39 -5
View File
@@ -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:
- <https://docs.gitea.com/usage/access-control/protected-branches/>
- <https://github.com/decaporg/decap-cms/issues/7115>
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
+1
View File
@@ -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": {
+2
View File
@@ -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"
}
}
+4
View File
@@ -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
+218
View File
@@ -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: []
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex,nofollow,noarchive" />
<meta name="referrer" content="no-referrer" />
<link href="/admin/config.yml" type="text/yaml" rel="cms-config-url" />
<title>Happy Tails preview content manager</title>
</head>
<body>
<noscript>The preview content manager requires JavaScript.</noscript>
<!-- Keep the exact version and integrity hash in sync with scripts/verify-decap.mjs. -->
<script
src="https://unpkg.com/decap-cms@3.15.1/dist/decap-cms.js"
integrity="sha384-in6eHztHveqQ7uMZ1fDaKlDmacQLFuLH2wWrFTiymyuS8zQ5bixwL8U3AeRi8h/L"
crossorigin="anonymous"
></script>
</body>
</html>
+44 -5
View File
@@ -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}`);
}
console.log('Intake fixtures, deployment manifest, site data, and image limits are valid.');
return files;
}
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.');
+15
View File
@@ -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('<meta name="robots" content="noindex,nofollow">')) 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.`);
+190
View File
@@ -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.');