diff --git a/.env.example b/.env.example index 1742768..2512f80 100644 --- a/.env.example +++ b/.env.example @@ -17,8 +17,23 @@ CMS_S3_ACCESS_KEY_ID= CMS_S3_SECRET_ACCESS_KEY= CMS_ASSETS_BUCKET=git-native-cms-sandbox-assets CMS_RELEASES_BUCKET=git-native-cms-sandbox-releases +CMS_STATE_BUCKET=git-native-cms-sandbox-state CMS_PUBLIC_ASSETS_URL= CMS_PUBLIC_RELEASES_URL= +CMS_REGISTRY_DIGEST= + +# Machine actors (use independent random values, 32+ characters) +CMS_SCHEDULE_TOKEN= +CMS_MCP_TOKEN= + +# Optional deployment and revalidation hooks (configure both) +CMS_DEPLOYMENT_HOOK_URL= +CMS_REVALIDATION_URL= +CMS_INTEGRATION_TOKEN= + +# Optional translation provider API +CMS_TRANSLATION_PROVIDER_URL= +CMS_TRANSLATION_PROVIDER_TOKEN= # Opt-in, read-only R2 contract smoke test (`pnpm test:integration`) CMS_R2_SMOKE=false diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..1358731 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,9 @@ +# Default ownership for product, security and release changes. +* @mateuszgawlik + +# Security-sensitive boundaries require an explicit owner review. +/packages/auth/ @mateuszgawlik +/packages/sessions/ @mateuszgawlik +/packages/permissions/ @mateuszgawlik +/packages/github/ @mateuszgawlik +/.github/workflows/ @mateuszgawlik diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d708405 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security report + url: https://github.com/DMTcorp/git-native-cms/security/advisories/new + about: Report vulnerabilities privately. Do not open a public issue. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..64d016e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +## Change + +Describe the user-facing outcome and the application command or query that owns it. + +## Verification + +- [ ] `pnpm check` +- [ ] Contract tests added or updated for every changed port +- [ ] Next.js and Astro behavior verified when framework integration changes +- [ ] Security, accessibility and bundle impact considered +- [ ] Documentation and Changeset updated when public behavior changes + +## Architecture + +- [ ] Route handlers, UI, CLI and MCP call the application layer +- [ ] No secret or installation credential is exposed to browser code or Git +- [ ] Mutations use `expectedRevision` and an idempotency key +- [ ] Deterministic outputs remain reproducible diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a2dda34 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Europe/Warsaw + versioning-strategy: increase + open-pull-requests-limit: 10 + groups: + production-dependencies: + dependency-type: production + development-dependencies: + dependency-type: development + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "06:30" + timezone: Europe/Warsaw + groups: + actions: + patterns: + - "*" diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..1bddab1 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,25 @@ +changelog: + exclude: + labels: + - skip-changelog + authors: + - dependabot + categories: + - title: Breaking changes + labels: + - breaking + - title: Features + labels: + - feature + - enhancement + - title: Fixes + labels: + - bug + - fix + - title: Documentation and maintenance + labels: + - documentation + - maintenance + - title: Other changes + labels: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79c1c08..f953b83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,9 +17,9 @@ jobs: matrix: node: [22, 24] steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} cache: pnpm @@ -34,9 +34,9 @@ jobs: e2e: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm @@ -55,9 +55,9 @@ jobs: env: CMS_CONTAINER_TESTS: "true" steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/cms-schedules.yml b/.github/workflows/cms-schedules.yml new file mode 100644 index 0000000..79cebd8 --- /dev/null +++ b/.github/workflows/cms-schedules.yml @@ -0,0 +1,30 @@ +name: CMS schedule executor + +on: + schedule: + - cron: "*/5 * * * *" + workflow_dispatch: + +concurrency: + group: cms-schedule-executor + cancel-in-progress: false + +permissions: + contents: read + +jobs: + execute: + runs-on: ubuntu-latest + steps: + - name: Execute due schedules + run: | + curl --fail-with-body --silent --show-error \ + --request POST \ + --header "authorization: Bearer $CMS_SCHEDULE_TOKEN" \ + --header "content-type: application/json" \ + --header "idempotency-key: schedule-${{ github.run_id }}-${{ github.run_attempt }}" \ + --data '{"configVersion":1,"schemaVersion":1}' \ + "$CMS_SCHEDULE_ENDPOINT" + env: + CMS_SCHEDULE_ENDPOINT: ${{ secrets.CMS_SCHEDULE_ENDPOINT }} + CMS_SCHEDULE_TOKEN: ${{ secrets.CMS_SCHEDULE_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d8ca1f..6955ca5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,11 +12,11 @@ jobs: changesets: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 with: version: 11 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index f2c4a84..7e98fae 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -12,10 +12,22 @@ permissions: security-events: write jobs: + dependency-audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm audit --prod --audit-level high + codeql: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: github/codeql-action/init@v3 with: languages: javascript-typescript @@ -26,7 +38,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: anchore/sbom-action@v0 with: path: . diff --git a/.gitignore b/.gitignore index 05be11e..4452c6f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ test-results/ !.env.example *.tsbuildinfo .DS_Store +.idea/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fc7b036..0025f3b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,10 +13,69 @@ the result. - Object storage stores content-addressed assets and immutable releases. - IndexedDB stores unsynchronized patches and editor recovery state only. +## Request flow + +Every entry point follows one direction: + +```text +React editor / Next route / Astro route / CLI / Actions / MCP + ↓ + Web API or command input + ↓ + @git-native-cms/application + ↓ + GitHub · content · session · storage capability ports + ↓ + GitHub App · S3/R2 · filesystem adapters +``` + +Framework routes only mount `handle(Request, ServerContext)` or obtain a server-computed editor +view. They do not choose workflow transitions, call GitHub directly or read storage credentials. +The editor sends typed commands and renders results; permission, revision, idempotency and audit +rules live in application handlers. + +## Public surfaces + +- `@git-native-cms/schema` compiles deterministic component/content contracts. +- `@git-native-cms/protocol` owns versioned HTTP, preview and MCP envelopes. +- `@git-native-cms/document-model` owns RFC 6901 patches, history, merge and conflicts. +- `@git-native-cms/application/ports` is the only capability-port definition site. +- `@git-native-cms/server` exposes one Fetch API handler. +- `@git-native-cms/react`, `/next` and `/astro` render content and mount thin integrations. +- `@git-native-cms/delivery` reads filesystem, Git, preview or immutable CDN releases. +- `@git-native-cms/mcp` and `cms` reuse application permissions and confirmation contracts. + +## Mutation invariants + +Mutations carry an idempotency key and the exact expected Git/pointer revision. Domain IDs use a +prefixed ULID shape; release IDs derive from SHA-256 of the canonical manifest. Timestamps are UTC +ISO-8601. Git writes and environment pointers use compare-and-swap. Retryable external hooks +receive deterministic keys. + +Preview sessions, OAuth tokens, installation credentials, S3 credentials and confirmation tokens +are server-only. Cookies are encrypted, rotated, `Secure`, `HttpOnly` and CSRF-bound. + +## Git and release workflow + +A Change starts from Production `main`. Review opens a pull request, approval requires an +independent reviewer, and adding to Staging squash-merges with `Change-ID` before deleting the +branch. Staging promotion uses a merge commit into `main`, forward-syncs Staging, builds a +reproducible immutable release and atomically moves the pointer. Rollback moves the pointer before +opening its audit revert pull request. + +Before the squash merge, the application compares the original base, the Change and current +Staging for every document, including document creation/deletion. Concurrent edits become +RFC 6901 field conflicts. A resolution command requires an explicit `change` or `staging` choice +for every conflict, carries the Change revision, preserves non-conflicting Staging updates, records +the new semantic base and resets an existing approval. The resolved result must therefore be +reviewed again before Staging can accept it. + ## Compatibility The supported server runtimes are Node.js 22.12+ and Node.js 24. Pure packages use Web APIs and are tested in browser and worker-like environments. Full Astro CMS support requires SSR. -See [ADR-0001](./docs/adr/0001-ports-and-adapters.md) and -[ADR-0002](./docs/adr/0002-deterministic-releases.md). +See [ADR-0001](./docs/adr/0001-ports-and-adapters.md), +[ADR-0002](./docs/adr/0002-deterministic-releases.md) and +[ADR-0003](./docs/adr/0003-preview-assets-and-team-capabilities.md), and +[ADR-0004](./docs/adr/0004-semantic-conflict-resolution.md). diff --git a/apps/docs/astro.config.ts b/apps/docs/astro.config.ts index a200c3b..a3accd0 100644 --- a/apps/docs/astro.config.ts +++ b/apps/docs/astro.config.ts @@ -11,16 +11,34 @@ export default defineConfig({ { icon: "github", label: "GitHub", href: "https://github.com/DMTcorp/git-native-cms" }, ], sidebar: [ - { label: "Start", items: ["index", "getting-started"] }, + { label: "Start", items: ["index", "getting-started", "troubleshooting"] }, { label: "Core concepts", - items: ["architecture", "changes-and-publishing", "content-modeling"], + items: [ + "architecture", + "changes-and-publishing", + "content-modeling", + "fields-and-sections", + "permissions", + ], }, { label: "Integrations", items: ["nextjs", "astro", "astro-static", "mcp", "adapters"], }, - { label: "Operations", items: ["security", "doctor-and-upgrades", "sandbox"] }, + { + label: "Operations", + items: [ + "assets", + "delivery", + "seo-localization-search", + "scheduling-integrations", + "security", + "doctor-and-upgrades", + "sandbox", + "acceptance", + ], + }, ], }), ], diff --git a/apps/docs/src/content/docs/acceptance.md b/apps/docs/src/content/docs/acceptance.md new file mode 100644 index 0000000..b67ac26 --- /dev/null +++ b/apps/docs/src/content/docs/acceptance.md @@ -0,0 +1,69 @@ +--- +title: Product acceptance +description: Executable evidence for the 25 product Definition of Done requirements. +--- + +The product Definition of Done is enforced by code, shared contracts and the sandbox acceptance +run. A requirement is not considered complete when only a mock or a document exists. + +| # | Requirement | Executable evidence | +| --: | ------------------------------------------ | ---------------------------------------------------------------------------------------- | +| 1 | Next.js and Astro installation | framework package builds, playground builds and `next-*` / `astro-*` Playwright projects | +| 2 | GitHub login without personal tokens | GitHub App OAuth with PKCE, rotating JWE session and live OAuth smoke test | +| 3 | editor creates a Change, page and sections | application command suite and editor Playwright flow | +| 4 | globals and pages share one Change | logical repository/application suite and sandbox acceptance flow | +| 5 | coherent full preview | MessageChannel handshake, registry renderer and no-reload Playwright assertion | +| 6 | semantic and visual review | diff unit suite, baseline/current preview panes and review UI flow | +| 7 | approval enters staging | independent-reviewer rule, required checks and application transition suite | +| 8 | staging immutable release | deterministic builder, immutable store and staging publish command | +| 9 | staging to main production release | merge-commit promotion, back-merge and production publish command | +| 10 | production CDN JSON | delivery client, fallback tests and live R2 manifest smoke test | +| 11 | atomic rollback | release-store CAS contract and pointer-first rollback command | +| 12 | all content models | schema compiler and fixtures for pages, posts, collections, globals, settings and blocks | +| 13 | SEO, locales, redirects, assets, schedules | package suites plus localized Playwright and integration tests | +| 14 | MCP parity | MCP delegates to application handlers and has permission/confirmation tests | +| 15 | zero editor runtime in public bundle | bundle budget and public Playwright assertion | +| 16 | every capability port has a contract | `@git-native-cms/adapter-kit` all-port contract suites | +| 17 | Next.js and Astro E2E | Chromium, Firefox and WebKit matrix for both frameworks | +| 18 | WCAG 2.2 AA | axe light/dark checks, keyboard focus and reduced-motion configuration | +| 19 | `cms doctor` | CLI environment, security, registry, storage and installation checks | +| 20 | `cms upgrade` | migration/codemod package and CLI test suite | +| 21 | install-from-zero docs | Getting started, Next.js, Astro, adapters, security and sandbox guides | +| 22 | application layer cannot be bypassed | dependency-cruiser architecture gate | +| 23 | thin routes and presentation components | framework route adapters delegate to Web API/application contracts | +| 24 | reproducible release | deterministic identity/checksum tests for equal SHA and config | +| 25 | tested security | CSRF, fixation, XSS, YAML, SVG, traversal, webhook replay, origin and permission suites | + +Run the local evidence: + +```bash +pnpm check +pnpm test:e2e +``` + +With Docker available, run adapter integrations: + +```bash +CMS_CONTAINER_TESTS=true pnpm test:integration +``` + +After deploying the stable sandbox origins, run: + +```bash +pnpm test:live +pnpm test:live:flow -- --session-secret-file /secure/path/to/session-secret +``` + +The live smoke test initializes both hosted runtimes and verifies their public/editor split, +OAuth+PKCE redirect, scheduler and MCP authentication, the production pointer and its immutable +manifest with the exact deployed registry digest. + +The opt-in live flow creates a real Change with a page plus pricing/navigation globals, directly +uploads an image to the separate R2 asset bucket, stores its metadata on the Change and selects it +through the same media reference consumed by the editor. It then updates the server-rendered +preview, opens and independently approves a GitHub review, squash-merges to Staging, promotes with +an independently verified immutable Staging release and atomic pointer, promotes with a release PR, +verifies the asset reference in immutable production CDN JSON, proves that the released asset +cannot be deleted, checks the localized content, hreflang and slug redirect artifacts, atomically +rolls back, and restores the verified release. It requires a production session secret supplied +through a local permission-restricted file; the value is never printed. diff --git a/apps/docs/src/content/docs/adapters.md b/apps/docs/src/content/docs/adapters.md index b810039..7bb3528 100644 --- a/apps/docs/src/content/docs/adapters.md +++ b/apps/docs/src/content/docs/adapters.md @@ -1,7 +1,46 @@ --- title: Adapter authoring +description: Implement stable ports and run the shared contracts. --- -Implement the stable ports exported by `@git-native-cms/application/ports`, then run the shared -contract harness from `@git-native-cms/testing`. Adapters must support abort signals, typed errors, -optimistic concurrency and idempotent retries. +Implement ports from `@git-native-cms/application`; application code never imports an adapter. +Adapters must preserve abort signals, typed errors, optimistic concurrency and idempotent retry +semantics. + +`@git-native-cms/adapter-kit` exports executable common suites: + +```ts +import { + GitProviderContract, + ContentRepositoryContract, + ReviewPortContract, + AssetStoreContract, + AssetProcessorPortContract, + AssetUsagePortContract, + ReleaseBuilderPortContract, + ReleaseStoreContract, + SessionStoreContract, + PreviewSessionPortContract, + TeamProvisioningPortContract, + DeploymentPortContract, + RevalidationPortContract, + PublicationNotifierPortContract, + TranslationProviderContract, + WebhookReplayStoreContract, + RateLimitPortContract, + SchedulerPortContract, + IdempotencyStoreContract, + AuditSinkContract, + FrameworkAdapterContract, + RendererContract, + contractPassed, +} from "@git-native-cms/adapter-kit"; +``` + +The repository runs all capability suites against memory fixtures, the sanitized GitHub Git Data +fixture and S3-compatible MinIO. A new adapter is not complete until every applicable shared suite +passes. + +External deployment and revalidation adapters receive deterministic idempotency keys. +Translation providers implement `createJob` and `readJob`; the returned XLIFF still passes through +the application import command, permissions, revision check and audit trail. diff --git a/apps/docs/src/content/docs/assets.md b/apps/docs/src/content/docs/assets.md new file mode 100644 index 0000000..b97bf19 --- /dev/null +++ b/apps/docs/src/content/docs/assets.md @@ -0,0 +1,29 @@ +--- +title: Asset storage and image processing +description: Direct uploads, content addressing, metadata, variants, usages and safe cleanup. +--- + +Asset bytes live in the asset bucket, independently from Git documents and release objects. The +editor uploads directly to a short-lived signed URL, then finalizes the upload through the Web API. +Finalization verifies declared size, MIME signature and SHA-256 before moving the object to: + +```text +assets// +``` + +The asset ID derives from the checksum. Repeating a finalize request is safe. SVG is rejected by +default; raster images pass through Sharp, have EXIF removed, enforce decode limits and may produce +AVIF/WebP/responsive variants. + +The **Assets** page browses and filters the storage-backed gallery. Inside a Change, asset fields +can select an existing item, upload a new item, edit alt text and focal point, and update preview +without a reload. Metadata changes are recorded on the Change branch and copied to S3/R2 object +metadata. + +Before deletion, the application checks active document references and immutable releases. +Released or referenced assets cannot be removed. Orphaned unfinished uploads are cleaned only +after the configured grace period. + +Use an object read/write token scoped to the asset, release and private state buckets. Do not use +account administrator permissions. Only asset/release delivery buckets may be public; keep the +state bucket private. diff --git a/apps/docs/src/content/docs/astro.md b/apps/docs/src/content/docs/astro.md index 37c5da0..5a925b1 100644 --- a/apps/docs/src/content/docs/astro.md +++ b/apps/docs/src/content/docs/astro.md @@ -1,7 +1,25 @@ --- -title: Astro +title: Astro 7 SSR +description: Server output, React editor island and Astro renderer. --- -Full editing requires Astro server output. Static projects can consume build-time or CDN content -and use the editor locally, but authentication, GitHub callbacks and mutations require a server -adapter. +Full editing requires Astro server output and the React integration: + +```bash +pnpm astro add react +pnpm add @git-native-cms/astro +pnpm add -D @git-native-cms/cli +pnpm cms init +``` + +The generator creates an `ALL` API route, an SSR `/cms/[...path]` page, the preview route and a +registered component library. `CmsHostedApp` is the only hydrated editor island; published Astro +pages use the lightweight renderer and CDN delivery data. + +Configure a server adapter such as `@astrojs/vercel` and keep `output: "server"`. The API, +GitHub OAuth callback, signed webhook, MCP endpoint and schedule executor all share the same Web +API handler. + +Astro static output can read production content at build time, but it cannot host authentication +or mutations. Keep the editor in a separate SSR deployment or use local editor mode as described +in [Astro static](/astro-static/). diff --git a/apps/docs/src/content/docs/changes-and-publishing.md b/apps/docs/src/content/docs/changes-and-publishing.md index fd017bf..0ae8e22 100644 --- a/apps/docs/src/content/docs/changes-and-publishing.md +++ b/apps/docs/src/content/docs/changes-and-publishing.md @@ -1,10 +1,29 @@ --- title: Changes and publishing +description: Branches, review, staging batches, releases and rollback. --- -A Change is an isolated editorial workspace backed by a branch. Sending it for review opens a -pull request to staging. Approved Changes are squash-merged into staging; a release pull request -moves the complete staging batch to Production. +A Change starts from the exact `main` SHA and gets an isolated `cms//` branch. +Every save uses Git Data compare-and-swap and includes `Change-ID` metadata. -Publication builds a deterministic immutable JSON release, verifies checksums, then changes one -environment pointer with optimistic concurrency. +Sending for review opens a pull request. Semantic field changes, live visual baseline, comments, +required checks and merge conflicts are shown in the workspace. When Staging and the Change edit +the same field, the review panel shows both values. Resolve every field by choosing **Keep this +Change** or **Use Staging**. The CMS carries over non-conflicting Staging edits, writes the merged +documents with compare-and-swap, records an audit event and resets the previous approval so the +actual merged result is reviewed. Approval records the reviewer; adding to staging squash-merges +the Change, deletes its branch and keeps the audit record. + +Staging is a batch. Publication: + +1. opens and merge-commits a staging → main release PR; +2. merges main back into staging; +3. builds a deterministic release from the exact Git SHA and registry digest; +4. writes immutable files and verifies checksums; +5. atomically switches the environment pointer; +6. calls deployment and revalidation hooks with retry-safe keys. + +Production reads `environments/production/current.json`, then immutable JSON/XML/TXT files from +that release. Rollback switches the pointer first with compare-and-swap, notifies integrations, +and then opens an auditable revert PR. A retry after any intermediate network failure resumes +without duplicating publication. diff --git a/apps/docs/src/content/docs/content-modeling.md b/apps/docs/src/content/docs/content-modeling.md index 5456e8c..6bbf95b 100644 --- a/apps/docs/src/content/docs/content-modeling.md +++ b/apps/docs/src/content/docs/content-modeling.md @@ -1,7 +1,22 @@ --- title: Content modeling +description: Schemas, references, reusable blocks, localization and search. --- -Fields and content types are explicitly registered with `@git-native-cms/schema`. The schema -compiler produces JSON Schema, editor manifests, TypeScript declarations, validation rules and -MCP descriptions without inspecting arbitrary components. +Use `fields.*`, `defineSection`, `defineCollection`, `definePageType`, `defineGlobal` and +`defineSettings` from the framework registry export. Compilation produces a stable AST, JSON +Schema, editor/MCP manifest, TypeScript declarations and validators. + +The content tree supports Pages, Posts, Collections, Globals, Settings and Reusable Blocks. +Sections can bind to a filtered collection query or reference a reusable block with instance +overrides; detached sections stay local to the page. + +Portable rich text is a validated JSON AST, never stored HTML. References and asset IDs build a +release graph used by “find usages”, broken-reference checks and deletion safety. + +Locale documents use `en-US` as the source and `pl-PL` as the first fallback demonstration. +Editors can export/import XLIFF or create an external translation job. Translation import records +the source revision and status inside the same Change. + +Release artifacts include `content-index.json`, `content-graph.json`, search index, redirects, +sitemap, canonical metadata and hreflang. Slug changes preserve redirect history. diff --git a/apps/docs/src/content/docs/delivery.md b/apps/docs/src/content/docs/delivery.md new file mode 100644 index 0000000..5ac6521 --- /dev/null +++ b/apps/docs/src/content/docs/delivery.md @@ -0,0 +1,30 @@ +--- +title: Releases and delivery +description: Reproducible artifacts, S3/R2 pointers, caching, fallback and atomic rollback. +--- + +A release is built from an exact Git SHA, config version, registry digest and schema version. +Documents, redirects, locale routes, sitemap, content graph, search index and checksums are sorted +and serialized deterministically. Equal input produces the same `rel_…` ID and bytes. + +Storage layout: + +```text +releases//manifest.json +releases//checksums.json +releases//content/... +environments/staging/current.json +environments/production/current.json +``` + +Release objects are immutable and may use a one-year cache. Environment pointers must revalidate. +The S3-compatible adapter rejects a different object body at an existing release key and switches +pointers with compare-and-swap. + +The typed delivery client supports Git/filesystem during development, immutable CDN JSON in +production and Change preview. It verifies the release manifest and can fall back to the last +known valid release if the pointer or network is temporarily unavailable. + +Rollback switches the Production pointer first, with an expected pointer revision, then opens an +auditable revert pull request and revalidates the frontend. This restores delivery immediately +without rewriting immutable artifacts. diff --git a/apps/docs/src/content/docs/doctor-and-upgrades.md b/apps/docs/src/content/docs/doctor-and-upgrades.md index 61528f5..e1a53e6 100644 --- a/apps/docs/src/content/docs/doctor-and-upgrades.md +++ b/apps/docs/src/content/docs/doctor-and-upgrades.md @@ -1,7 +1,25 @@ --- title: Doctor and upgrades +description: Diagnose installations and perform recoverable migrations. --- -`cms doctor` checks runtime, routes, configuration, GitHub permissions, registry locks, storage, -Actions and security headers. `cms upgrade` creates a backup branch and performs config codemods -and content migrations through a reviewable Change. +`cms doctor` verifies Node, framework package, config, API/editor/preview routes, registry, +server-only GitHub variables, session and machine-token strength, complete S3/R2 configuration, +full registry digest, paired publication hooks, translation configuration and generated Actions. + +```bash +pnpm cms doctor +``` + +Each failed check prints a repair action and exits non-zero, so the same command is safe in CI. +Adapter runtimes may append live checks for GitHub permissions, storage access and deployment +headers. + +`cms upgrade` creates: + +- a filesystem backup under `.cms/backups/`; +- a recoverable Git branch named `cms/backup/upgrade-`; +- updated config through the ordered migration chain; +- generated route/config codemods without overwriting user-owned files. + +Review the resulting diff and keep the backup branch until production verification succeeds. diff --git a/apps/docs/src/content/docs/fields-and-sections.md b/apps/docs/src/content/docs/fields-and-sections.md new file mode 100644 index 0000000..2585415 --- /dev/null +++ b/apps/docs/src/content/docs/fields-and-sections.md @@ -0,0 +1,45 @@ +--- +title: Fields, sections and validators +description: Define visual components once and compile every runtime contract deterministically. +--- + +The registry schema is the contract between stored content, the editor inspector, preview, +delivery renderer and MCP. A section definition contains a stable name, integer version, label, +category, fields and deterministic defaults. + +```tsx +import { defineSection, fields } from "@git-native-cms/schema"; +import { registerReactSection } from "@git-native-cms/react"; + +const hero = defineSection({ + name: "hero", + version: 1, + label: "Hero", + category: "Introduction", + fields: { + heading: fields.text({ required: true, inline: true }), + body: fields.richText({ required: true }), + media: fields.asset({ accept: ["image/*"], aspectRatio: [4, 3] }), + theme: fields.select({ options: ["light", "dark"] }), + }, + defaults: { heading: "A clear statement", body: { root: { children: [] } }, media: null }, +}); + +export const registeredHero = registerReactSection(hero, ({ section }) => ( +
+

{String(section.heading)}

+
+)); +``` + +Available field families cover text, textarea, number, boolean, date/time, select, asset, +reference, list, object, JSON and portable rich text. Asset fields store a stable asset reference, +not bytes or an expiring upload URL. Reference fields participate in the content graph and safe +deletion checks. + +Schema compilation produces a sorted AST, JSON Schema, manifest, TypeScript declaration and Ajv +validator. Reordering source object keys must not change the digest. Change a section version when +stored data needs a migration, then add the ordered migration before deploying the new registry. + +Never render stored rich text as untrusted HTML. Use the portable AST renderer, which rejects +unsafe links, unknown node types and executable payloads. diff --git a/apps/docs/src/content/docs/getting-started.md b/apps/docs/src/content/docs/getting-started.md index 412f56b..f8c0819 100644 --- a/apps/docs/src/content/docs/getting-started.md +++ b/apps/docs/src/content/docs/getting-started.md @@ -1,19 +1,104 @@ --- title: Getting started -description: Install the CMS and open the editor. +description: Install the CMS, connect GitHub and R2, and open the editor. --- ## Requirements -- Node.js 22.12 or newer -- Next.js 16 App Router or Astro 7 with server output -- a GitHub App installation and a separate content repository +- Node.js 22.12 or newer and pnpm 11 +- Next.js 16 App Router or Astro 7 in SSR mode +- a GitHub organization where you can create and install a private GitHub App +- a content repository with protected `main` and `staging` branches +- three S3-compatible buckets: assets, immutable releases and a private runtime-state bucket + +## 1. Install and generate the integration + +Next.js: ```bash pnpm add @git-native-cms/next +pnpm add -D @git-native-cms/cli +pnpm cms init +``` + +Astro: + +```bash +pnpm add @git-native-cms/astro @astrojs/react +pnpm add -D @git-native-cms/cli +pnpm astro add react pnpm cms init +``` + +`cms init` creates the server API, `/cms`, `/__cms/preview`, the component registry, +`.cms/project.yaml`, validation Actions and the schedule executor. Generated files are never +overwritten. + +## 2. Prepare the content repository + +Create `main`, branch `staging` from it, and commit the generated `.cms` directory plus your +`content/` documents. Protect both branches. Editors do not need personal access tokens: the +server uses a GitHub App installation and users sign in through GitHub OAuth with PKCE. + +Create and install the private App without copying a manifest secret by hand: + +```bash +pnpm cms github setup --origin https://YOUR_ORIGIN --owner YOUR_ORGANIZATION +``` + +The command starts a loopback callback, opens GitHub's one-time manifest form, converts the +returned code, opens the installation page and writes server-only credentials to +`.env.cms.local` with mode `0600`. GitHub only shows the generated client secret and private key +once; if the callback expires, rerun the command and create new credentials. + +The GitHub App needs: + +| Repository permission | Access | +| --------------------- | -------------- | +| Contents | Read and write | +| Pull requests | Read and write | +| Issues | Read and write | +| Checks | Read and write | +| Deployments | Read and write | +| Metadata | Read | +| Members | Read and write | + +Subscribe to `check_run`, `check_suite`, `pull_request`, `pull_request_review`, +`pull_request_review_comment`, `push`, `deployment`, `deployment_status`, `installation` and +`installation_repositories`. Set the OAuth callback to +`https://YOUR_ORIGIN/api/cms/auth/github/callback` and the webhook to +`https://YOUR_ORIGIN/api/cms/webhooks/github`. Install the App only on the content repository. + +## 3. Configure server-only environment variables + +Copy `.env.example`. At minimum configure the GitHub App/OAuth values, a 32+ character +`CMS_SESSION_SECRET`, S3 credentials, both bucket names and public bucket URLs. Also set separate +32+ character `CMS_SCHEDULE_TOKEN` and `CMS_MCP_TOKEN`. + +Keep `CMS_STATE_BUCKET` private; unlike immutable delivery and asset buckets it contains +idempotency, audit, replay and rate-limit state and must never have an `r2.dev` or public custom +domain. + +`CMS_REGISTRY_DIGEST` is `sha256:` followed by the full SHA-256 digest of the deployed component +registry. Recompute it whenever registered components or schemas change. + +```bash +pnpm registry:digest src/cms/registry.tsx +``` + +Never prefix these values with `NEXT_PUBLIC_` or expose them through Astro `PUBLIC_` variables. + +## 4. Verify before starting + +```bash pnpm cms doctor +pnpm build pnpm dev ``` -Open `/cms`. The generated framework routes are thin mounts around the shared CMS server. +Open `/cms`, sign in with GitHub, create a Change, edit a page and publish it through Review → +Staging → Live. A failed `doctor` check includes the exact repair action. + +For production, add `CMS_SCHEDULE_ENDPOINT=https://YOUR_ORIGIN/api/cms/schedules/execute` and +`CMS_SCHEDULE_TOKEN` as GitHub Actions secrets. The generated workflow calls the same +permission-aware application commands as the UI. diff --git a/apps/docs/src/content/docs/mcp.md b/apps/docs/src/content/docs/mcp.md index 646b2aa..a7e3a82 100644 --- a/apps/docs/src/content/docs/mcp.md +++ b/apps/docs/src/content/docs/mcp.md @@ -1,7 +1,29 @@ --- title: MCP and AI safety +description: Stdio/HTTP transport, permissions and confirmations. --- -MCP tools use the current actor, application commands and permissions. Content is edited through -typed patches rather than raw YAML. Publication and rollback require a scoped confirmation token -in addition to the actor permission. +The MCP server exposes project, Change, document and release resources plus tools for creating a +Change, applying typed patches, requesting review, previewing, publishing and rollback. Stdio and +Streamable HTTP call the same application handlers as UI, HTTP and CLI. + +For hosted HTTP, send: + +```http +Authorization: Bearer +Content-Type: application/json +``` + +The machine actor is intentionally mapped to the `editor` role. It can create a Change and obtain +a preview, but cannot approve, stage, publish or rollback by token alone. + +`list_conflicts` exposes the same semantic base/Change/Staging comparison as the review panel. +`resolve_conflicts` requires an explicit choice for every path and delegates to the application +handler, so it resets an existing approval and records `source: mcp` in the audit trail exactly +like the UI transport. + +Destructive tools require both the normal actor permission and a short-lived confirmation token +for the exact action. Confirmation tokens are encrypted, actor-bound, expire quickly and are +single-use. Every command audit event records `source: mcp`. + +Never pass GitHub, R2 or session secrets through MCP resources, prompts or tool arguments. diff --git a/apps/docs/src/content/docs/nextjs.md b/apps/docs/src/content/docs/nextjs.md index 8effe30..7b1d03b 100644 --- a/apps/docs/src/content/docs/nextjs.md +++ b/apps/docs/src/content/docs/nextjs.md @@ -1,7 +1,45 @@ --- -title: Next.js +title: Next.js 16 +description: App Router, SSR editor and zero-editor-runtime delivery. --- -Mount `CmsEditorPage`, the preview renderer and `createNextCmsRouteHandlers` in App Router catch-all -routes. Keep the editor stylesheet inside the `/cms` layout so the public application receives no -CMS client runtime. +Run `pnpm cms init` in a Next.js App Router project. It generates: + +- `src/cms/runtime.ts` — the server-only GitHub/R2 runtime; +- `src/cms/registry.tsx` — explicitly registered visual sections; +- `src/cms/preview.tsx` — the MessageChannel preview bridge; +- `app/api/cms/[[...path]]/route.ts` — thin Web API mount; +- `app/cms/[[...path]]` — editor shell and its scoped stylesheet; +- `app/%5F%5Fcms/preview/[[...slug]]` — full-page preview. + +If the project uses `src/app`, every route is generated under it automatically. + +The public page must import only the delivery client and renderer. Keep +`@git-native-cms/next/styles.css` inside the `/cms` layout. The production bundle gate verifies +that the public route loads 0 bytes of editor runtime. + +Use the CDN client in a server component: + +```bash +pnpm add @git-native-cms/delivery @git-native-cms/react +``` + +```tsx +import { cdnSource, createContentClient, loadContentGraph } from "@git-native-cms/delivery"; +import { CmsPageRenderer } from "@git-native-cms/react"; +import { cmsRegistry } from "@/cms/registry"; + +const content = createContentClient({ + environment: "production", + source: cdnSource({ baseUrl: process.env.CMS_PUBLIC_RELEASES_URL! }), +}); + +export default async function Page() { + const graph = await loadContentGraph(content); + const page = graph.find((document) => document.id === "doc_home"); + return ; +} +``` + +Do not cache `environments/production/current.json` permanently. Release files are immutable and +may use a one-year cache; the pointer must revalidate. diff --git a/apps/docs/src/content/docs/permissions.md b/apps/docs/src/content/docs/permissions.md new file mode 100644 index 0000000..5b2e974 --- /dev/null +++ b/apps/docs/src/content/docs/permissions.md @@ -0,0 +1,38 @@ +--- +title: GitHub teams and CMS permissions +description: Provision organization members and review custom role mappings through Git. +--- + +GitHub owns organization membership and teams. The CMS maps those teams to product roles from the +versioned `.cms/permissions.yaml` file. The browser never receives an organization token. + +Open **Team & permissions** to: + +- inspect organization members and teams; +- invite a user through the GitHub App; +- add an existing member to a GitHub team; +- propose team-to-role mappings in an auditable pull request. + +Built-in roles are `viewer`, `author`, `editor`, `translator`, `reviewer`, `publisher`, +`developer` and `administrator`. Custom roles contain an explicit allowlist of CMS actions: + +```yaml +version: 1 +customRoles: + - name: legal-reviewer + actions: + - project.read + - change.review +mappings: + - team: example/legal + roles: + - legal-reviewer +``` + +The runtime reads this file from `main`; environment mappings are only a bootstrap fallback. +Invalid actions fail closed. Resource policies can further deny actions, restrict content types +or require ownership. + +GitHub login proves identity but does not grant publication rights. The application layer checks +every command independently. A Change owner cannot self-approve; publishing and rollback require +publisher permission plus an actor/action-bound confirmation token. diff --git a/apps/docs/src/content/docs/sandbox.md b/apps/docs/src/content/docs/sandbox.md index e5e5e40..d1a8b6f 100644 --- a/apps/docs/src/content/docs/sandbox.md +++ b/apps/docs/src/content/docs/sandbox.md @@ -1,22 +1,65 @@ --- title: Sandbox deployment -description: External services and secrets required by the public demonstration. +description: Deploy the Next.js and Astro playgrounds with GitHub, Vercel and Cloudflare R2. --- -The public sandbox uses two Vercel projects (`git-native-cms-next` and -`git-native-cms-astro`), separate GitHub Apps and two Cloudflare R2 buckets. Keep every secret in -GitHub Environments or Vercel Environment Variables; the browser bundle and content repository -must never receive credentials. +The reference sandbox uses: -## Provisioning checklist +- source: `DMTcorp/git-native-cms`; +- content: `DMTcorp/git-native-cms-sandbox-content`; +- Vercel: `git-native-cms-next` and `git-native-cms-astro`; +- R2: `git-native-cms-sandbox-assets`, `git-native-cms-sandbox-releases` and private + `git-native-cms-sandbox-state`; +- one private GitHub App per playground. -1. Create `DMTcorp/git-native-cms` and `DMTcorp/git-native-cms-sandbox-content`. -2. Create one GitHub App per playground with its own callback and webhook URL. -3. Create the `git-native-cms-sandbox-assets` and `git-native-cms-sandbox-releases` buckets. -4. Configure the variables documented in `.env.example` for each deployment environment. -5. Run `pnpm cms doctor`, `pnpm check`, `pnpm test:integration` and `pnpm test:e2e`. -6. Deploy both SSR playgrounds, then exercise publish, delivery and rollback against the sandbox - content repository. +## R2 -Set `CMS_R2_SMOKE=true` only in a protected integration environment. The smoke suite is read-only -and validates R2 through the same S3 adapter used by production delivery. +Create separate buckets and an API token limited to those buckets. Keep the state bucket private; +only assets and releases receive public development URLs. Use the S3 endpoint +`https://ACCOUNT_ID.r2.cloudflarestorage.com`, region `auto`, and public development URLs for +`CMS_PUBLIC_ASSETS_URL` and `CMS_PUBLIC_RELEASES_URL`. + +The assets bucket CORS policy allows `PUT` and `HEAD` only from the two stable Vercel origins, +including `content-type` and signed `x-amz-*` headers. Configure this policy in the Cloudflare +dashboard. The least-privilege Object Read & Write token used by +`tooling/scripts/configure-r2.mjs` verifies object access and updates Vercel, but intentionally +cannot edit bucket settings. The live acceptance flow verifies the real browser preflight before +every direct upload. + +Set immutable release and asset objects to long-lived cache. Configure +`environments/*/current.json` for revalidation/no-cache. The adapter uses conditional writes for +immutable files and compare-and-swap for pointers. + +## Vercel + +Set every variable from `.env.example` in Production and Preview. `CMS_ORIGIN` must match the +stable deployment origin used by the GitHub App. Set `CMS_HOSTED_RUNTIME=true`. + +The two playgrounds must use different App IDs, private keys, OAuth client secrets and webhook +secrets. They may share the content repository and R2 buckets. + +## GitHub Actions + +Add repository secrets: + +```text +CMS_SCHEDULE_ENDPOINT=https://YOUR_STABLE_ORIGIN/api/cms/schedules/execute +CMS_SCHEDULE_TOKEN= +``` + +The five-minute executor is concurrency-locked and safe to retry. Publication hooks and +translation providers are optional; configure both deployment/revalidation URLs together. + +## Acceptance run + +```bash +pnpm cms doctor +pnpm check +CMS_CONTAINER_TESTS=true pnpm test:integration +pnpm test:e2e +pnpm test:live +``` + +Then verify GitHub login, a page plus global navigation/pricing in one Change, preview, review, +staging, production CDN delivery, rollback, asset deletion safety, `en-US`/`pl-PL`, scheduling and +an editor-only MCP actor that cannot publish. diff --git a/apps/docs/src/content/docs/scheduling-integrations.md b/apps/docs/src/content/docs/scheduling-integrations.md new file mode 100644 index 0000000..f17662e --- /dev/null +++ b/apps/docs/src/content/docs/scheduling-integrations.md @@ -0,0 +1,23 @@ +--- +title: Scheduling and integrations +description: Idempotent Actions, locks, webhooks, deployments, revalidation and translation ports. +--- + +Scheduling creates a reviewed schedule file and generated GitHub Actions workflow on the Change. +The executor is protected by an independent machine token, verifies the scheduled UTC instant and +uses a concurrency group so retries cannot publish twice. + +```bash +pnpm cms schedule create --cron "0 8 * * 1-5" --environment production +``` + +Publish and unpublish execute through application commands with deterministic idempotency keys. +An unpublish removes the selected documents before building the next immutable release; it does +not mutate an old release. + +GitHub webhooks require SHA-256 HMAC and claim each delivery ID once. Deployment and revalidation +providers receive the release ID, environment, exact Git revision and retry-safe key. Configure +both hook URLs and one integration token or leave both disabled. + +Translation providers implement job creation and polling only. The returned XLIFF is still +validated and imported through the normal authorization, revision and audit path. diff --git a/apps/docs/src/content/docs/security.md b/apps/docs/src/content/docs/security.md index f379f46..e769cd0 100644 --- a/apps/docs/src/content/docs/security.md +++ b/apps/docs/src/content/docs/security.md @@ -1,7 +1,37 @@ --- -title: Security +title: Security model +description: Authentication, untrusted content, machine actors and storage boundaries. --- -OAuth uses state and PKCE. Sessions are encrypted JWE cookies with absolute and idle expiration. -Mutations require a session-bound CSRF token. Webhook signatures, delivery IDs, preview origins, -asset MIME types and every protocol message are validated before domain commands run. +GitHub OAuth uses state and PKCE. The access token is stored only inside an encrypted, rotating +JWE cookie and is revoked during logout. Cookies are `Secure`, `HttpOnly`, `SameSite=Lax`, have +absolute and idle expiry, and each session carries an independent CSRF secret. + +All mutations require JSON, a same-origin request, CSRF, an idempotency key and an exact expected +Git revision. Publication and rollback additionally require a short-lived, actor/action-scoped +JWE confirmation token whose JTI can be claimed only once. + +GitHub webhooks require SHA-256 HMAC verification and a one-time delivery ID. MCP machine access +uses a separate bearer token and maps to an editor-only actor; it cannot stage or publish without +permission and a confirmation token. + +Untrusted boundaries enforce: + +- 1 MiB HTTP bodies, depth/node limits and protected-key rejection; +- deterministic JSON/YAML with alias limits and no custom object types; +- RFC 6901 patch paths with prototype-pollution protection; +- sanitized portable rich text and blocked `javascript:` links; +- non-SVG allowlisted uploads, declared/actual size and MIME checks, SHA-256 addressing; +- Sharp EXIF removal and a 40-megapixel decode limit; +- safe release paths, full Git/registry digests and immutable checksum verification; +- exact preview origin/session handshake plus Ajv validation in both MessageChannel directions; +- HTTPS-only external integration URLs and redirect rejection. + +Assets and immutable releases may use public R2 delivery URLs. `CMS_STATE_BUCKET` must remain +private because it contains audit, idempotency, webhook replay, one-time confirmation and +distributed rate-limit records. + +The Vercel configs add CSP, HSTS, `nosniff`, frame, referrer, permissions, COOP and CORP headers. +Run unit security tests, the production dependency audit, MinIO/R2 contracts, three-browser E2E +and axe WCAG checks before release. The scheduled security workflow also runs CodeQL and emits an +SPDX SBOM. diff --git a/apps/docs/src/content/docs/seo-localization-search.md b/apps/docs/src/content/docs/seo-localization-search.md new file mode 100644 index 0000000..a1b175f --- /dev/null +++ b/apps/docs/src/content/docs/seo-localization-search.md @@ -0,0 +1,20 @@ +--- +title: SEO, localization and search +description: Inheritance, locale/market fallback, redirects, XLIFF, hreflang and usage graphs. +--- + +Project defaults, page-type defaults and document overrides form the SEO inheritance chain. +Release generation emits canonical metadata, Open Graph values, sitemap entries and `hreflang` +alternates. A slug change preserves the old localized path in the redirects artifact. + +Locales use explicit BCP 47 identifiers. The reference project uses `en-US` as source and `pl-PL` +as the first demonstration locale. Locale and market lookup follows the configured fallback chain; +missing required translations are reported before publication. + +Editors can export a document to XLIFF, import the translated units with the expected source +revision, or create an asynchronous job through a translation provider port. Imported strings +remain part of the same Change and carry translation status. + +The release builder emits a content index, reference/asset usage graph and search index. The +editor uses them for global search, broken-reference checks and **Find usages**. Index generation +and queries are deterministic and included in the performance budget against 10,000 documents. diff --git a/apps/docs/src/content/docs/troubleshooting.md b/apps/docs/src/content/docs/troubleshooting.md new file mode 100644 index 0000000..bf65a7e --- /dev/null +++ b/apps/docs/src/content/docs/troubleshooting.md @@ -0,0 +1,44 @@ +--- +title: Troubleshooting +description: Diagnose GitHub App, R2, preview, publishing and framework integration failures. +--- + +Start with: + +```bash +pnpm cms doctor +pnpm cms registry validate +``` + +## GitHub App form rejects the manifest + +Regenerate it with `pnpm cms github setup`. Do not add `installation` as a default event: GitHub +does not allow that event in the manifest default-events list. The command handles the one-time +conversion callback and writes new credentials with restricted permissions. + +## The callback code expired + +Rerun the setup command. GitHub private keys and client secrets are shown once; create new values +if they were not saved. Install the App on the content repository and update the installation ID. + +## R2 credentials fail + +Use **Object Read & Write**, scoped to the three project buckets. Copy the S3 Access Key ID and +Secret Access Key from the token success screen; the Cloudflare API token itself is not an S3 +secret. Confirm endpoint, region `auto`, bucket names and public delivery URLs. + +## Preview never connects + +Check that the preview route is SSR, parent and child origins match the allowlist, CSP permits the +frame, and the deployed registry digest equals `CMS_REGISTRY_DIGEST`. A session is short-lived and +bound to actor, Change, frontend ref and locale. + +## A save or publish returns conflict + +Another operation moved the exact Git ref or environment pointer. Reload the Change, review the +semantic conflicts and retry with the new `expectedRevision`; never disable compare-and-swap. + +## Astro editor routes are missing + +Full editing requires `output: "server"`, a server adapter, React integration, `runtimeModule` and +`registryModule`. Static mode deliberately provides delivery only. diff --git a/apps/e2e-fixtures/README.md b/apps/e2e-fixtures/README.md new file mode 100644 index 0000000..c101153 --- /dev/null +++ b/apps/e2e-fixtures/README.md @@ -0,0 +1,5 @@ +# End-to-end fixtures + +Sanitized GitHub API responses are stored in `fixtures/github`; deterministic content fixtures are +stored in `fixtures/content`. Tests must never record access tokens, installation credentials, +private keys, webhook secrets or raw customer content. diff --git a/apps/e2e-fixtures/package.json b/apps/e2e-fixtures/package.json new file mode 100644 index 0000000..32734d1 --- /dev/null +++ b/apps/e2e-fixtures/package.json @@ -0,0 +1,33 @@ +{ + "name": "@git-native-cms/e2e-fixtures", + "version": "0.0.0", + "private": true, + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "dependencies": { + "@git-native-cms/application": "workspace:*", + "@git-native-cms/content-codecs": "workspace:*", + "@git-native-cms/core": "workspace:*", + "@git-native-cms/document-model": "workspace:*", + "@git-native-cms/hosted-runtime": "workspace:*", + "@git-native-cms/permissions": "workspace:*", + "@git-native-cms/release-builder": "workspace:*", + "@git-native-cms/server": "workspace:*", + "@git-native-cms/testing": "workspace:*" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run --config vitest.config.ts" + }, + "devDependencies": { + "typescript": "^6.0.0", + "vitest": "latest" + } +} diff --git a/apps/e2e-fixtures/src/index.test.ts b/apps/e2e-fixtures/src/index.test.ts new file mode 100644 index 0000000..5cba942 --- /dev/null +++ b/apps/e2e-fixtures/src/index.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it } from "vitest"; +import type { Actor, ActorId, ContentDocument, DocumentId, Revision } from "@git-native-cms/core"; +import { createMemoryHostedRuntime } from "./index.js"; + +const actor: Actor = { + id: "actor_fixture" as ActorId, + githubId: 1, + login: "fixture-editor", + displayName: "Fixture Editor", + roles: ["administrator"], + source: "ui", +}; + +const document: ContentDocument = { + id: "doc_home" as DocumentId, + type: "pages", + schemaVersion: 1, + revision: "0000000000000000000000000000000000000001" as Revision, + data: { + title: "Homepage", + route: { path: "/" }, + sections: [ + { + id: "sec_hero", + type: "hero", + version: 1, + heading: "Initial heading", + description: "Initial description", + }, + ], + }, +}; + +const navigation: ContentDocument = { + id: "doc_navigation_primary" as DocumentId, + type: "navigation", + schemaVersion: 1, + revision: "0000000000000000000000000000000000000001" as Revision, + data: { + title: "Primary navigation", + slug: "primary", + items: [{ label: "Journal", href: "/journal" }], + }, +}; + +const plan: ContentDocument = { + id: "doc_plan_lite" as DocumentId, + type: "plans", + schemaVersion: 1, + revision: "0000000000000000000000000000000000000001" as Revision, + data: { + title: "Lite", + slug: "lite", + name: "Lite", + price: { amount: 1900, currency: "USD" }, + }, +}; + +const settings: ContentDocument = { + id: "doc_settings_site" as DocumentId, + type: "settings", + schemaVersion: 1, + revision: "0000000000000000000000000000000000000001" as Revision, + data: { + title: "Site settings", + slug: "site", + siteName: "Fieldnotes", + defaultLocale: "en-US", + }, +}; + +async function mutate( + runtime: ReturnType, + path: string, + body: Readonly>, + method: "POST" | "PATCH" = "POST", +): Promise<{ readonly status: number; readonly payload: Readonly> }> { + const response = await runtime.handle( + new Request(`https://fixture.test/api/cms${path}`, { + method, + headers: { + "content-type": "application/json", + "idempotency-key": `fixture-${crypto.randomUUID()}`, + "x-csrf-token": "sandbox", + }, + body: JSON.stringify(body), + }), + ); + const envelope = (await response.json()) as { + readonly payload: Readonly>; + }; + return { status: response.status, payload: envelope.payload }; +} + +describe("stateful browser fixture", () => { + it("moves edited content through review, staging, immutable delivery and rollback", async () => { + const runtime = createMemoryHostedRuntime({ + actor, + documents: [document, navigation, plan, settings], + projectName: "E2E fixture", + }); + const dashboard = await runtime.editorState(null); + expect(dashboard).toMatchObject({ authenticated: true, view: "dashboard" }); + if (!dashboard.authenticated || dashboard.view !== "dashboard") return; + expect(dashboard.releases).toHaveLength(1); + const initialRelease = dashboard.releases[0]; + if (initialRelease === undefined) throw new Error("Expected the initial release."); + + const created = await mutate(runtime, "/changes", { + name: "Complete publication", + description: "Exercise the entire product workflow.", + collaborators: ["reviewer", "team:publishers"], + }); + expect(created.status).toBe(201); + const change = created.payload.change as { + readonly id: string; + readonly status: string; + }; + expect(change.status).toBe("draft"); + + const workspace = await runtime.editorState(null, `changes/${change.id}`); + if (!workspace.authenticated || workspace.view !== "workspace") { + throw new Error("Expected the workspace fixture."); + } + const saved = await mutate( + runtime, + `/changes/${change.id}/documents/${workspace.document.id}`, + { + expectedRevision: workspace.document.revision, + patches: [ + { + op: "set", + path: "/sections/0/heading", + value: "Published through the complete workflow", + metadata: { + id: "patch_fixture", + actorId: actor.id, + createdAt: "2026-07-27T12:00:00.000Z", + source: "editor", + }, + }, + ], + }, + "PATCH", + ); + expect(saved.status).toBe(200); + const savedRevision = (saved.payload.document as { readonly revision: Revision }).revision; + + const navigationSaved = await mutate( + runtime, + `/changes/${change.id}/documents/${navigation.id}`, + { + expectedRevision: savedRevision, + patches: [ + { + op: "set", + path: "/items/0/label", + value: "Dispatches", + metadata: { + id: "patch_navigation", + actorId: actor.id, + createdAt: "2026-07-27T12:00:00.000Z", + source: "editor", + }, + }, + ], + }, + "PATCH", + ); + expect(navigationSaved.status).toBe(200); + const navigationRevision = (navigationSaved.payload.document as { readonly revision: Revision }) + .revision; + + const planSaved = await mutate( + runtime, + `/changes/${change.id}/documents/${plan.id}`, + { + expectedRevision: navigationRevision, + patches: [ + { + op: "set", + path: "/price/amount", + value: 2500, + metadata: { + id: "patch_plan", + actorId: actor.id, + createdAt: "2026-07-27T12:00:00.000Z", + source: "editor", + }, + }, + ], + }, + "PATCH", + ); + expect(planSaved.status).toBe(200); + const planRevision = (planSaved.payload.document as { readonly revision: Revision }).revision; + + const settingsSaved = await mutate( + runtime, + `/changes/${change.id}/documents/${settings.id}`, + { + expectedRevision: planRevision, + patches: [ + { + op: "set", + path: "/siteName", + value: "Fieldnotes Studio", + metadata: { + id: "patch_settings", + actorId: actor.id, + createdAt: "2026-07-27T12:00:00.000Z", + source: "editor", + }, + }, + ], + }, + "PATCH", + ); + expect(settingsSaved.status).toBe(200); + const settingsRevision = (settingsSaved.payload.document as { readonly revision: Revision }) + .revision; + + const coordinatedWorkspace = await runtime.editorState( + null, + `changes/${change.id}/documents/${settings.id}`, + ); + if (!coordinatedWorkspace.authenticated || coordinatedWorkspace.view !== "workspace") { + throw new Error("Expected the coordinated workspace fixture."); + } + expect(coordinatedWorkspace.review.summary.changedDocumentIds).toHaveLength(4); + + const submitted = await mutate(runtime, `/changes/${change.id}/submit`, { + expectedRevision: settingsRevision, + }); + expect(submitted.status).toBe(200); + const submittedChange = submitted.payload.change as { + readonly pullRequestNumber: number; + readonly status: string; + }; + expect(submittedChange.status).toBe("in_review"); + + const commented = await mutate(runtime, `/changes/${change.id}/comments`, { + pullRequestNumber: submittedChange.pullRequestNumber, + body: "The preview and localized route look correct.", + path: "/sections/0", + }); + expect(commented.status).toBe(201); + + const approved = await mutate(runtime, `/changes/${change.id}/approve`, { + pullRequestNumber: submittedChange.pullRequestNumber, + expectedRevision: submitted.payload.revision, + }); + expect(approved.status).toBe(200); + expect((approved.payload.change as { readonly status: string }).status).toBe("approved"); + + const staged = await mutate(runtime, `/changes/${change.id}/staging`, { + pullRequestNumber: submittedChange.pullRequestNumber, + expectedRevision: approved.payload.revision, + }); + expect(staged.status).toBe(200); + expect((staged.payload.change as { readonly status: string }).status).toBe("staging"); + + const published = await mutate(runtime, "/staging/publish", { + expectedStagingRevision: staged.payload.revision, + title: "Release complete publication", + configVersion: 1, + registryDigest: `sha256:${"0".repeat(64)}`, + schemaVersion: 1, + confirmationToken: "sandbox-confirmation", + }); + expect(published.status).toBe(200); + + const releases = await runtime.editorState(null, "releases"); + if (!releases.authenticated || releases.view !== "releases") { + throw new Error("Expected the releases fixture."); + } + expect(releases.releases).toHaveLength(2); + const production = releases.pointers.find((pointer) => pointer.environment === "production"); + expect(production?.releaseId).toBe(published.payload.releaseId); + const currentRelease = releases.releases.find( + (release) => release.id === production?.releaseId, + ); + expect(currentRelease?.files["content/pages/doc_home/index.json"]).toContain( + "Published through the complete workflow", + ); + expect(currentRelease?.files["content/navigation/doc_navigation_primary/index.json"]).toContain( + "Dispatches", + ); + expect(currentRelease?.files["content/plans/doc_plan_lite/index.json"]).toContain("2500"); + expect(currentRelease?.files["content/settings/doc_settings_site/index.json"]).toContain( + "Fieldnotes Studio", + ); + + const rolledBack = await mutate(runtime, `/releases/${initialRelease.id}/rollback`, { + expectedPointerRevision: production?.revision, + confirmationToken: "sandbox-confirmation", + }); + expect(rolledBack.status).toBe(200); + + const restored = await runtime.editorState(null, "releases"); + if (!restored.authenticated || restored.view !== "releases") { + throw new Error("Expected the restored releases fixture."); + } + expect( + restored.pointers.find((pointer) => pointer.environment === "production")?.releaseId, + ).toBe(initialRelease.id); + }); +}); diff --git a/apps/e2e-fixtures/src/index.ts b/apps/e2e-fixtures/src/index.ts new file mode 100644 index 0000000..bfdfe71 --- /dev/null +++ b/apps/e2e-fixtures/src/index.ts @@ -0,0 +1,750 @@ +import { + createCmsApplication, + type Asset, + type AuditEvent, + type ContentRepository, + type DocumentSummary, + type EnvironmentPointer, + type GitProvider, + type Page, + type ReleaseStore, + type ReviewAssignment, + type ReviewCheck, + type ReviewComment, + type ReviewPort, + type StoredRelease, +} from "@git-native-cms/application"; +import { canonicalJson } from "@git-native-cms/content-codecs"; +import { + CmsError, + type Actor, + type Change, + type ContentDocument, + type DocumentId, + type GitCommitSha, + type ReleaseId, + type Revision, +} from "@git-native-cms/core"; +import type { + HostedCmsRuntime, + HostedEditablePage, + HostedEditorState, +} from "@git-native-cms/hosted-runtime"; +import { AuthorizationService } from "@git-native-cms/permissions"; +import { deterministicReleaseBuilder } from "@git-native-cms/release-builder"; +import { createCmsServer } from "@git-native-cms/server"; +import { + DeterministicIds, + FixedClock, + MemoryAuditSink, + MemoryGitProvider, + MemoryIdempotencyStore, +} from "@git-native-cms/testing"; + +const registryDigest = `sha256:${"0".repeat(64)}`; + +function cloneDocuments( + documents: ReadonlyMap, +): Map { + return new Map([...documents.entries()].map(([id, document]) => [id, structuredClone(document)])); +} + +class CoupledContentRepository implements ContentRepository { + private readonly refs = new Map>(); + private readonly snapshots = new Map>(); + private readonly mutations = new Map(); + + constructor(private readonly git: GitProvider) {} + + async seed(ref: string, documents: readonly ContentDocument[]): Promise { + const revision = (await this.git.resolveRef(ref)).sha; + const values = new Map( + documents.map((document) => [ + document.id, + { ...structuredClone(document), revision } satisfies ContentDocument, + ]), + ); + this.refs.set(ref, values); + this.snapshots.set(revision, cloneDocuments(values)); + } + + copyRevision(revision: GitCommitSha, target: string): void { + const source = this.snapshots.get(revision); + if (source !== undefined) this.refs.set(target, cloneDocuments(source)); + } + + recordRevision(ref: string, revision: GitCommitSha): void { + const documents = this.refs.get(ref); + if (documents === undefined) return; + const revised = new Map( + [...documents.entries()].map(([id, document]) => [ + id, + { ...structuredClone(document), revision } satisfies ContentDocument, + ]), + ); + this.refs.set(ref, revised); + this.snapshots.set(revision, cloneDocuments(revised)); + } + + mergeRef(source: string, target: string, revision: GitCommitSha): void { + const documents = this.refs.get(source); + if (documents === undefined) return; + const merged = new Map( + [...documents.entries()].map(([id, document]) => [ + id, + { ...structuredClone(document), revision } satisfies ContentDocument, + ]), + ); + this.refs.set(target, merged); + this.snapshots.set(revision, cloneDocuments(merged)); + } + + private async documents(ref: string): Promise> { + const branch = this.refs.get(ref); + if (branch !== undefined) { + const revision = (await this.git.resolveRef(ref)).sha; + return new Map( + [...branch.entries()].map(([id, document]) => [ + id, + { ...structuredClone(document), revision } satisfies ContentDocument, + ]), + ); + } + const snapshot = this.snapshots.get(ref); + if (snapshot !== undefined) return cloneDocuments(snapshot); + throw new Error(`Unknown content ref ${ref}.`); + } + + async listDocuments(input: { + readonly ref: string; + readonly type?: string; + }): Promise> { + const documents = [...(await this.documents(input.ref)).values()].filter( + (document) => input.type === undefined || document.type === input.type, + ); + return { + items: documents + .map((document) => ({ + id: document.id, + type: document.type, + title: + typeof document.data === "object" && + document.data !== null && + "title" in document.data && + typeof document.data.title === "string" + ? document.data.title + : document.id, + path: `content/${document.type}/${document.id}/index.yaml`, + revision: document.revision, + })) + .sort((left, right) => left.path.localeCompare(right.path)), + }; + } + + async readDocument(input: { + readonly ref: string; + readonly documentId: DocumentId; + }): Promise { + const document = (await this.documents(input.ref)).get(input.documentId); + if (document === undefined) throw new Error(`Unknown document ${input.documentId}.`); + return document; + } + + async writeDocuments(input: { + readonly ref: string; + readonly documents: readonly ContentDocument[]; + readonly expectedRevision: Revision; + readonly message: string; + readonly actor: Actor; + readonly idempotencyKey: string; + }): Promise { + const previous = this.mutations.get(input.idempotencyKey); + if (previous !== undefined) return previous; + const current = await this.git.resolveRef(input.ref); + if (current.sha !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_GIT_012", + message: "The content branch moved while saving.", + category: "conflict", + retryable: true, + }); + } + const existing = this.refs.get(input.ref) ?? new Map(); + const committed = await this.git.commitFiles({ + branch: input.ref, + expectedSha: current.sha, + files: input.documents.map((document) => ({ + path: `content/${document.type}/${document.id}/index.yaml`, + content: canonicalJson(document), + })), + message: input.message, + author: input.actor, + idempotencyKey: input.idempotencyKey, + }); + for (const document of input.documents) { + existing.set(document.id, { ...structuredClone(document), revision: committed.sha }); + } + for (const [id, document] of existing) { + existing.set(id, { ...document, revision: committed.sha }); + } + this.refs.set(input.ref, existing); + this.snapshots.set(committed.sha, cloneDocuments(existing)); + this.mutations.set(input.idempotencyKey, committed.sha); + return committed.sha; + } + + async deleteDocuments(input: { + readonly ref: string; + readonly documentIds: readonly DocumentId[]; + readonly expectedRevision: Revision; + readonly actor: Actor; + readonly idempotencyKey: string; + }): Promise { + const previous = this.mutations.get(input.idempotencyKey); + if (previous !== undefined) return previous; + const current = await this.git.resolveRef(input.ref); + if (current.sha !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_GIT_012", + message: "The content branch moved while deleting.", + category: "conflict", + retryable: true, + }); + } + const existing = this.refs.get(input.ref) ?? new Map(); + const files = input.documentIds.map((id) => { + const document = existing.get(id); + return { + path: `content/${document?.type ?? "documents"}/${id}/index.yaml`, + content: null, + }; + }); + const committed = await this.git.commitFiles({ + branch: input.ref, + expectedSha: current.sha, + files, + message: `Delete ${String(input.documentIds.length)} content document(s)`, + author: input.actor, + idempotencyKey: input.idempotencyKey, + }); + for (const id of input.documentIds) existing.delete(id); + for (const [id, document] of existing) { + existing.set(id, { ...document, revision: committed.sha }); + } + this.refs.set(input.ref, existing); + this.snapshots.set(committed.sha, cloneDocuments(existing)); + this.mutations.set(input.idempotencyKey, committed.sha); + return committed.sha; + } + + async readProjectConfig(): Promise<{ + readonly configVersion: number; + readonly defaultLocale: string; + }> { + return { configVersion: 1, defaultLocale: "en-US" }; + } + + async readRegistryLock(): Promise<{ + readonly registryDigest: string; + readonly schemaVersion: number; + }> { + return { registryDigest, schemaVersion: 1 }; + } +} + +class CoupledGitProvider extends MemoryGitProvider { + private content?: CoupledContentRepository; + + connect(content: CoupledContentRepository): void { + this.content = content; + } + + override async createBranch( + input: Parameters[0], + ): Promise>> { + const branch = await super.createBranch(input); + this.content?.copyRevision(input.from, input.branch); + return branch; + } + + override async commitFiles( + input: Parameters[0], + ): Promise>> { + const committed = await super.commitFiles(input); + this.content?.recordRevision(input.branch, committed.sha); + return committed; + } + + override async mergePullRequest( + input: Parameters[0], + ): Promise>> { + const pullRequest = this.pullRequest(input.number); + const merged = await super.mergePullRequest(input); + if (pullRequest !== undefined) { + this.content?.mergeRef(pullRequest.head, pullRequest.base, merged.sha); + } + return merged; + } +} + +class MemoryReviewPort implements ReviewPort { + private readonly comments = new Map(); + private readonly assignments = new Map(); + + async addComment(input: { + readonly pullRequestNumber: number; + readonly body: string; + readonly path?: string; + readonly line?: number; + }): Promise { + const values = this.comments.get(input.pullRequestNumber) ?? []; + const comment: ReviewComment = { + id: `comment_${String(values.length + 1)}`, + author: "sandbox-reviewer", + body: input.body, + ...(input.path === undefined ? {} : { path: input.path }), + ...(input.line === undefined ? {} : { line: input.line }), + createdAt: new Date("2026-07-27T12:00:00.000Z").toISOString(), + resolved: false, + }; + values.push(comment); + this.comments.set(input.pullRequestNumber, values); + return structuredClone(comment); + } + + async listComments(pullRequestNumber: number): Promise { + return structuredClone(this.comments.get(pullRequestNumber) ?? []); + } + + async resolveComment(input: { + readonly pullRequestNumber: number; + readonly commentId: string; + readonly resolved: boolean; + }): Promise { + const values = this.comments.get(input.pullRequestNumber) ?? []; + const index = values.findIndex((comment) => comment.id === input.commentId); + if (index < 0) throw new Error("Review comment not found."); + const existing = values[index]; + if (existing === undefined) throw new Error("Review comment not found."); + const comment = { ...existing, resolved: input.resolved }; + values[index] = comment; + return structuredClone(comment); + } + + async assignReviewers(input: { + readonly pullRequestNumber: number; + readonly users: readonly string[]; + readonly teams: readonly string[]; + }): Promise { + const assignment = { + users: [...new Set(input.users)].sort(), + teams: [...new Set(input.teams)].sort(), + }; + this.assignments.set(input.pullRequestNumber, assignment); + return structuredClone(assignment); + } + + async listReviewers(pullRequestNumber: number): Promise { + return structuredClone(this.assignments.get(pullRequestNumber) ?? { users: [], teams: [] }); + } + + async listChecks(ref?: GitCommitSha, signal?: AbortSignal): Promise { + void ref; + void signal; + return [ + { + name: "Content validation", + status: "completed", + conclusion: "success", + required: true, + }, + { + name: "Preview render", + status: "completed", + conclusion: "success", + required: true, + }, + ]; + } +} + +class MemoryReleaseStore implements ReleaseStore { + private readonly releases = new Map(); + private readonly environments = new Map(); + + async writeRelease(release: StoredRelease): Promise { + const existing = this.releases.get(release.id); + if (existing !== undefined && canonicalJson(existing) !== canonicalJson(release)) { + throw new Error("An immutable release cannot be overwritten."); + } + this.releases.set(release.id, structuredClone(release)); + } + + async readRelease(id: ReleaseId): Promise { + const release = this.releases.get(id); + return release === undefined ? undefined : structuredClone(release); + } + + async listReleases( + input: { + readonly cursor?: string; + readonly signal?: AbortSignal; + } = {}, + ): Promise> { + void input; + return { + items: [...this.releases.values()] + .sort((left, right) => String(right.id).localeCompare(String(left.id))) + .map((release) => structuredClone(release)), + }; + } + + async readPointer( + environment: EnvironmentPointer["environment"], + ): Promise { + const pointer = this.environments.get(environment); + return pointer === undefined ? undefined : structuredClone(pointer); + } + + async compareAndSwapPointer(input: { + readonly next: EnvironmentPointer; + readonly expectedRevision?: string; + }): Promise { + const current = this.environments.get(input.next.environment); + if (input.expectedRevision !== undefined && current?.revision !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_STORAGE_409", + message: "The environment pointer changed.", + category: "conflict", + retryable: true, + }); + } + this.environments.set(input.next.environment, structuredClone(input.next)); + return structuredClone(input.next); + } + + pointers(): readonly EnvironmentPointer[] { + return [...this.environments.values()].map((pointer) => structuredClone(pointer)); + } +} + +export interface MemoryHostedRuntimeInput { + readonly actor: Actor; + readonly reviewer?: Actor; + readonly initialChange?: Change; + readonly documents: readonly ContentDocument[]; + readonly assets?: readonly Asset[]; + readonly projectName: string; + readonly stagingUrl?: string; + readonly productionUrl?: string; +} + +const sharedRuntimes = globalThis as typeof globalThis & { + __gitNativeCmsMemoryRuntimes?: Map; +}; + +function documentRef(change: Change): string { + if (change.status === "published") return "main"; + if (change.status === "staging") return "staging"; + return change.branchName; +} + +function isChange(value: unknown): value is Change { + return ( + typeof value === "object" && + value !== null && + "id" in value && + typeof value.id === "string" && + "status" in value && + typeof value.status === "string" + ); +} + +function isHostedCmsRuntime(value: unknown): value is HostedCmsRuntime { + if (typeof value !== "object" || value === null) return false; + const record = value as Readonly>; + return typeof record.handle === "function" && typeof record.editorState === "function"; +} + +export function createMemoryHostedRuntime(input: MemoryHostedRuntimeInput): HostedCmsRuntime { + const runtimes = + sharedRuntimes.__gitNativeCmsMemoryRuntimes ?? + (sharedRuntimes.__gitNativeCmsMemoryRuntimes = new Map()); + const cached: unknown = runtimes.get(input.projectName); + if (isHostedCmsRuntime(cached)) return cached; + const git = new CoupledGitProvider(); + const content = new CoupledContentRepository(git); + git.connect(content); + const audit = new MemoryAuditSink(); + const review = new MemoryReviewPort(); + const releases = new MemoryReleaseStore(); + const changes = new Map(); + const assets = [...(input.assets ?? [])]; + let initialized: Promise | undefined; + let application: ReturnType; + let server: ReturnType; + + const reviewer: Actor = + input.reviewer ?? + ({ + ...input.actor, + id: `${String(input.actor.id)}_reviewer` as Actor["id"], + login: `${input.actor.login}-reviewer`, + displayName: `${input.actor.displayName} Reviewer`, + roles: ["publisher"], + } satisfies Actor); + + async function listPointers(): Promise { + return releases.pointers(); + } + + async function ensureInitialized(): Promise { + initialized ??= (async () => { + await content.seed("main", input.documents); + await content.seed("staging", input.documents); + if (input.initialChange !== undefined) { + const main = await git.resolveRef("main"); + await git.createBranch({ + branch: input.initialChange.branchName, + from: main.sha, + idempotencyKey: "fixture:initial-change", + }); + changes.set(input.initialChange.id, { + ...input.initialChange, + baseCommit: main.sha, + }); + } + + application = createCmsApplication({ + git, + content, + authorization: new AuthorizationService(), + clock: new FixedClock(), + ids: new DeterministicIds(), + idempotency: new MemoryIdempotencyStore(), + audit, + auditQuery: audit, + review, + releaseStore: releases, + releaseBuilder: deterministicReleaseBuilder, + }); + server = createCmsServer({ + application, + actorForRequest: async (request) => + new URL(request.url).pathname.endsWith("/approve") ? reviewer : input.actor, + verifyCsrf: async (request) => request.headers.get("x-csrf-token") === "sandbox", + verifyConfirmation: async (token) => token === "sandbox-confirmation", + queries: { + bootstrap: async () => ({ + actor: input.actor, + project: { name: input.projectName, locales: ["en-US", "pl-PL"] }, + capabilities: { preview: true, github: true, releases: true }, + }), + staging: async () => ({ + revision: (await git.resolveRef("staging")).sha, + changes: [...changes.values()].filter((change) => change.status === "staging"), + pointer: await releases.readPointer("staging"), + }), + listChanges: async () => [...changes.values()].map((change) => structuredClone(change)), + getChange: async (id) => { + const change = changes.get(id); + if (change === undefined) throw new Error(`Unknown Change ${id}.`); + return structuredClone(change); + }, + listDocuments: async (changeId) => { + const change = changes.get(changeId); + if (change === undefined) throw new Error(`Unknown Change ${changeId}.`); + return content.listDocuments({ ref: documentRef(change) }); + }, + getDocument: async (changeId, documentId) => { + const change = changes.get(changeId); + if (change === undefined) throw new Error(`Unknown Change ${changeId}.`); + return content.readDocument({ ref: documentRef(change), documentId }); + }, + listReleases: async () => (await releases.listReleases({})).items, + listAssets: async () => ({ items: assets }), + getAsset: async (id) => { + const asset = assets.find((candidate) => candidate.id === id); + if (asset === undefined) throw new Error("Asset not found."); + return asset; + }, + assetUsages: async () => [], + search: async () => [], + findUsages: async () => [], + exportTranslation: async () => "", + }, + }); + + const main = await git.resolveRef("main"); + await application.buildAndPublishRelease.execute( + { + ref: "main", + expectedRevision: main.sha, + environment: "production", + configVersion: 1, + registryDigest, + schemaVersion: 1, + idempotencyKey: "fixture:initial-release", + }, + { actor: input.actor, requestId: "fixture_initial_release" }, + ); + })(); + return initialized; + } + + async function editorState( + _request: Request | string | null, + path = "", + ): Promise { + await ensureInitialized(); + const segments = path.split("/").filter(Boolean).map(decodeURIComponent); + const changeId = + segments[0] === "changes" && segments[1] !== undefined ? segments[1] : undefined; + if (changeId === undefined) { + const requestedView = segments[0]; + const view = + requestedView === "staging" || + requestedView === "releases" || + requestedView === "assets" || + requestedView === "settings" || + requestedView === "developer" + ? requestedView + : "dashboard"; + const staging = await application.readStagingBatch.execute({ + actor: input.actor, + requestId: "fixture_staging", + }); + return { + authenticated: true, + view, + actor: input.actor, + changes: [...changes.values()], + releases: (await releases.listReleases({})).items, + pointers: await listPointers(), + assets, + stagingRevision: staging.revision, + ...(staging.lock === undefined ? {} : { stagingLock: staging.lock }), + registryDigest, + stagingUrl: input.stagingUrl ?? "/", + productionUrl: input.productionUrl ?? "/", + csrfToken: "sandbox", + projectName: input.projectName, + }; + } + const change = changes.get(changeId); + if (change === undefined) throw new Error(`Unknown Change ${changeId}.`); + const ref = documentRef(change); + const summaries = await content.listDocuments({ ref }); + const requestedId = + segments[2] === "documents" && segments[3] !== undefined + ? (segments[3] as DocumentId) + : undefined; + const documentId = + requestedId ?? + summaries.items.find((summary) => summary.id === input.documents[0]?.id)?.id ?? + summaries.items[0]?.id; + if (documentId === undefined) throw new Error("The fixture has no content documents."); + const contentDocuments = (await Promise.all( + summaries.items.map((summary) => content.readDocument({ ref, documentId: summary.id })), + )) as readonly ContentDocument[]; + const document = (await content.readDocument({ + ref, + documentId, + })) as ContentDocument; + const previewDocument = + contentDocuments.find((candidate) => candidate.type === "pages") ?? document; + const pullRequestNumber = change.pullRequestNumber; + const comments = + pullRequestNumber === undefined ? [] : await review.listComments(pullRequestNumber); + const checks = + pullRequestNumber === undefined ? [] : await review.listChecks(change.baseCommit); + const assignment = + pullRequestNumber === undefined + ? { users: [], teams: [] } + : await review.listReviewers(pullRequestNumber); + const timeline = await audit.list({ resourceId: change.id }); + const conflictState = await application.readChangeConflicts.execute( + { change }, + { actor: input.actor, requestId: "fixture_conflicts" }, + ); + const baseContentDocuments = (await Promise.all( + summaries.items.map((summary) => + content + .readDocument({ ref: change.baseCommit, documentId: summary.id }) + .catch(() => undefined), + ), + )) as readonly (ContentDocument | undefined)[]; + const baseDocument = baseContentDocuments.find((candidate) => candidate?.id === documentId); + const productionDocument = (await content + .readDocument({ ref: "main", documentId }) + .catch(() => undefined)) as ContentDocument | undefined; + const changedDocumentIds = contentDocuments + .filter((candidate, index) => { + const base = baseContentDocuments[index]; + return base === undefined || canonicalJson(base.data) !== canonicalJson(candidate.data); + }) + .map((candidate) => candidate.id); + return { + authenticated: true, + view: "workspace", + actor: input.actor, + change, + document, + ...(baseDocument === undefined ? {} : { baseDocument }), + ...(productionDocument === undefined ? {} : { productionDocument }), + conflicts: conflictState.conflicts, + documents: summaries.items, + contentDocuments, + previewDocument, + assets, + review: { + comments, + checks, + assignment, + timeline, + summary: { + changedDocumentIds, + affectedUsages: 0, + warnings: 0, + }, + }, + translationProviderAvailable: false, + registryDigest, + csrfToken: "sandbox", + projectName: input.projectName, + }; + } + + const runtime: HostedCmsRuntime = { + async handle(request): Promise { + await ensureInitialized(); + const url = new URL(request.url); + if (request.method === "POST" && url.pathname === "/api/cms/confirmations") { + return Response.json({ token: "sandbox-confirmation" }); + } + const response = await server.handle(request); + const envelope = (await response + .clone() + .json() + .catch(() => undefined)) as + { readonly payload?: Readonly> } | undefined; + const changed = envelope?.payload?.change; + if (isChange(changed)) changes.set(changed.id, structuredClone(changed)); + if (request.method === "POST" && url.pathname === "/api/cms/changes") { + const created = envelope?.payload?.change; + if (isChange(created)) changes.set(created.id, structuredClone(created)); + } + if (request.method === "POST" && url.pathname === "/api/cms/staging/publish" && response.ok) { + for (const [id, change] of changes) { + if (change.status === "staging") changes.set(id, { ...change, status: "published" }); + } + } + return response; + }, + editorState, + }; + runtimes.set(input.projectName, runtime); + return runtime; +} + +export type { AuditEvent }; diff --git a/apps/e2e-fixtures/tsconfig.json b/apps/e2e-fixtures/tsconfig.json new file mode 100644 index 0000000..54216bf --- /dev/null +++ b/apps/e2e-fixtures/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tooling/tsconfig.package.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/.tsbuildinfo" + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/e2e-fixtures/vitest.config.ts b/apps/e2e-fixtures/vitest.config.ts new file mode 100644 index 0000000..ce36a74 --- /dev/null +++ b/apps/e2e-fixtures/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/apps/example-astro-static/README.md b/apps/example-astro-static/README.md new file mode 100644 index 0000000..03f38e5 --- /dev/null +++ b/apps/example-astro-static/README.md @@ -0,0 +1,6 @@ +# Astro static example + +This build-time delivery example intentionally omits editor, API, OAuth and preview routes. It +demonstrates the supported static matrix: fetch or read an immutable release during the build, +render registered sections, and deploy plain HTML. Use `output: "server"` and the full Astro +integration when visual editing is required. diff --git a/apps/example-astro-static/astro.config.ts b/apps/example-astro-static/astro.config.ts new file mode 100644 index 0000000..ba0ec5f --- /dev/null +++ b/apps/example-astro-static/astro.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "astro/config"; +import { gitNativeCms } from "@git-native-cms/astro"; + +export default defineConfig({ + output: "static", + integrations: [gitNativeCms()], +}); diff --git a/apps/example-astro-static/package.json b/apps/example-astro-static/package.json new file mode 100644 index 0000000..1b2a9c6 --- /dev/null +++ b/apps/example-astro-static/package.json @@ -0,0 +1,19 @@ +{ + "name": "@git-native-cms/example-astro-static", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "astro build", + "typecheck": "astro check" + }, + "dependencies": { + "@git-native-cms/astro": "workspace:*", + "@git-native-cms/astro-renderer": "workspace:*", + "astro": "latest" + }, + "devDependencies": { + "@astrojs/check": "latest", + "typescript": "^6.0.0" + } +} diff --git a/apps/example-astro-static/src/pages/index.astro b/apps/example-astro-static/src/pages/index.astro new file mode 100644 index 0000000..688cba7 --- /dev/null +++ b/apps/example-astro-static/src/pages/index.astro @@ -0,0 +1,41 @@ +--- +import { createAstroRegistry, renderAstroSections } from "@git-native-cms/astro-renderer"; + +const registry = createAstroRegistry({ + hero: (section) => ` +
+ Astro static delivery +

${String(section.heading)}

+

${String(section.description)}

+
+ `, +}); +const html = await renderAstroSections({ + registry, + sections: [ + { + id: "sec_static_hero", + type: "hero", + version: 1, + heading: "Immutable content at build time", + description: "Static Astro supports delivery builds; the full visual editor requires SSR.", + }, + ], +}); +--- + + + + + + Astro static · Git-native CMS + + +
+ diff --git a/apps/example-astro-static/tsconfig.json b/apps/example-astro-static/tsconfig.json new file mode 100644 index 0000000..bcbf8b5 --- /dev/null +++ b/apps/example-astro-static/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "astro/tsconfigs/strict" +} diff --git a/apps/example-next-enterprise/README.md b/apps/example-next-enterprise/README.md new file mode 100644 index 0000000..3488121 --- /dev/null +++ b/apps/example-next-enterprise/README.md @@ -0,0 +1,9 @@ +# Next.js enterprise example + +This app demonstrates the production mounting contract: registered React sections, a public +server-rendered page, the catch-all CMS route and one Web API handler. Copy `.env.example` from the +repository root, run `cms github setup --origin --owner `, and configure R2 before +opening `/cms`. + +For a zero-credential editable demo use `apps/playground-next`; this example intentionally fails +closed when the production GitHub App or storage configuration is absent. diff --git a/apps/example-next-enterprise/app/%5F%5Fcms/preview/[[...slug]]/page.tsx b/apps/example-next-enterprise/app/%5F%5Fcms/preview/[[...slug]]/page.tsx new file mode 100644 index 0000000..eb9c590 --- /dev/null +++ b/apps/example-next-enterprise/app/%5F%5Fcms/preview/[[...slug]]/page.tsx @@ -0,0 +1,6 @@ +import type { ReactElement } from "react"; +import { EnterprisePreview } from "./preview-client"; + +export default function PreviewPage(): ReactElement { + return ; +} diff --git a/apps/example-next-enterprise/app/%5F%5Fcms/preview/[[...slug]]/preview-client.tsx b/apps/example-next-enterprise/app/%5F%5Fcms/preview/[[...slug]]/preview-client.tsx new file mode 100644 index 0000000..d61ea2f --- /dev/null +++ b/apps/example-next-enterprise/app/%5F%5Fcms/preview/[[...slug]]/preview-client.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect, useRef, useState, type ReactElement } from "react"; +import { createPreviewBridge } from "@git-native-cms/editor-bridge"; +import { CmsPageRenderer, type CmsPageDocument } from "@git-native-cms/react"; +import { enterpriseHomeDocument } from "../../../../cms.content"; +import { enterpriseRegistry } from "../../../../cms.registry"; + +export function EnterprisePreview(): ReactElement { + const [document, setDocument] = useState(enterpriseHomeDocument); + const [content, setContent] = useState([]); + const documentRef = useRef(document); + const contentRef = useRef(content); + documentRef.current = document; + contentRef.current = content; + + useEffect(() => { + const sessionId = new URLSearchParams(window.location.search).get("cmsSession"); + if (sessionId === null) return; + const bridge = createPreviewBridge({ + parentOrigin: window.location.origin, + sessionId, + getDocument: () => documentRef.current, + setDocument: (next) => setDocument(next as CmsPageDocument), + setContent, + getContent: () => contentRef.current, + }); + return () => bridge.destroy(); + }, []); + + return ( +
+ { + if (typeof value !== "object" || value === null) return []; + const item = value as { + readonly id?: unknown; + readonly type?: unknown; + readonly data?: unknown; + }; + return typeof item.id === "string" && + typeof item.type === "string" && + typeof item.data === "object" && + item.data !== null + ? [ + { + id: item.id, + type: item.type, + data: item.data as Readonly>, + }, + ] + : []; + })} + preview + /> +
+ ); +} diff --git a/apps/example-next-enterprise/app/api/cms/[[...path]]/route.ts b/apps/example-next-enterprise/app/api/cms/[[...path]]/route.ts new file mode 100644 index 0000000..d796e9a --- /dev/null +++ b/apps/example-next-enterprise/app/api/cms/[[...path]]/route.ts @@ -0,0 +1,9 @@ +import { hostedRuntime } from "../../../../cms.runtime"; + +const handle = (request: Request) => hostedRuntime.handle(request); + +export const GET = handle; +export const POST = handle; +export const PATCH = handle; +export const PUT = handle; +export const DELETE = handle; diff --git a/apps/example-next-enterprise/app/cms/[[...path]]/page.tsx b/apps/example-next-enterprise/app/cms/[[...path]]/page.tsx new file mode 100644 index 0000000..22ea565 --- /dev/null +++ b/apps/example-next-enterprise/app/cms/[[...path]]/page.tsx @@ -0,0 +1,18 @@ +import { headers } from "next/headers"; +import { CmsHostedApp } from "@git-native-cms/hosted-runtime/react"; +import { enterpriseRegistry } from "../../../cms.registry"; +import { hostedRuntime } from "../../../cms.runtime"; + +export const dynamic = "force-dynamic"; + +export default async function CmsPage(props: { + readonly params: Promise<{ readonly path?: readonly string[] }>; +}) { + const requestHeaders = await headers(); + const params = await props.params; + const state = await hostedRuntime.editorState( + requestHeaders.get("cookie"), + params.path?.join("/") ?? "", + ); + return ; +} diff --git a/apps/example-next-enterprise/app/layout.tsx b/apps/example-next-enterprise/app/layout.tsx new file mode 100644 index 0000000..487ef70 --- /dev/null +++ b/apps/example-next-enterprise/app/layout.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; +import "@git-native-cms/next/styles.css"; +import "./styles.css"; + +export default function Layout(props: { readonly children: ReactNode }) { + return ( + + {props.children} + + ); +} diff --git a/apps/example-next-enterprise/app/page.tsx b/apps/example-next-enterprise/app/page.tsx new file mode 100644 index 0000000..92c528c --- /dev/null +++ b/apps/example-next-enterprise/app/page.tsx @@ -0,0 +1,12 @@ +import { CmsPageRenderer } from "@git-native-cms/react"; +import { enterpriseHomeDocument } from "../cms.content"; +import { enterpriseRegistry } from "../cms.registry"; + +export default function Home() { + return ( +
+ + Open CMS +
+ ); +} diff --git a/apps/example-next-enterprise/app/styles.css b/apps/example-next-enterprise/app/styles.css new file mode 100644 index 0000000..70b14b2 --- /dev/null +++ b/apps/example-next-enterprise/app/styles.css @@ -0,0 +1,26 @@ +:root { + color: #17212b; + background: #f7f8fa; + font-family: system-ui, sans-serif; +} + +body { + margin: 0; +} + +main { + width: min(920px, calc(100% - 40px)); + margin: 15vh auto; +} + +.hero { + padding: clamp(32px, 8vw, 96px); + border: 1px solid #e5eaf0; + border-radius: 24px; + background: white; +} + +.hero h1 { + max-width: 16ch; + font: 700 clamp(2.5rem, 8vw, 5rem) / 0.98 Georgia, serif; +} diff --git a/apps/example-next-enterprise/cms.content.ts b/apps/example-next-enterprise/cms.content.ts new file mode 100644 index 0000000..8e37f68 --- /dev/null +++ b/apps/example-next-enterprise/cms.content.ts @@ -0,0 +1,13 @@ +export const enterpriseHomeDocument = { + id: "doc_enterprise_home", + sections: [ + { + id: "sec_enterprise_hero", + type: "enterpriseHero", + version: 1, + eyebrow: "Enterprise example", + heading: "Git-native publishing for a Next.js estate", + description: "Registered components render on the server; the editor is isolated under /cms.", + }, + ], +} as const; diff --git a/apps/example-next-enterprise/cms.registry.tsx b/apps/example-next-enterprise/cms.registry.tsx new file mode 100644 index 0000000..cd399f4 --- /dev/null +++ b/apps/example-next-enterprise/cms.registry.tsx @@ -0,0 +1,35 @@ +import { createReactRegistry, registerReactSection } from "@git-native-cms/react"; +import { defineSection, fields } from "@git-native-cms/schema"; + +const hero = defineSection({ + name: "enterpriseHero", + version: 1, + label: "Enterprise hero", + category: "Introduction", + fields: { + eyebrow: fields.text({ inline: true }), + heading: fields.text({ required: true, inline: true }), + description: fields.text({ inline: true }), + }, + defaults: { + eyebrow: "Enterprise example", + heading: "Content delivery with an auditable Git workflow", + description: "The public route renders registered components without loading the editor.", + }, +}); + +function text(value: unknown): string { + return typeof value === "string" || typeof value === "number" ? String(value) : ""; +} + +export const enterpriseRegistry = createReactRegistry({ + sections: [ + registerReactSection(hero, ({ section }) => ( +
+ {text(section.eyebrow)} +

{text(section.heading)}

+

{text(section.description)}

+
+ )), + ], +}); diff --git a/apps/example-next-enterprise/cms.runtime.ts b/apps/example-next-enterprise/cms.runtime.ts new file mode 100644 index 0000000..7619834 --- /dev/null +++ b/apps/example-next-enterprise/cms.runtime.ts @@ -0,0 +1,16 @@ +import { createHostedCmsRuntime } from "@git-native-cms/hosted-runtime"; +import { enterpriseRegistry } from "./cms.registry"; + +export const hostedRuntime = createHostedCmsRuntime({ + origin: process.env.CMS_ORIGIN ?? "http://localhost:3100", + projectName: "Enterprise CMS example", + environment: process.env, + registryManifest: enterpriseRegistry.manifest, + repository: { + owner: process.env.CMS_GITHUB_OWNER ?? "DMTcorp", + name: process.env.CMS_GITHUB_REPOSITORY ?? "git-native-cms-sandbox-content", + mainBranch: "main", + stagingBranch: "staging", + homeDocumentId: "doc_home", + }, +}); diff --git a/apps/example-next-enterprise/next-env.d.ts b/apps/example-next-enterprise/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/apps/example-next-enterprise/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/example-next-enterprise/next.config.ts b/apps/example-next-enterprise/next.config.ts new file mode 100644 index 0000000..bee8177 --- /dev/null +++ b/apps/example-next-enterprise/next.config.ts @@ -0,0 +1,11 @@ +import type { NextConfig } from "next"; + +export default { + reactStrictMode: true, + transpilePackages: [ + "@git-native-cms/hosted-runtime", + "@git-native-cms/next", + "@git-native-cms/react", + "@git-native-cms/schema", + ], +} satisfies NextConfig; diff --git a/apps/example-next-enterprise/package.json b/apps/example-next-enterprise/package.json new file mode 100644 index 0000000..576c175 --- /dev/null +++ b/apps/example-next-enterprise/package.json @@ -0,0 +1,25 @@ +{ + "name": "@git-native-cms/example-next-enterprise", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "next build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@git-native-cms/editor-bridge": "workspace:*", + "@git-native-cms/hosted-runtime": "workspace:*", + "@git-native-cms/next": "workspace:*", + "@git-native-cms/react": "workspace:*", + "@git-native-cms/schema": "workspace:*", + "next": "latest", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@types/react": "latest", + "@types/react-dom": "latest", + "typescript": "^6.0.0" + } +} diff --git a/apps/example-next-enterprise/tsconfig.json b/apps/example-next-enterprise/tsconfig.json new file mode 100644 index 0000000..f2b10dd --- /dev/null +++ b/apps/example-next-enterprise/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowJs": true, + "jsx": "react-jsx", + "lib": ["DOM", "DOM.Iterable", "ES2023"], + "incremental": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "plugins": [{ "name": "next" }] + }, + "include": [".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/apps/playground-astro/astro.config.ts b/apps/playground-astro/astro.config.ts index d9ea9b2..268d815 100644 --- a/apps/playground-astro/astro.config.ts +++ b/apps/playground-astro/astro.config.ts @@ -1,10 +1,27 @@ import { defineConfig } from "astro/config"; import react from "@astrojs/react"; import vercel from "@astrojs/vercel"; +import { gitNativeCms } from "@git-native-cms/astro"; +import { createRequire } from "node:module"; +import { dirname } from "node:path"; + +const require = createRequire(import.meta.url); +const sharpRuntimeFiles = [ + `@img/sharp-${process.platform}-${process.arch}`, + `@img/sharp-libvips-${process.platform}-${process.arch}`, +].map((packageName) => dirname(require.resolve(`${packageName}/package`))); export default defineConfig({ output: "server", - adapter: vercel(), - integrations: [react()], + adapter: vercel({ includeFiles: sharpRuntimeFiles }), + integrations: [ + react(), + gitNativeCms({ + runtimeModule: new URL("./src/cms-runtime.ts", import.meta.url).pathname, + runtimeExport: "hostedRuntime", + registryModule: new URL("./src/cms-registry.tsx", import.meta.url).pathname, + registryExport: "sandboxRegistry", + }), + ], server: { port: 3001 }, }); diff --git a/apps/playground-astro/package.json b/apps/playground-astro/package.json index 4b34a64..a235ddb 100644 --- a/apps/playground-astro/package.json +++ b/apps/playground-astro/package.json @@ -11,13 +11,19 @@ "dependencies": { "@astrojs/react": "latest", "@astrojs/vercel": "latest", + "@fontsource/atkinson-hyperlegible": "catalog:", + "@fontsource/ibm-plex-mono": "catalog:", + "@fontsource/source-serif-4": "catalog:", "@git-native-cms/application": "workspace:*", + "@git-native-cms/astro": "workspace:*", "@git-native-cms/core": "workspace:*", "@git-native-cms/delivery": "workspace:*", "@git-native-cms/editor": "workspace:*", "@git-native-cms/editor-bridge": "workspace:*", "@git-native-cms/editor-ui": "workspace:*", + "@git-native-cms/e2e-fixtures": "workspace:*", "@git-native-cms/hosted-runtime": "workspace:*", + "@git-native-cms/localization": "workspace:*", "@git-native-cms/permissions": "workspace:*", "@git-native-cms/react": "workspace:*", "@git-native-cms/schema": "workspace:*", @@ -25,7 +31,8 @@ "@git-native-cms/testing": "workspace:*", "astro": "latest", "react": "catalog:", - "react-dom": "catalog:" + "react-dom": "catalog:", + "sharp": "latest" }, "devDependencies": { "@astrojs/check": "latest", diff --git a/apps/playground-astro/src/cms-fixture.ts b/apps/playground-astro/src/cms-fixture.ts index 4cea117..02acbe4 100644 --- a/apps/playground-astro/src/cms-fixture.ts +++ b/apps/playground-astro/src/cms-fixture.ts @@ -9,6 +9,8 @@ import type { IsoTimestamp, Revision, } from "@git-native-cms/core"; +import type { Asset } from "@git-native-cms/application"; +import type { AssetId } from "@git-native-cms/core"; export const actor: Actor = { id: "actor_astro" as ActorId, @@ -24,22 +26,42 @@ export const change: Change = { name: "Astro launch", ownerId: actor.id, baseBranch: "main", - baseCommit: "sha_main_1" as GitCommitSha, + baseCommit: "0000000000000000000000000000000000000001" as GitCommitSha, branchName: "cms/astro-editor/astro-launch-demo", status: "draft", createdAt: "2026-07-27T12:00:00.000Z" as IsoTimestamp, updatedAt: "2026-07-27T12:00:00.000Z" as IsoTimestamp, }; +export const assets: readonly Asset[] = [ + { + id: "ast_0123456789abcdef01234567" as AssetId, + fileName: "editorial-grid.png", + mimeType: "image/png", + size: 68, + checksum: "9d7f1cda29a611c744467d427f3f8726172b68f24e505b2afdc67cf1b5744c54", + url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + altText: "Blue editorial grid placeholder", + }, +]; + export const document: ContentDocument<{ readonly title: string; readonly route: { readonly path: string }; + readonly redirectFrom: readonly string[]; + readonly seo: { readonly title: string; readonly description: string }; + readonly locales: Readonly< + Record> }> + >; readonly sections: readonly { readonly id: string; readonly type: string; readonly version: number; - readonly heading: string; - readonly description: string; + readonly heading?: string; + readonly description?: string; + readonly bindings?: Readonly>; + readonly ref?: string; + readonly overrides?: Readonly>; }[]; }> = { id: "doc_astro_home" as DocumentId, @@ -49,6 +71,23 @@ export const document: ContentDocument<{ data: { title: "Astro homepage", route: { path: "/" }, + redirectFrom: ["/welcome"], + seo: { + title: "Fieldnotes · Astro Git-native CMS", + description: "A live Astro demonstration of Git-native visual publishing.", + }, + locales: { + "pl-PL": { + status: "translated", + fields: { + "/title": "Strona główna Astro", + "/sections/0/heading": "Jeden model treści. Dwa prawdziwe renderery.", + "/sections/0/description": "Ten sam proces obsługuje Next.js i Astro.", + "/sections/1/heading": "Edycja renderowana na serwerze", + "/sections/1/description": "Astro uruchamia pełny CMS przez adapter serwerowy.", + }, + }, + }, sections: [ { id: "sec_astro_hero", @@ -64,6 +103,141 @@ export const document: ContentDocument<{ heading: "Server-rendered editing", description: "Astro runs the full CMS through its Node adapter.", }, + { + id: "sec_astro_pricing", + type: "pricingGrid", + version: 1, + heading: "Plans materialized from a collection", + bindings: { plans: { collection: "plans" } }, + }, + { + id: "sec_astro_reusable", + type: "reference", + version: 1, + ref: "reusable-blocks/editorial-note", + overrides: {}, + }, + ], + }, +}; + +export const navigation: ContentDocument<{ + readonly title: string; + readonly slug: string; + readonly items: readonly { + readonly label: string; + readonly href: string; + }[]; +}> = { + id: "doc_navigation_primary" as DocumentId, + type: "navigation", + schemaVersion: 1, + revision: "sha_content_1" as Revision, + data: { + title: "Primary navigation", + slug: "primary", + items: [ + { label: "Renderer", href: "/#renderer" }, + { label: "Plans", href: "/#plans" }, ], }, }; + +export const pricingPlans: readonly ContentDocument<{ + readonly title: string; + readonly slug: string; + readonly name: string; + readonly description: string; + readonly price: { + readonly amount: number; + readonly currency: string; + }; + readonly locale: string; +}>[] = [ + { + id: "doc_plan_lite" as DocumentId, + type: "plans", + schemaVersion: 1, + revision: "sha_content_1" as Revision, + data: { + title: "Fieldnotes Lite", + slug: "lite", + name: "Lite", + description: "A focused workflow for one publication.", + price: { amount: 1900, currency: "USD" }, + locale: "en-US", + }, + }, + { + id: "doc_plan_studio" as DocumentId, + type: "plans", + schemaVersion: 1, + revision: "sha_content_1" as Revision, + data: { + title: "Fieldnotes Studio", + slug: "studio", + name: "Studio", + description: "Review, staging and releases for editorial teams.", + price: { amount: 4900, currency: "USD" }, + locale: "en-US", + }, + }, +]; + +export const settings: ContentDocument<{ + readonly title: string; + readonly slug: string; + readonly siteName: string; + readonly siteUrl: string; + readonly defaultLocale: string; +}> = { + id: "doc_settings_site" as DocumentId, + type: "settings", + schemaVersion: 1, + revision: "sha_content_1" as Revision, + data: { + title: "Site settings", + slug: "site", + siteName: "Fieldnotes / Astro", + siteUrl: "https://git-native-cms-astro.vercel.app", + defaultLocale: "en-US", + }, +}; + +export const reusableBlock: ContentDocument<{ + readonly title: string; + readonly slug: string; + readonly sections: readonly { + readonly id: string; + readonly type: string; + readonly version: number; + readonly heading: string; + readonly description: string; + }[]; +}> = { + id: "doc_reusable_editorial_note" as DocumentId, + type: "reusable-blocks", + schemaVersion: 1, + revision: "sha_content_1" as Revision, + data: { + title: "Editorial note", + slug: "editorial-note", + sections: [ + { + id: "sec_astro_editorial_note", + type: "proof", + version: 1, + heading: "One source, two frameworks", + description: "Reusable content is resolved identically by the Next.js and Astro renderers.", + }, + ], + }, +}; + +export const contentDocuments: readonly ContentDocument[] = [ + document, + navigation, + ...pricingPlans, + settings, + reusableBlock, +]; diff --git a/apps/playground-astro/src/cms-registry.tsx b/apps/playground-astro/src/cms-registry.tsx index 75a9825..59e6536 100644 --- a/apps/playground-astro/src/cms-registry.tsx +++ b/apps/playground-astro/src/cms-registry.tsx @@ -1,38 +1,121 @@ import { createReactRegistry, registerReactSection } from "@git-native-cms/react"; import { defineSection, fields } from "@git-native-cms/schema"; -const sectionDefinition = (name: string, label: string) => +const sectionDefinition = (name: string, label: string, media = false) => defineSection({ name, version: 1, label, + category: name === "hero" ? "Introduction" : "Evidence", + description: + name === "hero" + ? "A primary statement with optional media from asset storage." + : "A focused proof point or editorial callout.", fields: { heading: fields.text({ required: true, inline: true }), description: fields.text({ inline: true }), + ...(media + ? { + media: fields.asset({ + label: "Hero media", + description: "An image selected from the project asset library.", + accept: ["image/*"], + aspectRatio: [4, 3], + }), + } + : {}), + }, + defaults: { + heading: name === "hero" ? "A clear headline" : "Why this matters", + description: "Add supporting context here.", + ...(media ? { media: null } : {}), }, }); +function text(value: unknown, fallback: string): string { + return typeof value === "string" || typeof value === "number" ? String(value) : fallback; +} + +function asset(value: unknown): + | { + readonly url: string; + readonly fileName: string; + readonly altText?: string; + } + | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + const record = value as Readonly>; + if (typeof record.url !== "string" || typeof record.fileName !== "string") return undefined; + return { + url: record.url, + fileName: record.fileName, + ...(typeof record.altText === "string" ? { altText: record.altText } : {}), + }; +} + export const sandboxRegistry = createReactRegistry({ sections: [ - registerReactSection(sectionDefinition("hero", "Hero"), ({ section }) => ( -
-
- Built with Astro -

{String(section.heading)}

-

{String(section.description)}

-
- -
- )), + registerReactSection(sectionDefinition("hero", "Hero", true), ({ section }) => { + const media = asset(section.media); + return ( +
+
+ Built with Astro +

{String(section.heading)}

+

{String(section.description)}

+
+ {media === undefined ? ( + + ) : ( +
+ {media.altText +
{media.fileName}
+
+ )} +
+ ); + }), registerReactSection(sectionDefinition("proof", "Proof"), ({ section }) => (
Renderer capability -

{String(section.heading)}

-

{String(section.description)}

+

{String(section.heading)}

+

{String(section.description)}

)), + registerReactSection( + defineSection({ + name: "pricingGrid", + version: 1, + label: "Pricing grid", + fields: { + heading: fields.text({ required: true, inline: true }), + bindings: fields.json(), + }, + }), + ({ section }) => ( +
+ Collection materialization +

{String(section.heading)}

+ {(Array.isArray(section.plans) ? section.plans : []).map((plan) => { + const value = plan as Readonly>; + const price = + typeof value.price === "object" && value.price !== null + ? (value.price as Readonly>) + : {}; + return ( +

+ {text(value.name, text(value.title, "Plan"))} + {typeof price.amount === "number" + ? ` · ${text(price.currency, "USD")} ${(price.amount / 100).toFixed(2)}` + : ""} +

+ ); + })} +
+ ), + ), ], }); diff --git a/apps/playground-astro/src/cms-runtime.ts b/apps/playground-astro/src/cms-runtime.ts index 951f283..5b22889 100644 --- a/apps/playground-astro/src/cms-runtime.ts +++ b/apps/playground-astro/src/cms-runtime.ts @@ -1,21 +1,13 @@ -import { createCmsApplication } from "@git-native-cms/application"; -import { createHostedCmsRuntime, type HostedCmsRuntime } from "@git-native-cms/hosted-runtime"; -import { AuthorizationService } from "@git-native-cms/permissions"; -import { createCmsServer } from "@git-native-cms/server"; -import { - DeterministicIds, - FixedClock, - MemoryAuditSink, - MemoryContentRepository, - MemoryGitProvider, - MemoryIdempotencyStore, -} from "@git-native-cms/testing"; -import { actor, change, document } from "./cms-fixture"; +import { createMemoryHostedRuntime } from "@git-native-cms/e2e-fixtures"; +import { createHostedCmsRuntime } from "@git-native-cms/hosted-runtime"; +import { actor, assets, change, contentDocuments } from "./cms-fixture"; +import { sandboxRegistry } from "./cms-registry"; const productionRuntime = createHostedCmsRuntime({ origin: "https://git-native-cms-astro.vercel.app", projectName: "Fieldnotes / Astro", environment: process.env, + registryManifest: sandboxRegistry.manifest, repository: { owner: "DMTcorp", name: "git-native-cms-sandbox-content", @@ -25,40 +17,14 @@ const productionRuntime = createHostedCmsRuntime({ }, }); -function memoryRuntime(): HostedCmsRuntime { - const content = new MemoryContentRepository(); - content.seed(change.branchName, document); - const server = createCmsServer({ - application: createCmsApplication({ - git: new MemoryGitProvider(), - content, - authorization: new AuthorizationService(), - clock: new FixedClock(), - ids: new DeterministicIds(), - idempotency: new MemoryIdempotencyStore(), - audit: new MemoryAuditSink(), - }), - actorForRequest: async () => actor, - verifyCsrf: async (request) => request.headers.get("x-csrf-token") === "sandbox", - queries: { - bootstrap: async () => ({ actor, project: { name: "Fieldnotes / Astro" } }), - listChanges: async () => [change], - getChange: async () => change, - listDocuments: async () => content.listDocuments({ ref: change.branchName }), - listReleases: async () => [], - }, +function memoryRuntime() { + return createMemoryHostedRuntime({ + actor, + initialChange: change, + documents: contentDocuments, + assets, + projectName: "Fieldnotes / Astro", }); - return { - handle: (request) => server.handle(request), - editorState: async () => ({ - authenticated: true, - actor, - change, - document, - csrfToken: "sandbox", - projectName: "Fieldnotes / Astro", - }), - }; } export const hostedRuntime = diff --git a/apps/playground-astro/src/components/CmsDemo.tsx b/apps/playground-astro/src/components/CmsDemo.tsx index 2383a09..8b0f943 100644 --- a/apps/playground-astro/src/components/CmsDemo.tsx +++ b/apps/playground-astro/src/components/CmsDemo.tsx @@ -1,59 +1,7 @@ -import type { Revision } from "@git-native-cms/core"; -import { EditorApp } from "@git-native-cms/editor"; import type { HostedEditorState } from "@git-native-cms/hosted-runtime"; -import { advanceHostedWorkflow } from "@git-native-cms/hosted-runtime/client"; +import { CmsHostedApp } from "@git-native-cms/hosted-runtime/react"; +import { sandboxRegistry } from "../cms-registry"; export function CmsDemo(props: { readonly state: HostedEditorState }) { - if (!props.state.authenticated) { - return ( -
-
- Hosted sandbox -

Open the real Git-backed editor.

-

- Sign in with GitHub. The CMS creates an isolated Change branch in the public content - repository and keeps credentials server-side. -

- - Continue with GitHub - - {props.state.projectName} -
-
- ); - } - const { change, document, csrfToken } = props.state; - return ( - { - const response = await fetch(`/api/cms/changes/${change.id}/documents/${document.id}`, { - method: "PATCH", - headers: { - "content-type": "application/json", - "idempotency-key": globalThis.crypto.randomUUID(), - "x-csrf-token": csrfToken, - }, - body: JSON.stringify({ expectedRevision, patches }), - }); - if (!response.ok) throw new Error("Save failed."); - const result = (await response.json()) as { - readonly payload: { readonly document: { readonly revision: Revision } }; - }; - return result.payload.document.revision; - }} - onWorkflowAction={({ action, expectedRevision, pullRequestNumber }) => - advanceHostedWorkflow({ - action, - changeId: change.id, - changeName: change.name, - csrfToken, - expectedRevision, - ...(pullRequestNumber === undefined ? {} : { pullRequestNumber }), - }) - } - /> - ); + return ; } diff --git a/apps/playground-astro/src/components/PreviewDemo.tsx b/apps/playground-astro/src/components/PreviewDemo.tsx index 1804346..6c75190 100644 --- a/apps/playground-astro/src/components/PreviewDemo.tsx +++ b/apps/playground-astro/src/components/PreviewDemo.tsx @@ -6,8 +6,11 @@ import { document as initialDocument } from "../cms-fixture"; export function PreviewDemo() { const [document, setDocument] = useState(initialDocument.data); + const [content, setContent] = useState([]); const documentRef = useRef(document); + const contentRef = useRef(content); documentRef.current = document; + contentRef.current = content; useEffect(() => { const sessionId = new URLSearchParams(window.location.search).get("cmsSession"); if (sessionId === null) return; @@ -16,6 +19,8 @@ export function PreviewDemo() { sessionId, getDocument: () => documentRef.current, setDocument: (next) => setDocument(next as typeof document), + setContent, + getContent: () => contentRef.current, }); return () => bridge.destroy(); }, []); @@ -24,6 +29,26 @@ export function PreviewDemo() { { + if (typeof value !== "object" || value === null) return []; + const item = value as { + readonly id?: unknown; + readonly type?: unknown; + readonly data?: unknown; + }; + return typeof item.id === "string" && + typeof item.type === "string" && + typeof item.data === "object" && + item.data !== null + ? [ + { + id: item.id, + type: item.type, + data: item.data as Readonly>, + }, + ] + : []; + })} preview />
diff --git a/apps/playground-astro/src/pages/[...slug].astro b/apps/playground-astro/src/pages/[...slug].astro index 95f6a52..1c35210 100644 --- a/apps/playground-astro/src/pages/[...slug].astro +++ b/apps/playground-astro/src/pages/[...slug].astro @@ -1,18 +1,63 @@ --- +import { CmsPageRenderer } from "@git-native-cms/react"; +import { sandboxRegistry } from "../cms-registry"; +import { loadPublishedRedirect, loadPublishedSite } from "../public-content"; import "../styles/site.css"; -import { PreviewDemo } from "../components/PreviewDemo"; -const isPreview = (Astro.params.slug ?? "").startsWith("__cms/preview"); -if (!isPreview) Astro.response.status = 404; +const segments = (Astro.params.slug ?? "").split("/").filter(Boolean); +const locale = segments[0] ?? ""; +if ((locale === "en-US" || locale === "pl-PL") && segments.length > 1) { + const target = await loadPublishedRedirect(`/${segments.slice(1).join("/")}`); + if (target !== undefined) { + return Astro.redirect(`/${locale}${target === "/" ? "" : target}`, 308); + } +} +const supportedLocale = (locale === "en-US" || locale === "pl-PL") && segments.length === 1; +if (!supportedLocale) Astro.response.status = 404; +const published = supportedLocale ? await loadPublishedSite(locale) : undefined; +const document = published?.page; +const renderableDocument = document ?? { id: "not-found", title: "Not found", sections: [] }; +const content = published?.content ?? []; +const navigation = content.find((item) => item.type === "navigation"); +const navigationItems = Array.isArray(navigation?.data.items) ? navigation.data.items : []; +const navigationLinks = navigationItems.flatMap((item) => { + if (typeof item !== "object" || item === null || Array.isArray(item)) return []; + const value = item as Readonly>; + return [{ href: String(value.href ?? "/"), label: String(value.label ?? "Link") }]; +}); +const origin = "https://git-native-cms-astro.vercel.app"; --- - + - Preview · Astro playground + {document?.seo?.title ?? (supportedLocale ? "Fieldnotes" : "Not found")} + {document?.seo?.description && } + {supportedLocale && } + {supportedLocale && } + {supportedLocale && } + {supportedLocale && } - {isPreview ? :

Not found

} + { + document ? ( +
+ + +
+ ) : ( +
+

Not found

+
+ ) + } diff --git a/apps/playground-astro/src/pages/api/cms/[...path].ts b/apps/playground-astro/src/pages/api/cms/[...path].ts deleted file mode 100644 index 4bd56c6..0000000 --- a/apps/playground-astro/src/pages/api/cms/[...path].ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { APIRoute } from "astro"; -import { hostedRuntime } from "../../../cms-runtime"; - -export const ALL: APIRoute = async ({ request }) => await hostedRuntime.handle(request); diff --git a/apps/playground-astro/src/pages/cms/[...path].astro b/apps/playground-astro/src/pages/cms/[...path].astro deleted file mode 100644 index e34e2e1..0000000 --- a/apps/playground-astro/src/pages/cms/[...path].astro +++ /dev/null @@ -1,16 +0,0 @@ ---- -import "@git-native-cms/editor-ui/styles.css"; -import { hostedRuntime } from "../../cms-runtime"; -import { CmsDemo } from "../../components/CmsDemo"; - -const state = await hostedRuntime.editorState(Astro.request); ---- - - - - - - CMS · Astro playground - - - diff --git a/apps/playground-astro/src/pages/index.astro b/apps/playground-astro/src/pages/index.astro index febfd35..6c63dd0 100644 --- a/apps/playground-astro/src/pages/index.astro +++ b/apps/playground-astro/src/pages/index.astro @@ -1,25 +1,39 @@ --- import { CmsPageRenderer } from "@git-native-cms/react"; import { sandboxRegistry } from "../cms-registry"; -import { loadPublishedPage } from "../public-content"; +import { loadPublishedSite } from "../public-content"; import "../styles/site.css"; -const document = await loadPublishedPage(); +const { page: document, content } = await loadPublishedSite(); +const navigation = content.find((item) => item.type === "navigation"); +const navigationItems = Array.isArray(navigation?.data.items) ? navigation.data.items : []; +const navigationLinks = navigationItems.flatMap((item) => { + if (typeof item !== "object" || item === null || Array.isArray(item)) return []; + const value = item as Readonly>; + return [{ href: String(value.href ?? "/"), label: String(value.label ?? "Link") }]; +}); --- - Git-native CMS · Astro playground + {document.seo?.title ?? "Git-native CMS · Astro playground"} + + + + +
- +
diff --git a/apps/playground-astro/src/public-content.ts b/apps/playground-astro/src/public-content.ts index 7db93de..37d9f74 100644 --- a/apps/playground-astro/src/public-content.ts +++ b/apps/playground-astro/src/public-content.ts @@ -1,30 +1,129 @@ -import { cdnSource, createContentClient } from "@git-native-cms/delivery"; -import type { CmsPageDocument } from "@git-native-cms/react"; -import { document as fixture } from "./cms-fixture"; +import { + cdnSource, + createContentClient, + loadContentGraph, + loadRedirects, + resolveRedirect, +} from "@git-native-cms/delivery"; +import { materializeLocalizedValue, type LocalizedDocument } from "@git-native-cms/localization"; +import type { CmsPageDocument, RenderContentDocument } from "@git-native-cms/react"; +import { contentDocuments, document as fixture } from "./cms-fixture"; interface PublishedPage extends CmsPageDocument { readonly title?: string; + readonly description?: string; + readonly locales?: Readonly>; + readonly seo?: { + readonly title?: string; + readonly description?: string; + }; +} + +const localeDefinitions = [ + { code: "en-US", language: "en" }, + { code: "pl-PL", language: "pl", fallback: "en-US" }, +] as const; + +function localizedPage(page: PublishedPage, locale: string): PublishedPage { + const translations = Object.entries(page.locales ?? {}).flatMap(([code, value]) => { + if (typeof value !== "object" || value === null) return []; + const record = value as Readonly>; + if (typeof record.fields !== "object" || record.fields === null) return []; + return [ + { + locale: code, + status: "translated" as const, + fields: record.fields as Record, + } satisfies LocalizedDocument>, + ]; + }); + return materializeLocalizedValue(page, locale, localeDefinitions, translations); } function fallbackPage(): PublishedPage { return { id: fixture.id, - route: fixture.data.route, - sections: fixture.data.sections, - title: fixture.data.title, + ...fixture.data, }; } -export async function loadPublishedPage(): Promise { +function fallbackContent(): readonly RenderContentDocument[] { + return contentDocuments + .filter((document) => document.type !== "pages") + .map((document) => ({ + id: document.id, + type: document.type, + data: + typeof document.data === "object" && document.data !== null && !Array.isArray(document.data) + ? (document.data as Readonly>) + : { value: document.data }, + })); +} + +export async function loadPublishedPage(locale = "en-US"): Promise { + const baseUrl = import.meta.env.CMS_PUBLIC_RELEASES_URL as string | undefined; + if (baseUrl === undefined || baseUrl.length === 0) return localizedPage(fallbackPage(), locale); + try { + const client = createContentClient({ + environment: "production", + source: cdnSource({ baseUrl }), + }); + return localizedPage(await client.get("content/pages/home/index.json"), locale); + } catch { + return localizedPage(fallbackPage(), locale); + } +} + +export async function loadPublishedRedirect(path: string): Promise { const baseUrl = import.meta.env.CMS_PUBLIC_RELEASES_URL as string | undefined; - if (baseUrl === undefined || baseUrl.length === 0) return fallbackPage(); + if (baseUrl === undefined || baseUrl.length === 0) { + return fixture.data.redirectFrom.includes(path) ? "/" : undefined; + } try { const client = createContentClient({ environment: "production", source: cdnSource({ baseUrl }), }); - return await client.get("content/pages/home/index.json"); + return resolveRedirect(await loadRedirects(client), path); } catch { - return fallbackPage(); + return fixture.data.redirectFrom.includes(path) ? "/" : undefined; + } +} + +function renderDocument(value: unknown): RenderContentDocument | undefined { + if (typeof value !== "object" || value === null) return undefined; + const record = value as Readonly>; + if (typeof record.id !== "string" || typeof record.type !== "string") return undefined; + const data = Object.fromEntries( + Object.entries(record).filter(([key]) => !["id", "type", "schemaVersion"].includes(key)), + ); + return { id: record.id, type: record.type, data }; +} + +export async function loadPublishedSite(locale = "en-US"): Promise<{ + readonly page: PublishedPage; + readonly content: readonly RenderContentDocument[]; +}> { + const page = await loadPublishedPage(locale); + const baseUrl = import.meta.env.CMS_PUBLIC_RELEASES_URL as string | undefined; + if (baseUrl === undefined || baseUrl.length === 0) { + return { page, content: fallbackContent() }; } + const client = createContentClient({ + environment: "production", + source: cdnSource({ baseUrl }), + }); + const content = await loadContentGraph(client).catch(async () => { + const values = await Promise.all( + [ + "content/collections/plans/lite/index.json", + "content/globals/navigation/primary/index.json", + ].map((path) => client.get(path).catch(() => undefined)), + ); + return values.flatMap((value) => { + const document = renderDocument(value); + return document === undefined ? [] : [document]; + }); + }); + return { page, content }; } diff --git a/apps/playground-astro/src/styles/site.css b/apps/playground-astro/src/styles/site.css index 7f89292..119aaa7 100644 --- a/apps/playground-astro/src/styles/site.css +++ b/apps/playground-astro/src/styles/site.css @@ -1,3 +1,8 @@ +@import "@fontsource/atkinson-hyperlegible/latin-400.css"; +@import "@fontsource/atkinson-hyperlegible/latin-700.css"; +@import "@fontsource/source-serif-4/latin-600.css"; +@import "@fontsource/ibm-plex-mono/latin-700.css"; + :root { color: #17212b; background: #f7f8fa; @@ -97,6 +102,30 @@ body { font-family: "IBM Plex Mono", monospace; } +.hero__media { + overflow: hidden; + margin: 0; + border: 1px solid #cbd3dc; + border-radius: 14px; + background: white; + box-shadow: 0 20px 55px rgb(23 33 43 / 12%); +} + +.hero__media img { + display: block; + width: 100%; + aspect-ratio: 4 / 3; + object-fit: cover; +} + +.hero__media figcaption { + padding: 10px 12px; + color: #627080; + font: + 700 10px/1.2 "IBM Plex Mono", + monospace; +} + @media (max-width: 760px) { .hero { grid-template-columns: 1fr; diff --git a/apps/playground-astro/vercel.json b/apps/playground-astro/vercel.json index da6139f..13eae27 100644 --- a/apps/playground-astro/vercel.json +++ b/apps/playground-astro/vercel.json @@ -1,5 +1,44 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", "framework": "astro", - "buildCommand": "cd ../.. && pnpm turbo run build --filter=@git-native-cms/playground-astro" + "buildCommand": "cd ../.. && pnpm turbo run build --filter=@git-native-cms/playground-astro", + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self' https://github.com; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://*.r2.cloudflarestorage.com https://*.r2.dev; frame-src 'self'; worker-src 'self' blob:; manifest-src 'self'; upgrade-insecure-requests" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=63072000; includeSubDomains; preload" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-Frame-Options", + "value": "SAMEORIGIN" + }, + { + "key": "Referrer-Policy", + "value": "strict-origin-when-cross-origin" + }, + { + "key": "Permissions-Policy", + "value": "camera=(), microphone=(), geolocation=(), payment=(), usb=()" + }, + { + "key": "Cross-Origin-Opener-Policy", + "value": "same-origin" + }, + { + "key": "Cross-Origin-Resource-Policy", + "value": "same-site" + } + ] + } + ] } diff --git a/apps/playground-next/app/[locale]/[[...slug]]/page.tsx b/apps/playground-next/app/[locale]/[[...slug]]/page.tsx new file mode 100644 index 0000000..05d58e6 --- /dev/null +++ b/apps/playground-next/app/[locale]/[[...slug]]/page.tsx @@ -0,0 +1,78 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import type { Metadata } from "next"; +import { CmsPageRenderer } from "@git-native-cms/react"; +import { sandboxRegistry } from "../../../cms.registry"; +import { + loadPublishedPage, + loadPublishedRedirect, + loadPublishedSite, +} from "../../../public-content"; + +export const dynamic = "force-dynamic"; + +const locales = new Set(["en-US", "pl-PL"]); + +function text(value: unknown, fallback: string): string { + return typeof value === "string" ? value : fallback; +} + +export async function generateMetadata(props: { + readonly params: Promise<{ readonly locale: string; readonly slug?: readonly string[] }>; +}): Promise { + const { locale } = await props.params; + if (!locales.has(locale)) return {}; + const page = await loadPublishedPage(locale); + const origin = process.env.CMS_ORIGIN ?? "https://git-native-cms-next.vercel.app"; + return { + title: page.seo?.title ?? page.title, + description: page.seo?.description ?? page.description, + alternates: { + canonical: `${origin}/${locale}`, + languages: { + "en-US": `${origin}/en-US`, + "pl-PL": `${origin}/pl-PL`, + "x-default": origin, + }, + }, + }; +} + +export default async function LocalizedPage(props: { + readonly params: Promise<{ readonly locale: string; readonly slug?: readonly string[] }>; +}) { + const { locale, slug } = await props.params; + if (!locales.has(locale)) notFound(); + if ((slug?.length ?? 0) > 0) { + const target = await loadPublishedRedirect(`/${slug?.join("/")}`); + if (target !== undefined) redirect(`/${locale}${target === "/" ? "" : target}`); + notFound(); + } + const { page: document, content } = await loadPublishedSite(locale); + const navigation = content.find((item) => item.type === "navigation"); + const navigationItems = Array.isArray(navigation?.data.items) ? navigation.data.items : []; + return ( +
+ + +
+ ); +} diff --git a/apps/playground-next/app/cms/[[...path]]/page.tsx b/apps/playground-next/app/cms/[[...path]]/page.tsx index 24c735c..93c8ee0 100644 --- a/apps/playground-next/app/cms/[[...path]]/page.tsx +++ b/apps/playground-next/app/cms/[[...path]]/page.tsx @@ -4,8 +4,14 @@ import { CmsDemo } from "../../../components/cms-demo"; export const dynamic = "force-dynamic"; -export default async function CmsPage() { +export default async function CmsPage(props: { + readonly params: Promise<{ readonly path?: readonly string[] }>; +}) { const requestHeaders = await headers(); - const state = await hostedRuntime.editorState(requestHeaders.get("cookie")); + const params = await props.params; + const state = await hostedRuntime.editorState( + requestHeaders.get("cookie"), + params.path?.join("/") ?? "", + ); return ; } diff --git a/apps/playground-next/app/layout.tsx b/apps/playground-next/app/layout.tsx index 722d6a4..bba0a3d 100644 --- a/apps/playground-next/app/layout.tsx +++ b/apps/playground-next/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { headers } from "next/headers"; import type { ReactNode } from "react"; import "./site.css"; @@ -7,9 +8,11 @@ export const metadata: Metadata = { description: "A real Next.js site edited by the embedded Git-native CMS.", }; -export default function RootLayout(props: { readonly children: ReactNode }) { +export default async function RootLayout(props: { readonly children: ReactNode }) { + const localeHeader = (await headers()).get("x-cms-locale"); + const locale = localeHeader === "en-US" || localeHeader === "pl-PL" ? localeHeader : "en-US"; return ( - + {props.children} ); diff --git a/apps/playground-next/app/page.tsx b/apps/playground-next/app/page.tsx index 158fa9f..f70e56a 100644 --- a/apps/playground-next/app/page.tsx +++ b/apps/playground-next/app/page.tsx @@ -1,24 +1,55 @@ import Link from "next/link"; +import type { Metadata } from "next"; import { CmsPageRenderer } from "@git-native-cms/react"; import { sandboxRegistry } from "../cms.registry"; -import { loadPublishedPage } from "../public-content"; +import { loadPublishedPage, loadPublishedSite } from "../public-content"; export const dynamic = "force-dynamic"; +export async function generateMetadata(): Promise { + const page = await loadPublishedPage(); + const origin = process.env.CMS_ORIGIN ?? "https://git-native-cms-next.vercel.app"; + return { + title: page.seo?.title ?? page.title, + description: page.seo?.description ?? page.description, + alternates: { + canonical: origin, + languages: { + "en-US": `${origin}/en-US`, + "pl-PL": `${origin}/pl-PL`, + "x-default": origin, + }, + }, + }; +} + +function text(value: unknown, fallback: string): string { + return typeof value === "string" ? value : fallback; +} + export default async function HomePage() { - const document = await loadPublishedPage(); + const { page: document, content } = await loadPublishedSite(); + const navigation = content.find((item) => item.type === "navigation"); + const navigationItems = Array.isArray(navigation?.data.items) ? navigation.data.items : []; return (
- +
); } diff --git a/apps/playground-next/app/site.css b/apps/playground-next/app/site.css index fbbcab5..17f71a8 100644 --- a/apps/playground-next/app/site.css +++ b/apps/playground-next/app/site.css @@ -1,3 +1,8 @@ +@import "@fontsource/atkinson-hyperlegible/latin-400.css"; +@import "@fontsource/atkinson-hyperlegible/latin-700.css"; +@import "@fontsource/source-serif-4/latin-600.css"; +@import "@fontsource/ibm-plex-mono/latin-700.css"; + :root { color: #17212b; background: #f7f8fa; @@ -108,6 +113,31 @@ body { list-style: none; } +.hero__media { + position: relative; + overflow: hidden; + margin: 0; + border: 1px solid #cbd3dc; + border-radius: 14px; + background: white; + box-shadow: 0 20px 55px rgb(23 33 43 / 12%); +} + +.hero__media img { + display: block; + width: 100%; + aspect-ratio: 4 / 3; + object-fit: cover; +} + +.hero__media figcaption { + padding: 10px 12px; + color: #627080; + font: + 700 10px/1.2 "IBM Plex Mono", + monospace; +} + .hero__proof li { display: flex; gap: 12px; @@ -138,6 +168,28 @@ body { letter-spacing: -0.035em; } +.pricing-grid__items { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; +} + +.pricing-grid__items article { + display: grid; + gap: 10px; + border: 1px solid #cbd3dc; + border-radius: 12px; + padding: 22px; + background: white; +} + +.pricing-grid__items article span { + color: #147a5a; + font: + 700 13px/1 "IBM Plex Mono", + monospace; +} + @media (max-width: 800px) { .hero { grid-template-columns: 1fr; diff --git a/apps/playground-next/cms.fixture.ts b/apps/playground-next/cms.fixture.ts index 24ffec8..a6a8334 100644 --- a/apps/playground-next/cms.fixture.ts +++ b/apps/playground-next/cms.fixture.ts @@ -9,6 +9,8 @@ import type { IsoTimestamp, Revision, } from "@git-native-cms/core"; +import type { Asset } from "@git-native-cms/application"; +import type { AssetId } from "@git-native-cms/core"; export const sandboxActor: Actor = { id: "actor_sandbox" as ActorId, @@ -25,22 +27,42 @@ export const sandboxChange: Change = { description: "Refresh the homepage introduction and proof points.", ownerId: sandboxActor.id, baseBranch: "main", - baseCommit: "sha_main_1" as GitCommitSha, + baseCommit: "0000000000000000000000000000000000000001" as GitCommitSha, branchName: "cms/sandbox-editor/autumn-campaign-demo", status: "draft", createdAt: "2026-07-27T12:00:00.000Z" as IsoTimestamp, updatedAt: "2026-07-27T12:00:00.000Z" as IsoTimestamp, }; +export const sandboxAssets: readonly Asset[] = [ + { + id: "ast_0123456789abcdef01234567" as AssetId, + fileName: "editorial-grid.png", + mimeType: "image/png", + size: 68, + checksum: "9d7f1cda29a611c744467d427f3f8726172b68f24e505b2afdc67cf1b5744c54", + url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + altText: "Blue editorial grid placeholder", + }, +]; + export const sandboxDocument: ContentDocument<{ readonly title: string; readonly route: { readonly path: string }; + readonly redirectFrom: readonly string[]; + readonly seo: { readonly title: string; readonly description: string }; + readonly locales: Readonly< + Record> }> + >; readonly sections: readonly { readonly id: string; readonly type: string; readonly version: number; - readonly heading: string; - readonly description: string; + readonly heading?: string; + readonly description?: string; + readonly bindings?: Readonly>; + readonly ref?: string; + readonly overrides?: Readonly>; }[]; }> = { id: "doc_home" as DocumentId, @@ -50,6 +72,25 @@ export const sandboxDocument: ContentDocument<{ data: { title: "Homepage", route: { path: "/" }, + redirectFrom: ["/welcome"], + seo: { + title: "Fieldnotes · Git-native CMS", + description: "A live Next.js demonstration of Git-native visual publishing.", + }, + locales: { + "pl-PL": { + status: "translated", + fields: { + "/title": "Strona główna", + "/sections/0/heading": "Praca redakcyjna bez widocznej maszynerii.", + "/sections/0/description": + "Buduj strony z prawdziwych komponentów i publikuj niezmienne wydania.", + "/sections/1/heading": "Jasna droga do publikacji", + "/sections/1/description": + "Każda zmiana przechodzi przez przegląd i staging przed publikacją.", + }, + }, + }, sections: [ { id: "sec_hero", @@ -65,6 +106,141 @@ export const sandboxDocument: ContentDocument<{ heading: "A clear path to publication", description: "Every Change moves through review and staging before it goes live.", }, + { + id: "sec_pricing", + type: "pricingGrid", + version: 1, + heading: "Plans that grow with the publication", + bindings: { plans: { collection: "plans" } }, + }, + { + id: "sec_reusable", + type: "reference", + version: 1, + ref: "reusable-blocks/editorial-note", + overrides: {}, + }, + ], + }, +}; + +export const sandboxNavigation: ContentDocument<{ + readonly title: string; + readonly slug: string; + readonly items: readonly { + readonly label: string; + readonly href: string; + }[]; +}> = { + id: "doc_navigation_primary" as DocumentId, + type: "navigation", + schemaVersion: 1, + revision: "sha_content_1" as Revision, + data: { + title: "Primary navigation", + slug: "primary", + items: [ + { label: "Journal", href: "/#journal" }, + { label: "Plans", href: "/#plans" }, ], }, }; + +export const sandboxPricingPlans: readonly ContentDocument<{ + readonly title: string; + readonly slug: string; + readonly name: string; + readonly description: string; + readonly price: { + readonly amount: number; + readonly currency: string; + }; + readonly locale: string; +}>[] = [ + { + id: "doc_plan_lite" as DocumentId, + type: "plans", + schemaVersion: 1, + revision: "sha_content_1" as Revision, + data: { + title: "Fieldnotes Lite", + slug: "lite", + name: "Lite", + description: "A focused workflow for one publication.", + price: { amount: 1900, currency: "USD" }, + locale: "en-US", + }, + }, + { + id: "doc_plan_studio" as DocumentId, + type: "plans", + schemaVersion: 1, + revision: "sha_content_1" as Revision, + data: { + title: "Fieldnotes Studio", + slug: "studio", + name: "Studio", + description: "Review, staging and releases for editorial teams.", + price: { amount: 4900, currency: "USD" }, + locale: "en-US", + }, + }, +]; + +export const sandboxSettings: ContentDocument<{ + readonly title: string; + readonly slug: string; + readonly siteName: string; + readonly siteUrl: string; + readonly defaultLocale: string; +}> = { + id: "doc_settings_site" as DocumentId, + type: "settings", + schemaVersion: 1, + revision: "sha_content_1" as Revision, + data: { + title: "Site settings", + slug: "site", + siteName: "Fieldnotes", + siteUrl: "https://git-native-cms-next.vercel.app", + defaultLocale: "en-US", + }, +}; + +export const sandboxReusableBlock: ContentDocument<{ + readonly title: string; + readonly slug: string; + readonly sections: readonly { + readonly id: string; + readonly type: string; + readonly version: number; + readonly heading: string; + readonly description: string; + }[]; +}> = { + id: "doc_reusable_editorial_note" as DocumentId, + type: "reusable-blocks", + schemaVersion: 1, + revision: "sha_content_1" as Revision, + data: { + title: "Editorial note", + slug: "editorial-note", + sections: [ + { + id: "sec_editorial_note", + type: "proof", + version: 1, + heading: "One source, every page", + description: "Reusable blocks stay synchronized until an editor explicitly detaches them.", + }, + ], + }, +}; + +export const sandboxContentDocuments: readonly ContentDocument[] = [ + sandboxDocument, + sandboxNavigation, + ...sandboxPricingPlans, + sandboxSettings, + sandboxReusableBlock, +]; diff --git a/apps/playground-next/cms.registry.tsx b/apps/playground-next/cms.registry.tsx index 54c7b56..d71c213 100644 --- a/apps/playground-next/cms.registry.tsx +++ b/apps/playground-next/cms.registry.tsx @@ -1,42 +1,129 @@ import { createReactRegistry, registerReactSection } from "@git-native-cms/react"; import { defineSection, fields } from "@git-native-cms/schema"; -const sectionDefinition = (name: string, label: string) => +const sectionDefinition = (name: string, label: string, media = false) => defineSection({ name, version: 1, label, + category: name === "hero" ? "Introduction" : "Evidence", + description: + name === "hero" + ? "A primary statement with optional media from asset storage." + : "A focused proof point or editorial callout.", fields: { heading: fields.text({ required: true, inline: true }), description: fields.text({ inline: true }), + ...(media + ? { + media: fields.asset({ + label: "Hero media", + description: "An image selected from the project asset library.", + accept: ["image/*"], + aspectRatio: [4, 3], + }), + } + : {}), + }, + defaults: { + heading: name === "hero" ? "A clear headline" : "Why this matters", + description: "Add supporting context here.", + ...(media ? { media: null } : {}), }, }); +function text(value: unknown, fallback: string): string { + return typeof value === "string" || typeof value === "number" ? String(value) : fallback; +} + +function asset(value: unknown): + | { + readonly url: string; + readonly fileName: string; + readonly altText?: string; + } + | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + const record = value as Readonly>; + if (typeof record.url !== "string" || typeof record.fileName !== "string") return undefined; + return { + url: record.url, + fileName: record.fileName, + ...(typeof record.altText === "string" ? { altText: record.altText } : {}), + }; +} + export const sandboxRegistry = createReactRegistry({ sections: [ - registerReactSection(sectionDefinition("hero", "Hero"), ({ section }) => ( -
-
- A Git-native publication -

{String(section.heading)}

-

{String(section.description)}

-
- -
- )), + registerReactSection(sectionDefinition("hero", "Hero", true), ({ section }) => { + const media = asset(section.media); + return ( +
+
+ A Git-native publication +

{String(section.heading)}

+

{String(section.description)}

+
+ {media === undefined ? ( + + ) : ( +
+ {media.altText +
{media.fileName}
+
+ )} +
+ ); + }), registerReactSection(sectionDefinition("proof", "Proof"), ({ section }) => (
Publication proof -

{String(section.heading)}

-

{String(section.description)}

+

{String(section.heading)}

+

{String(section.description)}

)), + registerReactSection( + defineSection({ + name: "pricingGrid", + version: 1, + label: "Pricing grid", + fields: { + heading: fields.text({ required: true, inline: true }), + bindings: fields.json(), + }, + }), + ({ section }) => ( +
+ Collection materialization +

{String(section.heading)}

+
+ {(Array.isArray(section.plans) ? section.plans : []).map((plan) => { + const value = plan as Readonly>; + const price = + typeof value.price === "object" && value.price !== null + ? (value.price as Readonly>) + : {}; + return ( +
+ {text(value.name, text(value.title, "Plan"))} + + {typeof price.amount === "number" + ? `${text(price.currency, "USD")} ${(price.amount / 100).toFixed(2)}` + : ""} + +
+ ); + })} +
+
+ ), + ), ], }); diff --git a/apps/playground-next/cms.runtime.ts b/apps/playground-next/cms.runtime.ts index dc0d54c..d95f636 100644 --- a/apps/playground-next/cms.runtime.ts +++ b/apps/playground-next/cms.runtime.ts @@ -1,21 +1,13 @@ -import { createCmsApplication } from "@git-native-cms/application"; -import { createHostedCmsRuntime, type HostedCmsRuntime } from "@git-native-cms/hosted-runtime"; -import { AuthorizationService } from "@git-native-cms/permissions"; -import { createCmsServer } from "@git-native-cms/server"; -import { - DeterministicIds, - FixedClock, - MemoryAuditSink, - MemoryContentRepository, - MemoryGitProvider, - MemoryIdempotencyStore, -} from "@git-native-cms/testing"; -import { sandboxActor, sandboxChange, sandboxDocument } from "./cms.fixture"; +import { createMemoryHostedRuntime } from "@git-native-cms/e2e-fixtures"; +import { createHostedCmsRuntime } from "@git-native-cms/hosted-runtime"; +import { sandboxActor, sandboxAssets, sandboxChange, sandboxContentDocuments } from "./cms.fixture"; +import { sandboxRegistry } from "./cms.registry"; const productionRuntime = createHostedCmsRuntime({ origin: "https://git-native-cms-next.vercel.app", projectName: "Fieldnotes / Next.js", environment: process.env, + registryManifest: sandboxRegistry.manifest, repository: { owner: "DMTcorp", name: "git-native-cms-sandbox-content", @@ -25,41 +17,14 @@ const productionRuntime = createHostedCmsRuntime({ }, }); -function memoryRuntime(): HostedCmsRuntime { - const git = new MemoryGitProvider(); - const content = new MemoryContentRepository(); - content.seed(sandboxChange.branchName, sandboxDocument); - const server = createCmsServer({ - application: createCmsApplication({ - git, - content, - authorization: new AuthorizationService(), - clock: new FixedClock(), - ids: new DeterministicIds(), - idempotency: new MemoryIdempotencyStore(), - audit: new MemoryAuditSink(), - }), - actorForRequest: async () => sandboxActor, - verifyCsrf: async (request) => request.headers.get("x-csrf-token") === "sandbox", - queries: { - bootstrap: async () => ({ actor: sandboxActor, project: { name: "Fieldnotes" } }), - listChanges: async () => [sandboxChange], - getChange: async () => sandboxChange, - listDocuments: async () => content.listDocuments({ ref: sandboxChange.branchName }), - listReleases: async () => [], - }, +function memoryRuntime() { + return createMemoryHostedRuntime({ + actor: sandboxActor, + initialChange: sandboxChange, + documents: sandboxContentDocuments, + assets: sandboxAssets, + projectName: "Fieldnotes / Next.js", }); - return { - handle: (request) => server.handle(request), - editorState: async () => ({ - authenticated: true, - actor: sandboxActor, - change: sandboxChange, - document: sandboxDocument, - csrfToken: "sandbox", - projectName: "Fieldnotes / Next.js", - }), - }; } export const hostedRuntime = diff --git a/apps/playground-next/components/cms-demo.tsx b/apps/playground-next/components/cms-demo.tsx index 7977cf4..f5268b6 100644 --- a/apps/playground-next/components/cms-demo.tsx +++ b/apps/playground-next/components/cms-demo.tsx @@ -1,65 +1,9 @@ "use client"; import type { HostedEditorState } from "@git-native-cms/hosted-runtime"; -import { advanceHostedWorkflow } from "@git-native-cms/hosted-runtime/client"; -import type { Revision } from "@git-native-cms/core"; -import { CmsEditorPage } from "@git-native-cms/next/editor"; +import { CmsHostedApp } from "@git-native-cms/hosted-runtime/react"; +import { sandboxRegistry } from "../cms.registry"; export function CmsDemo(props: { readonly state: HostedEditorState }) { - if (!props.state.authenticated) { - return ( -
-
- Hosted sandbox -

Open the real Git-backed editor.

-

- Sign in with GitHub. The CMS will create your own Change branch in the public sandbox - content repository; no personal repository access is requested. -

- - Continue with GitHub - - {props.state.projectName} -
-
- ); - } - const { change, document, csrfToken } = props.state; - return ( - { - const response = await fetch(`/api/cms/changes/${change.id}/documents/${document.id}`, { - method: "PATCH", - headers: { - "content-type": "application/json", - "idempotency-key": globalThis.crypto.randomUUID(), - "x-csrf-token": csrfToken, - }, - body: JSON.stringify({ - expectedRevision, - patches, - idempotencyKey: globalThis.crypto.randomUUID(), - }), - }); - if (!response.ok) throw new Error("Save failed."); - const envelope = (await response.json()) as { - readonly payload: { readonly document: { readonly revision: Revision } }; - }; - return envelope.payload.document.revision; - }} - onWorkflowAction={({ action, expectedRevision, pullRequestNumber }) => - advanceHostedWorkflow({ - action, - changeId: change.id, - changeName: change.name, - csrfToken, - expectedRevision, - ...(pullRequestNumber === undefined ? {} : { pullRequestNumber }), - }) - } - /> - ); + return ; } diff --git a/apps/playground-next/components/preview-demo.tsx b/apps/playground-next/components/preview-demo.tsx index 646bab1..bb5e04f 100644 --- a/apps/playground-next/components/preview-demo.tsx +++ b/apps/playground-next/components/preview-demo.tsx @@ -8,8 +8,11 @@ import { sandboxDocument } from "../cms.fixture"; export function PreviewDemo() { const [document, setDocument] = useState(sandboxDocument.data); + const [content, setContent] = useState([]); const documentRef = useRef(document); + const contentRef = useRef(content); documentRef.current = document; + contentRef.current = content; useEffect(() => { const sessionId = new URLSearchParams(window.location.search).get("cmsSession"); if (sessionId === null) return; @@ -18,6 +21,8 @@ export function PreviewDemo() { sessionId, getDocument: () => documentRef.current, setDocument: (next) => setDocument(next as typeof document), + setContent, + getContent: () => contentRef.current, }); return () => bridge.destroy(); }, []); @@ -26,6 +31,26 @@ export function PreviewDemo() { { + if (typeof value !== "object" || value === null) return []; + const item = value as { + readonly id?: unknown; + readonly type?: unknown; + readonly data?: unknown; + }; + return typeof item.id === "string" && + typeof item.type === "string" && + typeof item.data === "object" && + item.data !== null + ? [ + { + id: item.id, + type: item.type, + data: item.data as Readonly>, + }, + ] + : []; + })} preview /> diff --git a/apps/playground-next/next.config.ts b/apps/playground-next/next.config.ts index 50eda4b..dc4a6a2 100644 --- a/apps/playground-next/next.config.ts +++ b/apps/playground-next/next.config.ts @@ -1,7 +1,17 @@ import type { NextConfig } from "next"; +import { fileURLToPath } from "node:url"; + +const sharpPlatform = `${process.platform}-${process.arch}`; const config: NextConfig = { reactStrictMode: true, + outputFileTracingRoot: fileURLToPath(new URL("../..", import.meta.url)), + outputFileTracingIncludes: { + "/api/cms/*": [ + `../../node_modules/.pnpm/@img+sharp-${sharpPlatform}@*/node_modules/@img/sharp-${sharpPlatform}/**/*`, + `../../node_modules/.pnpm/@img+sharp-libvips-${sharpPlatform}@*/node_modules/@img/sharp-libvips-${sharpPlatform}/**/*`, + ], + }, transpilePackages: [ "@git-native-cms/editor", "@git-native-cms/editor-ui", diff --git a/apps/playground-next/package.json b/apps/playground-next/package.json index 6d0e4b0..8da85db 100644 --- a/apps/playground-next/package.json +++ b/apps/playground-next/package.json @@ -9,13 +9,18 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@fontsource/atkinson-hyperlegible": "catalog:", + "@fontsource/ibm-plex-mono": "catalog:", + "@fontsource/source-serif-4": "catalog:", "@git-native-cms/application": "workspace:*", "@git-native-cms/core": "workspace:*", "@git-native-cms/delivery": "workspace:*", "@git-native-cms/editor": "workspace:*", "@git-native-cms/editor-bridge": "workspace:*", "@git-native-cms/editor-ui": "workspace:*", + "@git-native-cms/e2e-fixtures": "workspace:*", "@git-native-cms/hosted-runtime": "workspace:*", + "@git-native-cms/localization": "workspace:*", "@git-native-cms/next": "workspace:*", "@git-native-cms/permissions": "workspace:*", "@git-native-cms/react": "workspace:*", @@ -24,7 +29,8 @@ "@git-native-cms/testing": "workspace:*", "next": "latest", "react": "catalog:", - "react-dom": "catalog:" + "react-dom": "catalog:", + "sharp": "latest" }, "devDependencies": { "@types/react": "latest", diff --git a/apps/playground-next/proxy.ts b/apps/playground-next/proxy.ts new file mode 100644 index 0000000..bedf439 --- /dev/null +++ b/apps/playground-next/proxy.ts @@ -0,0 +1,12 @@ +import { NextResponse, type NextRequest } from "next/server"; + +export function proxy(request: NextRequest): NextResponse { + const headers = new Headers(request.headers); + const locale = request.nextUrl.pathname.split("/")[1]; + headers.set("x-cms-locale", locale === "en-US" || locale === "pl-PL" ? locale : "en-US"); + return NextResponse.next({ request: { headers } }); +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], +}; diff --git a/apps/playground-next/public-content.ts b/apps/playground-next/public-content.ts index 725c4c9..41dd662 100644 --- a/apps/playground-next/public-content.ts +++ b/apps/playground-next/public-content.ts @@ -1,30 +1,129 @@ -import type { CmsPageDocument } from "@git-native-cms/react"; -import { cdnSource, createContentClient } from "@git-native-cms/delivery"; -import { sandboxDocument } from "./cms.fixture"; +import type { CmsPageDocument, RenderContentDocument } from "@git-native-cms/react"; +import { + cdnSource, + createContentClient, + loadContentGraph, + loadRedirects, + resolveRedirect, +} from "@git-native-cms/delivery"; +import { materializeLocalizedValue, type LocalizedDocument } from "@git-native-cms/localization"; +import { sandboxContentDocuments, sandboxDocument } from "./cms.fixture"; interface PublishedPage extends CmsPageDocument { readonly title?: string; + readonly description?: string; + readonly locales?: Readonly>; + readonly seo?: { + readonly title?: string; + readonly description?: string; + }; +} + +const localeDefinitions = [ + { code: "en-US", language: "en" }, + { code: "pl-PL", language: "pl", fallback: "en-US" }, +] as const; + +function localizedPage(page: PublishedPage, locale: string): PublishedPage { + const translations = Object.entries(page.locales ?? {}).flatMap(([code, value]) => { + if (typeof value !== "object" || value === null) return []; + const record = value as Readonly>; + if (typeof record.fields !== "object" || record.fields === null) return []; + return [ + { + locale: code, + status: "translated" as const, + fields: record.fields as Record, + } satisfies LocalizedDocument>, + ]; + }); + return materializeLocalizedValue(page, locale, localeDefinitions, translations); } function fallbackPage(): PublishedPage { return { id: sandboxDocument.id, - route: sandboxDocument.data.route, - sections: sandboxDocument.data.sections, - title: sandboxDocument.data.title, + ...sandboxDocument.data, }; } -export async function loadPublishedPage(): Promise { +function fallbackContent(): readonly RenderContentDocument[] { + return sandboxContentDocuments + .filter((document) => document.type !== "pages") + .map((document) => ({ + id: document.id, + type: document.type, + data: + typeof document.data === "object" && document.data !== null && !Array.isArray(document.data) + ? (document.data as Readonly>) + : { value: document.data }, + })); +} + +export async function loadPublishedPage(locale = "en-US"): Promise { + const baseUrl = process.env.CMS_PUBLIC_RELEASES_URL; + if (baseUrl === undefined || baseUrl.length === 0) return localizedPage(fallbackPage(), locale); + try { + const client = createContentClient({ + environment: "production", + source: cdnSource({ baseUrl }), + }); + return localizedPage(await client.get("content/pages/home/index.json"), locale); + } catch { + return localizedPage(fallbackPage(), locale); + } +} + +export async function loadPublishedRedirect(path: string): Promise { const baseUrl = process.env.CMS_PUBLIC_RELEASES_URL; - if (baseUrl === undefined || baseUrl.length === 0) return fallbackPage(); + if (baseUrl === undefined || baseUrl.length === 0) { + return sandboxDocument.data.redirectFrom.includes(path) ? "/" : undefined; + } try { const client = createContentClient({ environment: "production", source: cdnSource({ baseUrl }), }); - return await client.get("content/pages/home/index.json"); + return resolveRedirect(await loadRedirects(client), path); } catch { - return fallbackPage(); + return sandboxDocument.data.redirectFrom.includes(path) ? "/" : undefined; + } +} + +function renderDocument(value: unknown): RenderContentDocument | undefined { + if (typeof value !== "object" || value === null) return undefined; + const record = value as Readonly>; + if (typeof record.id !== "string" || typeof record.type !== "string") return undefined; + const data = Object.fromEntries( + Object.entries(record).filter(([key]) => !["id", "type", "schemaVersion"].includes(key)), + ); + return { id: record.id, type: record.type, data }; +} + +export async function loadPublishedSite(locale = "en-US"): Promise<{ + readonly page: PublishedPage; + readonly content: readonly RenderContentDocument[]; +}> { + const page = await loadPublishedPage(locale); + const baseUrl = process.env.CMS_PUBLIC_RELEASES_URL; + if (baseUrl === undefined || baseUrl.length === 0) { + return { page, content: fallbackContent() }; } + const client = createContentClient({ + environment: "production", + source: cdnSource({ baseUrl }), + }); + const content = await loadContentGraph(client).catch(async () => { + const values = await Promise.all( + [ + "content/collections/plans/lite/index.json", + "content/globals/navigation/primary/index.json", + ].map((path) => client.get(path).catch(() => undefined)), + ); + return values.flatMap((value) => { + const document = renderDocument(value); + return document === undefined ? [] : [document]; + }); + }); + return { page, content }; } diff --git a/apps/playground-next/vercel.json b/apps/playground-next/vercel.json index 2d542ed..6516f6a 100644 --- a/apps/playground-next/vercel.json +++ b/apps/playground-next/vercel.json @@ -1,5 +1,44 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", "framework": "nextjs", - "buildCommand": "cd ../.. && pnpm turbo run build --filter=@git-native-cms/playground-next" + "buildCommand": "cd ../.. && pnpm turbo run build --filter=@git-native-cms/playground-next", + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self' https://github.com; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://*.r2.cloudflarestorage.com https://*.r2.dev; frame-src 'self'; worker-src 'self' blob:; manifest-src 'self'; upgrade-insecure-requests" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=63072000; includeSubDomains; preload" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-Frame-Options", + "value": "SAMEORIGIN" + }, + { + "key": "Referrer-Policy", + "value": "strict-origin-when-cross-origin" + }, + { + "key": "Permissions-Policy", + "value": "camera=(), microphone=(), geolocation=(), payment=(), usb=()" + }, + { + "key": "Cross-Origin-Opener-Policy", + "value": "same-origin" + }, + { + "key": "Cross-Origin-Resource-Policy", + "value": "same-site" + } + ] + } + ] } diff --git a/apps/visual-regression/README.md b/apps/visual-regression/README.md new file mode 100644 index 0000000..36db77b --- /dev/null +++ b/apps/visual-regression/README.md @@ -0,0 +1,5 @@ +# Visual regression fixtures + +Canonical light and dark snapshots live in `tests/e2e/snapshots`. Run +`pnpm exec playwright test --grep "visual"` against both framework projects; update snapshots only +after reviewing the rendered editor, preview overlay, focus states and reduced-motion behavior. diff --git a/apps/visual-regression/package.json b/apps/visual-regression/package.json new file mode 100644 index 0000000..425b18e --- /dev/null +++ b/apps/visual-regression/package.json @@ -0,0 +1,5 @@ +{ + "name": "@git-native-cms/visual-regression", + "version": "0.0.0", + "private": true +} diff --git a/docs/adr/0003-preview-assets-and-team-capabilities.md b/docs/adr/0003-preview-assets-and-team-capabilities.md new file mode 100644 index 0000000..0f758f2 --- /dev/null +++ b/docs/adr/0003-preview-assets-and-team-capabilities.md @@ -0,0 +1,29 @@ +# ADR-0003: Preview, asset metadata and team provisioning are application capabilities + +Status: accepted + +## Context + +Preview session signing, storage asset metadata and GitHub organization provisioning touch +external systems and are used by HTTP, UI, CLI or MCP. Implementing any of them directly in a +framework route or React component would bypass authorization, idempotency and audit behavior. + +## Decision + +- `PreviewSessionPort` issues, verifies and refreshes short-lived actor/Change/frontend/locale-bound + sessions. The default adapter signs them with JOSE. +- `AssetStore.updateAssetMetadata` changes object metadata, while `UpdateAssetHandler` records the + reviewed metadata on the Change branch with compare-and-swap. +- `TeamProvisioningPort` reads members/teams and performs invitations/membership changes through + the GitHub App. CMS role mappings are proposed through `UpdateTeamRoleMappingsHandler` as a + pull request changing `.cms/permissions.yaml`. +- The single Web API, hosted editor and future transports only translate their inputs into these + application handlers. +- Shared adapter contracts cover retry behavior and stable read/write semantics. + +## Consequences + +Secrets remain server-side, team permission changes have a reviewable Git history, asset bytes +remain independent from content, and preview credentials cannot be reused across actors or +Changes. Adapters have more explicit contracts, but the application layer remains independent of +GitHub, S3/R2, JOSE and frameworks. diff --git a/docs/adr/0004-semantic-conflict-resolution.md b/docs/adr/0004-semantic-conflict-resolution.md new file mode 100644 index 0000000..0243646 --- /dev/null +++ b/docs/adr/0004-semantic-conflict-resolution.md @@ -0,0 +1,30 @@ +# ADR-0004: Semantic conflict resolution is an application command + +Status: accepted + +## Context + +A Git pull request can report a textual conflict, but editors need field-level choices that remain +correct when YAML formatting, document order or unrelated Staging content changes. Conflict +resolution also changes the content that was previously approved, so it cannot be implemented as +presentation-only state or an unchecked Git operation. + +## Decision + +- `ReadChangeConflictsHandler` compares the exact Change base, current Change branch and current + Staging revision across the union of their document IDs. +- The merge uses RFC 6901 paths and covers field edits, document creation and document deletion. +- `ResolveChangeConflictsHandler` requires an explicit `change` or `staging` choice for every + conflict and the current Change revision. +- Non-conflicting Staging values are carried into the Change branch. Resolved documents and + deletions use the content repository capability with compare-and-swap and idempotency keys. +- The Change records Staging as its new semantic base. An approved Change returns to `in_review`, + and the audit timeline records paths and choices without duplicating content values. +- HTTP and React integrations only validate/collect input and delegate to these handlers. + +## Consequences + +The pull request diff is relative to the content editors actually reconciled, a stale branch cannot +silently overwrite Staging, and no approval survives a post-review content merge. Large Changes +must resolve all concurrent conflicts as one auditable operation; this intentionally prevents a +partially resolved Change from entering Staging. diff --git a/eslint.config.mjs b/eslint.config.mjs index 5a7d0e0..1772b86 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,78 +1,3 @@ -import eslint from "@eslint/js"; -import boundaries from "eslint-plugin-boundaries"; -import tseslint from "typescript-eslint"; +import { cmsEslintConfig } from "./tooling/eslint-config/index.mjs"; -export default tseslint.config( - { - ignores: [ - "**/dist/**", - "**/.next/**", - "**/.astro/**", - "**/.vercel/**", - "**/coverage/**", - "**/node_modules/**", - "**/playwright-report/**", - "**/test-results/**", - ], - }, - eslint.configs.recommended, - ...tseslint.configs.strictTypeChecked, - { - ...tseslint.configs.disableTypeChecked, - files: ["**/*.{js,cjs,mjs}", "*.ts", "tooling/**/*.ts", "tests/**/*.ts"], - rules: { - ...tseslint.configs.disableTypeChecked.rules, - "no-undef": "off", - }, - }, - { - files: ["packages/**/*.{ts,tsx}", "apps/**/*.{ts,tsx}"], - languageOptions: { - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, - }, - plugins: { boundaries }, - settings: { - "boundaries/elements": [{ type: "package", pattern: "packages/*", capture: ["package"] }], - }, - rules: { - "@typescript-eslint/consistent-type-imports": "error", - "@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }], - "@typescript-eslint/no-explicit-any": "error", - "@typescript-eslint/no-confusing-void-expression": "off", - "@typescript-eslint/no-dynamic-delete": "off", - "@typescript-eslint/no-empty-object-type": "off", - "@typescript-eslint/no-unnecessary-condition": "off", - "@typescript-eslint/no-unnecessary-type-parameters": "off", - "@typescript-eslint/only-throw-error": "off", - "@typescript-eslint/prefer-promise-reject-errors": "off", - "@typescript-eslint/require-await": "off", - "@typescript-eslint/restrict-plus-operands": "off", - "@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }], - }, - }, - { - files: ["**/*.test.{ts,tsx}", "apps/**/*.{ts,tsx}"], - rules: { - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - }, - }, - { - files: [ - "packages/delivery/**/*.{ts,tsx}", - "packages/editor/**/*.{ts,tsx}", - "packages/editor-bridge/**/*.{ts,tsx}", - "packages/testing/**/*.{ts,tsx}", - ], - rules: { - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - }, - }, -); +export default cmsEslintConfig({ tsconfigRootDir: import.meta.dirname }); diff --git a/git-native-visual-cms-implementation-plan.md b/git-native-visual-cms-implementation-plan.md index 71ac9c2..b1116e9 100644 --- a/git-native-visual-cms-implementation-plan.md +++ b/git-native-visual-cms-implementation-plan.md @@ -1107,6 +1107,36 @@ interface AssetStore { } ``` +Asset storage jest capability niezależnym od repozytorium contentowego i release storage. Produkcyjnie +korzysta z osobnego bucketu/prefixu oraz osobnego publicznego originu. Dokumenty nie zapisują kopii +pliku ani tymczasowego signed URL, tylko stabilną referencję: + +```ts +interface AssetReference { + readonly id: AssetId; + readonly url: string; + readonly mimeType: string; + readonly fileName: string; + readonly altText?: string; +} +``` + +Editor udostępnia pełną galerię assetów: + +- przeglądanie miniaturek i plików z paginacją; +- wyszukiwanie po nazwie, typie i alt text; +- filtrowanie zgodne z `fields.asset({ accept })`; +- bezpośredni upload do storage oraz bezpieczne finalizowanie; +- wybór istniejącego assetu z inspectora sekcji/bloku; +- natychmiastową aktualizację preview przez ten sam patch stream; +- podgląd wariantów, wymiarów, rozmiaru i usage graph; +- usunięcie tylko wtedy, gdy asset nie jest używany przez Change ani immutable release; +- pełną obsługę klawiatury, focus management i czytelny empty/error state. + +Każde pole sekcji lub content type zadeklarowane jako `fields.asset()` renderuje asset picker. +Picker zapisuje `AssetReference` pod właściwą ścieżką RFC 6901, więc wybór media działa jednakowo +dla zwykłych bloków, Reusable Blocks, Globals i dokumentów kolekcji. + Providerzy: - local filesystem; @@ -2793,8 +2823,14 @@ To nie jest redukcja scope’u. Jest to kolejność budowania kompletnego system - variants; - image pipeline; - usage graph; +- storage-backed asset gallery; +- schema-driven media picker dla bloków i content types; - asset UI. +Gate: upload do osobnego storage → asset pojawia się w galerii → pole `fields.asset()` filtruje +kompatybilne media → wybór aktualizuje preview bez reloadu → zapis Change utrwala stabilną +referencję → użyty lub opublikowany asset nie może zostać usunięty. + ## Etap 9 — SEO/i18n/search - full SEO; diff --git a/package.json b/package.json index d5be4de..5c5a7b0 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,17 @@ "scripts": { "build": "turbo run build", "dev": "turbo run dev --parallel", - "lint": "eslint .", + "lint": "turbo run build --filter='./packages/*' && eslint .", "typecheck": "turbo run typecheck", "test": "vitest run", "test:integration": "vitest run --config vitest.integration.config.ts", + "test:live": "node tooling/scripts/smoke-live.mjs", + "test:live:flow": "tsx tooling/scripts/e2e-live.ts", "test:watch": "vitest", "test:e2e": "turbo run build --filter=@git-native-cms/playground-next --filter=@git-native-cms/playground-astro && playwright test", "architecture": "dependency-cruiser packages --config dependency-cruiser.cjs", "budgets": "node tooling/scripts/check-bundle-budgets.mjs", + "registry:digest": "tsx tooling/scripts/registry-digest.ts", "check": "pnpm lint && pnpm typecheck && pnpm test && pnpm architecture && pnpm build && pnpm budgets", "changeset": "changeset", "version-packages": "changeset version", @@ -27,7 +30,9 @@ "cms": "tsx packages/cli/src/bin.ts" }, "devDependencies": { + "@aws-sdk/client-s3": "catalog:", "@changesets/cli": "latest", + "@axe-core/playwright": "latest", "@eslint/js": "latest", "@playwright/test": "latest", "@types/node": "latest", diff --git a/packages/adapter-kit/package.json b/packages/adapter-kit/package.json index f98fcc1..242a27f 100644 --- a/packages/adapter-kit/package.json +++ b/packages/adapter-kit/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@git-native-cms/application": "workspace:*", + "@git-native-cms/core": "workspace:*", "@git-native-cms/testing": "workspace:*" }, "scripts": { diff --git a/packages/adapter-kit/src/all-ports.test.ts b/packages/adapter-kit/src/all-ports.test.ts new file mode 100644 index 0000000..7fc9e9b --- /dev/null +++ b/packages/adapter-kit/src/all-ports.test.ts @@ -0,0 +1,373 @@ +import type { + Asset, + AuditEvent, + DeploymentPort, + PublicationNotifierPort, + PreviewSession, + RateLimitPort, + RevalidationPort, + ReviewCheck, + ReviewComment, + ReviewPort, + SchedulerPort, + TranslationProvider, + WebhookReplayStore, +} from "@git-native-cms/application"; +import type { + AssetId, + ContentDocument, + DocumentId, + GitCommitSha, + ReleaseId, + Revision, +} from "@git-native-cms/core"; +import { + MemoryAuditSink, + MemoryContentRepository, + MemoryIdempotencyStore, +} from "@git-native-cms/testing"; +import { describe, expect, it } from "vitest"; +import { + AssetProcessorPortContract, + AssetUsagePortContract, + AuditQueryPortContract, + AuditSinkContract, + ContentRepositoryContract, + contractPassed, + DeploymentPortContract, + IdempotencyStoreContract, + PublicationNotifierPortContract, + RateLimitPortContract, + ReleaseBuilderPortContract, + RevalidationPortContract, + ReviewPortContract, + SchedulerPortContract, + PreviewSessionPortContract, + TeamProvisioningPortContract, + TranslationProviderContract, + WebhookReplayStoreContract, +} from "./contracts.js"; + +const revision = "1".repeat(40) as GitCommitSha; +const releaseId = "rel_0123456789abcdef01234567" as ReleaseId; +const asset: Asset = { + id: "ast_0123456789abcdef01234567" as AssetId, + fileName: "contract.png", + mimeType: "image/png", + size: 4, + checksum: "a".repeat(64), + url: "https://assets.example.test/contract.png", +}; + +function passed(results: readonly { readonly passed: boolean; readonly details?: string }[]): void { + expect( + contractPassed( + results.map((result, index) => ({ + name: `contract-${index}`, + ...result, + })), + ), + results + .filter((result) => !result.passed) + .map((result) => result.details ?? "contract failed") + .join("\n"), + ).toBe(true); +} + +class ContractReviewPort implements ReviewPort { + readonly comments: ReviewComment[] = []; + private assignment = { users: [] as string[], teams: [] as string[] }; + readonly checks: ReviewCheck[] = [ + { name: "contract", status: "completed", conclusion: "success", required: true }, + ]; + + async addComment(input: Parameters[0]): Promise { + const comment: ReviewComment = { + id: `comment-${this.comments.length + 1}`, + author: "contract", + body: input.body, + createdAt: "2026-07-27T12:00:00.000Z", + resolved: false, + }; + this.comments.push(comment); + return comment; + } + + async listComments(): Promise { + return this.comments; + } + + async resolveComment( + input: Parameters[0], + ): Promise { + const index = this.comments.findIndex((comment) => comment.id === input.commentId); + const current = this.comments[index]; + if (current === undefined) throw new Error("comment missing"); + const updated = { ...current, resolved: input.resolved }; + this.comments[index] = updated; + return updated; + } + + async assignReviewers( + input: Parameters[0], + ): Promise<{ readonly users: readonly string[]; readonly teams: readonly string[] }> { + this.assignment = { users: [...input.users], teams: [...input.teams] }; + return this.assignment; + } + + async listReviewers(): Promise<{ + readonly users: readonly string[]; + readonly teams: readonly string[]; + }> { + return this.assignment; + } + + async listChecks(): Promise { + return this.checks; + } +} + +class ContractDeploymentPort implements DeploymentPort { + private readonly results = new Map(); + + async deploy(input: Parameters[0]) { + const result = this.results.get(input.idempotencyKey) ?? { + deploymentId: "deployment-contract", + url: "https://deployment.example.test", + }; + this.results.set(input.idempotencyKey, result); + return result; + } +} + +class ContractTranslationProvider implements TranslationProvider { + async createJob() { + return { jobId: "translation-contract" }; + } + + async readJob() { + return { status: "complete" as const, xliff: '' }; + } +} + +class ContractWebhookReplayStore implements WebhookReplayStore { + private readonly deliveries = new Set(); + + async claim(deliveryId: string): Promise { + if (this.deliveries.has(deliveryId)) return false; + this.deliveries.add(deliveryId); + return true; + } +} + +class ContractRateLimitPort implements RateLimitPort { + private readonly counts = new Map(); + + async consume(input: Parameters[0]) { + const windowStart = Math.floor(new Date(input.now).getTime() / input.windowMs) * input.windowMs; + const key = `${input.scope}:${input.key}:${windowStart}`; + const count = this.counts.get(key) ?? 0; + const allowed = count < input.limit; + if (allowed) this.counts.set(key, count + 1); + return { + allowed, + remaining: Math.max(0, input.limit - count - (allowed ? 1 : 0)), + resetAt: new Date(windowStart + input.windowMs).toISOString(), + }; + } +} + +const revalidation: RevalidationPort = { + revalidate: async () => undefined, +}; +const notifier: PublicationNotifierPort = { + notify: async () => undefined, +}; +const scheduler: SchedulerPort = { + workflow: (input) => ({ + path: `.github/workflows/cms-${input.scheduleId}.yml`, + content: `name: ${input.scheduleId}\naction: ${input.action}\n`, + }), +}; + +describe("all application capability port contracts", () => { + it("covers review and logical content repository adapters", async () => { + passed( + await ReviewPortContract({ + review: new ContractReviewPort(), + pullRequestNumber: 1, + ref: revision, + }), + ); + const repository = new MemoryContentRepository(); + const document: ContentDocument = { + id: "doc_contract" as DocumentId, + type: "pages", + schemaVersion: 1, + revision: "sha_content_1" as Revision, + data: { title: "Contract" }, + }; + passed( + await ContentRepositoryContract({ + repository, + ref: "contract", + expectedRevision: document.revision, + document, + actor: { + id: "act_contract" as never, + githubId: 1, + login: "contract", + displayName: "Contract", + roles: ["administrator"], + source: "cli", + }, + }), + ); + }); + + it("covers release, asset, deployment and publication adapters", async () => { + passed( + await ReleaseBuilderPortContract({ + builder: { + build: async (input) => { + const manifest = { + releaseId, + gitCommit: input.gitCommit, + registryDigest: input.registryDigest, + }; + return { + id: releaseId, + manifest, + files: { + "manifest.json": JSON.stringify(manifest), + "checksums.json": "{}", + }, + }; + }, + }, + gitCommit: revision, + registryDigest: `sha256:${"a".repeat(64)}`, + }), + ); + passed( + await AssetUsagePortContract({ + usage: { + usages: async () => ["content/pages/contract.json#/hero/image"], + isReleased: async () => true, + }, + assetId: asset.id, + expectedPath: "content/pages/contract.json#/hero/image", + released: true, + }), + ); + passed( + await AssetProcessorPortContract({ + processor: { process: async (value) => ({ ...value, variants: [] }) }, + asset, + }), + ); + passed( + await DeploymentPortContract({ + deployment: new ContractDeploymentPort(), + releaseId, + revision, + }), + ); + passed(await RevalidationPortContract({ revalidation })); + passed(await PublicationNotifierPortContract({ notifier, releaseId, revision })); + }); + + it("covers translation, replay, rate-limit, scheduler and state adapters", async () => { + passed(await TranslationProviderContract({ provider: new ContractTranslationProvider() })); + passed(await WebhookReplayStoreContract({ store: new ContractWebhookReplayStore() })); + passed(await RateLimitPortContract({ rateLimit: new ContractRateLimitPort() })); + passed(await SchedulerPortContract({ scheduler })); + passed(await IdempotencyStoreContract({ store: new MemoryIdempotencyStore() })); + const audit = new MemoryAuditSink(); + passed( + await AuditSinkContract({ + sink: audit, + readEvents: async (): Promise => audit.events, + }), + ); + passed(await AuditQueryPortContract({ sink: audit, query: audit })); + }); + + it("covers preview session and GitHub organization provisioning adapters", async () => { + let previewSession: PreviewSession = { + id: "prv_contract", + actorId: "act_contract" as PreviewSession["actorId"], + changeId: "chg_contract" as PreviewSession["changeId"], + frontendRef: "cms/contract-preview", + locale: "pl-PL", + createdAt: "2026-07-27T12:00:00.000Z", + expiresAt: "2026-07-27T12:05:00.000Z", + token: "contract-token", + }; + passed( + await PreviewSessionPortContract({ + sessions: { + async issue(input) { + previewSession = { + ...previewSession, + actorId: input.actorId, + changeId: input.changeId, + frontendRef: input.frontendRef, + locale: input.locale, + createdAt: input.now.toISOString(), + }; + return previewSession; + }, + async verify() { + return previewSession; + }, + async refresh(input) { + previewSession = { + ...previewSession, + id: "prv_contract_refreshed", + createdAt: input.now.toISOString(), + expiresAt: "2026-07-27T12:06:00.000Z", + token: "contract-token-refreshed", + }; + return previewSession; + }, + }, + actorId: previewSession.actorId, + changeId: previewSession.changeId, + }), + ); + + const memberships: string[] = []; + passed( + await TeamProvisioningPortContract({ + provisioning: { + async listMembers() { + return [ + { + id: "1", + login: "contract-editor", + displayName: "Contract Editor", + organizationRole: "member", + }, + ]; + }, + async listTeams() { + return [{ id: "1", slug: "editors", name: "Editors" }]; + }, + async invite(input) { + return { + id: "1", + role: input.role, + ...(input.email === undefined ? {} : { email: input.email }), + status: "pending", + }; + }, + async addMemberToTeam(input) { + memberships.push(`${input.teamSlug}:${input.username}:${input.role}`); + }, + }, + }), + ); + expect(memberships).toEqual(["editors:contract-editor:member"]); + }); +}); diff --git a/packages/adapter-kit/src/contracts.test.ts b/packages/adapter-kit/src/contracts.test.ts new file mode 100644 index 0000000..40713b8 --- /dev/null +++ b/packages/adapter-kit/src/contracts.test.ts @@ -0,0 +1,478 @@ +import type { + Asset, + AuditEvent, + AssetStore, + EnvironmentPointer, + Page, + ReviewComment, + ReleaseStore, + SessionRecord, + SessionStore, + StoredRelease, +} from "@git-native-cms/application"; +import type { + Actor, + AssetId, + ContentDocument, + DocumentId, + GitCommitSha, + ReleaseId, + Revision, +} from "@git-native-cms/core"; +import { + DeterministicIds, + FixedClock, + MemoryAuditSink, + MemoryContentRepository, + MemoryGitProvider, + MemoryIdempotencyStore, +} from "@git-native-cms/testing"; +import { describe, expect, it } from "vitest"; +import { + AssetStoreContract, + AssetProcessorPortContract, + AssetUsagePortContract, + AuditSinkContract, + ClockContract, + contractPassed, + ContentRepositoryContract, + DeploymentPortContract, + FrameworkAdapterContract, + GitProviderContract, + IdentityProviderContract, + IdempotencyStoreContract, + IdGeneratorContract, + PublicationNotifierPortContract, + RateLimitPortContract, + ReleaseBuilderPortContract, + ReleaseStoreContract, + RevalidationPortContract, + ReviewPortContract, + SchedulerPortContract, + RendererContract, + SessionStoreContract, + TranslationProviderContract, + WebhookReplayStoreContract, +} from "./contracts.js"; + +const actor: Actor = { + id: "act_contract" as Actor["id"], + githubId: 1, + login: "contract", + displayName: "Contract Adapter", + roles: ["administrator"], + source: "cli", +}; + +function expectContract(results: Awaited>): void { + expect( + contractPassed(results), + results + .filter((result) => !result.passed) + .map((result) => `${result.name}: ${result.details ?? "failed"}`) + .join("\n"), + ).toBe(true); +} + +class ContractAssetStore implements AssetStore { + private readonly assets = new Map(); + + async createUpload(input: Parameters[0]) { + return { + uploadId: "upl_contract", + url: "https://uploads.example.test/upl_contract", + headers: { "content-type": input.mimeType }, + }; + } + + async finalizeUpload(input: Parameters[0]): Promise { + const id = `ast_${input.checksum.slice(0, 24)}` as AssetId; + const asset = + this.assets.get(id) ?? + ({ + id, + fileName: "proof.png", + mimeType: "image/png", + size: 4, + checksum: input.checksum, + url: `https://assets.example.test/${input.checksum}/proof.png`, + } satisfies Asset); + this.assets.set(id, asset); + return asset; + } + + async readAsset(id: AssetId): Promise { + return this.assets.get(id); + } + + async updateAssetMetadata( + input: Parameters[0], + ): Promise { + const current = this.assets.get(input.id); + if (current === undefined) throw new Error("asset missing"); + const stable = { ...current }; + delete stable.altText; + delete stable.focalPoint; + const asset: Asset = { + ...stable, + ...(input.altText === undefined ? {} : { altText: input.altText }), + ...(input.focalPoint === undefined ? {} : { focalPoint: input.focalPoint }), + }; + this.assets.set(asset.id, asset); + return asset; + } + + async deleteAsset(id: AssetId): Promise { + this.assets.delete(id); + } + + async listAssets(): Promise> { + return { items: [...this.assets.values()] }; + } +} + +class ContractReleaseStore implements ReleaseStore { + private readonly releases = new Map(); + private readonly pointers = new Map(); + + async writeRelease(release: StoredRelease): Promise { + const current = this.releases.get(release.id); + if (current !== undefined && JSON.stringify(current) !== JSON.stringify(release)) { + throw new Error("immutable release changed"); + } + this.releases.set(release.id, structuredClone(release)); + } + + async readRelease(id: ReleaseId): Promise { + return this.releases.get(id); + } + + async listReleases(): Promise<{ readonly items: readonly StoredRelease[] }> { + return { items: [...this.releases.values()] }; + } + + async readPointer( + environment: EnvironmentPointer["environment"], + ): Promise { + return this.pointers.get(environment); + } + + async compareAndSwapPointer( + input: Parameters[0], + ): Promise { + const current = this.pointers.get(input.next.environment); + if (input.expectedRevision !== undefined && current?.revision !== input.expectedRevision) { + throw new Error("stale pointer"); + } + this.pointers.set(input.next.environment, input.next); + return input.next; + } +} + +class ContractSessionStore implements SessionStore { + private readonly sessions = new Map(); + + async read(id: string): Promise { + return this.sessions.get(id); + } + + async write(session: SessionRecord): Promise { + this.sessions.set(session.id, structuredClone(session)); + } + + async delete(id: string): Promise { + this.sessions.delete(id); + } +} + +describe("shared adapter contracts", () => { + it("exercises GitProvider", async () => { + expectContract( + await GitProviderContract({ + provider: new MemoryGitProvider(), + baseRef: "main", + branch: "contract/git-provider", + actor, + }), + ); + }); + + it("exercises AssetStore", async () => { + expectContract( + await AssetStoreContract({ + store: new ContractAssetStore(), + fileName: "proof.png", + mimeType: "image/png", + size: 4, + checksum: "a".repeat(64), + actor, + put: async () => undefined, + }), + ); + }); + + it("exercises ReleaseStore", async () => { + const release: StoredRelease = { + id: "rel_0123456789abcdef01234567" as ReleaseId, + manifest: { formatVersion: 1 }, + files: { "manifest.json": '{"formatVersion":1}' }, + }; + expectContract( + await ReleaseStoreContract({ + store: new ContractReleaseStore(), + release, + pointer: { + environment: "production", + releaseId: release.id, + revision: "contract-pointer-1", + updatedAt: "2026-07-27T12:00:00.000Z", + }, + }), + ); + }); + + it("exercises SessionStore", async () => { + expectContract( + await SessionStoreContract({ + store: new ContractSessionStore(), + session: { + id: "ses_contract", + actor, + csrfSecret: "csrf-contract", + createdAt: "2026-07-27T12:00:00.000Z", + expiresAt: "2026-07-27T20:00:00.000Z", + idleExpiresAt: "2026-07-27T13:00:00.000Z", + }, + }), + ); + }); + + it("exercises framework and renderer boundaries", async () => { + expectContract( + await FrameworkAdapterContract({ + handle: async () => + Response.json({ ok: true }, { headers: { "cache-control": "no-store" } }), + }), + ); + expectContract(await RendererContract({ render: () => "
Published content
" })); + }); + + it("exercises every remaining application capability port", async () => { + const revision = "rev_contract" as Revision; + const document: ContentDocument = { + id: "doc_contract" as DocumentId, + type: "pages", + schemaVersion: 1, + revision, + data: { title: "Contract" }, + }; + expectContract( + await ContentRepositoryContract({ + repository: new MemoryContentRepository(), + ref: "contract/content", + expectedRevision: revision, + document, + actor, + }), + ); + + const comments: ReviewComment[] = []; + let reviewers = { users: [] as string[], teams: [] as string[] }; + expectContract( + await ReviewPortContract({ + review: { + async addComment(input) { + const comment = { + id: `comment-${String(comments.length + 1)}`, + author: actor.login, + body: input.body, + createdAt: "2026-07-27T12:00:00.000Z", + resolved: false, + }; + comments.push(comment); + return comment; + }, + async listComments() { + return comments; + }, + async resolveComment(input) { + const index = comments.findIndex((comment) => comment.id === input.commentId); + const current = comments[index]; + if (current === undefined) throw new Error("comment missing"); + const updated = { ...current, resolved: input.resolved }; + comments[index] = updated; + return updated; + }, + async assignReviewers(input) { + reviewers = { users: [...input.users], teams: [...input.teams] }; + return reviewers; + }, + async listReviewers() { + return reviewers; + }, + async listChecks() { + return [ + { name: "contract", status: "completed", conclusion: "success", required: true }, + ]; + }, + }, + pullRequestNumber: 1, + ref: "a".repeat(40) as GitCommitSha, + }), + ); + + const releaseId = "rel_contract_builder" as ReleaseId; + expectContract( + await ReleaseBuilderPortContract({ + builder: { + async build() { + return { + id: releaseId, + manifest: { releaseId }, + files: { "manifest.json": "{}", "checksums.json": "{}" }, + }; + }, + }, + gitCommit: "b".repeat(40) as GitCommitSha, + registryDigest: `sha256:${"c".repeat(64)}`, + }), + ); + const asset: Asset = { + id: "ast_contract" as AssetId, + fileName: "contract.png", + mimeType: "image/png", + size: 8, + checksum: "d".repeat(64), + url: "https://assets.example.test/contract.png", + }; + expectContract( + await AssetUsagePortContract({ + usage: { + async usages() { + return ["content/pages/contract.yaml"]; + }, + async isReleased() { + return true; + }, + }, + assetId: asset.id, + expectedPath: "content/pages/contract.yaml", + released: true, + }), + ); + expectContract( + await AssetProcessorPortContract({ + processor: { + async process(value) { + return value; + }, + }, + asset, + }), + ); + + const deployments = new Map(); + expectContract( + await DeploymentPortContract({ + deployment: { + async deploy(input) { + const result = deployments.get(input.idempotencyKey) ?? { + deploymentId: "deployment-contract", + url: "https://deployment.example.test", + }; + deployments.set(input.idempotencyKey, result); + return result; + }, + }, + releaseId, + revision: "e".repeat(40) as GitCommitSha, + }), + ); + expectContract(await RevalidationPortContract({ revalidation: { async revalidate() {} } })); + expectContract( + await PublicationNotifierPortContract({ + notifier: { async notify() {} }, + releaseId, + revision: "f".repeat(40) as GitCommitSha, + }), + ); + + expectContract( + await TranslationProviderContract({ + provider: { + async createJob() { + return { jobId: "translation-contract" }; + }, + async readJob() { + return { status: "complete", xliff: '' }; + }, + }, + }), + ); + const deliveries = new Set(); + expectContract( + await WebhookReplayStoreContract({ + store: { + async claim(deliveryId) { + if (deliveries.has(deliveryId)) return false; + deliveries.add(deliveryId); + return true; + }, + }, + }), + ); + let consumed = false; + expectContract( + await RateLimitPortContract({ + rateLimit: { + async consume() { + const allowed = !consumed; + consumed = true; + return { + allowed, + remaining: 0, + resetAt: "2026-07-27T12:01:00.000Z", + }; + }, + }, + }), + ); + expectContract( + await SchedulerPortContract({ + scheduler: { + workflow(input) { + return { + path: `.github/workflows/${input.scheduleId}.yaml`, + content: JSON.stringify(input), + }; + }, + }, + }), + ); + expectContract(await IdempotencyStoreContract({ store: new MemoryIdempotencyStore() })); + const audit = new MemoryAuditSink(); + expectContract( + await AuditSinkContract({ + sink: audit, + readEvents: async (): Promise => audit.events, + }), + ); + expectContract( + await IdentityProviderContract({ + provider: { + async resolve() { + return { + externalId: "42", + login: "contract", + displayName: "Contract User", + capabilities: { push: true }, + teams: ["DMTcorp/editors"], + }; + }, + }, + }), + ); + expectContract(await ClockContract({ clock: new FixedClock() })); + expectContract(await IdGeneratorContract({ ids: new DeterministicIds() })); + }); +}); diff --git a/packages/adapter-kit/src/contracts.ts b/packages/adapter-kit/src/contracts.ts new file mode 100644 index 0000000..8dc93dc --- /dev/null +++ b/packages/adapter-kit/src/contracts.ts @@ -0,0 +1,869 @@ +import type { + Asset, + AssetProcessorPort, + AssetStore, + AssetUsagePort, + AuditEvent, + AuditQueryPort, + AuditSink, + Clock, + ContentRepository, + DeploymentPort, + EnvironmentPointer, + GitProvider, + IdentityProvider, + IdGenerator, + IdempotencyStore, + PublicationNotifierPort, + PreviewSessionPort, + RateLimitPort, + ReleaseBuilderPort, + ReleaseStore, + RevalidationPort, + ReviewPort, + SchedulerPort, + SessionRecord, + SessionStore, + StoredRelease, + TeamProvisioningPort, + TranslationProvider, + WebhookReplayStore, +} from "@git-native-cms/application"; +import type { + Actor, + AssetId, + ChangeId, + ContentDocument, + DocumentId, + GitCommitSha, + Revision, +} from "@git-native-cms/core"; +import type { ContractTestResult } from "./index.js"; + +async function check( + name: string, + assertion: () => Promise | boolean, +): Promise { + try { + const passed = await assertion(); + return { + name, + passed, + ...(passed ? {} : { details: "The adapter returned an unexpected result." }), + } satisfies ContractTestResult; + } catch (error) { + return { + name, + passed: false, + details: error instanceof Error ? error.message : String(error), + } satisfies ContractTestResult; + } +} + +function same(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +export async function GitProviderContract(input: { + readonly provider: GitProvider; + readonly baseRef: string; + readonly branch: string; + readonly actor: Actor; +}): Promise { + const base = await input.provider.resolveRef(input.baseRef); + const created = await input.provider.createBranch({ + branch: input.branch, + from: base.sha, + idempotencyKey: `${input.branch}:create`, + }); + const repeated = await input.provider.createBranch({ + branch: input.branch, + from: base.sha, + idempotencyKey: `${input.branch}:create`, + }); + const results = [ + await check("GitProvider/createBranch is idempotent", () => same(created, repeated)), + ]; + const committed = await input.provider.commitFiles({ + branch: input.branch, + expectedSha: created.sha, + files: [{ path: ".cms/contract.txt", content: "contract-v1" }], + message: "Exercise GitProvider contract", + author: input.actor, + idempotencyKey: `${input.branch}:commit`, + }); + results.push( + await check("GitProvider/readFile observes committed content", async () => { + const file = await input.provider.readFile({ + ref: input.branch, + path: ".cms/contract.txt", + }); + return file?.content === "contract-v1"; + }), + ); + results.push( + await check("GitProvider rejects stale compare-and-swap commits", async () => { + try { + await input.provider.commitFiles({ + branch: input.branch, + expectedSha: created.sha, + files: [{ path: ".cms/stale.txt", content: "must-not-commit" }], + message: "Stale contract write", + author: input.actor, + idempotencyKey: `${input.branch}:stale`, + }); + return false; + } catch { + return true; + } + }), + ); + const pullRequest = await input.provider.createPullRequest({ + head: input.branch, + base: input.baseRef, + title: "Contract pull request", + body: "Adapter contract verification.", + idempotencyKey: `${input.branch}:pr`, + }); + const repeatedPullRequest = await input.provider.createPullRequest({ + head: input.branch, + base: input.baseRef, + title: "Contract pull request", + body: "Adapter contract verification.", + idempotencyKey: `${input.branch}:pr`, + }); + results.push( + await check("GitProvider/createPullRequest is idempotent", () => + same(pullRequest, repeatedPullRequest), + ), + ); + results.push( + await check("GitProvider/listFiles is deterministic", async () => { + const files = await input.provider.listFiles({ ref: input.branch, prefix: ".cms/" }); + return ( + files.some((file) => file.path === ".cms/contract.txt") && + (await input.provider.resolveRef(input.branch)).sha === committed.sha + ); + }), + ); + await input.provider.mergePullRequest({ + number: pullRequest.number, + strategy: "squash", + expectedHeadSha: committed.sha, + }); + const revert = await input.provider.createRevertPullRequest({ + pullRequestNumber: pullRequest.number, + title: "Revert contract pull request", + body: "Adapter contract revert verification.", + idempotencyKey: `${input.branch}:revert`, + }); + const repeatedRevert = await input.provider.createRevertPullRequest({ + pullRequestNumber: pullRequest.number, + title: "Revert contract pull request", + body: "Adapter contract revert verification.", + idempotencyKey: `${input.branch}:revert`, + }); + results.push( + await check("GitProvider/createRevertPullRequest is idempotent", () => + same(revert, repeatedRevert), + ), + ); + const revertHead = await input.provider.resolveRef(revert.head); + await input.provider.mergePullRequest({ + number: revert.number, + strategy: "merge", + expectedHeadSha: revertHead.sha, + }); + results.push( + await check("GitProvider/revert restores the base without rewriting history", async () => { + const restored = await input.provider.readFile({ + ref: input.baseRef, + path: ".cms/contract.txt", + }); + return restored === undefined; + }), + ); + await input.provider.deleteBranch({ branch: revert.head }); + await input.provider.deleteBranch({ branch: input.branch }); + return results; +} + +export async function AssetStoreContract(input: { + readonly store: AssetStore; + readonly fileName: string; + readonly mimeType: string; + readonly size: number; + readonly checksum: string; + readonly actor: Actor; + readonly put: (upload: { + readonly uploadId: string; + readonly url: string; + readonly headers: Record; + }) => Promise; +}): Promise { + const upload = await input.store.createUpload({ + fileName: input.fileName, + mimeType: input.mimeType, + size: input.size, + checksum: input.checksum, + actor: input.actor, + }); + await input.put(upload); + const finalized = await input.store.finalizeUpload({ + uploadId: upload.uploadId, + checksum: input.checksum, + }); + const repeated = await input.store.finalizeUpload({ + uploadId: upload.uploadId, + checksum: input.checksum, + }); + const results = [ + await check("AssetStore/finalizeUpload is retry-safe", () => same(finalized, repeated)), + await check("AssetStore/readAsset returns finalized metadata", async () => + same(await input.store.readAsset(finalized.id), finalized), + ), + await check("AssetStore/listAssets contains finalized assets", async () => + (await input.store.listAssets({})).items.some((asset) => asset.id === finalized.id), + ), + ]; + const updated = await input.store.updateAssetMetadata({ + id: finalized.id, + altText: "Adapter contract asset", + focalPoint: { x: 0.25, y: 0.75 }, + }); + results.push( + await check( + "AssetStore/updateAssetMetadata persists reviewed metadata", + async () => + updated.altText === "Adapter contract asset" && + updated.focalPoint?.x === 0.25 && + updated.focalPoint.y === 0.75 && + same(await input.store.readAsset(finalized.id), updated), + ), + ); + await input.store.deleteAsset(finalized.id); + results.push( + await check( + "AssetStore/deleteAsset removes the asset", + async () => (await input.store.readAsset(finalized.id)) === undefined, + ), + ); + return results; +} + +export async function ReleaseStoreContract(input: { + readonly store: ReleaseStore; + readonly release: StoredRelease; + readonly pointer: EnvironmentPointer; +}): Promise { + await input.store.writeRelease(input.release); + await input.store.writeRelease(input.release); + const results = [ + await check("ReleaseStore immutable writes are retry-safe", async () => + same(await input.store.readRelease(input.release.id), input.release), + ), + await check("ReleaseStore lists rollbackable immutable releases", async () => + (await input.store.listReleases({})).items.some((release) => release.id === input.release.id), + ), + ]; + await input.store.compareAndSwapPointer({ next: input.pointer }); + results.push( + await check("ReleaseStore pointer read follows CAS", async () => + same(await input.store.readPointer(input.pointer.environment), input.pointer), + ), + ); + results.push( + await check("ReleaseStore rejects a stale pointer revision", async () => { + try { + await input.store.compareAndSwapPointer({ + next: { ...input.pointer, revision: `${input.pointer.revision}:next` }, + expectedRevision: "stale-contract-revision", + }); + return false; + } catch { + return true; + } + }), + ); + return results; +} + +export async function SessionStoreContract(input: { + readonly store: SessionStore; + readonly session: SessionRecord; +}): Promise { + await input.store.write(input.session); + const results = [ + await check("SessionStore round-trips a session", async () => + same(await input.store.read(input.session.id), input.session), + ), + ]; + const updated = { ...input.session, idleExpiresAt: input.session.expiresAt }; + await input.store.write(updated); + results.push( + await check("SessionStore overwrites atomically by ID", async () => + same(await input.store.read(input.session.id), updated), + ), + ); + await input.store.delete(input.session.id); + results.push( + await check( + "SessionStore deletes a session", + async () => (await input.store.read(input.session.id)) === undefined, + ), + ); + return results; +} + +export async function PreviewSessionPortContract(input: { + readonly sessions: PreviewSessionPort; + readonly actorId: Actor["id"]; + readonly changeId: ChangeId; +}): Promise { + const now = new Date("2026-07-27T12:00:00.000Z"); + const issued = await input.sessions.issue({ + actorId: input.actorId, + changeId: input.changeId, + frontendRef: "cms/contract-preview", + locale: "pl-PL", + now, + }); + const verified = await input.sessions.verify({ + id: issued.id, + token: issued.token, + now, + }); + const refreshed = await input.sessions.refresh({ + id: issued.id, + token: issued.token, + now: new Date("2026-07-27T12:01:00.000Z"), + }); + return [ + await check( + "PreviewSessionPort binds actor, Change, frontend ref and locale", + () => + issued.actorId === input.actorId && + issued.changeId === input.changeId && + issued.frontendRef === "cms/contract-preview" && + issued.locale === "pl-PL", + ), + await check("PreviewSessionPort verifies an issued token", () => same(verified, issued)), + await check( + "PreviewSessionPort refreshes without changing the actor or Change", + () => + refreshed.actorId === issued.actorId && + refreshed.changeId === issued.changeId && + refreshed.expiresAt >= issued.expiresAt, + ), + ]; +} + +export async function TeamProvisioningPortContract(input: { + readonly provisioning: TeamProvisioningPort; +}): Promise { + const firstMembers = await input.provisioning.listMembers(); + const secondMembers = await input.provisioning.listMembers(); + const firstTeams = await input.provisioning.listTeams(); + const secondTeams = await input.provisioning.listTeams(); + const invitation = await input.provisioning.invite({ + email: "contract-editor@example.test", + role: "direct_member", + }); + let membershipAdded = false; + const team = firstTeams[0]; + if (team !== undefined) { + await input.provisioning.addMemberToTeam({ + teamSlug: team.slug, + username: firstMembers[0]?.login ?? "contract-editor", + role: "member", + }); + membershipAdded = true; + } + return [ + await check("TeamProvisioningPort returns deterministic members", () => + same(firstMembers, secondMembers), + ), + await check("TeamProvisioningPort returns deterministic teams", () => + same(firstTeams, secondTeams), + ), + await check( + "TeamProvisioningPort creates an organization invitation", + () => + invitation.role === "direct_member" && + invitation.status === "pending" && + invitation.id.length > 0, + ), + await check( + "TeamProvisioningPort accepts team membership operations", + () => team === undefined || membershipAdded, + ), + ]; +} + +export async function ReviewPortContract(input: { + readonly review: ReviewPort; + readonly pullRequestNumber: number; + readonly ref: GitCommitSha; +}): Promise { + const comment = await input.review.addComment({ + pullRequestNumber: input.pullRequestNumber, + body: "Adapter contract review comment.", + }); + const comments = await input.review.listComments(input.pullRequestNumber); + const resolved = await input.review.resolveComment({ + pullRequestNumber: input.pullRequestNumber, + commentId: comment.id, + resolved: true, + }); + const assigned = await input.review.assignReviewers({ + pullRequestNumber: input.pullRequestNumber, + users: ["contract-reviewer"], + teams: ["contract-team"], + }); + const listedReviewers = await input.review.listReviewers(input.pullRequestNumber); + const firstChecks = await input.review.listChecks(input.ref); + const secondChecks = await input.review.listChecks(input.ref); + return [ + await check("ReviewPort lists a newly created comment", () => + comments.some((candidate) => candidate.id === comment.id), + ), + await check( + "ReviewPort resolves a conversation thread", + () => resolved.id === comment.id && resolved.resolved, + ), + await check( + "ReviewPort assigns and lists users and teams", + () => same(assigned, listedReviewers), + ), + await check("ReviewPort check reads are deterministic", () => same(firstChecks, secondChecks)), + await check("ReviewPort returns valid check states", () => + firstChecks.every((candidate) => + ["queued", "in_progress", "completed"].includes(candidate.status), + ), + ), + ]; +} + +export async function ContentRepositoryContract(input: { + readonly repository: ContentRepository; + readonly ref: string; + readonly expectedRevision: Revision; + readonly document: ContentDocument; + readonly actor: Actor; +}): Promise { + const idempotencyKey = `contract:content:${input.document.id}`; + const revision = await input.repository.writeDocuments({ + ref: input.ref, + documents: [input.document], + expectedRevision: input.expectedRevision, + message: "Exercise ContentRepository contract", + actor: input.actor, + idempotencyKey, + }); + const repeated = await input.repository.writeDocuments({ + ref: input.ref, + documents: [input.document], + expectedRevision: input.expectedRevision, + message: "Exercise ContentRepository contract", + actor: input.actor, + idempotencyKey, + }); + const results = [ + await check("ContentRepository writes are idempotent", () => revision === repeated), + await check("ContentRepository round-trips a document", async () => { + const stored = await input.repository.readDocument({ + ref: input.ref, + documentId: input.document.id, + }); + return ( + stored.id === input.document.id && + stored.type === input.document.type && + stored.schemaVersion === input.document.schemaVersion && + stored.revision === revision && + same(stored.data, input.document.data) + ); + }), + await check("ContentRepository lists the written document", async () => + (await input.repository.listDocuments({ ref: input.ref })).items.some( + (document) => document.id === input.document.id, + ), + ), + ]; + await input.repository.deleteDocuments({ + ref: input.ref, + documentIds: [input.document.id], + expectedRevision: revision, + actor: input.actor, + idempotencyKey: `${idempotencyKey}:delete`, + }); + results.push( + await check("ContentRepository delete removes the document from listings", async () => + (await input.repository.listDocuments({ ref: input.ref })).items.every( + (document) => document.id !== input.document.id, + ), + ), + ); + results.push( + await check("ContentRepository reads project configuration", async () => { + const config = await input.repository.readProjectConfig(input.ref); + return Number.isSafeInteger(config.configVersion) && config.configVersion > 0; + }), + ); + results.push( + await check("ContentRepository reads the registry lock", async () => { + const lock = await input.repository.readRegistryLock(input.ref); + return /^sha256:[a-f0-9]{64}$/iu.test(lock.registryDigest); + }), + ); + return results; +} + +export async function ReleaseBuilderPortContract(input: { + readonly builder: ReleaseBuilderPort; + readonly gitCommit: GitCommitSha; + readonly registryDigest: string; +}): Promise { + const buildInput = { + gitCommit: input.gitCommit, + configVersion: 1, + registryDigest: input.registryDigest, + schemaVersion: 1, + documents: [{ path: "content/pages/contract.json", value: { title: "Contract" } }], + } as const; + const first = await input.builder.build(buildInput); + const second = await input.builder.build(buildInput); + return [ + await check("ReleaseBuilderPort is deterministic", () => same(first, second)), + await check("ReleaseBuilderPort emits immutable manifest files", () => { + return ( + first.files["manifest.json"] !== undefined && + first.files["checksums.json"] !== undefined && + first.manifest.releaseId === first.id + ); + }), + ]; +} + +export async function AssetUsagePortContract(input: { + readonly usage: AssetUsagePort; + readonly assetId: AssetId; + readonly expectedPath: string; + readonly released: boolean; +}): Promise { + return [ + await check("AssetUsagePort returns deterministic usages", async () => { + const first = await input.usage.usages(input.assetId); + const second = await input.usage.usages(input.assetId); + return same(first, second) && first.includes(input.expectedPath); + }), + await check( + "AssetUsagePort reports release protection", + async () => (await input.usage.isReleased(input.assetId)) === input.released, + ), + ]; +} + +export async function AssetProcessorPortContract(input: { + readonly processor: AssetProcessorPort; + readonly asset: Asset; +}): Promise { + const first = await input.processor.process(input.asset); + const second = await input.processor.process(input.asset); + return [ + await check("AssetProcessorPort is retry-safe", () => same(first, second)), + await check( + "AssetProcessorPort preserves content identity", + () => first.id === input.asset.id && first.checksum === input.asset.checksum, + ), + ]; +} + +export async function DeploymentPortContract(input: { + readonly deployment: DeploymentPort; + readonly releaseId: StoredRelease["id"]; + readonly revision: GitCommitSha; +}): Promise { + const request = { + environment: "production", + releaseId: input.releaseId, + revision: input.revision, + idempotencyKey: `contract:deployment:${input.releaseId}`, + } as const; + const first = await input.deployment.deploy(request); + const second = await input.deployment.deploy(request); + return [ + await check("DeploymentPort is idempotent", () => same(first, second)), + await check("DeploymentPort returns an external identity", () => first.deploymentId.length > 0), + ]; +} + +export async function RevalidationPortContract(input: { + readonly revalidation: RevalidationPort; +}): Promise { + const request = { + environment: "production", + tags: ["document:doc_contract"], + paths: ["/contract"], + idempotencyKey: "contract:revalidation", + } as const; + await input.revalidation.revalidate(request); + await input.revalidation.revalidate(request); + return [await check("RevalidationPort accepts idempotent retries", () => true)]; +} + +export async function PublicationNotifierPortContract(input: { + readonly notifier: PublicationNotifierPort; + readonly releaseId: StoredRelease["id"]; + readonly revision: GitCommitSha; +}): Promise { + const request = { + environment: "production", + releaseId: input.releaseId, + revision: input.revision, + tags: ["document:doc_contract"], + paths: ["content/pages/contract.json"], + idempotencyKey: "contract:publication", + } as const; + await input.notifier.notify(request); + await input.notifier.notify(request); + return [await check("PublicationNotifierPort accepts idempotent retries", () => true)]; +} + +export async function TranslationProviderContract(input: { + readonly provider: TranslationProvider; +}): Promise { + const request = { + sourceLocale: "en-US", + targetLocale: "pl-PL", + xliff: '', + idempotencyKey: "contract:translation", + }; + const first = await input.provider.createJob(request); + const second = await input.provider.createJob(request); + const job = await input.provider.readJob(first.jobId); + return [ + await check("TranslationProvider job creation is idempotent", () => same(first, second)), + await check("TranslationProvider returns a valid job state", () => + ["queued", "working", "complete", "failed"].includes(job.status), + ), + ]; +} + +export async function WebhookReplayStoreContract(input: { + readonly store: WebhookReplayStore; +}): Promise { + const deliveryId = `contract-${globalThis.crypto.randomUUID()}`; + const expiresAt = "2026-07-28T12:00:00.000Z"; + const first = await input.store.claim(deliveryId, expiresAt); + const second = await input.store.claim(deliveryId, expiresAt); + return [ + await check("WebhookReplayStore atomically claims a delivery once", () => first && !second), + ]; +} + +export async function RateLimitPortContract(input: { + readonly rateLimit: RateLimitPort; +}): Promise { + const request = { + key: `contract-${globalThis.crypto.randomUUID()}`, + scope: "contract", + limit: 1, + windowMs: 60_000, + now: "2026-07-27T12:00:00.000Z", + }; + const first = await input.rateLimit.consume(request); + const second = await input.rateLimit.consume(request); + return [ + await check( + "RateLimitPort enforces a deterministic window", + () => + first.allowed && + first.remaining === 0 && + !second.allowed && + first.resetAt === second.resetAt, + ), + ]; +} + +export async function SchedulerPortContract(input: { + readonly scheduler: SchedulerPort; +}): Promise { + const request = { + scheduleId: "sch_contract", + executeAt: "2026-07-28T12:00:00.000Z", + action: "publish", + documentIds: ["doc_contract" as DocumentId], + } satisfies Parameters[0]; + const first = input.scheduler.workflow(request); + const second = input.scheduler.workflow(request); + return [ + await check("SchedulerPort output is deterministic", () => same(first, second)), + await check( + "SchedulerPort emits a workflow path and content", + () => first.path.length > 0 && first.content.includes(request.scheduleId), + ), + ]; +} + +export async function IdempotencyStoreContract(input: { + readonly store: IdempotencyStore; +}): Promise { + const key = `contract-${globalThis.crypto.randomUUID()}`; + const value = { releaseId: "rel_contract", accepted: true }; + await input.store.write(key, value); + await input.store.write(key, value); + return [ + await check("IdempotencyStore round-trips retry results", async () => + same(await input.store.read(key), value), + ), + ]; +} + +export async function IdentityProviderContract(input: { + readonly provider: IdentityProvider; +}): Promise { + const first = await input.provider.resolve("contract-token"); + const second = await input.provider.resolve("contract-token"); + return [ + await check("IdentityProvider resolves deterministically", () => same(first, second)), + await check( + "IdentityProvider returns a usable principal", + () => first.externalId.length > 0 && first.login.length > 0 && first.displayName.length > 0, + ), + await check("IdentityProvider never returns credentials", () => { + const serialized = JSON.stringify(first).toLocaleLowerCase(); + return !serialized.includes("contract-token") && !serialized.includes("accesstoken"); + }), + ]; +} + +export async function ClockContract(input: { + readonly clock: Clock; +}): Promise { + const first = input.clock.now(); + const second = input.clock.now(); + return [ + await check( + "Clock returns valid defensive Date values", + () => + Number.isFinite(first.getTime()) && Number.isFinite(second.getTime()) && first !== second, + ), + ]; +} + +export async function IdGeneratorContract(input: { + readonly ids: IdGenerator; +}): Promise { + const changeIds = [input.ids.changeId(), input.ids.changeId()]; + const documentIds = [input.ids.documentId(), input.ids.documentId()]; + return [ + await check( + "IdGenerator returns unique prefixed domain IDs", + () => + new Set(changeIds).size === changeIds.length && + changeIds.every((id) => id.startsWith("chg_")) && + new Set(documentIds).size === documentIds.length && + documentIds.every((id) => id.startsWith("doc_")), + ), + await check( + "IdGenerator emits workflow identities", + () => + input.ids.scheduleId().startsWith("sch_") && + input.ids.requestId().length > 0 && + input.ids.suffix().length > 0, + ), + ]; +} + +export async function AuditSinkContract(input: { + readonly sink: AuditSink; + readonly readEvents: () => Promise; +}): Promise { + const event: AuditEvent = { + type: "contract.verified", + actorId: "act_contract", + requestId: globalThis.crypto.randomUUID(), + source: "cli", + timestamp: "2026-07-27T12:00:00.000Z", + }; + await input.sink.write(event); + return [ + await check("AuditSink durably records the event", async () => + (await input.readEvents()).some((candidate) => candidate.requestId === event.requestId), + ), + ]; +} + +export async function AuditQueryPortContract(input: { + readonly sink: AuditSink; + readonly query: AuditQueryPort; +}): Promise { + const resourceId = `chg_contract_${globalThis.crypto.randomUUID()}`; + const unrelatedId = `chg_other_${globalThis.crypto.randomUUID()}`; + const events: readonly AuditEvent[] = [ + { + type: "contract.started", + actorId: "act_contract", + requestId: globalThis.crypto.randomUUID(), + source: "ui", + timestamp: "2026-07-27T12:00:00.000Z", + resourceId, + }, + { + type: "contract.unrelated", + actorId: "act_contract", + requestId: globalThis.crypto.randomUUID(), + source: "ui", + timestamp: "2026-07-27T12:01:00.000Z", + resourceId: unrelatedId, + }, + { + type: "contract.completed", + actorId: "act_contract", + requestId: globalThis.crypto.randomUUID(), + source: "mcp", + timestamp: "2026-07-27T12:02:00.000Z", + resourceId, + }, + ]; + for (const event of events) await input.sink.write(event); + const listed = await input.query.list({ resourceId, limit: 1 }); + return [ + await check( + "AuditQueryPort filters a resource and returns newest events first", + () => + listed.length === 1 && + listed[0]?.type === "contract.completed" && + listed[0]?.resourceId === resourceId, + ), + ]; +} + +export async function FrameworkAdapterContract(input: { + readonly handle: (request: Request) => Promise; +}): Promise { + const response = await input.handle(new Request("https://cms.example.test/api/cms/health")); + return [ + await check("FrameworkAdapter returns a Web Response", () => response instanceof Response), + await check("FrameworkAdapter does not emit cacheable authenticated data", () => { + const cacheControl = response.headers.get("cache-control"); + return cacheControl === null || /no-store|private/u.test(cacheControl); + }), + ]; +} + +export async function RendererContract(input: { + readonly render: () => Promise | string; +}): Promise { + const first = await input.render(); + const second = await input.render(); + return [ + await check("Renderer is deterministic", () => first === second), + await check("Renderer produces non-empty markup", () => first.trim().length > 0), + await check("Renderer excludes editor runtime markers", () => !first.includes("cms-editor")), + ]; +} + +export function contractPassed(results: readonly ContractTestResult[]): boolean { + return results.every((result) => result.passed); +} diff --git a/packages/adapter-kit/src/index.ts b/packages/adapter-kit/src/index.ts index adb8a3b..1d783d7 100644 --- a/packages/adapter-kit/src/index.ts +++ b/packages/adapter-kit/src/index.ts @@ -28,3 +28,5 @@ export async function verifyIdempotent( ...(passed ? {} : { details: "Repeated calls returned different results." }), }; } + +export * from "./contracts.js"; diff --git a/packages/application/package.json b/packages/application/package.json index a3d7651..bd567a0 100644 --- a/packages/application/package.json +++ b/packages/application/package.json @@ -22,7 +22,10 @@ "@git-native-cms/core": "workspace:*", "@git-native-cms/document-model": "workspace:*", "@git-native-cms/git": "workspace:*", - "@git-native-cms/permissions": "workspace:*" + "@git-native-cms/localization": "workspace:*", + "@git-native-cms/permissions": "workspace:*", + "@git-native-cms/search": "workspace:*", + "@git-native-cms/seo": "workspace:*" }, "scripts": { "build": "tsc -p tsconfig.json", diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 84d639a..c0ef596 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -1,8 +1,9 @@ -import { yamlCodec } from "@git-native-cms/content-codecs"; +import { canonicalJson, yamlCodec } from "@git-native-cms/content-codecs"; import { CmsError, isoTimestamp, type Actor, + type AssetId, type Change, type ChangeStatus, type ContentDocument, @@ -11,23 +12,56 @@ import { type ReleaseId, type Revision, } from "@git-native-cms/core"; -import { applyPatches, type ContentPatch } from "@git-native-cms/document-model"; +import { + applyPatch, + applyPatches, + contentPath, + mergeDocuments, + parseContentPath, + type ContentPatch, + type ContentPath, +} from "@git-native-cms/document-model"; import { buildChangeBranchName, changeCommitMessage } from "@git-native-cms/git"; -import type { AuthorizationService } from "@git-native-cms/permissions"; +import { importXliff } from "@git-native-cms/localization"; +import { buildReferenceGraph, buildSearchIndex } from "@git-native-cms/search"; +import { auditSeo, buildHreflang, buildSitemap, type SeoMetadata } from "@git-native-cms/seo"; +import { + parsePermissionConfiguration, + type AuthorizationService, + type TeamRoleMapping, +} from "@git-native-cms/permissions"; import type { AuditSink, + AuditEvent, + AuditQueryPort, + Asset, + AssetStore, + AssetProcessorPort, + AssetUsagePort, Clock, ContentRepository, + ContentScheduleAction, DocumentSummary, + EnvironmentPointer, GitProvider, IdGenerator, IdempotencyStore, PullRequest, + PublicationNotifierPort, + PreviewSession, + PreviewSessionPort, ReleaseBuilderPort, + ReviewAssignment, ReviewComment, ReviewPort, ReleaseStore, + SchedulerPort, StoredRelease, + TeamInvitation, + TeamMember, + TeamProvisioningPort, + OrganizationTeam, + TranslationProvider, } from "./ports.js"; export * from "./ports.js"; @@ -46,9 +80,18 @@ export interface CommandDependencies { readonly ids: IdGenerator; readonly idempotency: IdempotencyStore; readonly audit: AuditSink; + readonly auditQuery?: AuditQueryPort; readonly releaseStore?: ReleaseStore; readonly releaseBuilder?: ReleaseBuilderPort; readonly review?: ReviewPort; + readonly assetStore?: AssetStore; + readonly assetUsage?: AssetUsagePort; + readonly assetProcessor?: AssetProcessorPort; + readonly scheduler?: SchedulerPort; + readonly publicationNotifier?: PublicationNotifierPort; + readonly previewSessions?: PreviewSessionPort; + readonly teamProvisioning?: TeamProvisioningPort; + readonly translationProvider?: TranslationProvider; readonly mainBranch?: string; readonly stagingBranch?: string; } @@ -131,10 +174,27 @@ export interface ChangeTransitionResult { readonly pullRequest?: PullRequest; } +export interface ChangeConflict { + readonly documentId: DocumentId; + readonly path: ContentPath; + readonly base: unknown; + readonly change: unknown; + readonly staging: unknown; + readonly scope: "field" | "document"; +} + +export interface ChangeConflictResolution { + readonly documentId: DocumentId; + readonly path: ContentPath; + readonly choice: "change" | "staging"; +} + export interface CreateChangeCommand { readonly name: string; readonly description?: string; readonly baseBranch?: string; + readonly collaborators?: readonly string[]; + readonly targetDate?: string; readonly idempotencyKey: string; readonly emergency?: boolean; } @@ -144,21 +204,88 @@ export class CreateChangeHandler { async execute(command: CreateChangeCommand, context: RequestContext): Promise { this.dependencies.authorization.assert(context.actor, "change.create"); + const name = command.name.trim(); + if (name.length < 2 || name.length > 120) { + throw new CmsError({ + code: "CMS_CHANGE_010", + message: "Change name must contain between 2 and 120 characters.", + category: "validation", + retryable: false, + }); + } + const collaborators = [ + ...new Set( + (command.collaborators ?? []) + .map((value) => value.trim().replace(/^@/u, "")) + .filter(Boolean), + ), + ]; + if ( + collaborators.length > 20 || + collaborators.some( + (value) => + value.length > 100 || + !/^(?:team:)?[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?$/u.test(value), + ) + ) { + throw new CmsError({ + code: "CMS_CHANGE_012", + message: "Collaborators must contain at most 20 GitHub users or team:slug values.", + category: "validation", + retryable: false, + }); + } + const targetDateValue = + command.targetDate === undefined + ? undefined + : new Date(`${command.targetDate}T00:00:00.000Z`); + if ( + command.targetDate !== undefined && + (!/^\d{4}-\d{2}-\d{2}$/u.test(command.targetDate) || + targetDateValue === undefined || + !Number.isFinite(targetDateValue.getTime()) || + !targetDateValue.toISOString().startsWith(command.targetDate)) + ) { + throw new CmsError({ + code: "CMS_CHANGE_013", + message: "The target date must use YYYY-MM-DD.", + category: "validation", + retryable: false, + }); + } + const requestedBaseBranch = + command.emergency === true + ? mainBranch(this.dependencies) + : (command.baseBranch ?? mainBranch(this.dependencies)); + if ( + requestedBaseBranch !== mainBranch(this.dependencies) && + requestedBaseBranch !== stagingBranch(this.dependencies) + ) { + throw new CmsError({ + code: "CMS_CHANGE_014", + message: "A Change can only start from the configured Production or Staging branch.", + category: "validation", + retryable: false, + }); + } return once(this.dependencies.idempotency, command.idempotencyKey, async () => { - const baseBranch = command.baseBranch ?? mainBranch(this.dependencies); + const baseBranch = requestedBaseBranch; const base = await this.dependencies.git.resolveRef(baseBranch, context.signal); const id = this.dependencies.ids.changeId(); const now = isoTimestamp(this.dependencies.clock.now()); const branchName = buildChangeBranchName({ actor: context.actor, - name: command.name, + name, suffix: this.dependencies.ids.suffix(), ...(command.emergency === undefined ? {} : { emergency: command.emergency }), }); const change: Change = { id, - name: command.name, + name, ...(command.description === undefined ? {} : { description: command.description }), + ...(collaborators.length === 0 ? {} : { collaborators }), + ...(command.targetDate === undefined ? {} : { targetDate: command.targetDate }), + ...(command.emergency === true ? { emergency: true } : {}), ownerId: context.actor.id, baseBranch, baseCommit: base.sha, @@ -190,6 +317,245 @@ export class CreateChangeHandler { } } +export class UpdateChangeHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly change: Change; + readonly name?: string; + readonly description?: string | null; + readonly expectedRevision: GitCommitSha; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise<{ readonly change: Change; readonly revision: GitCommitSha }> { + this.dependencies.authorization.assert(context.actor, "change.edit", { + ownerId: input.change.ownerId, + policy: { ownerOnly: ["change.edit"] }, + }); + if (!["draft", "changes_requested"].includes(input.change.status)) { + throw new CmsError({ + code: "CMS_CHANGE_005", + message: "Only an editable Change can be updated.", + category: "conflict", + retryable: false, + }); + } + const name = input.name?.trim(); + if (name !== undefined && (name.length < 2 || name.length > 120)) { + throw new CmsError({ + code: "CMS_CHANGE_010", + message: "Change name must contain between 2 and 120 characters.", + category: "validation", + retryable: false, + }); + } + if ( + input.description !== undefined && + input.description !== null && + input.description.length > 4_000 + ) { + throw new CmsError({ + code: "CMS_CHANGE_011", + message: "Change description cannot exceed 4,000 characters.", + category: "validation", + retryable: false, + }); + } + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const current = await this.dependencies.git.resolveRef( + input.change.branchName, + context.signal, + ); + if (current.sha !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_CHANGE_003", + message: "The Change moved before its details could be updated.", + category: "conflict", + retryable: true, + }); + } + const stable = { ...input.change }; + delete stable.description; + const change: Change = { + ...stable, + ...(input.description === undefined + ? input.change.description === undefined + ? {} + : { description: input.change.description } + : input.description === null || input.description.trim().length === 0 + ? {} + : { description: input.description.trim() }), + ...(name === undefined ? {} : { name }), + updatedAt: isoTimestamp(this.dependencies.clock.now()), + }; + const committed = await this.dependencies.git.commitFiles({ + branch: change.branchName, + expectedSha: input.expectedRevision, + files: [{ path: ".cms/change.yaml", content: yamlCodec.serialize(change) }], + message: changeCommitMessage(change, `Update Change "${change.name}"`), + author: context.actor, + idempotencyKey: input.idempotencyKey, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "change.updated", + change.id, + ); + return { change, revision: committed.sha }; + }); + } +} + +export class DeleteChangeHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly change: Change; + readonly expectedRevision: GitCommitSha; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise<{ readonly changeId: Change["id"] }> { + this.dependencies.authorization.assert(context.actor, "change.edit", { + ownerId: input.change.ownerId, + policy: { ownerOnly: ["change.edit"] }, + }); + if (input.change.status !== "draft" || input.change.pullRequestNumber !== undefined) { + throw new CmsError({ + code: "CMS_CHANGE_012", + message: + "Only a draft Change without a pull request can be deleted; archive reviewed work.", + category: "conflict", + retryable: false, + }); + } + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const current = await this.dependencies.git.resolveRef( + input.change.branchName, + context.signal, + ); + if (current.sha !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_CHANGE_003", + message: "The Change moved before it could be deleted.", + category: "conflict", + retryable: true, + }); + } + await this.dependencies.git.deleteBranch({ + branch: input.change.branchName, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "change.deleted", + input.change.id, + ); + return { changeId: input.change.id }; + }); + } +} + +export class CommitChangeHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly change: Change; + readonly documents: readonly { + readonly documentId: DocumentId; + readonly patches: readonly ContentPatch[]; + }[]; + readonly expectedRevision: Revision; + readonly message?: string; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise<{ readonly documents: readonly ContentDocument[]; readonly revision: Revision }> { + this.dependencies.authorization.assert(context.actor, "change.edit", { + ownerId: input.change.ownerId, + policy: { ownerOnly: ["change.edit"] }, + }); + if (!["draft", "changes_requested"].includes(input.change.status)) { + throw new CmsError({ + code: "CMS_CHANGE_005", + message: "Only an editable Change can save a version.", + category: "conflict", + retryable: false, + }); + } + if (input.documents.length === 0 || input.documents.length > 100) { + throw new CmsError({ + code: "CMS_DOCUMENT_010", + message: "A version must contain between 1 and 100 changed documents.", + category: "validation", + retryable: false, + }); + } + if ( + new Set(input.documents.map((document) => document.documentId)).size !== + input.documents.length + ) { + throw new CmsError({ + code: "CMS_DOCUMENT_011", + message: "A version cannot contain the same document more than once.", + category: "validation", + retryable: false, + }); + } + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const documents = await Promise.all( + input.documents.map(async (update) => { + const current = await this.dependencies.content.readDocument({ + ref: input.change.branchName, + documentId: update.documentId, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + if (current.revision !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_CHANGE_003", + message: "A document changed before this version could be saved.", + category: "conflict", + retryable: true, + context: { documentId: update.documentId }, + }); + } + return { ...current, data: applyPatches(current.data, update.patches) }; + }), + ); + const revision = await this.dependencies.content.writeDocuments({ + ref: input.change.branchName, + documents, + expectedRevision: input.expectedRevision, + message: + input.message?.trim() || + changeCommitMessage(input.change, `Save version (${documents.length} documents)`), + actor: context.actor, + idempotencyKey: input.idempotencyKey, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + const saved = documents.map((document) => ({ ...document, revision })); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "change.version-saved", + input.change.id, + { documents: documents.map((document) => document.id), revision }, + ); + return { documents: saved, revision }; + }); + } +} + export interface UpdateDocumentCommand { readonly change: Change; readonly documentId: DocumentId; @@ -206,6 +572,14 @@ export class UpdateDocumentHandler { ownerId: command.change.ownerId, policy: { ownerOnly: ["change.edit"] }, }); + if (!["draft", "changes_requested"].includes(command.change.status)) { + throw new CmsError({ + code: "CMS_CHANGE_006", + message: "Documents can only be edited while a Change is editable.", + category: "conflict", + retryable: false, + }); + } return once(this.dependencies.idempotency, command.idempotencyKey, async () => { const document = await this.dependencies.content.readDocument({ ref: command.change.branchName, @@ -249,165 +623,2691 @@ export class UpdateDocumentHandler { } } -export interface SubmitChangeCommand { +export interface CreateDocumentCommand { readonly change: Change; - readonly expectedRevision: GitCommitSha; + readonly type: string; + readonly schemaVersion: number; + readonly data: unknown; + readonly expectedRevision: Revision; readonly idempotencyKey: string; } -export class SubmitChangeHandler { +export class CreateDocumentHandler { constructor(private readonly dependencies: CommandDependencies) {} - async execute( - command: SubmitChangeCommand, - context: RequestContext, - ): Promise { - this.dependencies.authorization.assert(context.actor, "change.submit", { + async execute(command: CreateDocumentCommand, context: RequestContext): Promise { + this.dependencies.authorization.assert(context.actor, "change.edit", { ownerId: command.change.ownerId, - policy: { ownerOnly: ["change.submit"] }, + policy: { ownerOnly: ["change.edit"] }, }); + if (!["draft", "changes_requested"].includes(command.change.status)) { + throw new CmsError({ + code: "CMS_CHANGE_006", + message: "Documents can only be created while a Change is editable.", + category: "conflict", + retryable: false, + }); + } + if (!/^[a-z][a-z0-9-]*$/u.test(command.type) || command.schemaVersion < 1) { + throw new CmsError({ + code: "CMS_DOCUMENT_012", + message: "Document type and schema version are invalid.", + category: "validation", + retryable: false, + }); + } return once(this.dependencies.idempotency, command.idempotencyKey, async () => { - const current = await this.dependencies.git.resolveRef( - command.change.branchName, - context.signal, - ); - if (current.sha !== command.expectedRevision) { - throw new CmsError({ - code: "CMS_CHANGE_003", - message: "The Change has a newer version. Refresh before sending it for review.", - category: "conflict", - retryable: true, - }); - } - const pullRequest = await this.dependencies.git.createPullRequest({ - head: command.change.branchName, - base: stagingBranch(this.dependencies), - title: command.change.name, - body: `${command.change.description ?? ""}\n\nChange-ID: ${command.change.id}`, + const document: ContentDocument = { + id: this.dependencies.ids.documentId(), + type: command.type, + schemaVersion: command.schemaVersion, + revision: command.expectedRevision, + data: structuredClone(command.data), + }; + const revision = await this.dependencies.content.writeDocuments({ + ref: command.change.branchName, + documents: [document], + expectedRevision: command.expectedRevision, + message: changeCommitMessage(command.change, `Create ${document.type}/${document.id}`), + actor: context.actor, idempotencyKey: command.idempotencyKey, ...(context.signal === undefined ? {} : { signal: context.signal }), }); - const transitioned = await persistChange({ - dependencies: this.dependencies, - change: command.change, - status: "in_review", - expectedRevision: current.sha, - actor: context.actor, - idempotencyKey: `${command.idempotencyKey}:status`, - context, - pullRequest, - }); + const created = { ...document, revision }; await audit( this.dependencies.audit, this.dependencies.clock, context, - "change.submitted", - command.change.id, - { pullRequest: pullRequest.number }, + "document.created", + document.id, + { changeId: command.change.id, type: document.type }, ); - return { ...transitioned, pullRequest }; + return created; }); } } -export class ApproveChangeHandler { +export class DeleteDocumentHandler { constructor(private readonly dependencies: CommandDependencies) {} async execute( - input: { + command: { readonly change: Change; - readonly pullRequestNumber: number; - readonly expectedRevision: GitCommitSha; + readonly documentId: DocumentId; + readonly expectedRevision: Revision; readonly idempotencyKey: string; - readonly body?: string; }, context: RequestContext, - ): Promise { - this.dependencies.authorization.assert(context.actor, "change.approve"); - return once(this.dependencies.idempotency, input.idempotencyKey, async () => { - const current = await this.dependencies.git.resolveRef( - input.change.branchName, - context.signal, - ); - if (current.sha !== input.expectedRevision) { - throw new CmsError({ - code: "CMS_CHANGE_003", - message: "The Change has a newer version. Refresh before approving it.", - category: "conflict", - retryable: true, - }); - } - const transitioned = await persistChange({ - dependencies: this.dependencies, - change: input.change, - status: "approved", - expectedRevision: current.sha, + ): Promise<{ readonly documentId: DocumentId; readonly revision: Revision }> { + this.dependencies.authorization.assert(context.actor, "change.edit", { + ownerId: command.change.ownerId, + policy: { ownerOnly: ["change.edit"] }, + }); + if (!["draft", "changes_requested"].includes(command.change.status)) { + throw new CmsError({ + code: "CMS_CHANGE_006", + message: "Documents can only be deleted while a Change is editable.", + category: "conflict", + retryable: false, + }); + } + return once(this.dependencies.idempotency, command.idempotencyKey, async () => { + const current = await this.dependencies.content.readDocument({ + ref: command.change.branchName, + documentId: command.documentId, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + if (current.revision !== command.expectedRevision) { + throw new CmsError({ + code: "CMS_CHANGE_003", + message: "The document changed before it could be deleted.", + category: "conflict", + retryable: true, + }); + } + const revision = await this.dependencies.content.deleteDocuments({ + ref: command.change.branchName, + documentIds: [command.documentId], + expectedRevision: command.expectedRevision, actor: context.actor, - idempotencyKey: `${input.idempotencyKey}:status`, + idempotencyKey: command.idempotencyKey, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, context, + "document.deleted", + command.documentId, + { changeId: command.change.id }, + ); + return { documentId: command.documentId, revision }; + }); + } +} + +export class ImportTranslationHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly change: Change; + readonly documentId: DocumentId; + readonly targetLocale: string; + readonly xliff: string; + readonly expectedRevision: Revision; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise { + this.dependencies.authorization.assert(context.actor, "change.edit", { + ownerId: input.change.ownerId, + policy: { ownerOnly: ["change.edit"] }, + }); + if (!/^[a-z]{2,3}(?:-[A-Z]{2})?$/u.test(input.targetLocale)) { + throw new CmsError({ + code: "CMS_LOCALE_001", + message: "Target locale must use a language or language-market code.", + category: "validation", + retryable: false, }); - let mirroredToGitHub = true; - try { - await this.dependencies.git.approvePullRequest({ - number: input.pullRequestNumber, - actor: context.actor, - ...(input.body === undefined ? {} : { body: input.body }), - ...(context.signal === undefined ? {} : { signal: context.signal }), + } + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const document = await this.dependencies.content.readDocument({ + ref: input.change.branchName, + documentId: input.documentId, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + if (document.revision !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_CHANGE_003", + message: "The document changed before the translation could be imported.", + category: "conflict", + retryable: true, }); - } catch { - mirroredToGitHub = false; } + const data = recordValue(document.data); + const locales = recordValue(data.locales); + const fields = Object.fromEntries( + importXliff(input.xliff) + .filter((unit) => unit.target !== undefined) + .map((unit) => [unit.id, unit.target]), + ); + const next: ContentDocument = { + ...document, + data: { + ...data, + locales: { + ...locales, + [input.targetLocale]: { + status: "translated", + sourceRevision: document.revision, + fields, + }, + }, + }, + }; + const revision = await this.dependencies.content.writeDocuments({ + ref: input.change.branchName, + documents: [next], + expectedRevision: input.expectedRevision, + message: changeCommitMessage( + input.change, + `Import ${input.targetLocale} translation for ${document.id}`, + ), + actor: context.actor, + idempotencyKey: input.idempotencyKey, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); await audit( this.dependencies.audit, this.dependencies.clock, context, - "change.approved", - input.change.id, - { mirroredToGitHub }, + "translation.imported", + document.id, + { locale: input.targetLocale, units: Object.keys(fields).length }, ); - return transitioned; + return { ...next, revision }; }); } } -export class ReviewChangeHandler { +function configuredTranslationProvider(dependencies: CommandDependencies): TranslationProvider { + if (dependencies.translationProvider === undefined) { + throw new CmsError({ + code: "CMS_TRANSLATION_010", + message: "No translation provider is configured.", + category: "configuration", + retryable: false, + }); + } + return dependencies.translationProvider; +} + +export class CreateTranslationJobHandler { constructor(private readonly dependencies: CommandDependencies) {} async execute( input: { readonly change: Change; - readonly pullRequestNumber: number; - readonly body: string; - readonly path?: string; - readonly line?: number; + readonly documentId: DocumentId; + readonly sourceLocale: string; + readonly targetLocale: string; + readonly xliff: string; + readonly expectedRevision: Revision; + readonly idempotencyKey: string; }, context: RequestContext, - ): Promise { - this.dependencies.authorization.assert(context.actor, "change.review"); - if (this.dependencies.review === undefined) { + ): Promise<{ readonly jobId: string }> { + this.dependencies.authorization.assert(context.actor, "change.edit", { + ownerId: input.change.ownerId, + policy: { ownerOnly: ["change.edit"] }, + }); + if ( + !/^[a-z]{2,3}(?:-[A-Z]{2})?$/u.test(input.sourceLocale) || + !/^[a-z]{2,3}(?:-[A-Z]{2})?$/u.test(input.targetLocale) || + input.sourceLocale === input.targetLocale + ) { throw new CmsError({ - code: "CMS_REVIEW_001", - message: "No review adapter is configured.", + code: "CMS_LOCALE_001", + message: "Translation jobs require different, valid source and target locales.", + category: "validation", + retryable: false, + }); + } + const document = await this.dependencies.content.readDocument({ + ref: input.change.branchName, + documentId: input.documentId, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + if (document.revision !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_CHANGE_003", + message: "The document changed before the translation job could be created.", + category: "conflict", + retryable: true, + }); + } + const provider = configuredTranslationProvider(this.dependencies); + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const result = await provider.createJob({ + sourceLocale: input.sourceLocale, + targetLocale: input.targetLocale, + xliff: input.xliff, + idempotencyKey: input.idempotencyKey, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "translation.job-created", + input.documentId, + { jobId: result.jobId, targetLocale: input.targetLocale }, + ); + return result; + }); + } +} + +export class ReadTranslationJobHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { readonly change: Change; readonly jobId: string }, + context: RequestContext, + ): ReturnType { + this.dependencies.authorization.assert(context.actor, "change.edit", { + ownerId: input.change.ownerId, + policy: { ownerOnly: ["change.edit"] }, + }); + if (!/^[a-zA-Z0-9._:-]{1,200}$/u.test(input.jobId)) { + throw new CmsError({ + code: "CMS_TRANSLATION_011", + message: "Translation job ID is invalid.", + category: "validation", + retryable: false, + }); + } + return configuredTranslationProvider(this.dependencies).readJob(input.jobId, context.signal); + } +} + +function configuredAssetStore(dependencies: CommandDependencies): AssetStore { + if (dependencies.assetStore === undefined) { + throw new CmsError({ + code: "CMS_ASSET_010", + message: "Asset storage is not configured.", + category: "configuration", + retryable: false, + }); + } + return dependencies.assetStore; +} + +export class CreateAssetUploadHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly fileName: string; + readonly mimeType: string; + readonly size: number; + readonly checksum: string; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise<{ + readonly uploadId: string; + readonly url: string; + readonly headers: Record; + }> { + this.dependencies.authorization.assert(context.actor, "asset.upload"); + return once(this.dependencies.idempotency, input.idempotencyKey, () => + configuredAssetStore(this.dependencies).createUpload({ + fileName: input.fileName, + mimeType: input.mimeType, + size: input.size, + checksum: input.checksum, + actor: context.actor, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }), + ); + } +} + +export class ReceiveAssetUploadHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly uploadId: string; + readonly bytes: Uint8Array; + readonly mimeType: string; + readonly token?: string; + }, + context: RequestContext, + ): Promise { + this.dependencies.authorization.assert(context.actor, "asset.upload"); + const store = configuredAssetStore(this.dependencies); + if (store.uploadBytes === undefined) { + throw new CmsError({ + code: "CMS_ASSET_011", + message: "This asset adapter only accepts direct signed storage uploads.", category: "configuration", retryable: false, }); } - const comment = await this.dependencies.review.addComment({ - pullRequestNumber: input.pullRequestNumber, - body: input.body, - ...(input.path === undefined ? {} : { path: input.path }), - ...(input.line === undefined ? {} : { line: input.line }), + await store.uploadBytes({ + ...input, ...(context.signal === undefined ? {} : { signal: context.signal }), }); - await audit( - this.dependencies.audit, - this.dependencies.clock, - context, - "review.comment-added", - input.change.id, - { commentId: comment.id }, - ); - return comment; + } +} + +export class FinalizeAssetUploadHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly change: Change; + readonly uploadId: string; + readonly checksum: string; + readonly expectedRevision: GitCommitSha; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise<{ readonly asset: Asset; readonly revision: GitCommitSha }> { + this.dependencies.authorization.assert(context.actor, "asset.upload"); + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + let asset = await configuredAssetStore(this.dependencies).finalizeUpload({ + uploadId: input.uploadId, + checksum: input.checksum, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + if (this.dependencies.assetProcessor !== undefined && asset.mimeType.startsWith("image/")) { + asset = await this.dependencies.assetProcessor.process(asset, context.signal); + } + const current = await this.dependencies.git.resolveRef( + input.change.branchName, + context.signal, + ); + if (current.sha !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_CHANGE_003", + message: "The Change moved before asset metadata could be saved.", + category: "conflict", + retryable: true, + }); + } + const committed = await this.dependencies.git.commitFiles({ + branch: input.change.branchName, + expectedSha: input.expectedRevision, + files: [ + { + path: `.cms/assets/${asset.id}.yaml`, + content: yamlCodec.serialize(asset), + }, + ], + message: changeCommitMessage(input.change, `Add asset ${asset.fileName}`), + author: context.actor, + idempotencyKey: `${input.idempotencyKey}:metadata`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "asset.finalized", + asset.id, + { changeId: input.change.id, checksum: asset.checksum }, + ); + return { asset, revision: committed.sha }; + }); + } +} + +export class DeleteAssetHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly change: Change; + readonly assetId: AssetId; + readonly expectedRevision: GitCommitSha; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise<{ readonly assetId: AssetId; readonly revision: GitCommitSha }> { + this.dependencies.authorization.assert(context.actor, "asset.delete"); + const usage = this.dependencies.assetUsage; + if (usage === undefined) { + throw new CmsError({ + code: "CMS_ASSET_010", + message: "Asset usage tracking is not configured.", + category: "configuration", + retryable: false, + }); + } + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const [usages, released] = await Promise.all([ + usage.usages(input.assetId, context.signal), + usage.isReleased(input.assetId, context.signal), + ]); + if (usages.length > 0 || released) { + throw new CmsError({ + code: "CMS_ASSET_009", + message: "This asset is still used by content or an immutable release.", + category: "conflict", + retryable: false, + context: { usages, released }, + }); + } + const committed = await this.dependencies.git.commitFiles({ + branch: input.change.branchName, + expectedSha: input.expectedRevision, + files: [{ path: `.cms/assets/${input.assetId}.yaml`, content: null }], + message: changeCommitMessage(input.change, `Delete asset ${input.assetId}`), + author: context.actor, + idempotencyKey: `${input.idempotencyKey}:metadata`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await configuredAssetStore(this.dependencies).deleteAsset(input.assetId, context.signal); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "asset.deleted", + input.assetId, + { changeId: input.change.id }, + ); + return { assetId: input.assetId, revision: committed.sha }; + }); + } +} + +export class UpdateAssetHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly change: Change; + readonly assetId: AssetId; + readonly altText?: string | null; + readonly focalPoint?: { readonly x: number; readonly y: number } | null; + readonly expectedRevision: GitCommitSha; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise<{ readonly asset: Asset; readonly revision: GitCommitSha }> { + this.dependencies.authorization.assert(context.actor, "asset.edit", { + ownerId: input.change.ownerId, + policy: { ownerOnly: ["asset.edit"] }, + }); + if ( + input.altText !== undefined && + input.altText !== null && + input.altText.trim().length > 1_000 + ) { + throw new CmsError({ + code: "CMS_ASSET_012", + message: "Asset alternative text cannot exceed 1,000 characters.", + category: "validation", + retryable: false, + }); + } + if ( + input.focalPoint !== undefined && + input.focalPoint !== null && + (![input.focalPoint.x, input.focalPoint.y].every(Number.isFinite) || + input.focalPoint.x < 0 || + input.focalPoint.x > 1 || + input.focalPoint.y < 0 || + input.focalPoint.y > 1) + ) { + throw new CmsError({ + code: "CMS_ASSET_013", + message: "Asset focal point coordinates must be numbers between 0 and 1.", + category: "validation", + retryable: false, + }); + } + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const store = configuredAssetStore(this.dependencies); + const existing = await store.readAsset(input.assetId, context.signal); + if (existing === undefined) { + throw new CmsError({ + code: "CMS_ASSET_404", + message: "The selected asset does not exist.", + category: "validation", + retryable: false, + }); + } + const currentRef = await this.dependencies.git.resolveRef( + input.change.branchName, + context.signal, + ); + if (currentRef.sha !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_CHANGE_003", + message: "The Change moved before asset metadata could be updated.", + category: "conflict", + retryable: true, + }); + } + const stableAsset = { ...existing }; + delete stableAsset.altText; + delete stableAsset.focalPoint; + const asset: Asset = { + ...stableAsset, + ...(input.altText === undefined + ? existing.altText === undefined + ? {} + : { altText: existing.altText } + : input.altText === null || input.altText.trim().length === 0 + ? {} + : { altText: input.altText.trim() }), + ...(input.focalPoint === undefined + ? existing.focalPoint === undefined + ? {} + : { focalPoint: existing.focalPoint } + : input.focalPoint === null + ? {} + : { focalPoint: input.focalPoint }), + }; + const stored = await store.updateAssetMetadata({ + id: asset.id, + ...(asset.altText === undefined ? {} : { altText: asset.altText }), + ...(asset.focalPoint === undefined ? {} : { focalPoint: asset.focalPoint }), + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + let committed: Awaited>; + try { + committed = await this.dependencies.git.commitFiles({ + branch: input.change.branchName, + expectedSha: input.expectedRevision, + files: [ + { + path: `.cms/assets/${stored.id}.yaml`, + content: yamlCodec.serialize(stored), + }, + ], + message: changeCommitMessage(input.change, `Update asset ${stored.fileName}`), + author: context.actor, + idempotencyKey: `${input.idempotencyKey}:metadata`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + } catch (cause) { + try { + await store.updateAssetMetadata({ + id: existing.id, + ...(existing.altText === undefined ? {} : { altText: existing.altText }), + ...(existing.focalPoint === undefined ? {} : { focalPoint: existing.focalPoint }), + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + } catch (compensationCause) { + throw new CmsError({ + code: "CMS_ASSET_014", + message: + "Asset metadata could not be committed or restored. Retry after refreshing the Change.", + category: "storage", + retryable: true, + context: { assetId: existing.id }, + cause: new AggregateError([cause, compensationCause]), + }); + } + throw cause; + } + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "asset.updated", + asset.id, + { changeId: input.change.id }, + ); + return { asset: stored, revision: committed.sha }; + }); + } +} + +function configuredPreviewSessions(dependencies: CommandDependencies): PreviewSessionPort { + if (dependencies.previewSessions === undefined) { + throw new CmsError({ + code: "CMS_PREVIEW_002", + message: "Preview sessions are not configured.", + category: "configuration", + retryable: false, + }); + } + return dependencies.previewSessions; +} + +function assertPreviewOwner(session: PreviewSession, context: RequestContext): void { + if (session.actorId !== context.actor.id) { + throw new CmsError({ + code: "CMS_PREVIEW_003", + message: "This preview session belongs to another actor.", + category: "authorization", + retryable: false, + }); + } +} + +export class CreatePreviewSessionHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly change: Change; + readonly frontendRef: string; + readonly locale: string; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise { + this.dependencies.authorization.assert(context.actor, "project.read"); + if ( + input.frontendRef.trim().length === 0 || + input.frontendRef.length > 200 || + !/^[a-zA-Z0-9._/@:-]+$/u.test(input.frontendRef) + ) { + throw new CmsError({ + code: "CMS_PREVIEW_004", + message: "The preview frontend ref is invalid.", + category: "validation", + retryable: false, + }); + } + if (!/^[a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,8})*$/u.test(input.locale)) { + throw new CmsError({ + code: "CMS_PREVIEW_005", + message: "The preview locale is invalid.", + category: "validation", + retryable: false, + }); + } + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const session = await configuredPreviewSessions(this.dependencies).issue({ + actorId: context.actor.id, + changeId: input.change.id, + frontendRef: input.frontendRef, + locale: input.locale, + now: this.dependencies.clock.now(), + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "preview.session-created", + session.id, + { changeId: input.change.id, frontendRef: input.frontendRef, locale: input.locale }, + ); + return session; + }); + } +} + +export class ReadPreviewSessionHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { readonly id: string; readonly token: string }, + context: RequestContext, + ): Promise { + this.dependencies.authorization.assert(context.actor, "project.read"); + const session = await configuredPreviewSessions(this.dependencies).verify({ + id: input.id, + token: input.token, + now: this.dependencies.clock.now(), + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + assertPreviewOwner(session, context); + return session; + } +} + +export class RefreshPreviewSessionHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { readonly id: string; readonly token: string; readonly idempotencyKey: string }, + context: RequestContext, + ): Promise { + this.dependencies.authorization.assert(context.actor, "project.read"); + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const current = await configuredPreviewSessions(this.dependencies).verify({ + id: input.id, + token: input.token, + now: this.dependencies.clock.now(), + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + assertPreviewOwner(current, context); + const refreshed = await configuredPreviewSessions(this.dependencies).refresh({ + id: input.id, + token: input.token, + now: this.dependencies.clock.now(), + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "preview.session-refreshed", + refreshed.id, + { previousSessionId: input.id, changeId: refreshed.changeId }, + ); + return refreshed; + }); + } +} + +function configuredTeamProvisioning(dependencies: CommandDependencies): TeamProvisioningPort { + if (dependencies.teamProvisioning === undefined) { + throw new CmsError({ + code: "CMS_TEAM_003", + message: "GitHub organization team provisioning is not configured.", + category: "configuration", + retryable: false, + }); + } + return dependencies.teamProvisioning; +} + +function isValidInvitationEmail(value: string): boolean { + if (value.length === 0 || value.length > 254) return false; + let atIndex = -1; + for (let index = 0; index < value.length; index += 1) { + const character = value[index] ?? ""; + const code = value.charCodeAt(index); + if (code <= 32 || code === 127) return false; + if (character === "@") { + if (atIndex !== -1) return false; + atIndex = index; + } + } + if (atIndex <= 0 || atIndex > 64 || atIndex === value.length - 1) return false; + const local = value.slice(0, atIndex); + const domain = value.slice(atIndex + 1); + if ( + local.startsWith(".") || + local.endsWith(".") || + local.includes("..") || + domain.length > 253 || + domain.startsWith(".") || + domain.endsWith(".") || + domain.includes("..") || + !domain.includes(".") + ) { + return false; + } + return domain + .split(".") + .every( + (label) => + label.length > 0 && label.length <= 63 && !label.startsWith("-") && !label.endsWith("-"), + ); +} + +export class ReadTeamDirectoryHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute(context: RequestContext): Promise<{ + readonly members: readonly TeamMember[]; + readonly teams: readonly OrganizationTeam[]; + }> { + this.dependencies.authorization.assert(context.actor, "team.manage"); + const provisioning = configuredTeamProvisioning(this.dependencies); + const [members, teams] = await Promise.all([ + provisioning.listMembers(context.signal), + provisioning.listTeams(context.signal), + ]); + return { members, teams }; + } +} + +export class InviteTeamMemberHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly email?: string; + readonly inviteeId?: number; + readonly role: "direct_member" | "admin"; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise { + this.dependencies.authorization.assert(context.actor, "team.manage"); + if (input.email !== undefined && !isValidInvitationEmail(input.email)) { + throw new CmsError({ + code: "CMS_TEAM_004", + message: "A valid invitation email address is required.", + category: "validation", + retryable: false, + }); + } + if ( + (input.email === undefined) === (input.inviteeId === undefined) || + (input.inviteeId !== undefined && + (!Number.isSafeInteger(input.inviteeId) || input.inviteeId <= 0)) + ) { + throw new CmsError({ + code: "CMS_TEAM_001", + message: "Invite exactly one valid GitHub user ID or email address.", + category: "validation", + retryable: false, + }); + } + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const invitation = await configuredTeamProvisioning(this.dependencies).invite({ + ...(input.email === undefined ? {} : { email: input.email }), + ...(input.inviteeId === undefined ? {} : { inviteeId: input.inviteeId }), + role: input.role, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "team.member-invited", + invitation.id, + { role: invitation.role }, + ); + return invitation; + }); + } +} + +export class AddTeamMemberHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly teamSlug: string; + readonly username: string; + readonly role: "member" | "maintainer"; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise { + this.dependencies.authorization.assert(context.actor, "team.manage"); + if ( + !/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,98}[a-zA-Z0-9])?$/u.test(input.username) || + !/^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/u.test(input.teamSlug) + ) { + throw new CmsError({ + code: "CMS_TEAM_005", + message: "GitHub username or team slug is invalid.", + category: "validation", + retryable: false, + }); + } + await once(this.dependencies.idempotency, input.idempotencyKey, async () => { + await configuredTeamProvisioning(this.dependencies).addMemberToTeam({ + teamSlug: input.teamSlug, + username: input.username, + role: input.role, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "team.member-added", + input.username, + { team: input.teamSlug, role: input.role }, + ); + }); + } +} + +export class UpdateTeamRoleMappingsHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly mappings: readonly TeamRoleMapping[]; + readonly customRoles?: readonly { + readonly name: string; + readonly actions: readonly string[]; + }[]; + readonly expectedRevision: GitCommitSha; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise<{ readonly pullRequest: PullRequest; readonly revision: GitCommitSha }> { + this.dependencies.authorization.assert(context.actor, "team.manage"); + if ( + input.mappings.length > 100 || + input.mappings.some( + (mapping) => + mapping.team.trim().length === 0 || + mapping.roles.length === 0 || + mapping.roles.some((role) => String(role).trim().length === 0), + ) + ) { + throw new CmsError({ + code: "CMS_TEAM_006", + message: "Team role mappings must contain a team and at least one role.", + category: "validation", + retryable: false, + }); + } + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const main = await this.dependencies.git.resolveRef( + mainBranch(this.dependencies), + context.signal, + ); + if (main.sha !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_CHANGE_003", + message: "Main changed before permissions could be updated.", + category: "conflict", + retryable: true, + }); + } + const branch = `cms-permissions/${this.dependencies.ids.suffix()}`; + const currentPermissions = await this.dependencies.git.readFile({ + ref: mainBranch(this.dependencies), + path: ".cms/permissions.yaml", + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + const customRoles = + input.customRoles ?? + (currentPermissions === undefined + ? [] + : parsePermissionConfiguration(yamlCodec.parse(currentPermissions.content)).customRoles); + const validated = parsePermissionConfiguration({ + version: 1, + customRoles, + mappings: input.mappings, + }); + const created = await this.dependencies.git.createBranch({ + branch, + from: main.sha, + idempotencyKey: `${input.idempotencyKey}:branch`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + const committed = await this.dependencies.git.commitFiles({ + branch, + expectedSha: created.sha, + files: [ + { + path: ".cms/permissions.yaml", + content: yamlCodec.serialize({ + version: 1, + customRoles: validated.customRoles.map((role) => ({ + name: String(role.name), + actions: [...role.actions].sort(), + })), + mappings: [...validated.mappings] + .map((mapping) => ({ + team: mapping.team, + roles: [...mapping.roles].map(String).sort(), + })) + .sort((left, right) => left.team.localeCompare(right.team)), + }), + }, + ], + message: "Update CMS team role mappings", + author: context.actor, + idempotencyKey: `${input.idempotencyKey}:commit`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + const pullRequest = await this.dependencies.git.createPullRequest({ + head: branch, + base: mainBranch(this.dependencies), + title: "Update CMS permissions", + body: "Update the audited GitHub team to CMS role mappings in `.cms/permissions.yaml`.", + idempotencyKey: `${input.idempotencyKey}:pr`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "team.role-mappings-updated", + branch, + { pullRequest: pullRequest.number, mappings: input.mappings.length }, + ); + return { pullRequest, revision: committed.sha }; + }); + } +} + +export interface ContentSchedule { + readonly id: string; + readonly changeId: Change["id"]; + readonly action: ContentScheduleAction; + readonly documentIds: readonly DocumentId[]; + readonly executeAt: string; + readonly status: "scheduled" | "executed"; + readonly createdBy: Actor["id"]; + readonly createdAt: string; + readonly executedAt?: string; + readonly releaseId?: ReleaseId; +} + +export class ScheduleContentHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly change: Change; + readonly action: ContentScheduleAction; + readonly documentIds: readonly DocumentId[]; + readonly executeAt: string; + readonly expectedRevision: GitCommitSha; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise<{ readonly schedule: ContentSchedule; readonly revision: GitCommitSha }> { + this.dependencies.authorization.assert(context.actor, "staging.publish"); + const scheduler = this.dependencies.scheduler; + if (scheduler === undefined) { + throw new CmsError({ + code: "CMS_SCHEDULE_010", + message: "No scheduler adapter is configured.", + category: "configuration", + retryable: false, + }); + } + const executeAt = new Date(input.executeAt); + if ( + Number.isNaN(executeAt.getTime()) || + executeAt.getTime() <= this.dependencies.clock.now().getTime() + ) { + throw new CmsError({ + code: "CMS_SCHEDULE_002", + message: "Scheduled publication time must be a valid future UTC timestamp.", + category: "validation", + retryable: false, + }); + } + if (input.documentIds.length === 0) { + throw new CmsError({ + code: "CMS_SCHEDULE_003", + message: "At least one document must be scheduled.", + category: "validation", + retryable: false, + }); + } + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const id = this.dependencies.ids.scheduleId(); + const schedule: ContentSchedule = { + id, + changeId: input.change.id, + action: input.action, + documentIds: [...new Set(input.documentIds)].sort(), + executeAt: executeAt.toISOString(), + status: "scheduled", + createdBy: context.actor.id, + createdAt: this.dependencies.clock.now().toISOString(), + }; + const workflow = scheduler.workflow({ + scheduleId: id, + executeAt: schedule.executeAt, + action: input.action, + documentIds: schedule.documentIds, + }); + const committed = await this.dependencies.git.commitFiles({ + branch: input.change.branchName, + expectedSha: input.expectedRevision, + files: [ + { + path: `.cms/schedules/${id}.yaml`, + content: yamlCodec.serialize(schedule), + }, + { path: workflow.path, content: workflow.content }, + ], + message: changeCommitMessage( + input.change, + `Schedule ${input.action} for ${schedule.executeAt}`, + ), + author: context.actor, + idempotencyKey: input.idempotencyKey, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "schedule.created", + id, + { action: input.action, executeAt: schedule.executeAt }, + ); + return { schedule, revision: committed.sha }; + }); + } +} + +function contentSchedule(source: string): ContentSchedule | undefined { + const value = yamlCodec.parse(source); + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + const schedule = value as Partial; + return typeof schedule.id === "string" && + typeof schedule.changeId === "string" && + [ + "publish", + "unpublish", + "availability-start", + "availability-end", + "visibility-start", + "visibility-end", + ].includes(schedule.action ?? "") && + Array.isArray(schedule.documentIds) && + schedule.documentIds.every((id) => typeof id === "string") && + typeof schedule.executeAt === "string" && + (schedule.status === "scheduled" || schedule.status === "executed") && + typeof schedule.createdBy === "string" && + typeof schedule.createdAt === "string" + ? (schedule as ContentSchedule) + : undefined; +} + +async function applyScheduledUnpublishes(input: { + readonly dependencies: CommandDependencies; + readonly schedules: readonly ContentSchedule[]; + readonly revision: GitCommitSha; + readonly idempotencyKey: string; + readonly context: RequestContext; +}): Promise { + const documentIds = [ + ...new Set( + input.schedules + .filter((schedule) => schedule.action === "unpublish") + .flatMap((schedule) => schedule.documentIds), + ), + ].sort(); + if (documentIds.length === 0) return input.revision; + return input.dependencies.content.deleteDocuments({ + ref: stagingBranch(input.dependencies), + documentIds, + expectedRevision: input.revision, + actor: input.context.actor, + idempotencyKey: `${input.idempotencyKey}:unpublish`, + ...(input.context.signal === undefined ? {} : { signal: input.context.signal }), + }); +} + +async function applyScheduledWindows(input: { + readonly dependencies: CommandDependencies; + readonly schedules: readonly ContentSchedule[]; + readonly revision: GitCommitSha; + readonly idempotencyKey: string; + readonly context: RequestContext; +}): Promise { + const schedules = input.schedules + .filter( + (schedule) => + schedule.action.startsWith("availability-") || schedule.action.startsWith("visibility-"), + ) + .sort( + (left, right) => + left.executeAt.localeCompare(right.executeAt) || left.id.localeCompare(right.id), + ); + if (schedules.length === 0) return input.revision; + const branch = stagingBranch(input.dependencies); + const documentIds = [...new Set(schedules.flatMap((schedule) => schedule.documentIds))].sort(); + const documents = await Promise.all( + documentIds.map((documentId) => + input.dependencies.content.readDocument({ + ref: branch, + documentId, + ...(input.context.signal === undefined ? {} : { signal: input.context.signal }), + }), + ), + ); + const updated = documents.map((document) => { + const data = + typeof document.data === "object" && document.data !== null && !Array.isArray(document.data) + ? (document.data as Readonly>) + : {}; + let next = data; + for (const schedule of schedules.filter((candidate) => + candidate.documentIds.includes(document.id), + )) { + const field = schedule.action.startsWith("availability-") + ? "availability" + : "visibilitySchedule"; + const current = + typeof next[field] === "object" && next[field] !== null && !Array.isArray(next[field]) + ? (next[field] as Readonly>) + : {}; + const boundary = schedule.action.endsWith("-start") ? "from" : "until"; + next = { ...next, [field]: { ...current, [boundary]: schedule.executeAt } }; + } + return { ...document, data: next }; + }); + return input.dependencies.content.writeDocuments({ + ref: branch, + documents: updated, + expectedRevision: input.revision, + message: `Apply ${schedules.length} scheduled availability/visibility rule(s)`, + actor: input.context.actor, + idempotencyKey: `${input.idempotencyKey}:windows`, + ...(input.context.signal === undefined ? {} : { signal: input.context.signal }), + }); +} + +export class ExecuteScheduleHandler { + private readonly publish: PublishStagingHandler; + + constructor(private readonly dependencies: CommandDependencies) { + this.publish = new PublishStagingHandler(dependencies); + } + + async execute( + input: { + readonly scheduleId: string; + readonly expectedAt: string; + readonly configVersion: number; + readonly registryDigest: string; + readonly schemaVersion: number; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise<{ + readonly status: "executed" | "already-executed" | "not-due"; + readonly schedule: ContentSchedule; + readonly releaseId?: ReleaseId; + }> { + this.dependencies.authorization.assert(context.actor, "staging.publish"); + const branch = stagingBranch(this.dependencies); + const path = `.cms/schedules/${input.scheduleId}.yaml`; + const file = await this.dependencies.git.readFile({ + ref: branch, + path, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + const schedule = file === undefined ? undefined : contentSchedule(file.content); + if (schedule === undefined || schedule.id !== input.scheduleId) { + throw new CmsError({ + code: "CMS_SCHEDULE_404", + message: "The scheduled publication was not found on Staging.", + category: "validation", + retryable: false, + }); + } + if (schedule.executeAt !== new Date(input.expectedAt).toISOString()) { + throw new CmsError({ + code: "CMS_SCHEDULE_004", + message: "The scheduled time does not match the audited schedule.", + category: "conflict", + retryable: false, + }); + } + if (schedule.status === "executed") { + return { + status: "already-executed", + schedule, + ...(schedule.releaseId === undefined ? {} : { releaseId: schedule.releaseId }), + }; + } + if (new Date(schedule.executeAt).getTime() > this.dependencies.clock.now().getTime()) { + return { status: "not-due", schedule }; + } + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const staging = await this.dependencies.git.resolveRef(branch, context.signal); + const unpublishRevision = await applyScheduledUnpublishes({ + dependencies: this.dependencies, + schedules: [schedule], + revision: staging.sha, + idempotencyKey: input.idempotencyKey, + context, + }); + const publicationRevision = await applyScheduledWindows({ + dependencies: this.dependencies, + schedules: [schedule], + revision: unpublishRevision, + idempotencyKey: input.idempotencyKey, + context, + }); + const publication = await this.publish.execute( + { + expectedStagingRevision: publicationRevision, + title: `Scheduled ${schedule.action} ${schedule.id}`, + configVersion: input.configVersion, + registryDigest: input.registryDigest, + schemaVersion: input.schemaVersion, + idempotencyKey: `${input.idempotencyKey}:publication`, + }, + context, + ); + const current = await this.dependencies.git.resolveRef(branch, context.signal); + const executed: ContentSchedule = { + ...schedule, + status: "executed", + executedAt: this.dependencies.clock.now().toISOString(), + releaseId: publication.release.id, + }; + await this.dependencies.git.commitFiles({ + branch, + expectedSha: current.sha, + files: [{ path, content: yamlCodec.serialize(executed) }], + message: `Record execution of ${schedule.id}`, + author: context.actor, + idempotencyKey: `${input.idempotencyKey}:status`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "schedule.executed", + schedule.id, + { action: schedule.action, releaseId: publication.release.id }, + ); + return { status: "executed" as const, schedule: executed, releaseId: publication.release.id }; + }); + } +} + +export class ExecuteDueSchedulesHandler { + private readonly publish: PublishStagingHandler; + + constructor(private readonly dependencies: CommandDependencies) { + this.publish = new PublishStagingHandler(dependencies); + } + + async execute( + input: { + readonly configVersion: number; + readonly registryDigest: string; + readonly schemaVersion: number; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise<{ + readonly status: "executed" | "nothing-due"; + readonly schedules: readonly ContentSchedule[]; + readonly releaseId?: ReleaseId; + }> { + this.dependencies.authorization.assert(context.actor, "staging.publish"); + const branch = stagingBranch(this.dependencies); + const files = await this.dependencies.git.listFiles({ + ref: branch, + prefix: ".cms/schedules/", + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + const now = this.dependencies.clock.now().getTime(); + const due = files + .flatMap((file) => { + try { + const schedule = contentSchedule(file.content); + return schedule === undefined ? [] : [{ path: file.path, schedule }]; + } catch { + return []; + } + }) + .filter( + ({ schedule }) => + schedule.status === "scheduled" && new Date(schedule.executeAt).getTime() <= now, + ) + .sort((left, right) => left.schedule.executeAt.localeCompare(right.schedule.executeAt)) + .slice(0, 50); + if (due.length === 0) return { status: "nothing-due", schedules: [] }; + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const staging = await this.dependencies.git.resolveRef(branch, context.signal); + const batchKey = due + .map(({ schedule }) => schedule.id) + .sort() + .join(","); + const unpublishRevision = await applyScheduledUnpublishes({ + dependencies: this.dependencies, + schedules: due.map(({ schedule }) => schedule), + revision: staging.sha, + idempotencyKey: `scheduled-batch:${batchKey}`, + context, + }); + const publicationRevision = await applyScheduledWindows({ + dependencies: this.dependencies, + schedules: due.map(({ schedule }) => schedule), + revision: unpublishRevision, + idempotencyKey: `scheduled-batch:${batchKey}`, + context, + }); + const publication = await this.publish.execute( + { + expectedStagingRevision: publicationRevision, + title: `Scheduled publication (${due.length})`, + configVersion: input.configVersion, + registryDigest: input.registryDigest, + schemaVersion: input.schemaVersion, + idempotencyKey: `scheduled-batch:${batchKey}`, + }, + context, + ); + const executedAt = this.dependencies.clock.now().toISOString(); + const schedules = due.map(({ schedule }) => ({ + ...schedule, + status: "executed" as const, + executedAt, + releaseId: publication.release.id, + })); + const current = await this.dependencies.git.resolveRef(branch, context.signal); + await this.dependencies.git.commitFiles({ + branch, + expectedSha: current.sha, + files: due.map(({ path }, index) => ({ + path, + content: yamlCodec.serialize(schedules[index]), + })), + message: `Record execution of ${due.length} scheduled publication(s)`, + author: context.actor, + idempotencyKey: `${input.idempotencyKey}:status`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + for (const schedule of schedules) { + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "schedule.executed", + schedule.id, + { action: schedule.action, releaseId: publication.release.id, batch: batchKey }, + ); + } + return { + status: "executed" as const, + schedules, + releaseId: publication.release.id, + }; + }); + } +} + +export interface SubmitChangeCommand { + readonly change: Change; + readonly expectedRevision: GitCommitSha; + readonly idempotencyKey: string; +} + +export class SubmitChangeHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + command: SubmitChangeCommand, + context: RequestContext, + ): Promise { + this.dependencies.authorization.assert(context.actor, "change.submit", { + ownerId: command.change.ownerId, + policy: { ownerOnly: ["change.submit"] }, + }); + return once(this.dependencies.idempotency, command.idempotencyKey, async () => { + const current = await this.dependencies.git.resolveRef( + command.change.branchName, + context.signal, + ); + if (current.sha !== command.expectedRevision) { + throw new CmsError({ + code: "CMS_CHANGE_003", + message: "The Change has a newer version. Refresh before sending it for review.", + category: "conflict", + retryable: true, + }); + } + const pullRequest = await this.dependencies.git.createPullRequest({ + head: command.change.branchName, + base: + command.change.emergency === true + ? mainBranch(this.dependencies) + : stagingBranch(this.dependencies), + title: command.change.name, + body: `${command.change.description ?? ""}\n\nChange-ID: ${command.change.id}`, + idempotencyKey: command.idempotencyKey, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + if ( + this.dependencies.review !== undefined && + (command.change.collaborators?.length ?? 0) > 0 + ) { + await this.dependencies.review.assignReviewers({ + pullRequestNumber: pullRequest.number, + users: + command.change.collaborators + ?.filter((value) => !value.startsWith("team:")) + .map((value) => value.replace(/^@/u, "")) ?? [], + teams: + command.change.collaborators + ?.filter((value) => value.startsWith("team:")) + .map((value) => value.slice("team:".length)) ?? [], + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + } + const transitioned = await persistChange({ + dependencies: this.dependencies, + change: command.change, + status: "in_review", + expectedRevision: current.sha, + actor: context.actor, + idempotencyKey: `${command.idempotencyKey}:status`, + context, + pullRequest, + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "change.submitted", + command.change.id, + { pullRequest: pullRequest.number }, + ); + return { ...transitioned, pullRequest }; + }); + } +} + +export class ApproveChangeHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly change: Change; + readonly pullRequestNumber: number; + readonly expectedRevision: GitCommitSha; + readonly idempotencyKey: string; + readonly body?: string; + }, + context: RequestContext, + ): Promise { + this.dependencies.authorization.assert(context.actor, "change.approve"); + if (input.change.ownerId === context.actor.id) { + throw new CmsError({ + code: "CMS_REVIEW_009", + message: "A Change must be approved by someone other than its owner.", + category: "authorization", + retryable: false, + }); + } + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const current = await this.dependencies.git.resolveRef( + input.change.branchName, + context.signal, + ); + if (current.sha !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_CHANGE_003", + message: "The Change has a newer version. Refresh before approving it.", + category: "conflict", + retryable: true, + }); + } + await this.dependencies.git.approvePullRequest({ + number: input.pullRequestNumber, + actor: context.actor, + ...(input.body === undefined ? {} : { body: input.body }), + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + const transitioned = await persistChange({ + dependencies: this.dependencies, + change: input.change, + status: "approved", + expectedRevision: current.sha, + actor: context.actor, + idempotencyKey: `${input.idempotencyKey}:status`, + context, + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "change.approved", + input.change.id, + { pullRequest: input.pullRequestNumber }, + ); + return transitioned; + }); + } +} + +export class ReviewChangeHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly change: Change; + readonly pullRequestNumber: number; + readonly body: string; + readonly path?: string; + readonly line?: number; + }, + context: RequestContext, + ): Promise { + this.dependencies.authorization.assert(context.actor, "change.review"); + if (this.dependencies.review === undefined) { + throw new CmsError({ + code: "CMS_REVIEW_001", + message: "No review adapter is configured.", + category: "configuration", + retryable: false, + }); + } + const comment = await this.dependencies.review.addComment({ + pullRequestNumber: input.pullRequestNumber, + body: input.body, + ...(input.path === undefined ? {} : { path: input.path }), + ...(input.line === undefined ? {} : { line: input.line }), + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "review.comment-added", + input.change.id, + { commentId: comment.id }, + ); + return comment; + } +} + +export class ResolveReviewCommentHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly change: Change; + readonly pullRequestNumber: number; + readonly commentId: string; + readonly resolved: boolean; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise { + this.dependencies.authorization.assert(context.actor, "change.review"); + const review = this.dependencies.review; + if (review === undefined) { + throw new CmsError({ + code: "CMS_REVIEW_001", + message: "No review adapter is configured.", + category: "configuration", + retryable: false, + }); + } + if (input.commentId.trim().length === 0) { + throw new CmsError({ + code: "CMS_REVIEW_010", + message: "A review comment ID is required.", + category: "validation", + retryable: false, + }); + } + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const comment = await review.resolveComment({ + pullRequestNumber: input.pullRequestNumber, + commentId: input.commentId, + resolved: input.resolved, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + input.resolved ? "review.thread-resolved" : "review.thread-reopened", + input.change.id, + { commentId: input.commentId, pullRequest: input.pullRequestNumber }, + ); + return comment; + }); + } +} + +export class AssignReviewersHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly change: Change; + readonly pullRequestNumber: number; + readonly users: readonly string[]; + readonly teams: readonly string[]; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise { + this.dependencies.authorization.assert(context.actor, "change.review"); + const review = this.dependencies.review; + if (review === undefined) { + throw new CmsError({ + code: "CMS_REVIEW_001", + message: "No review adapter is configured.", + category: "configuration", + retryable: false, + }); + } + const username = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,98}[a-zA-Z0-9])?$/u; + const team = /^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/u; + if ( + input.users.length + input.teams.length === 0 || + input.users.length + input.teams.length > 30 || + input.users.some((value) => !username.test(value)) || + input.teams.some((value) => !team.test(value)) + ) { + throw new CmsError({ + code: "CMS_REVIEW_011", + message: "Assign one to thirty valid GitHub users or teams.", + category: "validation", + retryable: false, + }); + } + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const assignment = await review.assignReviewers({ + pullRequestNumber: input.pullRequestNumber, + users: [...new Set(input.users)].sort(), + teams: [...new Set(input.teams)].sort(), + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "review.reviewers-assigned", + input.change.id, + { + pullRequest: input.pullRequestNumber, + users: assignment.users, + teams: assignment.teams, + }, + ); + return assignment; + }); + } +} + +export class RequestChangesHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly change: Change; + readonly pullRequestNumber: number; + readonly expectedRevision: GitCommitSha; + readonly body: string; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise { + this.dependencies.authorization.assert(context.actor, "change.review"); + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const current = await this.dependencies.git.resolveRef( + input.change.branchName, + context.signal, + ); + if (current.sha !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_CHANGE_003", + message: "The Change has a newer version. Refresh before requesting changes.", + category: "conflict", + retryable: true, + }); + } + if (this.dependencies.review !== undefined) { + await this.dependencies.review.addComment({ + pullRequestNumber: input.pullRequestNumber, + body: `Changes requested: ${input.body}`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + } + const transitioned = await persistChange({ + dependencies: this.dependencies, + change: input.change, + status: "changes_requested", + expectedRevision: current.sha, + actor: context.actor, + idempotencyKey: `${input.idempotencyKey}:status`, + context, + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "change.changes-requested", + input.change.id, + { pullRequestNumber: input.pullRequestNumber }, + ); + return transitioned; + }); + } +} + +export class ArchiveChangeHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly change: Change; + readonly expectedRevision: GitCommitSha; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise { + this.dependencies.authorization.assert(context.actor, "change.edit", { + ownerId: input.change.ownerId, + policy: { ownerOnly: ["change.edit"] }, + }); + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const current = await this.dependencies.git.resolveRef( + input.change.branchName, + context.signal, + ); + if (current.sha !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_CHANGE_003", + message: "The Change has a newer version. Refresh before archiving it.", + category: "conflict", + retryable: true, + }); + } + const transitioned = await persistChange({ + dependencies: this.dependencies, + change: input.change, + status: "archived", + expectedRevision: current.sha, + actor: context.actor, + idempotencyKey: input.idempotencyKey, + context, + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "change.archived", + input.change.id, + ); + return transitioned; + }); + } +} + +const STAGING_LOCK_PATH = ".cms/staging-lock.yaml"; + +export interface StagingBatchLock { + readonly batchRevision: GitCommitSha; + readonly lockedBy: Actor["id"]; + readonly lockedAt: string; + readonly checklist: readonly string[]; +} + +function parseStagingBatchLock(source: string | undefined): StagingBatchLock | undefined { + if (source === undefined) return undefined; + const value = yamlCodec.parse(source); + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + const lock = value as Partial; + return typeof lock.batchRevision === "string" && + typeof lock.lockedBy === "string" && + typeof lock.lockedAt === "string" && + Array.isArray(lock.checklist) && + lock.checklist.every((item) => typeof item === "string") + ? (lock as StagingBatchLock) + : undefined; +} + +export class ReadStagingBatchHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute(context: RequestContext): Promise<{ + readonly revision: GitCommitSha; + readonly lock?: StagingBatchLock; + }> { + const branch = stagingBranch(this.dependencies); + const ref = await this.dependencies.git.resolveRef(branch, context.signal); + const file = await this.dependencies.git.readFile({ + ref: branch, + path: STAGING_LOCK_PATH, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + const lock = parseStagingBatchLock(file?.content); + return { revision: ref.sha, ...(lock === undefined ? {} : { lock }) }; + } +} + +export class ReadAuditTimelineHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { readonly resourceId: string; readonly limit?: number }, + context: RequestContext, + ): Promise { + if (input.resourceId.trim().length === 0) { + throw new CmsError({ + code: "CMS_AUDIT_001", + message: "An audit resource is required.", + category: "validation", + retryable: false, + }); + } + return ( + (await this.dependencies.auditQuery?.list({ + resourceId: input.resourceId, + limit: Math.max(1, Math.min(200, input.limit ?? 100)), + ...(context.signal === undefined ? {} : { signal: context.signal }), + })) ?? [] + ); + } +} + +export class LockStagingBatchHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly expectedRevision: GitCommitSha; + readonly checklist: readonly string[]; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise<{ readonly lock: StagingBatchLock; readonly revision: GitCommitSha }> { + this.dependencies.authorization.assert(context.actor, "staging.publish"); + if ( + input.checklist.length === 0 || + input.checklist.length > 50 || + input.checklist.some((item) => item.trim().length === 0 || item.length > 200) + ) { + throw new CmsError({ + code: "CMS_STAGING_010", + message: "Complete at least one valid release check before locking Staging.", + category: "validation", + retryable: false, + }); + } + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const branch = stagingBranch(this.dependencies); + const current = await this.dependencies.git.resolveRef(branch, context.signal); + if (current.sha !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_STAGING_011", + message: "Staging changed before the release candidate could be locked.", + category: "conflict", + retryable: true, + }); + } + const existing = await this.dependencies.git.readFile({ + ref: branch, + path: STAGING_LOCK_PATH, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + if (existing !== undefined) { + throw new CmsError({ + code: "CMS_STAGING_012", + message: "Staging is already locked for release testing.", + category: "conflict", + retryable: true, + }); + } + const lock: StagingBatchLock = { + batchRevision: current.sha, + lockedBy: context.actor.id, + lockedAt: isoTimestamp(this.dependencies.clock.now()), + checklist: [...new Set(input.checklist.map((item) => item.trim()))].sort(), + }; + const committed = await this.dependencies.git.commitFiles({ + branch, + expectedSha: current.sha, + files: [{ path: STAGING_LOCK_PATH, content: yamlCodec.serialize(lock) }], + message: "Lock Staging release candidate", + author: context.actor, + idempotencyKey: `${input.idempotencyKey}:lock`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "staging.locked", + committed.sha, + { batchRevision: lock.batchRevision, checklist: lock.checklist }, + ); + return { lock, revision: committed.sha }; + }); + } +} + +export class UnlockStagingBatchHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly expectedRevision: GitCommitSha; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise<{ readonly revision: GitCommitSha }> { + this.dependencies.authorization.assert(context.actor, "staging.publish"); + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const branch = stagingBranch(this.dependencies); + const current = await this.dependencies.git.resolveRef(branch, context.signal); + if (current.sha !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_STAGING_011", + message: "Staging changed before the release candidate could be unlocked.", + category: "conflict", + retryable: true, + }); + } + const existing = await this.dependencies.git.readFile({ + ref: branch, + path: STAGING_LOCK_PATH, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + if (existing === undefined) return { revision: current.sha }; + const committed = await this.dependencies.git.commitFiles({ + branch, + expectedSha: current.sha, + files: [{ path: STAGING_LOCK_PATH, content: null }], + message: "Unlock Staging release candidate", + author: context.actor, + idempotencyKey: `${input.idempotencyKey}:unlock`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "staging.unlocked", + committed.sha, + ); + return { revision: committed.sha }; + }); + } +} + +export class RemoveChangeFromStagingHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly change: Change; + readonly expectedRevision: GitCommitSha; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise<{ + readonly change: Change; + readonly revision: GitCommitSha; + readonly pullRequest: PullRequest; + }> { + this.dependencies.authorization.assert(context.actor, "staging.publish"); + if (input.change.status !== "staging" || input.change.pullRequestNumber === undefined) { + throw new CmsError({ + code: "CMS_STAGING_014", + message: "Only a staged Change with a completed review can be removed.", + category: "validation", + retryable: false, + }); + } + const originalPullRequestNumber = input.change.pullRequestNumber; + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const branch = stagingBranch(this.dependencies); + const [current, lock] = await Promise.all([ + this.dependencies.git.resolveRef(branch, context.signal), + this.dependencies.git.readFile({ + ref: branch, + path: STAGING_LOCK_PATH, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }), + ]); + if (lock !== undefined) { + throw new CmsError({ + code: "CMS_STAGING_013", + message: "Unlock the tested release candidate before removing a Change.", + category: "conflict", + retryable: true, + }); + } + if (current.sha !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_STAGING_011", + message: "Staging changed before the selected Change could be removed.", + category: "conflict", + retryable: true, + }); + } + const pullRequest = await this.dependencies.git.createRevertPullRequest({ + pullRequestNumber: originalPullRequestNumber, + title: `Remove ${input.change.name} from Staging`, + body: [ + `Revert Change ${input.change.id} before the next Production release.`, + "", + `Change-ID: ${input.change.id}`, + ].join("\n"), + idempotencyKey: `${input.idempotencyKey}:revert-pr`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + const revertHead = await this.dependencies.git.resolveRef(pullRequest.head, context.signal); + const reverted = await this.dependencies.git.mergePullRequest({ + number: pullRequest.number, + strategy: "merge", + expectedHeadSha: revertHead.sha, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + const change: Change = { + ...input.change, + status: "archived", + updatedAt: isoTimestamp(this.dependencies.clock.now()), + }; + const recorded = await this.dependencies.git.commitFiles({ + branch, + expectedSha: reverted.sha, + files: [ + { + path: `.cms/changes/${change.id}.yaml`, + content: yamlCodec.serialize(change), + }, + ], + message: `Record removal of ${change.name} from Staging\n\nChange-ID: ${change.id}`, + author: context.actor, + idempotencyKey: `${input.idempotencyKey}:status`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "change.removed-from-staging", + change.id, + { pullRequestNumber: pullRequest.number, revision: recorded.sha }, + ); + return { change, revision: recorded.sha, pullRequest }; + }); + } +} + +interface ChangeDocumentMerge { + readonly documentId: DocumentId; + readonly base?: ContentDocument; + readonly change?: ContentDocument; + readonly staging?: ContentDocument; + readonly candidate?: ContentDocument; + readonly conflicts: readonly ChangeConflict[]; +} + +function sameDocument( + left: ContentDocument | undefined, + right: ContentDocument | undefined, +): boolean { + if (left === undefined || right === undefined) return left === right; + return ( + left.type === right.type && + left.schemaVersion === right.schemaVersion && + canonicalJson(left.data) === canonicalJson(right.data) + ); +} + +async function readOptionalDocument(input: { + readonly dependencies: CommandDependencies; + readonly ref: string; + readonly documentId: DocumentId; + readonly signal?: AbortSignal; +}): Promise { + return input.dependencies.content + .readDocument({ + ref: input.ref, + documentId: input.documentId, + ...(input.signal === undefined ? {} : { signal: input.signal }), + }) + .catch((error: unknown) => { + if (error instanceof CmsError && error.code === "CMS_DOCUMENT_404") return undefined; + throw error; + }); +} + +async function listAllDocumentIds(input: { + readonly dependencies: CommandDependencies; + readonly refs: readonly string[]; + readonly signal?: AbortSignal; +}): Promise { + const ids = new Set(); + for (const ref of input.refs) { + let cursor: string | undefined; + do { + const page = await input.dependencies.content.listDocuments({ + ref, + ...(cursor === undefined ? {} : { cursor }), + ...(input.signal === undefined ? {} : { signal: input.signal }), + }); + for (const document of page.items) ids.add(document.id); + cursor = page.nextCursor; + } while (cursor !== undefined); + } + return [...ids].sort(); +} + +function documentConflict(input: { + readonly documentId: DocumentId; + readonly base?: ContentDocument; + readonly change?: ContentDocument; + readonly staging?: ContentDocument; +}): ChangeConflict { + return { + documentId: input.documentId, + path: "" as ContentPath, + base: input.base?.data, + change: input.change?.data, + staging: input.staging?.data, + scope: "document", + }; +} + +function mergeDocumentVersions(input: { + readonly documentId: DocumentId; + readonly base?: ContentDocument; + readonly change?: ContentDocument; + readonly staging?: ContentDocument; +}): ChangeDocumentMerge { + const { documentId, base, change, staging } = input; + if (base === undefined) { + if (change === undefined) { + return { + documentId, + ...(staging === undefined ? {} : { staging, candidate: staging }), + conflicts: [], + }; + } + if (staging === undefined || sameDocument(change, staging)) { + return { + documentId, + change, + ...(staging === undefined ? {} : { staging }), + candidate: change, + conflicts: [], + }; + } + return { + documentId, + change, + staging, + candidate: change, + conflicts: [documentConflict({ documentId, change, staging })], + }; + } + + if (change === undefined) { + if (staging === undefined) { + return { documentId, base, conflicts: [] }; + } + if (sameDocument(base, staging)) { + return { documentId, base, staging, conflicts: [] }; + } + return { + documentId, + base, + staging, + conflicts: [documentConflict({ documentId, base, staging })], + }; + } + if (staging === undefined) { + if (sameDocument(base, change)) { + return { documentId, base, change, conflicts: [] }; + } + return { + documentId, + base, + change, + candidate: change, + conflicts: [documentConflict({ documentId, base, change })], + }; + } + + const changeMetadataChanged = + change.type !== base.type || change.schemaVersion !== base.schemaVersion; + const stagingMetadataChanged = + staging.type !== base.type || staging.schemaVersion !== base.schemaVersion; + if ( + changeMetadataChanged && + stagingMetadataChanged && + (change.type !== staging.type || change.schemaVersion !== staging.schemaVersion) + ) { + return { + documentId, + base, + change, + staging, + candidate: change, + conflicts: [documentConflict({ documentId, base, change, staging })], + }; + } + + const merged = mergeDocuments(base.data, change.data, staging.data); + const metadataSource = stagingMetadataChanged ? staging : change; + return { + documentId, + base, + change, + staging, + candidate: { + ...metadataSource, + id: documentId, + revision: change.revision, + data: merged.document, + }, + conflicts: merged.conflicts.map((conflict) => ({ + documentId, + path: conflict.path, + base: conflict.base, + change: conflict.ours, + staging: conflict.theirs, + scope: "field", + })), + }; +} + +async function inspectChangeConflicts(input: { + readonly dependencies: CommandDependencies; + readonly change: Change; + readonly signal?: AbortSignal; +}): Promise<{ + readonly stagingRevision: GitCommitSha; + readonly documents: readonly ChangeDocumentMerge[]; +}> { + const stagingRef = await input.dependencies.git.resolveRef( + stagingBranch(input.dependencies), + input.signal, + ); + const ids = await listAllDocumentIds({ + dependencies: input.dependencies, + refs: [input.change.baseCommit, input.change.branchName, stagingBranch(input.dependencies)], + ...(input.signal === undefined ? {} : { signal: input.signal }), + }); + const documents = await Promise.all( + ids.map(async (documentId) => { + const [base, change, staging] = await Promise.all([ + readOptionalDocument({ + dependencies: input.dependencies, + ref: input.change.baseCommit, + documentId, + ...(input.signal === undefined ? {} : { signal: input.signal }), + }), + readOptionalDocument({ + dependencies: input.dependencies, + ref: input.change.branchName, + documentId, + ...(input.signal === undefined ? {} : { signal: input.signal }), + }), + readOptionalDocument({ + dependencies: input.dependencies, + ref: stagingBranch(input.dependencies), + documentId, + ...(input.signal === undefined ? {} : { signal: input.signal }), + }), + ]); + return mergeDocumentVersions({ + documentId, + ...(base === undefined ? {} : { base }), + ...(change === undefined ? {} : { change }), + ...(staging === undefined ? {} : { staging }), + }); + }), + ); + return { stagingRevision: stagingRef.sha, documents }; +} + +function valueAtPath( + value: unknown, + path: ContentPath, +): { readonly exists: boolean; readonly value: unknown } { + let current = value; + for (const segment of parseContentPath(path)) { + if (typeof current !== "object" || current === null) { + return { exists: false, value: undefined }; + } + if (Array.isArray(current)) { + if (!/^(0|[1-9]\d*)$/u.test(segment)) return { exists: false, value: undefined }; + const index = Number(segment); + if (!Number.isSafeInteger(index) || index >= current.length) { + return { exists: false, value: undefined }; + } + current = current[index]; + continue; + } + if (!Object.hasOwn(current, segment)) return { exists: false, value: undefined }; + current = (current as Readonly>)[segment]; + } + return { exists: true, value: current }; +} + +export class ReadChangeConflictsHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { readonly change: Change }, + context: RequestContext, + ): Promise<{ + readonly conflicts: readonly ChangeConflict[]; + readonly stagingRevision: GitCommitSha; + }> { + this.dependencies.authorization.assert(context.actor, "project.read"); + const inspected = await inspectChangeConflicts({ + dependencies: this.dependencies, + change: input.change, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + return { + conflicts: inspected.documents.flatMap((document) => document.conflicts), + stagingRevision: inspected.stagingRevision, + }; + } +} + +export class ResolveChangeConflictsHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly change: Change; + readonly expectedRevision: GitCommitSha; + readonly resolutions: readonly ChangeConflictResolution[]; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise<{ readonly change: Change; readonly revision: GitCommitSha }> { + this.dependencies.authorization.assert(context.actor, "change.edit", { + ownerId: input.change.ownerId, + policy: { ownerOnly: ["change.edit"] }, + }); + if (!["draft", "changes_requested", "in_review", "approved"].includes(input.change.status)) { + throw new CmsError({ + code: "CMS_CHANGE_016", + message: "Conflicts can only be resolved before a Change enters Staging.", + category: "conflict", + retryable: false, + }); + } + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const branch = await this.dependencies.git.resolveRef( + input.change.branchName, + context.signal, + ); + if (branch.sha !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_CHANGE_003", + message: "The Change moved before its conflicts could be resolved.", + category: "conflict", + retryable: true, + }); + } + const inspected = await inspectChangeConflicts({ + dependencies: this.dependencies, + change: input.change, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + const allConflicts = inspected.documents.flatMap((document) => document.conflicts); + if (allConflicts.length === 0) { + throw new CmsError({ + code: "CMS_CHANGE_017", + message: "This Change has no semantic conflicts with Staging.", + category: "validation", + retryable: false, + }); + } + const resolutions = input.resolutions.map((resolution) => { + if (resolution.choice !== "change" && resolution.choice !== "staging") { + throw new CmsError({ + code: "CMS_CHANGE_018", + message: "A conflict resolution must choose this Change or Staging.", + category: "validation", + retryable: false, + }); + } + return { ...resolution, path: contentPath(resolution.path) }; + }); + const knownConflicts = new Set( + allConflicts.map((conflict) => `${conflict.documentId}:${conflict.path}`), + ); + const choices = new Map(); + for (const resolution of resolutions) { + const key = `${resolution.documentId}:${resolution.path}`; + const previousChoice = choices.get(key); + if ( + !knownConflicts.has(key) || + (previousChoice !== undefined && previousChoice !== resolution.choice) + ) { + throw new CmsError({ + code: "CMS_CHANGE_018", + message: `Conflict ${key} is unknown or has contradictory resolutions.`, + category: "validation", + retryable: false, + }); + } + choices.set(key, resolution.choice); + } + const unresolved = allConflicts.filter( + (conflict) => !choices.has(`${conflict.documentId}:${conflict.path}`), + ); + if (unresolved.length > 0) { + throw new CmsError({ + code: "CMS_CHANGE_019", + message: "Choose a value for every semantic conflict before continuing.", + category: "validation", + retryable: false, + context: { + paths: unresolved.map((conflict) => `${conflict.documentId}${conflict.path}`), + }, + }); + } + + const finalDocuments = new Map(); + for (const merged of inspected.documents) { + let candidate = merged.candidate; + const wholeDocumentConflict = merged.conflicts.find( + (conflict) => conflict.scope === "document", + ); + if (wholeDocumentConflict !== undefined) { + candidate = + choices.get(`${merged.documentId}:${wholeDocumentConflict.path}`) === "staging" + ? merged.staging + : merged.change; + } else if (candidate !== undefined) { + let data = candidate.data; + for (const conflict of merged.conflicts) { + if (choices.get(`${merged.documentId}:${conflict.path}`) !== "staging") continue; + const stagingValue = valueAtPath(merged.staging?.data, conflict.path); + data = applyPatch( + data, + stagingValue.exists + ? { + op: "set", + path: conflict.path, + value: stagingValue.value, + metadata: { + id: `resolve-${merged.documentId}-${conflict.path}`, + actorId: context.actor.id, + createdAt: this.dependencies.clock.now().toISOString(), + source: "editor", + }, + } + : { + op: "unset", + path: conflict.path, + metadata: { + id: `resolve-${merged.documentId}-${conflict.path}`, + actorId: context.actor.id, + createdAt: this.dependencies.clock.now().toISOString(), + source: "editor", + }, + }, + ); + } + candidate = { ...candidate, data }; + } + finalDocuments.set(merged.documentId, candidate); + } + + let contentRevision = branch.sha; + const writes = inspected.documents.flatMap((merged) => { + const candidate = finalDocuments.get(merged.documentId); + return candidate !== undefined && !sameDocument(candidate, merged.change) + ? [{ ...candidate, revision: contentRevision }] + : []; + }); + if (writes.length > 0) { + contentRevision = await this.dependencies.content.writeDocuments({ + ref: input.change.branchName, + documents: writes, + expectedRevision: contentRevision, + message: `Resolve ${String(allConflicts.length)} conflict(s) with Staging`, + actor: context.actor, + idempotencyKey: `${input.idempotencyKey}:documents`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + } + const deletions = inspected.documents + .filter( + (merged) => + merged.change !== undefined && finalDocuments.get(merged.documentId) === undefined, + ) + .map((merged) => merged.documentId); + if (deletions.length > 0) { + contentRevision = await this.dependencies.content.deleteDocuments({ + ref: input.change.branchName, + documentIds: deletions, + expectedRevision: contentRevision, + actor: context.actor, + idempotencyKey: `${input.idempotencyKey}:deletions`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + } + + const change: Change = { + ...input.change, + baseBranch: stagingBranch(this.dependencies), + baseCommit: inspected.stagingRevision, + status: input.change.status === "approved" ? "in_review" : input.change.status, + updatedAt: isoTimestamp(this.dependencies.clock.now()), + }; + const committed = await this.dependencies.git.commitFiles({ + branch: input.change.branchName, + // Continue from the exact CAS result of the document writes. Re-reading + // the ref here can be stale on GitHub immediately after an update and + // must not broaden the transaction to an unrelated concurrent commit. + expectedSha: contentRevision, + files: [{ path: ".cms/change.yaml", content: yamlCodec.serialize(change) }], + message: changeCommitMessage(change, "Resolve semantic conflicts with Staging"), + author: context.actor, + idempotencyKey: `${input.idempotencyKey}:metadata`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "change.conflicts-resolved", + change.id, + { + revision: committed.sha, + stagingRevision: inspected.stagingRevision, + resolutions: resolutions.map((resolution) => ({ + documentId: resolution.documentId, + path: resolution.path, + choice: resolution.choice, + })), + approvalReset: input.change.status === "approved", + }, + ); + return { change, revision: committed.sha }; + }); } } @@ -425,6 +3325,36 @@ export class AddChangeToStagingHandler { ): Promise { this.dependencies.authorization.assert(context.actor, "staging.add"); return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const lock = await this.dependencies.git.readFile({ + ref: stagingBranch(this.dependencies), + path: STAGING_LOCK_PATH, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + if (lock !== undefined) { + throw new CmsError({ + code: "CMS_STAGING_013", + message: "Staging is locked while the current release candidate is being tested.", + category: "conflict", + retryable: true, + }); + } + const inspected = await inspectChangeConflicts({ + dependencies: this.dependencies, + change: input.change, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + const conflicts = inspected.documents.flatMap((document) => + document.conflicts.map((conflict) => `${conflict.documentId}${conflict.path}`), + ); + if (conflicts.length > 0) { + throw new CmsError({ + code: "CMS_CHANGE_009", + message: "Resolve semantic conflicts with Staging before adding this Change.", + category: "conflict", + retryable: true, + context: { paths: conflicts }, + }); + } const checks = await this.dependencies.review?.listChecks( input.expectedRevision, context.signal, @@ -515,17 +3445,25 @@ async function markStagedChangesPublished(input: { return change === undefined ? [] : [{ path: file.path, change }]; }); if (changes.length === 0) return input.revision; + const stagingLock = await input.dependencies.git.readFile({ + ref: branch, + path: STAGING_LOCK_PATH, + ...(input.context.signal === undefined ? {} : { signal: input.context.signal }), + }); const committed = await input.dependencies.git.commitFiles({ branch, expectedSha: input.revision, - files: changes.map(({ path, change }) => ({ - path, - content: yamlCodec.serialize({ - ...change, - status: "published", - updatedAt: isoTimestamp(input.dependencies.clock.now()), - } satisfies Change), - })), + files: [ + ...changes.map(({ path, change }) => ({ + path, + content: yamlCodec.serialize({ + ...change, + status: "published", + updatedAt: isoTimestamp(input.dependencies.clock.now()), + } satisfies Change), + })), + ...(stagingLock === undefined ? [] : [{ path: STAGING_LOCK_PATH, content: null }]), + ], message: `Prepare ${changes.length} Change(s) for publication`, author: input.context.actor, idempotencyKey: `${input.idempotencyKey}:published-status`, @@ -638,6 +3576,190 @@ function releaseDocumentValue(document: ContentDocument): unknown { }; } +function recordValue(value: unknown): Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Readonly>) + : {}; +} + +function releaseManifestDetails(release: StoredRelease): { + readonly revision: GitCommitSha; + readonly tags: readonly string[]; + readonly paths: readonly string[]; +} { + const manifest = recordValue(release.manifest); + if (typeof manifest.gitCommit !== "string" || manifest.gitCommit.length === 0) { + throw new CmsError({ + code: "CMS_PUBLISH_010", + message: "The release manifest does not contain its Git revision.", + category: "validation", + retryable: false, + }); + } + return { + revision: manifest.gitCommit as GitCommitSha, + tags: Array.isArray(manifest.tags) + ? manifest.tags.filter((tag): tag is string => typeof tag === "string") + : [], + paths: Object.keys(release.files) + .filter((path) => path !== "manifest.json" && path !== "checksums.json") + .sort(), + }; +} + +async function switchReleasePointer(input: { + readonly store: ReleaseStore; + readonly release: StoredRelease; + readonly environment: EnvironmentPointer["environment"]; + readonly expectedPointerRevision?: string; + readonly pointerRevision: string; + readonly updatedAt: string; + readonly signal?: AbortSignal; +}): Promise { + const current = await input.store.readPointer(input.environment, input.signal); + if (current?.releaseId === input.release.id && current.revision === input.pointerRevision) { + return; + } + await input.store.compareAndSwapPointer({ + next: { + environment: input.environment, + releaseId: input.release.id, + revision: input.pointerRevision, + updatedAt: input.updatedAt, + }, + ...(input.expectedPointerRevision === undefined + ? {} + : { expectedRevision: input.expectedPointerRevision }), + ...(input.signal === undefined ? {} : { signal: input.signal }), + }); +} + +async function notifyPublication(input: { + readonly notifier: PublicationNotifierPort | undefined; + readonly release: StoredRelease; + readonly environment: EnvironmentPointer["environment"]; + readonly idempotencyKey: string; + readonly signal?: AbortSignal; +}): Promise { + if (input.notifier === undefined) return; + const details = releaseManifestDetails(input.release); + await input.notifier.notify({ + environment: input.environment, + releaseId: input.release.id, + revision: details.revision, + tags: details.tags, + paths: details.paths, + idempotencyKey: input.idempotencyKey, + ...(input.signal === undefined ? {} : { signal: input.signal }), + }); +} + +function publicationArtifacts( + documents: readonly { + readonly summary: DocumentSummary; + readonly document: ContentDocument; + readonly value: unknown; + }[], +): { + readonly redirects: Readonly>; + readonly artifacts: Readonly>; +} { + const settings = documents.find((entry) => entry.document.type === "settings"); + const siteUrl = + typeof recordValue(settings?.document.data).siteUrl === "string" + ? String(recordValue(settings?.document.data).siteUrl).replace(/\/$/u, "") + : ""; + const redirects: Record = {}; + const pages: { + canonical: string; + hreflang?: Readonly>; + include?: boolean; + }[] = []; + const seoEntries: { path: string; metadata: SeoMetadata }[] = []; + const localeManifest: Record = {}; + for (const entry of documents) { + const data = recordValue(entry.document.data); + const route = recordValue(data.route); + const path = typeof route.path === "string" ? route.path : undefined; + const seo = recordValue(data.seo) as SeoMetadata; + const declaredRedirects = recordValue(data.redirects); + for (const [source, target] of Object.entries(declaredRedirects)) { + if (typeof target === "string") redirects[source] = target; + } + const redirectFrom = Array.isArray(data.redirectFrom) + ? data.redirectFrom + : typeof data.redirectFrom === "string" + ? [data.redirectFrom] + : []; + if (path !== undefined) { + for (const source of redirectFrom) { + if (typeof source === "string" && source !== path) redirects[source] = path; + } + } + const locales = recordValue(data.locales); + if (Object.keys(locales).length > 0) { + localeManifest[entry.document.id] = locales; + } + if (entry.document.type !== "pages" || path === undefined) continue; + const localizedRoutes = Object.fromEntries( + Object.entries(locales).flatMap(([locale, value]) => { + const localizedRoute = recordValue(recordValue(value).route); + return typeof localizedRoute.path === "string" + ? [[locale, localizedRoute.path] as const] + : []; + }), + ); + const hreflang = + Object.keys(localizedRoutes).length === 0 + ? undefined + : buildHreflang({ + baseUrl: siteUrl, + routes: { "en-US": path, ...localizedRoutes }, + defaultLocale: "en-US", + }); + const canonical = typeof seo.canonical === "string" ? seo.canonical : `${siteUrl}${path}`; + pages.push({ + canonical, + ...(hreflang === undefined ? {} : { hreflang }), + include: seo.sitemap !== false && seo.robots?.index !== false, + }); + seoEntries.push({ + path, + metadata: { ...seo, ...(hreflang === undefined ? {} : { hreflang }) }, + }); + } + const searchIndex = buildSearchIndex( + documents.map((entry) => ({ + id: entry.document.id, + type: entry.document.type, + title: entry.summary.title, + path: entry.summary.path, + value: entry.value, + })), + ); + const referenceGraph = buildReferenceGraph(searchIndex.documents); + return { + redirects, + artifacts: { + "content-index.json": canonicalJson( + documents + .map((entry) => ({ + id: entry.document.id, + type: entry.document.type, + title: entry.summary.title, + path: releasePath(entry.summary.path), + })) + .sort((left, right) => left.path.localeCompare(right.path)), + ), + "sitemap.xml": buildSitemap(pages), + "search-index.json": canonicalJson(searchIndex), + "content-graph.json": canonicalJson(referenceGraph), + "locales.json": canonicalJson(localeManifest), + "seo-diagnostics.json": canonicalJson(auditSeo(seoEntries)), + }, + }; +} + export class BuildAndPublishReleaseHandler { constructor(private readonly dependencies: CommandDependencies) {} @@ -677,30 +3799,38 @@ export class BuildAndPublishReleaseHandler { summaries.push(...page.items); cursor = page.nextCursor; } while (cursor !== undefined); - const documents = await Promise.all( + const sourceDocuments = await Promise.all( summaries.map(async (summary) => { const document = await this.dependencies.content.readDocument({ ref: input.ref, documentId: summary.id, ...(context.signal === undefined ? {} : { signal: context.signal }), }); - return { - path: releasePath(summary.path), - value: releaseDocumentValue(document), - tags: [`document:${document.id}`, `type:${document.type}`], - }; + return { summary, document, value: releaseDocumentValue(document) }; }), ); + const generated = publicationArtifacts(sourceDocuments); + const documents = sourceDocuments.map(({ summary, document, value }) => ({ + path: releasePath(summary.path), + value, + tags: [`document:${document.id}`, `type:${document.type}`], + })); const release = await builder.build({ gitCommit: ref.sha, configVersion: input.configVersion, registryDigest: input.registryDigest, schemaVersion: input.schemaVersion, documents, + redirects: generated.redirects, + artifacts: generated.artifacts, }); await store.writeRelease(release, context.signal); const verified = await store.readRelease(release.id, context.signal); - if (verified?.files["manifest.json"] !== release.files["manifest.json"]) { + if ( + verified === undefined || + Object.keys(verified.files).length !== Object.keys(release.files).length || + !Object.entries(release.files).every(([path, content]) => verified.files[path] === content) + ) { throw new CmsError({ code: "CMS_PUBLISH_006", message: "The immutable release failed verification.", @@ -708,16 +3838,22 @@ export class BuildAndPublishReleaseHandler { retryable: true, }); } - await store.compareAndSwapPointer({ - next: { - environment: input.environment, - releaseId: release.id, - revision: release.id, - updatedAt: this.dependencies.clock.now().toISOString(), - }, + await switchReleasePointer({ + store, + release, + environment: input.environment, + pointerRevision: release.id, + updatedAt: this.dependencies.clock.now().toISOString(), ...(input.expectedPointerRevision === undefined ? {} - : { expectedRevision: input.expectedPointerRevision }), + : { expectedPointerRevision: input.expectedPointerRevision }), + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await notifyPublication({ + notifier: this.dependencies.publicationNotifier, + release, + environment: input.environment, + idempotencyKey: `${input.idempotencyKey}:notify`, ...(context.signal === undefined ? {} : { signal: context.signal }), }); await audit( @@ -791,6 +3927,148 @@ export class PublishStagingHandler { } } +export interface PublishEmergencyChangeCommand { + readonly change: Change; + readonly pullRequestNumber: number; + readonly expectedRevision: GitCommitSha; + readonly configVersion: number; + readonly registryDigest: string; + readonly schemaVersion: number; + readonly expectedPointerRevision?: string; + readonly idempotencyKey: string; +} + +export class PublishEmergencyChangeHandler { + private readonly buildAndPublish: BuildAndPublishReleaseHandler; + + constructor(private readonly dependencies: CommandDependencies) { + this.buildAndPublish = new BuildAndPublishReleaseHandler(dependencies); + } + + async execute( + input: PublishEmergencyChangeCommand, + context: RequestContext, + ): Promise<{ + readonly change: Change; + readonly revision: GitCommitSha; + readonly stagingRevision: GitCommitSha; + readonly release: StoredRelease; + }> { + this.dependencies.authorization.assert(context.actor, "staging.publish"); + if (input.change.emergency !== true || input.change.status !== "approved") { + throw new CmsError({ + code: "CMS_CHANGE_012", + message: "Only an approved Emergency Change can use the direct Production path.", + category: "conflict", + retryable: false, + }); + } + return once(this.dependencies.idempotency, input.idempotencyKey, async () => { + const head = await this.dependencies.git.resolveRef(input.change.branchName, context.signal); + if (head.sha !== input.expectedRevision) { + throw new CmsError({ + code: "CMS_CHANGE_003", + message: "The Emergency Change moved while publication was being prepared.", + category: "conflict", + retryable: true, + }); + } + const checks = await this.dependencies.review?.listChecks(head.sha, context.signal); + const blocking = + checks?.filter( + (check) => + check.required && (check.status !== "completed" || check.conclusion !== "success"), + ) ?? []; + if (blocking.length > 0) { + throw new CmsError({ + code: "CMS_REVIEW_008", + message: "Required checks must pass before an Emergency Change can be published.", + category: "conflict", + retryable: true, + context: { checks: blocking.map((check) => check.name) }, + }); + } + const merged = await this.dependencies.git.mergePullRequest({ + number: input.pullRequestNumber, + strategy: "squash", + expectedHeadSha: head.sha, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + const change: Change = { + ...input.change, + status: "published", + updatedAt: isoTimestamp(this.dependencies.clock.now()), + }; + const recorded = await this.dependencies.git.commitFiles({ + branch: mainBranch(this.dependencies), + expectedSha: merged.sha, + files: [ + { + path: `.cms/changes/${change.id}.yaml`, + content: yamlCodec.serialize(change), + }, + ], + message: changeCommitMessage(change, `Record Emergency Change "${change.name}"`), + author: context.actor, + idempotencyKey: `${input.idempotencyKey}:status`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + const release = await this.buildAndPublish.execute( + { + ref: mainBranch(this.dependencies), + expectedRevision: recorded.sha, + environment: "production", + configVersion: input.configVersion, + registryDigest: input.registryDigest, + schemaVersion: input.schemaVersion, + ...(input.expectedPointerRevision === undefined + ? {} + : { expectedPointerRevision: input.expectedPointerRevision }), + idempotencyKey: `${input.idempotencyKey}:release`, + }, + context, + ); + const syncPullRequest = await this.dependencies.git.createPullRequest({ + head: mainBranch(this.dependencies), + base: stagingBranch(this.dependencies), + title: `Forward-sync Emergency Change: ${change.name}`, + body: `Synchronize Emergency Change ${change.id} from Production into Staging.\n\nMain-Revision: ${recorded.sha}`, + idempotencyKey: `${input.idempotencyKey}:sync-pr`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + const synchronized = await this.dependencies.git.mergePullRequest({ + number: syncPullRequest.number, + strategy: "merge", + expectedHeadSha: recorded.sha, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await this.dependencies.git.deleteBranch({ + branch: input.change.branchName, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "change.emergency-published", + change.id, + { + releaseId: release.id, + mainRevision: recorded.sha, + stagingRevision: synchronized.sha, + syncPullRequest: syncPullRequest.number, + }, + ); + return { + change, + revision: recorded.sha, + stagingRevision: synchronized.sha, + release, + }; + }); + } +} + export class PublishReleaseHandler { constructor(private readonly dependencies: CommandDependencies) {} @@ -817,8 +4095,11 @@ export class PublishReleaseHandler { await store.writeRelease(input.release, context.signal); const verified = await store.readRelease(input.release.id, context.signal); if ( - verified?.files["manifest.json"] === undefined || - verified.files["manifest.json"] !== input.release.files["manifest.json"] + verified === undefined || + Object.keys(verified.files).length !== Object.keys(input.release.files).length || + !Object.entries(input.release.files).every( + ([path, content]) => verified.files[path] === content, + ) ) { throw new CmsError({ code: "CMS_PUBLISH_006", @@ -827,16 +4108,22 @@ export class PublishReleaseHandler { retryable: true, }); } - await store.compareAndSwapPointer({ - next: { - environment: input.environment, - releaseId: input.release.id, - revision: input.idempotencyKey, - updatedAt: this.dependencies.clock.now().toISOString(), - }, + await switchReleasePointer({ + store, + release: input.release, + environment: input.environment, + pointerRevision: input.idempotencyKey, + updatedAt: this.dependencies.clock.now().toISOString(), ...(input.expectedPointerRevision === undefined ? {} - : { expectedRevision: input.expectedPointerRevision }), + : { expectedPointerRevision: input.expectedPointerRevision }), + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await notifyPublication({ + notifier: this.dependencies.publicationNotifier, + release: input.release, + environment: input.environment, + idempotencyKey: `${input.idempotencyKey}:notify`, ...(context.signal === undefined ? {} : { signal: context.signal }), }); await audit( @@ -851,6 +4138,99 @@ export class PublishReleaseHandler { } } +export class RevalidateReleaseHandler { + constructor(private readonly dependencies: CommandDependencies) {} + + async execute( + input: { + readonly releaseId: ReleaseId; + readonly environment: "preview" | "staging" | "production"; + readonly idempotencyKey: string; + }, + context: RequestContext, + ): Promise { + this.dependencies.authorization.assert(context.actor, "staging.publish"); + const store = this.dependencies.releaseStore; + if (store === undefined) { + throw new CmsError({ + code: "CMS_STORAGE_001", + message: "No release store is configured.", + category: "configuration", + retryable: false, + }); + } + const release = await store.readRelease(input.releaseId, context.signal); + if (release === undefined) { + throw new CmsError({ + code: "CMS_PUBLISH_008", + message: "The selected release does not exist.", + category: "validation", + retryable: false, + }); + } + await once(this.dependencies.idempotency, input.idempotencyKey, async () => { + if (this.dependencies.publicationNotifier === undefined) { + throw new CmsError({ + code: "CMS_INTEGRATION_001", + message: "No publication revalidation adapter is configured.", + category: "configuration", + retryable: false, + }); + } + await notifyPublication({ + notifier: this.dependencies.publicationNotifier, + release, + environment: input.environment, + idempotencyKey: input.idempotencyKey, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await audit( + this.dependencies.audit, + this.dependencies.clock, + context, + "release.revalidated", + release.id, + { environment: input.environment }, + ); + }); + } +} + +function rollbackRepositoryFiles(release: StoredRelease): readonly { + readonly path: string; + readonly content: string; +}[] { + const files: { path: string; content: string }[] = []; + for (const [path, source] of Object.entries(release.files)) { + if (!path.startsWith("content/") || !path.endsWith(".json")) continue; + let value: unknown; + try { + value = JSON.parse(source); + } catch { + continue; + } + const record = recordValue(value); + if ( + typeof record.id !== "string" || + typeof record.type !== "string" || + typeof record.schemaVersion !== "number" + ) { + continue; + } + files.push({ + path: `${path.slice(0, -".json".length)}.yaml`, + content: yamlCodec.serialize(record), + }); + } + return files.sort((left, right) => left.path.localeCompare(right.path)); +} + +export interface RollbackReleaseResult { + readonly pullRequest: PullRequest; + readonly stagingPullRequest: PullRequest; + readonly revision: GitCommitSha; +} + export class RollbackReleaseHandler { constructor(private readonly dependencies: CommandDependencies) {} @@ -861,7 +4241,7 @@ export class RollbackReleaseHandler { readonly idempotencyKey: string; }, context: RequestContext, - ): Promise { + ): Promise { this.dependencies.authorization.assert(context.actor, "release.rollback"); const store = this.dependencies.releaseStore; if (store === undefined) { @@ -882,14 +4262,20 @@ export class RollbackReleaseHandler { }); } return once(this.dependencies.idempotency, input.idempotencyKey, async () => { - await store.compareAndSwapPointer({ - next: { - environment: "production", - releaseId: input.releaseId, - revision: input.idempotencyKey, - updatedAt: this.dependencies.clock.now().toISOString(), - }, - expectedRevision: input.expectedPointerRevision, + await switchReleasePointer({ + store, + release, + environment: "production", + expectedPointerRevision: input.expectedPointerRevision, + pointerRevision: input.idempotencyKey, + updatedAt: this.dependencies.clock.now().toISOString(), + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + await notifyPublication({ + notifier: this.dependencies.publicationNotifier, + release, + environment: "production", + idempotencyKey: `${input.idempotencyKey}:notify`, ...(context.signal === undefined ? {} : { signal: context.signal }), }); const main = await this.dependencies.git.resolveRef( @@ -903,21 +4289,34 @@ export class RollbackReleaseHandler { idempotencyKey: `${input.idempotencyKey}:branch`, ...(context.signal === undefined ? {} : { signal: context.signal }), }); + const currentContent = await this.dependencies.git.listFiles({ + ref: mainBranch(this.dependencies), + prefix: "content/", + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + const targetContent = rollbackRepositoryFiles(release); + const targetPaths = new Set(targetContent.map((file) => file.path)); const committed = await this.dependencies.git.commitFiles({ branch, expectedSha: created.sha, files: [ + ...currentContent + .filter((file) => !targetPaths.has(file.path)) + .map((file) => ({ path: file.path, content: null })), + ...targetContent, { path: ".cms/rollback.yaml", content: yamlCodec.serialize({ releaseId: input.releaseId, + releaseRevision: releaseManifestDetails(release).revision, previousPointerRevision: input.expectedPointerRevision, requestedBy: context.actor.login, requestedAt: this.dependencies.clock.now().toISOString(), + restoredDocuments: targetContent.length, }), }, ], - message: `Record rollback to ${input.releaseId}`, + message: `Reconcile repository with rollback ${input.releaseId}`, author: context.actor, idempotencyKey: `${input.idempotencyKey}:commit`, ...(context.signal === undefined ? {} : { signal: context.signal }), @@ -925,50 +4324,143 @@ export class RollbackReleaseHandler { const pullRequest = await this.dependencies.git.createPullRequest({ head: branch, base: mainBranch(this.dependencies), - title: `Audit rollback to ${input.releaseId}`, - body: `Production was atomically restored to ${input.releaseId} before this audit PR was opened.`, + title: `Reconcile Production with rollback ${input.releaseId}`, + body: `Production was atomically restored to ${input.releaseId} before this repository reconciliation PR was opened.\n\nThe content tree is restored from the immutable release and remains fully auditable.`, idempotencyKey: `${input.idempotencyKey}:pr`, ...(context.signal === undefined ? {} : { signal: context.signal }), }); + const stagingBranchName = `rollback-staging/${input.releaseId}-${this.dependencies.ids.suffix()}`; + await this.dependencies.git.createBranch({ + branch: stagingBranchName, + from: committed.sha, + idempotencyKey: `${input.idempotencyKey}:staging-branch`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + const stagingPullRequest = await this.dependencies.git.createPullRequest({ + head: stagingBranchName, + base: stagingBranch(this.dependencies), + title: `Synchronize Staging after rollback ${input.releaseId}`, + body: `Apply the same repository reconciliation as Production so the next release cannot accidentally reintroduce reverted content.`, + idempotencyKey: `${input.idempotencyKey}:staging-pr`, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); await audit( this.dependencies.audit, this.dependencies.clock, context, "release.rolled-back", input.releaseId, - { pointerFirst: true, auditPullRequest: pullRequest.number, revision: committed.sha }, + { + pointerFirst: true, + reconciliationPullRequest: pullRequest.number, + stagingPullRequest: stagingPullRequest.number, + revision: committed.sha, + restoredDocuments: targetContent.length, + }, ); - return pullRequest; + return { pullRequest, stagingPullRequest, revision: committed.sha }; }); } } export interface CmsApplication { readonly createChange: CreateChangeHandler; + readonly updateChange: UpdateChangeHandler; + readonly deleteChange: DeleteChangeHandler; + readonly commitChange: CommitChangeHandler; + readonly createDocument: CreateDocumentHandler; readonly updateDocument: UpdateDocumentHandler; + readonly deleteDocument: DeleteDocumentHandler; + readonly importTranslation: ImportTranslationHandler; + readonly createTranslationJob: CreateTranslationJobHandler; + readonly readTranslationJob: ReadTranslationJobHandler; + readonly createAssetUpload: CreateAssetUploadHandler; + readonly receiveAssetUpload: ReceiveAssetUploadHandler; + readonly finalizeAssetUpload: FinalizeAssetUploadHandler; + readonly updateAsset: UpdateAssetHandler; + readonly deleteAsset: DeleteAssetHandler; + readonly createPreviewSession: CreatePreviewSessionHandler; + readonly readPreviewSession: ReadPreviewSessionHandler; + readonly refreshPreviewSession: RefreshPreviewSessionHandler; + readonly readTeamDirectory: ReadTeamDirectoryHandler; + readonly inviteTeamMember: InviteTeamMemberHandler; + readonly addTeamMember: AddTeamMemberHandler; + readonly updateTeamRoleMappings: UpdateTeamRoleMappingsHandler; + readonly scheduleContent: ScheduleContentHandler; + readonly executeSchedule: ExecuteScheduleHandler; + readonly executeDueSchedules: ExecuteDueSchedulesHandler; readonly submitChange: SubmitChangeHandler; readonly reviewChange: ReviewChangeHandler; + readonly resolveReviewComment: ResolveReviewCommentHandler; + readonly assignReviewers: AssignReviewersHandler; + readonly requestChanges: RequestChangesHandler; + readonly archiveChange: ArchiveChangeHandler; + readonly readStagingBatch: ReadStagingBatchHandler; + readonly readAuditTimeline: ReadAuditTimelineHandler; + readonly lockStagingBatch: LockStagingBatchHandler; + readonly unlockStagingBatch: UnlockStagingBatchHandler; + readonly removeChangeFromStaging: RemoveChangeFromStagingHandler; readonly approveChange: ApproveChangeHandler; + readonly readChangeConflicts: ReadChangeConflictsHandler; + readonly resolveChangeConflicts: ResolveChangeConflictsHandler; readonly addChangeToStaging: AddChangeToStagingHandler; readonly promoteStaging: PromoteStagingHandler; readonly buildAndPublishRelease: BuildAndPublishReleaseHandler; readonly publishStaging: PublishStagingHandler; + readonly publishEmergencyChange: PublishEmergencyChangeHandler; readonly publishRelease: PublishReleaseHandler; + readonly revalidateRelease: RevalidateReleaseHandler; readonly rollbackRelease: RollbackReleaseHandler; } export function createCmsApplication(dependencies: CommandDependencies): CmsApplication { return { createChange: new CreateChangeHandler(dependencies), + updateChange: new UpdateChangeHandler(dependencies), + deleteChange: new DeleteChangeHandler(dependencies), + commitChange: new CommitChangeHandler(dependencies), + createDocument: new CreateDocumentHandler(dependencies), updateDocument: new UpdateDocumentHandler(dependencies), + deleteDocument: new DeleteDocumentHandler(dependencies), + importTranslation: new ImportTranslationHandler(dependencies), + createTranslationJob: new CreateTranslationJobHandler(dependencies), + readTranslationJob: new ReadTranslationJobHandler(dependencies), + createAssetUpload: new CreateAssetUploadHandler(dependencies), + receiveAssetUpload: new ReceiveAssetUploadHandler(dependencies), + finalizeAssetUpload: new FinalizeAssetUploadHandler(dependencies), + updateAsset: new UpdateAssetHandler(dependencies), + deleteAsset: new DeleteAssetHandler(dependencies), + createPreviewSession: new CreatePreviewSessionHandler(dependencies), + readPreviewSession: new ReadPreviewSessionHandler(dependencies), + refreshPreviewSession: new RefreshPreviewSessionHandler(dependencies), + readTeamDirectory: new ReadTeamDirectoryHandler(dependencies), + inviteTeamMember: new InviteTeamMemberHandler(dependencies), + addTeamMember: new AddTeamMemberHandler(dependencies), + updateTeamRoleMappings: new UpdateTeamRoleMappingsHandler(dependencies), + scheduleContent: new ScheduleContentHandler(dependencies), + executeSchedule: new ExecuteScheduleHandler(dependencies), + executeDueSchedules: new ExecuteDueSchedulesHandler(dependencies), submitChange: new SubmitChangeHandler(dependencies), reviewChange: new ReviewChangeHandler(dependencies), + resolveReviewComment: new ResolveReviewCommentHandler(dependencies), + assignReviewers: new AssignReviewersHandler(dependencies), + requestChanges: new RequestChangesHandler(dependencies), + archiveChange: new ArchiveChangeHandler(dependencies), + readStagingBatch: new ReadStagingBatchHandler(dependencies), + readAuditTimeline: new ReadAuditTimelineHandler(dependencies), + lockStagingBatch: new LockStagingBatchHandler(dependencies), + unlockStagingBatch: new UnlockStagingBatchHandler(dependencies), + removeChangeFromStaging: new RemoveChangeFromStagingHandler(dependencies), approveChange: new ApproveChangeHandler(dependencies), + readChangeConflicts: new ReadChangeConflictsHandler(dependencies), + resolveChangeConflicts: new ResolveChangeConflictsHandler(dependencies), addChangeToStaging: new AddChangeToStagingHandler(dependencies), promoteStaging: new PromoteStagingHandler(dependencies), buildAndPublishRelease: new BuildAndPublishReleaseHandler(dependencies), publishStaging: new PublishStagingHandler(dependencies), + publishEmergencyChange: new PublishEmergencyChangeHandler(dependencies), publishRelease: new PublishReleaseHandler(dependencies), + revalidateRelease: new RevalidateReleaseHandler(dependencies), rollbackRelease: new RollbackReleaseHandler(dependencies), }; } diff --git a/packages/application/src/ports.ts b/packages/application/src/ports.ts index 8adfb68..6510d49 100644 --- a/packages/application/src/ports.ts +++ b/packages/application/src/ports.ts @@ -35,6 +35,10 @@ export interface PullRequest { export interface GitProvider { resolveRef(ref: string, signal?: AbortSignal): Promise; + listBranches(input: { + readonly prefix?: string; + readonly signal?: AbortSignal; + }): Promise; createBranch(input: { readonly branch: string; readonly from: GitCommitSha; @@ -55,7 +59,11 @@ export interface GitProvider { commitFiles(input: { readonly branch: string; readonly expectedSha: GitCommitSha; - readonly files: readonly { readonly path: string; readonly content: string | null }[]; + readonly files: readonly { + readonly path: string; + readonly content: string | null; + readonly encoding?: "utf-8" | "base64"; + }[]; readonly message: string; readonly author: Actor; readonly idempotencyKey: string; @@ -69,6 +77,13 @@ export interface GitProvider { readonly idempotencyKey: string; readonly signal?: AbortSignal; }): Promise; + createRevertPullRequest(input: { + readonly pullRequestNumber: number; + readonly title: string; + readonly body: string; + readonly idempotencyKey: string; + readonly signal?: AbortSignal; + }): Promise; approvePullRequest(input: { readonly number: number; readonly actor: Actor; @@ -90,6 +105,12 @@ export interface ReviewComment { readonly path?: string; readonly line?: number; readonly createdAt: string; + readonly resolved: boolean; +} + +export interface ReviewAssignment { + readonly users: readonly string[]; + readonly teams: readonly string[]; } export interface ReviewCheck { @@ -109,6 +130,22 @@ export interface ReviewPort { readonly signal?: AbortSignal; }): Promise; listComments(pullRequestNumber: number, signal?: AbortSignal): Promise; + resolveComment(input: { + readonly pullRequestNumber: number; + readonly commentId: string; + readonly resolved: boolean; + readonly signal?: AbortSignal; + }): Promise; + assignReviewers(input: { + readonly pullRequestNumber: number; + readonly users: readonly string[]; + readonly teams: readonly string[]; + readonly signal?: AbortSignal; + }): Promise; + listReviewers( + pullRequestNumber: number, + signal?: AbortSignal, + ): Promise; listChecks(ref: GitCommitSha, signal?: AbortSignal): Promise; } @@ -120,6 +157,18 @@ export interface DocumentSummary { readonly revision: Revision; } +export interface ProjectConfig { + readonly configVersion: number; + readonly defaultLocale?: string; + readonly [key: string]: unknown; +} + +export interface RegistryLock { + readonly registryDigest: string; + readonly schemaVersion?: number; + readonly [key: string]: unknown; +} + export interface ContentRepository { listDocuments(input: { readonly ref: string; @@ -149,6 +198,8 @@ export interface ContentRepository { readonly idempotencyKey: string; readonly signal?: AbortSignal; }): Promise; + readProjectConfig(ref: string, signal?: AbortSignal): Promise; + readRegistryLock(ref: string, signal?: AbortSignal): Promise; } export interface StoredRelease { @@ -171,6 +222,7 @@ export interface ReleaseBuilderPort { readonly schemaVersion: number; readonly documents: readonly ReleaseBuildDocument[]; readonly redirects?: Readonly>; + readonly artifacts?: Readonly>; }): Promise; } @@ -184,6 +236,10 @@ export interface EnvironmentPointer { export interface ReleaseStore { writeRelease(release: StoredRelease, signal?: AbortSignal): Promise; readRelease(id: ReleaseId, signal?: AbortSignal): Promise; + listReleases(input: { + readonly cursor?: string; + readonly signal?: AbortSignal; + }): Promise>; readPointer( environment: EnvironmentPointer["environment"], signal?: AbortSignal, @@ -202,6 +258,28 @@ export interface Asset { readonly size: number; readonly checksum: string; readonly url: string; + readonly altText?: string; + readonly width?: number; + readonly height?: number; + readonly focalPoint?: { + readonly x: number; + readonly y: number; + }; + readonly variants?: readonly { + readonly name?: string; + readonly width: number; + readonly height: number; + readonly format: string; + readonly url: string; + }[]; +} + +export interface AssetReference { + readonly id: AssetId; + readonly fileName: string; + readonly mimeType: string; + readonly url: string; + readonly altText?: string; } export interface AssetStore { @@ -209,6 +287,7 @@ export interface AssetStore { readonly fileName: string; readonly mimeType: string; readonly size: number; + readonly checksum: string; readonly actor: Actor; readonly signal?: AbortSignal; }): Promise<{ @@ -216,12 +295,28 @@ export interface AssetStore { readonly url: string; readonly headers: Record; }>; + uploadBytes?(input: { + readonly uploadId: string; + readonly bytes: Uint8Array; + readonly mimeType: string; + readonly token?: string; + readonly signal?: AbortSignal; + }): Promise; finalizeUpload(input: { readonly uploadId: string; readonly checksum: string; readonly signal?: AbortSignal; }): Promise; readAsset(id: AssetId, signal?: AbortSignal): Promise; + updateAssetMetadata(input: { + readonly id: AssetId; + readonly altText?: string; + readonly focalPoint?: { + readonly x: number; + readonly y: number; + }; + readonly signal?: AbortSignal; + }): Promise; deleteAsset(id: AssetId, signal?: AbortSignal): Promise; listAssets(input: { readonly cursor?: string; @@ -229,6 +324,49 @@ export interface AssetStore { }): Promise>; } +export interface PreviewSession { + readonly id: string; + readonly actorId: Actor["id"]; + readonly changeId: Change["id"]; + readonly frontendRef: string; + readonly locale: string; + readonly createdAt: string; + readonly expiresAt: string; + readonly token: string; +} + +export interface PreviewSessionPort { + issue(input: { + readonly actorId: Actor["id"]; + readonly changeId: Change["id"]; + readonly frontendRef: string; + readonly locale: string; + readonly now: Date; + readonly signal?: AbortSignal; + }): Promise; + verify(input: { + readonly id: string; + readonly token: string; + readonly now: Date; + readonly signal?: AbortSignal; + }): Promise; + refresh(input: { + readonly id: string; + readonly token: string; + readonly now: Date; + readonly signal?: AbortSignal; + }): Promise; +} + +export interface AssetUsagePort { + usages(id: AssetId, signal?: AbortSignal): Promise; + isReleased(id: AssetId, signal?: AbortSignal): Promise; +} + +export interface AssetProcessorPort { + process(asset: Asset, signal?: AbortSignal): Promise; +} + export interface SessionRecord { readonly id: string; readonly actor: Actor; @@ -245,6 +383,58 @@ export interface SessionStore { delete(id: string): Promise; } +export interface IdentityProfile { + readonly externalId: string; + readonly login: string; + readonly displayName: string; + readonly capabilities: Readonly>; + readonly teams: readonly string[]; +} + +export interface IdentityProvider { + resolve(accessToken: string, signal?: AbortSignal): Promise; +} + +export interface TeamMember { + readonly id: string; + readonly login: string; + readonly displayName: string; + readonly avatarUrl?: string; + readonly organizationRole: "member" | "admin"; +} + +export interface OrganizationTeam { + readonly id: string; + readonly slug: string; + readonly name: string; + readonly description?: string; +} + +export interface TeamInvitation { + readonly id: string; + readonly email?: string; + readonly login?: string; + readonly role: "direct_member" | "admin"; + readonly status: "pending"; +} + +export interface TeamProvisioningPort { + listMembers(signal?: AbortSignal): Promise; + listTeams(signal?: AbortSignal): Promise; + invite(input: { + readonly email?: string; + readonly inviteeId?: number; + readonly role: "direct_member" | "admin"; + readonly signal?: AbortSignal; + }): Promise; + addMemberToTeam(input: { + readonly teamSlug: string; + readonly username: string; + readonly role: "member" | "maintainer"; + readonly signal?: AbortSignal; + }): Promise; +} + export interface DeploymentPort { deploy(input: { readonly environment: "preview" | "staging" | "production"; @@ -265,6 +455,18 @@ export interface RevalidationPort { }): Promise; } +export interface PublicationNotifierPort { + notify(input: { + readonly environment: "preview" | "staging" | "production"; + readonly releaseId: ReleaseId; + readonly revision: GitCommitSha; + readonly tags: readonly string[]; + readonly paths: readonly string[]; + readonly idempotencyKey: string; + readonly signal?: AbortSignal; + }): Promise; +} + export interface TranslationProvider { createJob(input: { readonly sourceLocale: string; @@ -287,16 +489,49 @@ export interface WebhookReplayStore { claim(deliveryId: string, expiresAt: string): Promise; } +export interface RateLimitPort { + consume(input: { + readonly key: string; + readonly scope: string; + readonly limit: number; + readonly windowMs: number; + readonly now: string; + }): Promise<{ + readonly allowed: boolean; + readonly remaining: number; + readonly resetAt: string; + }>; +} + export interface Clock { now(): Date; } export interface IdGenerator { changeId(): Change["id"]; + documentId(): DocumentId; + scheduleId(): string; requestId(): string; suffix(): string; } +export type ContentScheduleAction = + | "publish" + | "unpublish" + | "availability-start" + | "availability-end" + | "visibility-start" + | "visibility-end"; + +export interface SchedulerPort { + workflow(input: { + readonly scheduleId: string; + readonly executeAt: string; + readonly action: ContentScheduleAction; + readonly documentIds: readonly DocumentId[]; + }): { readonly path: string; readonly content: string }; +} + export interface IdempotencyStore { read(key: string): Promise; write(key: string, result: TResult): Promise; @@ -315,3 +550,11 @@ export interface AuditEvent { export interface AuditSink { write(event: AuditEvent): Promise; } + +export interface AuditQueryPort { + list(input: { + readonly resourceId?: string; + readonly limit?: number; + readonly signal?: AbortSignal; + }): Promise; +} diff --git a/packages/assets/package.json b/packages/assets/package.json index c22acf2..2ea2639 100644 --- a/packages/assets/package.json +++ b/packages/assets/package.json @@ -11,6 +11,14 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./node": { + "types": "./dist/node.d.ts", + "import": "./dist/node.js" + }, + "./git": { + "types": "./dist/git.d.ts", + "import": "./dist/git.js" } }, "dependencies": { @@ -19,6 +27,11 @@ "@git-native-cms/application": "workspace:*", "@git-native-cms/core": "workspace:*" }, + "devDependencies": { + "@git-native-cms/adapter-kit": "workspace:*", + "@git-native-cms/testing": "workspace:*", + "testcontainers": "catalog:" + }, "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/packages/assets/src/git.test.ts b/packages/assets/src/git.test.ts new file mode 100644 index 0000000..c2ecf9c --- /dev/null +++ b/packages/assets/src/git.test.ts @@ -0,0 +1,98 @@ +import { MemoryGitProvider } from "@git-native-cms/testing"; +import type { Actor } from "@git-native-cms/core"; +import { describe, expect, it } from "vitest"; +import { GitAssetStore } from "./git.js"; + +const actor: Actor = { + id: "act_git_asset" as Actor["id"], + githubId: 9, + login: "git-editor", + displayName: "Git Editor", + roles: ["administrator"], + source: "ui", +}; + +async function checksum(bytes: Uint8Array): Promise { + const value = new Uint8Array( + await crypto.subtle.digest("SHA-256", new Uint8Array(bytes)), + ); + return [...value].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +describe("Git asset storage", () => { + it("stores verified binary blobs and content-addressed metadata in the content repository", async () => { + const git = new MemoryGitProvider(); + const store = new GitAssetStore({ + git, + publicBaseUrl: "https://raw.example.test/content/main", + systemActor: actor, + }); + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const digest = await checksum(bytes); + const upload = await store.createUpload({ + fileName: "Campaign hero.png", + mimeType: "image/png", + size: bytes.byteLength, + checksum: digest, + actor, + }); + await store.uploadBytes?.({ + uploadId: upload.uploadId, + bytes, + mimeType: "image/png", + ...(upload.headers["x-cms-upload-token"] === undefined + ? {} + : { token: upload.headers["x-cms-upload-token"] }), + }); + const asset = await store.finalizeUpload({ uploadId: upload.uploadId, checksum: digest }); + + expect(asset).toMatchObject({ + fileName: "Campaign-hero.png", + checksum: digest, + url: `https://raw.example.test/content/main/assets/files/${digest}/Campaign-hero.png`, + }); + await expect(store.listAssets({})).resolves.toMatchObject({ items: [asset] }); + await expect( + store.updateAssetMetadata({ + id: asset.id, + altText: "Campaign hero", + focalPoint: { x: 0.5, y: 0.4 }, + }), + ).resolves.toMatchObject({ + altText: "Campaign hero", + focalPoint: { x: 0.5, y: 0.4 }, + }); + expect( + await git.readFile({ ref: "main", path: `assets/files/${digest}/Campaign-hero.png` }), + ).toBeDefined(); + + await store.deleteAsset(asset.id); + await expect(store.listAssets({})).resolves.toMatchObject({ items: [] }); + }); + + it("rejects a forged upload token before writing content", async () => { + const git = new MemoryGitProvider(); + const store = new GitAssetStore({ + git, + publicBaseUrl: "https://raw.example.test/content/main", + systemActor: actor, + }); + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const digest = await checksum(bytes); + const upload = await store.createUpload({ + fileName: "hero.png", + mimeType: "image/png", + size: bytes.byteLength, + checksum: digest, + actor, + }); + await expect( + store.uploadBytes?.({ + uploadId: upload.uploadId, + bytes, + mimeType: "image/png", + token: "forged", + }), + ).rejects.toMatchObject({ code: "CMS_ASSET_003" }); + }); +}); diff --git a/packages/assets/src/git.ts b/packages/assets/src/git.ts new file mode 100644 index 0000000..b67f221 --- /dev/null +++ b/packages/assets/src/git.ts @@ -0,0 +1,419 @@ +import { Buffer } from "node:buffer"; +import type { Asset, AssetStore, GitProvider, Page } from "@git-native-cms/application"; +import { CmsError, type Actor, type AssetId } from "@git-native-cms/core"; +import { assetBytesMatchMime, type AssetVirusScanner } from "./index.js"; + +interface PendingGitUpload { + readonly uploadId: string; + readonly tokenDigest: string; + readonly fileName: string; + readonly mimeType: string; + readonly size: number; + readonly checksum: string; + readonly actor: Actor; + readonly createdAt: string; +} + +export interface GitAssetStoreOptions { + readonly git: GitProvider; + readonly ref?: string; + readonly publicBaseUrl: string; + readonly uploadBaseUrl?: string; + readonly maximumUploadBytes?: number; + readonly allowedMimeTypes?: readonly string[]; + readonly virusScanner?: AssetVirusScanner; + readonly systemActor: Actor; +} + +function safeFileName(value: string): string { + const normalized = value + .normalize("NFKC") + .replace(/[^a-zA-Z0-9._-]/gu, "-") + .slice(0, 120); + return normalized.length === 0 || normalized === "." || normalized === ".." + ? "asset" + : normalized; +} + +function normalizedChecksum(value: string): string { + if (!/^[a-f0-9]{64}$/iu.test(value)) { + throw new CmsError({ + code: "CMS_ASSET_004", + message: "A SHA-256 checksum is required for an upload.", + category: "validation", + retryable: false, + }); + } + return value.toLowerCase(); +} + +async function sha256(bytes: Uint8Array): Promise { + const digest = new Uint8Array( + await globalThis.crypto.subtle.digest("SHA-256", new Uint8Array(bytes)), + ); + return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function validateUpload( + input: { readonly mimeType: string; readonly size: number }, + options: GitAssetStoreOptions, +): void { + const maximum = options.maximumUploadBytes ?? 10 * 1024 * 1024; + const allowed = options.allowedMimeTypes ?? [ + "image/avif", + "image/jpeg", + "image/png", + "image/webp", + "application/pdf", + ]; + if (!Number.isSafeInteger(input.size) || input.size < 1 || input.size > maximum) { + throw new CmsError({ + code: "CMS_ASSET_001", + message: `Git asset uploads must be between 1 byte and ${String(maximum)} bytes.`, + category: "validation", + retryable: false, + }); + } + if (!allowed.includes(input.mimeType) || input.mimeType === "image/svg+xml") { + throw new CmsError({ + code: "CMS_ASSET_002", + message: `The file type ${input.mimeType} is not allowed.`, + category: "validation", + retryable: false, + }); + } +} + +function encodedPath(path: string): string { + return path.split("/").map(encodeURIComponent).join("/"); +} + +export class GitAssetStore implements AssetStore { + private readonly ref: string; + + constructor(private readonly options: GitAssetStoreOptions) { + this.ref = options.ref ?? "main"; + } + + async createUpload(input: Parameters[0]): Promise<{ + readonly uploadId: string; + readonly url: string; + readonly headers: Record; + }> { + input.signal?.throwIfAborted(); + validateUpload(input, this.options); + const uploadId = `upl_${globalThis.crypto.randomUUID()}`; + const token = globalThis.crypto.randomUUID(); + const pending: PendingGitUpload = { + uploadId, + tokenDigest: await sha256(new TextEncoder().encode(token)), + fileName: safeFileName(input.fileName), + mimeType: input.mimeType, + size: input.size, + checksum: normalizedChecksum(input.checksum), + actor: input.actor, + createdAt: new Date().toISOString(), + }; + await this.commit( + [ + { + path: this.pendingMetadataPath(uploadId), + content: `${JSON.stringify(pending, null, 2)}\n`, + }, + ], + `Prepare asset upload "${pending.fileName}"`, + input.actor, + input.signal, + ); + const base = (this.options.uploadBaseUrl ?? "/api/cms/assets/uploads").replace(/\/$/u, ""); + return { + uploadId, + url: `${base}/${encodeURIComponent(uploadId)}/content`, + headers: { + "content-type": input.mimeType, + "x-cms-upload-token": token, + }, + }; + } + + async uploadBytes(input: { + readonly uploadId: string; + readonly bytes: Uint8Array; + readonly mimeType: string; + readonly token?: string; + readonly signal?: AbortSignal; + }): Promise { + input.signal?.throwIfAborted(); + const pending = await this.pendingUpload(input.uploadId, input.signal); + const tokenDigest = await sha256(new TextEncoder().encode(input.token ?? "")); + if ( + tokenDigest !== pending.tokenDigest || + input.mimeType !== pending.mimeType || + input.bytes.byteLength !== pending.size + ) { + throw new CmsError({ + code: "CMS_ASSET_003", + message: "The Git upload does not match its signed upload session.", + category: "validation", + retryable: false, + }); + } + await this.commit( + [ + { + path: this.pendingContentPath(input.uploadId), + content: Buffer.from(input.bytes).toString("base64"), + }, + ], + `Receive asset upload "${pending.fileName}"`, + pending.actor, + input.signal, + ); + } + + async finalizeUpload(input: Parameters[0]): Promise { + input.signal?.throwIfAborted(); + const expectedChecksum = normalizedChecksum(input.checksum); + const existing = await this.assetFromChecksum(expectedChecksum, input.signal); + if (existing !== undefined) return existing; + const pending = await this.pendingUpload(input.uploadId, input.signal); + const encoded = await this.options.git.readFile({ + ref: this.ref, + path: this.pendingContentPath(input.uploadId), + ...(input.signal === undefined ? {} : { signal: input.signal }), + }); + if (encoded === undefined) { + throw new CmsError({ + code: "CMS_ASSET_005", + message: "The Git upload has no received content.", + category: "validation", + retryable: false, + }); + } + const bytes = new Uint8Array(Buffer.from(encoded.content, "base64")); + if ( + pending.checksum !== expectedChecksum || + (await sha256(bytes)) !== expectedChecksum || + !assetBytesMatchMime(bytes, pending.mimeType) + ) { + throw new CmsError({ + code: "CMS_ASSET_003", + message: "The Git upload failed checksum or media-type verification.", + category: "validation", + retryable: false, + }); + } + if (this.options.virusScanner !== undefined) { + const scan = await this.options.virusScanner.scan({ + bytes, + fileName: pending.fileName, + mimeType: pending.mimeType, + checksum: expectedChecksum, + ...(input.signal === undefined ? {} : { signal: input.signal }), + }); + if (!scan.clean) { + await this.discardPending(pending, input.signal); + throw new CmsError({ + code: "CMS_ASSET_010", + message: "The upload was rejected by the configured malware scanner.", + category: "validation", + retryable: false, + ...(scan.threat === undefined ? {} : { context: { threat: scan.threat } }), + }); + } + } + const path = this.assetContentPath(expectedChecksum, pending.fileName); + const asset: Asset = { + id: `ast_${expectedChecksum.slice(0, 24)}` as AssetId, + fileName: pending.fileName, + mimeType: pending.mimeType, + size: pending.size, + checksum: expectedChecksum, + url: `${this.options.publicBaseUrl.replace(/\/$/u, "")}/${encodedPath(path)}`, + }; + await this.commit( + [ + { path, content: encoded.content, encoding: "base64" }, + { + path: this.assetMetadataPath(expectedChecksum), + content: `${JSON.stringify(asset, null, 2)}\n`, + }, + { path: this.pendingMetadataPath(input.uploadId), content: null }, + { path: this.pendingContentPath(input.uploadId), content: null }, + ], + `Add asset "${pending.fileName}"`, + pending.actor, + input.signal, + ); + return asset; + } + + async readAsset(id: AssetId, signal?: AbortSignal): Promise { + const files = await this.options.git.listFiles({ + ref: this.ref, + prefix: "assets/metadata/", + ...(signal === undefined ? {} : { signal }), + }); + for (const file of files) { + const asset = this.parseAsset(file.content); + if (asset?.id === id) return asset; + } + return undefined; + } + + async updateAssetMetadata( + input: Parameters[0], + ): Promise { + input.signal?.throwIfAborted(); + const existing = await this.readAsset(input.id, input.signal); + if (existing === undefined) { + throw new CmsError({ + code: "CMS_ASSET_404", + message: "The selected asset does not exist.", + category: "validation", + retryable: false, + }); + } + const stable = { ...existing }; + delete stable.altText; + delete stable.focalPoint; + const asset: Asset = { + ...stable, + ...(input.altText === undefined ? {} : { altText: input.altText }), + ...(input.focalPoint === undefined ? {} : { focalPoint: input.focalPoint }), + }; + await this.commit( + [ + { + path: this.assetMetadataPath(asset.checksum), + content: `${JSON.stringify(asset, null, 2)}\n`, + }, + ], + `Update asset "${asset.fileName}"`, + this.options.systemActor, + input.signal, + ); + return asset; + } + + async deleteAsset(id: AssetId, signal?: AbortSignal): Promise { + const asset = await this.readAsset(id, signal); + if (asset === undefined) return; + await this.commit( + [ + { path: this.assetMetadataPath(asset.checksum), content: null }, + { path: this.assetContentPath(asset.checksum, asset.fileName), content: null }, + ], + `Remove asset "${asset.fileName}"`, + this.options.systemActor, + signal, + ); + } + + async listAssets(input: { + readonly cursor?: string; + readonly signal?: AbortSignal; + }): Promise> { + input.signal?.throwIfAborted(); + const files = await this.options.git.listFiles({ + ref: this.ref, + prefix: "assets/metadata/", + ...(input.signal === undefined ? {} : { signal: input.signal }), + }); + const assets = files + .map((file) => this.parseAsset(file.content)) + .filter((asset): asset is Asset => asset !== undefined) + .sort((left, right) => left.fileName.localeCompare(right.fileName)); + const offset = Number(input.cursor ?? "0"); + const page = assets.slice(offset, offset + 100); + return { + items: page, + ...(offset + page.length < assets.length ? { nextCursor: String(offset + page.length) } : {}), + }; + } + + private async commit( + files: Parameters[0]["files"], + message: string, + actor: Actor, + signal?: AbortSignal, + ): Promise { + const ref = await this.options.git.resolveRef(this.ref, signal); + await this.options.git.commitFiles({ + branch: this.ref, + expectedSha: ref.sha, + files, + message, + author: actor, + idempotencyKey: `asset:${globalThis.crypto.randomUUID()}`, + ...(signal === undefined ? {} : { signal }), + }); + } + + private async pendingUpload(uploadId: string, signal?: AbortSignal): Promise { + const file = await this.options.git.readFile({ + ref: this.ref, + path: this.pendingMetadataPath(uploadId), + ...(signal === undefined ? {} : { signal }), + }); + if (file === undefined) { + throw new CmsError({ + code: "CMS_ASSET_005", + message: "The Git upload session does not exist or expired.", + category: "validation", + retryable: false, + }); + } + return JSON.parse(file.content) as PendingGitUpload; + } + + private async assetFromChecksum(value: string, signal?: AbortSignal): Promise { + const file = await this.options.git.readFile({ + ref: this.ref, + path: this.assetMetadataPath(value), + ...(signal === undefined ? {} : { signal }), + }); + return file === undefined ? undefined : this.parseAsset(file.content); + } + + private parseAsset(value: string): Asset | undefined { + try { + const parsed = JSON.parse(value) as Asset; + return typeof parsed.id === "string" && + typeof parsed.checksum === "string" && + typeof parsed.url === "string" + ? parsed + : undefined; + } catch { + return undefined; + } + } + + private async discardPending(pending: PendingGitUpload, signal?: AbortSignal): Promise { + await this.commit( + [ + { path: this.pendingMetadataPath(pending.uploadId), content: null }, + { path: this.pendingContentPath(pending.uploadId), content: null }, + ], + `Reject asset upload "${pending.fileName}"`, + pending.actor, + signal, + ); + } + + private pendingMetadataPath(uploadId: string): string { + return `.cms/uploads/${uploadId}.json`; + } + + private pendingContentPath(uploadId: string): string { + return `.cms/uploads/${uploadId}.base64`; + } + + private assetMetadataPath(value: string): string { + return `assets/metadata/${value}.json`; + } + + private assetContentPath(value: string, fileName: string): string { + return `assets/files/${value}/${safeFileName(fileName)}`; + } +} diff --git a/packages/assets/src/index.test.ts b/packages/assets/src/index.test.ts index 61b3cf2..f9f01f8 100644 --- a/packages/assets/src/index.test.ts +++ b/packages/assets/src/index.test.ts @@ -1,12 +1,42 @@ -import type { AssetId } from "@git-native-cms/core"; +import { + CopyObjectCommand, + HeadObjectCommand, + ListObjectsV2Command, + S3Client, +} from "@aws-sdk/client-s3"; +import type { Actor, AssetId } from "@git-native-cms/core"; import { describe, expect, it } from "vitest"; -import { assertAssetCanBeDeleted, buildAssetUsageGraph } from "./index.js"; +import { + assertAssetCanBeDeleted, + assetBytesMatchMime, + buildAssetUsageGraph, + S3AssetStore, +} from "./index.js"; + +const actor: Actor = { + id: "act_asset_security" as Actor["id"], + githubId: 1, + login: "asset-security", + displayName: "Asset Security", + roles: ["administrator"], + source: "cli", +}; describe("asset usage safety", () => { it("finds content references and prevents unsafe deletion", () => { const assetId = "ast_0123456789abcdef01234567" as AssetId; const graph = buildAssetUsageGraph([ - { hero: { image: { id: assetId, alt: "Proofing desk" } } }, + { + hero: { + image: { + id: assetId, + fileName: "proofing-desk.png", + mimeType: "image/png", + url: "https://assets.example.test/assets/proofing-desk.png", + altText: "Proofing desk", + }, + }, + }, ]); expect(graph).toEqual([ { @@ -16,4 +46,137 @@ describe("asset usage safety", () => { ]); expect(() => assertAssetCanBeDeleted(assetId, graph, new Set())).toThrow(/still used/i); }); + + it("rejects SVG/upload bombs and signs the exact content checksum", async () => { + const client = new S3Client({ + endpoint: "https://s3.example.test", + region: "auto", + forcePathStyle: true, + credentials: { accessKeyId: "test", secretAccessKey: "test-secret" }, + }); + const store = new S3AssetStore({ + client, + bucket: "assets", + publicBaseUrl: "https://assets.example.test", + maximumUploadBytes: 8, + }); + await expect( + store.createUpload({ + fileName: "payload.svg", + mimeType: "image/svg+xml", + size: 4, + checksum: "a".repeat(64), + actor, + }), + ).rejects.toMatchObject({ code: "CMS_ASSET_002" }); + await expect( + store.createUpload({ + fileName: "bomb.png", + mimeType: "image/png", + size: 9, + checksum: "a".repeat(64), + actor, + }), + ).rejects.toMatchObject({ code: "CMS_ASSET_001" }); + const upload = await store.createUpload({ + fileName: "../../proof.png", + mimeType: "image/png", + size: 4, + checksum: "a".repeat(64), + actor, + }); + const signedUrl = new URL(upload.url); + expect(signedUrl.searchParams.get("x-amz-meta-declaredsha256")).toBeNull(); + expect(signedUrl.searchParams.get("X-Amz-SignedHeaders")).toContain( + "x-amz-meta-declaredsha256", + ); + expect(upload.headers).toMatchObject({ + "content-type": "image/png", + "x-amz-meta-declaredmime": "image/png", + "x-amz-meta-declaredsha256": "a".repeat(64), + "x-amz-meta-declaredsize": "4", + "x-amz-meta-originalfilename": "..-..-proof.png", + }); + expect(decodeURIComponent(upload.url)).not.toContain("/../"); + client.destroy(); + }); + + it("rejects media-type spoofing before an object becomes public", () => { + expect(assetBytesMatchMime(new TextEncoder().encode("