Add preview-only Decap CMS pilot
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+1
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: []
|
||||
@@ -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
@@ -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.');
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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.');
|
||||
Reference in New Issue
Block a user