From f3b50a5adbb134eac3a896e7e46784864e2d539a Mon Sep 17 00:00:00 2001 From: Karol Chudzik Date: Sun, 19 Apr 2026 17:06:23 +0200 Subject: [PATCH 1/4] chore(cms-190): remove apps/studio-review and audit-confirmed dead code Repo-wide dead-code audit. Removes the retired studio-review app and other code paths with no remaining consumers, then prunes the supporting scripts, deps, docs, and specs so the repo still builds and tests cleanly. Removed - apps/studio-review (entire app) + root studio:review npm scripts, .changeset/config.json ignore entry, AGENTS.md row, apps/docs pages that described it, and docs/specs/SPEC-012. - docs/specs/SPEC-013 (orphan planning brief not in the spec catalog). - apps/server OwnerInvariant* helpers in rbac.ts (superseded by assertOwnerMutationAllowed). - apps/cli action-catalog-adapter.ts plus its two tests and the @elysiajs/eden / elysia deps that only existed to serve it. - apps/cli credentials.clearCredentialsFile (zero callers). - packages/studio legacy lib/ui/{button,utils}.tsx (superseded by runtime-ui/), runtime-registry.{ts,test.ts} (never wired), and the runtime-ui components editor-sidebar.tsx, mock-data.ts, calendar.tsx, collapsible.tsx, checkbox.tsx plus the @radix-ui/react-checkbox, @radix-ui/react-collapsible, @tiptap/extension-image, @tiptap/extension-placeholder, @tiptap/extension-typography, react-day-picker, and date-fns deps that only served them. - packages/shared lib/shared.ts placeholder function + its index re-export. - Root devDeps @swc-node/register, @swc/core, @swc/helpers (no references anywhere; repo uses Bun native + tsc). Fixed - docs/adrs/README.md moved ADR-006 from "Proposed" to "Accepted" to match its own status: accepted frontmatter. Verification - bun run check (build + typecheck): passes - bun run unit (514 tests across 58 files + studio embed smoke): passes - bun run format:check: passes --- .changeset/config.json | 7 +- AGENTS.md | 1 - apps/cli/package.json | 2 - apps/cli/src/lib/action-catalog-adapter.ts | 107 ---- apps/cli/src/lib/cli.test.ts | 61 +-- apps/cli/src/lib/credentials.ts | 13 +- apps/docs/architecture/overview.mdx | 4 - apps/docs/development/packages.mdx | 13 - apps/docs/development/setup.mdx | 13 +- apps/server/src/lib/rbac.test.ts | 23 - apps/server/src/lib/rbac.ts | 37 -- apps/studio-review/README.md | 41 -- apps/studio-review/app/layout.tsx | 14 - apps/studio-review/app/page.test.tsx | 14 - apps/studio-review/app/page.tsx | 42 -- .../[scenario]/api/v1/actions/[id]/route.ts | 34 -- .../[scenario]/api/v1/actions/route.ts | 9 - .../[scenario]/api/v1/auth/session/route.ts | 14 - .../v1/content/[documentId]/publish/route.ts | 33 -- .../api/v1/content/[documentId]/route.ts | 59 --- .../[documentId]/versions/[version]/route.ts | 48 -- .../v1/content/[documentId]/versions/route.ts | 48 -- .../[scenario]/api/v1/content/route.ts | 54 -- .../api/v1/environments/[id]/route.ts | 48 -- .../api/v1/environments/route.test.ts | 54 -- .../[scenario]/api/v1/environments/route.ts | 86 ---- .../api/v1/me/capabilities/route.ts | 17 - .../[scenario]/api/v1/schema/route.ts | 39 -- .../studio/assets/[buildId]/[file]/route.ts | 58 --- .../api/v1/studio/bootstrap/route.ts | 52 -- .../admin/[[...path]]/page.test.tsx | 81 --- .../[scenario]/admin/[[...path]]/page.tsx | 50 -- .../[scenario]/admin/admin-studio-client.tsx | 31 -- .../admin/resolve-studio-review-app-root.ts | 1 - .../review/[scenario]/admin/studio-config.ts | 106 ---- apps/studio-review/components/mdx/Callout.tsx | 87 ---- apps/studio-review/components/mdx/Chart.tsx | 125 ----- .../components/mdx/PricingTable.editor.tsx | 204 -------- .../components/mdx/PricingTable.tsx | 115 ----- .../studio-review/lib/review-studio-config.ts | 39 -- apps/studio-review/mdcms.config.ts | 48 -- apps/studio-review/middleware.ts | 52 -- apps/studio-review/next-env.d.ts | 6 - apps/studio-review/next.config.mjs | 18 - apps/studio-review/package.json | 28 - apps/studio-review/review/actions.test.ts | 47 -- apps/studio-review/review/actions.ts | 167 ------ apps/studio-review/review/app-root.ts | 32 -- .../review/content-documents.test.ts | 25 - .../studio-review/review/content-documents.ts | 231 --------- .../studio-review/review/environments.test.ts | 97 ---- apps/studio-review/review/environments.ts | 217 -------- .../review/runtime-artifacts.test.ts | 95 ---- .../studio-review/review/runtime-artifacts.ts | 67 --- apps/studio-review/review/runtime-build.ts | 53 -- apps/studio-review/review/runtime-entry.ts | 1 - apps/studio-review/review/scenarios.test.ts | 36 -- apps/studio-review/review/scenarios.ts | 324 ------------ .../scripts/build-review-runtime.ts | 14 - .../scripts/dev-runtime-watch.test.ts | 18 - .../scripts/dev-runtime-watch.ts | 110 ---- apps/studio-review/tsconfig.json | 35 -- bun.lock | 67 +-- docs/adrs/README.md | 23 +- docs/specs/README.md | 1 - ...-studio-review-app-and-preview-workflow.md | 153 ------ docs/specs/SPEC-013-mintlify-docs-site.md | 302 ----------- package.json | 5 - packages/shared/src/index.ts | 1 - packages/shared/src/lib/shared.ts | 3 - packages/studio/package.json | 7 - .../studio/src/lib/markdown-pipeline.test.ts | 5 +- .../studio/src/lib/runtime-registry.test.ts | 156 ------ packages/studio/src/lib/runtime-registry.ts | 299 ----------- .../editor/code-block-languages.test.ts | 7 +- .../components/editor/editor-sidebar.tsx | 470 ----------------- .../editor/mdx-props-panel.test.tsx | 1 - .../lib/runtime-ui/components/ui/calendar.tsx | 204 -------- .../lib/runtime-ui/components/ui/checkbox.tsx | 32 -- .../runtime-ui/components/ui/collapsible.tsx | 33 -- .../src/lib/runtime-ui/lib/mock-data.ts | 484 ------------------ packages/studio/src/lib/ui/button.tsx | 43 -- packages/studio/src/lib/ui/utils.ts | 6 - 83 files changed, 28 insertions(+), 5879 deletions(-) delete mode 100644 apps/cli/src/lib/action-catalog-adapter.ts delete mode 100644 apps/studio-review/README.md delete mode 100644 apps/studio-review/app/layout.tsx delete mode 100644 apps/studio-review/app/page.test.tsx delete mode 100644 apps/studio-review/app/page.tsx delete mode 100644 apps/studio-review/app/review-api/[scenario]/api/v1/actions/[id]/route.ts delete mode 100644 apps/studio-review/app/review-api/[scenario]/api/v1/actions/route.ts delete mode 100644 apps/studio-review/app/review-api/[scenario]/api/v1/auth/session/route.ts delete mode 100644 apps/studio-review/app/review-api/[scenario]/api/v1/content/[documentId]/publish/route.ts delete mode 100644 apps/studio-review/app/review-api/[scenario]/api/v1/content/[documentId]/route.ts delete mode 100644 apps/studio-review/app/review-api/[scenario]/api/v1/content/[documentId]/versions/[version]/route.ts delete mode 100644 apps/studio-review/app/review-api/[scenario]/api/v1/content/[documentId]/versions/route.ts delete mode 100644 apps/studio-review/app/review-api/[scenario]/api/v1/content/route.ts delete mode 100644 apps/studio-review/app/review-api/[scenario]/api/v1/environments/[id]/route.ts delete mode 100644 apps/studio-review/app/review-api/[scenario]/api/v1/environments/route.test.ts delete mode 100644 apps/studio-review/app/review-api/[scenario]/api/v1/environments/route.ts delete mode 100644 apps/studio-review/app/review-api/[scenario]/api/v1/me/capabilities/route.ts delete mode 100644 apps/studio-review/app/review-api/[scenario]/api/v1/schema/route.ts delete mode 100644 apps/studio-review/app/review-api/[scenario]/api/v1/studio/assets/[buildId]/[file]/route.ts delete mode 100644 apps/studio-review/app/review-api/[scenario]/api/v1/studio/bootstrap/route.ts delete mode 100644 apps/studio-review/app/review/[scenario]/admin/[[...path]]/page.test.tsx delete mode 100644 apps/studio-review/app/review/[scenario]/admin/[[...path]]/page.tsx delete mode 100644 apps/studio-review/app/review/[scenario]/admin/admin-studio-client.tsx delete mode 100644 apps/studio-review/app/review/[scenario]/admin/resolve-studio-review-app-root.ts delete mode 100644 apps/studio-review/app/review/[scenario]/admin/studio-config.ts delete mode 100644 apps/studio-review/components/mdx/Callout.tsx delete mode 100644 apps/studio-review/components/mdx/Chart.tsx delete mode 100644 apps/studio-review/components/mdx/PricingTable.editor.tsx delete mode 100644 apps/studio-review/components/mdx/PricingTable.tsx delete mode 100644 apps/studio-review/lib/review-studio-config.ts delete mode 100644 apps/studio-review/mdcms.config.ts delete mode 100644 apps/studio-review/middleware.ts delete mode 100644 apps/studio-review/next-env.d.ts delete mode 100644 apps/studio-review/next.config.mjs delete mode 100644 apps/studio-review/package.json delete mode 100644 apps/studio-review/review/actions.test.ts delete mode 100644 apps/studio-review/review/actions.ts delete mode 100644 apps/studio-review/review/app-root.ts delete mode 100644 apps/studio-review/review/content-documents.test.ts delete mode 100644 apps/studio-review/review/content-documents.ts delete mode 100644 apps/studio-review/review/environments.test.ts delete mode 100644 apps/studio-review/review/environments.ts delete mode 100644 apps/studio-review/review/runtime-artifacts.test.ts delete mode 100644 apps/studio-review/review/runtime-artifacts.ts delete mode 100644 apps/studio-review/review/runtime-build.ts delete mode 100644 apps/studio-review/review/runtime-entry.ts delete mode 100644 apps/studio-review/review/scenarios.test.ts delete mode 100644 apps/studio-review/review/scenarios.ts delete mode 100644 apps/studio-review/scripts/build-review-runtime.ts delete mode 100644 apps/studio-review/scripts/dev-runtime-watch.test.ts delete mode 100644 apps/studio-review/scripts/dev-runtime-watch.ts delete mode 100644 apps/studio-review/tsconfig.json delete mode 100644 docs/specs/SPEC-012-studio-review-app-and-preview-workflow.md delete mode 100644 docs/specs/SPEC-013-mintlify-docs-site.md delete mode 100644 packages/shared/src/lib/shared.ts delete mode 100644 packages/studio/src/lib/runtime-registry.test.ts delete mode 100644 packages/studio/src/lib/runtime-registry.ts delete mode 100644 packages/studio/src/lib/runtime-ui/components/editor/editor-sidebar.tsx delete mode 100644 packages/studio/src/lib/runtime-ui/components/ui/calendar.tsx delete mode 100644 packages/studio/src/lib/runtime-ui/components/ui/checkbox.tsx delete mode 100644 packages/studio/src/lib/runtime-ui/components/ui/collapsible.tsx delete mode 100644 packages/studio/src/lib/runtime-ui/lib/mock-data.ts delete mode 100644 packages/studio/src/lib/ui/button.tsx delete mode 100644 packages/studio/src/lib/ui/utils.ts diff --git a/.changeset/config.json b/.changeset/config.json index 7d85574f..98dd4129 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,10 +7,5 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [ - "@mdcms/server", - "@mdcms/modules", - "@mdcms/studio-example", - "@mdcms/studio-review" - ] + "ignore": ["@mdcms/server", "@mdcms/modules", "@mdcms/studio-example"] } diff --git a/AGENTS.md b/AGENTS.md index 00e41d84..190760bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,6 @@ Upcoming work is focused on live preview (real-time content rendering in the con | `apps/server` | Elysia HTTP server, the backend for everything | | `apps/cli` | CLI binary (`mdcms`) for push/pull/sync workflows | | `apps/studio-example` | Next.js app that embeds the Studio component | -| `apps/studio-review` | Internal app for visual PR review of Studio changes | | `packages/shared` | Contracts, types, and schema utilities used everywhere | | `packages/sdk` | Client SDK for reading content from applications | | `packages/studio` | The embeddable React Studio component | diff --git a/apps/cli/package.json b/apps/cli/package.json index 27eb29ec..ba42acb4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -47,10 +47,8 @@ "dev": "bun --conditions @mdcms/source src/bin/mdcms.ts --help" }, "dependencies": { - "@elysiajs/eden": "^1.4.8", "@inquirer/prompts": "^7.0.0", "@mdcms/shared": "^0.1.4", - "elysia": "^1.4.25", "ora": "^8.0.0", "tslib": "^2.3.0", "yaml": "^2.8.2", diff --git a/apps/cli/src/lib/action-catalog-adapter.ts b/apps/cli/src/lib/action-catalog-adapter.ts deleted file mode 100644 index dae17327..00000000 --- a/apps/cli/src/lib/action-catalog-adapter.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { treaty } from "@elysiajs/eden"; -import { - RuntimeError, - assertActionCatalogItem, - assertActionCatalogList, - type ActionCatalogItem, -} from "@mdcms/shared"; -import type { ActionCatalogContractApp } from "@mdcms/shared/action-catalog-contract"; - -export type ActionCatalogHeaders = Record; - -export type ActionCatalogRequestOptions = { - headers?: ActionCatalogHeaders; - signal?: AbortSignal; -}; - -export type CliActionCatalogAdapterOptions = { - headers?: ActionCatalogHeaders; - fetcher?: ( - input: string | URL | Request, - init?: RequestInit, - ) => Promise; -}; - -export type CliActionCatalogAdapter = { - list: (options?: ActionCatalogRequestOptions) => Promise; - getById: ( - id: string, - options?: ActionCatalogRequestOptions, - ) => Promise; -}; - -function mergeHeaders( - defaultHeaders?: ActionCatalogHeaders, - requestHeaders?: ActionCatalogHeaders, -): ActionCatalogHeaders | undefined { - if (!defaultHeaders && !requestHeaders) { - return undefined; - } - - return { - ...(defaultHeaders ?? {}), - ...(requestHeaders ?? {}), - }; -} - -function toAdapterError( - action: "list" | "get", - status: number, - error: unknown, -): RuntimeError { - return new RuntimeError({ - code: "ACTION_CATALOG_REQUEST_FAILED", - message: `CLI action catalog ${action} request failed.`, - statusCode: status, - details: { - status, - error, - }, - }); -} - -/** - * createCliActionCatalogAdapter resolves `/api/v1/actions` contract data - * through Eden/Treaty with shared runtime schema validation. - */ -export function createCliActionCatalogAdapter( - baseUrl: string, - options: CliActionCatalogAdapterOptions = {}, -): CliActionCatalogAdapter { - const client = treaty(baseUrl, { - fetcher: options.fetcher, - }); - - return { - async list(requestOptions = {}) { - const response = await client.api.v1.actions.get({ - headers: mergeHeaders(options.headers, requestOptions.headers), - fetch: { - signal: requestOptions.signal, - }, - }); - - if (response.error) { - throw toAdapterError("list", response.status, response.error); - } - - assertActionCatalogList(response.data, "response.data"); - return response.data; - }, - async getById(id, requestOptions = {}) { - const response = await client.api.v1.actions({ id }).get({ - headers: mergeHeaders(options.headers, requestOptions.headers), - fetch: { - signal: requestOptions.signal, - }, - }); - - if (response.error) { - throw toAdapterError("get", response.status, response.error); - } - - assertActionCatalogItem(response.data, "response.data"); - return response.data; - }, - }; -} diff --git a/apps/cli/src/lib/cli.test.ts b/apps/cli/src/lib/cli.test.ts index 5bcf829b..7c480ddf 100644 --- a/apps/cli/src/lib/cli.test.ts +++ b/apps/cli/src/lib/cli.test.ts @@ -1,14 +1,13 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { RuntimeError, type ActionCatalogItem } from "@mdcms/shared"; +import { RuntimeError } from "@mdcms/shared"; import { createCliRuntimeContext, formatCliErrorEnvelope, resolveCliEnv, } from "./cli.js"; -import { createCliActionCatalogAdapter } from "./action-catalog-adapter.js"; test("resolveCliEnv parses core env and applies CLI defaults", () => { const env = resolveCliEnv({ @@ -85,61 +84,3 @@ test("formatCliErrorEnvelope keeps RuntimeError code", () => { assert.equal(envelope.code, "INVALID_INPUT"); assert.equal(envelope.message, "Bad argument."); }); - -test("createCliActionCatalogAdapter lists actions from /api/v1/actions", async () => { - const adapter = createCliActionCatalogAdapter("http://localhost", { - fetcher: async (input: string | URL | Request, init?: RequestInit) => { - assert.equal(String(input), "http://localhost/api/v1/actions"); - assert.equal(init?.method, "GET"); - - const payload: ActionCatalogItem[] = [ - { - id: "content.list", - kind: "query", - method: "GET", - path: "/api/v1/content", - permissions: ["content:read"], - }, - ]; - - return new Response(JSON.stringify(payload), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }, - }); - - const result = await adapter.list(); - - assert.equal(result.length, 1); - assert.equal(result[0]?.id, "content.list"); -}); - -test("createCliActionCatalogAdapter resolves detail and validates shape", async () => { - const adapter = createCliActionCatalogAdapter("http://localhost", { - fetcher: async (input: string | URL | Request, init?: RequestInit) => { - assert.equal( - String(input), - "http://localhost/api/v1/actions/content.publish", - ); - assert.equal(init?.method, "GET"); - - return new Response( - JSON.stringify({ - id: "content.publish", - kind: "command", - method: "POST", - path: "/api/v1/content/:id/publish", - permissions: ["content:publish"], - } satisfies ActionCatalogItem), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ); - }, - }); - - const result = await adapter.getById("content.publish"); - assert.equal(result.id, "content.publish"); -}); diff --git a/apps/cli/src/lib/credentials.ts b/apps/cli/src/lib/credentials.ts index b8f4fbb8..c9fe52d7 100644 --- a/apps/cli/src/lib/credentials.ts +++ b/apps/cli/src/lib/credentials.ts @@ -1,12 +1,5 @@ import { spawnSync } from "node:child_process"; -import { - chmod, - mkdir, - readFile, - rename, - rm, - writeFile, -} from "node:fs/promises"; +import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; @@ -520,7 +513,3 @@ export function createInMemoryCredentialStore( }, }; } - -export async function clearCredentialsFile(path: string): Promise { - await rm(path, { force: true }); -} diff --git a/apps/docs/architecture/overview.mdx b/apps/docs/architecture/overview.mdx index dfc9fd69..d4cf9733 100644 --- a/apps/docs/architecture/overview.mdx +++ b/apps/docs/architecture/overview.mdx @@ -78,7 +78,6 @@ mdcms/ │ ├── server/ # Elysia HTTP server (the CMS API) │ ├── cli/ # `mdcms` CLI tool for push/pull/schema sync/migrate │ ├── studio-example/ # Next.js reference app embedding @mdcms/studio -│ ├── studio-review/ # Scenario-based visual review environment for Studio │ └── docs/ # Mintlify documentation site (you are here) ├── packages/ │ ├── shared/ # Runtime utilities, contracts, error types, validation @@ -105,7 +104,6 @@ graph LR server["apps/server"] cli["apps/cli"] studioExample["apps/studio-example"] - studioReview["apps/studio-review"] sdk --> shared studio --> shared @@ -118,8 +116,6 @@ graph LR cli --> sdk studioExample --> studio studioExample --> sdk - studioReview --> studio - studioReview --> sdk ``` diff --git a/apps/docs/development/packages.mdx b/apps/docs/development/packages.mdx index deeb7153..af55d057 100644 --- a/apps/docs/development/packages.mdx +++ b/apps/docs/development/packages.mdx @@ -27,7 +27,6 @@ graph TD cli --> modules studioEx["apps/studio-example"] --> studio["packages/studio"] studioEx --> sdk["packages/sdk"] - studioReview["apps/studio-review"] --> studio studio --> shared sdk --> shared modules --> shared @@ -86,18 +85,6 @@ A demo Next.js application that embeds the `` component from `@mdcms/s Dependencies: `@mdcms/studio`, `@mdcms/sdk`. -### apps/studio-review - -| | | -| --------------- | -------------------------- | -| **Package** | `@mdcms/studio-review` | -| **Port** | 3000 | -| **Dev command** | `bun nx dev studio-review` | - -An internal visual review application that renders deterministic scenarios for PR review. Deployed as a standalone Vercel deployment, allowing reviewers to see UI changes across predefined states without needing a full local setup. - -Dependencies: `@mdcms/studio`. - ## Shared Packages ### packages/shared diff --git a/apps/docs/development/setup.mdx b/apps/docs/development/setup.mdx index 0541cb68..31ee75e4 100644 --- a/apps/docs/development/setup.mdx +++ b/apps/docs/development/setup.mdx @@ -14,12 +14,12 @@ This guide walks you through setting up a local MDCMS development environment fr Before you begin, install the following tools: -| Tool | Version | Purpose | -| --------------------------- | ---------------------------------- | --------------------------------------------------------- | -| **Bun** | 1.3.11+ (pinned in `.bun-version`) | Package manager, runtime, and test runner | -| **Docker & Docker Compose** | Latest stable | Infrastructure services (PostgreSQL, Redis, MinIO) | -| **Node.js** | 20+ | Required for Next.js apps (studio-example, studio-review) | -| **Git** | Latest stable | Source control | +| Tool | Version | Purpose | +| --------------------------- | ---------------------------------- | -------------------------------------------------- | +| **Bun** | 1.3.11+ (pinned in `.bun-version`) | Package manager, runtime, and test runner | +| **Docker & Docker Compose** | Latest stable | Infrastructure services (PostgreSQL, Redis, MinIO) | +| **Node.js** | 20+ | Required for Next.js apps (studio-example) | +| **Git** | Latest stable | Source control | @@ -181,7 +181,6 @@ Once running, these services are available: | -------------- | ---------------------------------------------- | | Server API | [http://localhost:4000](http://localhost:4000) | | Studio Example | [http://localhost:4173](http://localhost:4173) | -| Studio Review | [http://localhost:3000](http://localhost:3000) | | MinIO Console | [http://localhost:9001](http://localhost:9001) | | Mailhog | [http://localhost:8025](http://localhost:8025) | | PostgreSQL | `localhost:5432` | diff --git a/apps/server/src/lib/rbac.test.ts b/apps/server/src/lib/rbac.test.ts index 560f46b9..4e96d416 100644 --- a/apps/server/src/lib/rbac.test.ts +++ b/apps/server/src/lib/rbac.test.ts @@ -2,7 +2,6 @@ import assert from "node:assert/strict"; import { test } from "bun:test"; import { - assertOwnerInvariant, assertOwnerMutationAllowed, evaluateEffectiveRole, evaluatePermission, @@ -254,28 +253,6 @@ test("RBAC rejects non-global Owner/Admin grants", () => { ); }); -test("owner invariant accepts exactly one active owner", () => { - const snapshot = assertOwnerInvariant({ - activeOwnerCount: 1, - }); - - assert.equal(snapshot.activeOwnerCount, 1); -}); - -test("owner invariant rejects zero or multiple active owners", () => { - assert.throws(() => - assertOwnerInvariant({ - activeOwnerCount: 0, - }), - ); - - assert.throws(() => - assertOwnerInvariant({ - activeOwnerCount: 2, - }), - ); -}); - test("owner mutation guard rejects removing/demoting the last owner", () => { assert.throws(() => assertOwnerMutationAllowed({ diff --git a/apps/server/src/lib/rbac.ts b/apps/server/src/lib/rbac.ts index 9f32bda3..069f0713 100644 --- a/apps/server/src/lib/rbac.ts +++ b/apps/server/src/lib/rbac.ts @@ -222,28 +222,6 @@ export function evaluatePermission(input: { }; } -export type OwnerInvariantSnapshot = { - activeOwnerCount: number; -}; - -export function assertOwnerInvariant( - snapshot: OwnerInvariantSnapshot, -): OwnerInvariantSnapshot { - if (snapshot.activeOwnerCount !== 1) { - throw new RuntimeError({ - code: "OWNER_INVARIANT_VIOLATION", - message: - "Exactly one non-removable Owner must exist per instance at all times.", - statusCode: 409, - details: { - activeOwnerCount: snapshot.activeOwnerCount, - }, - }); - } - - return snapshot; -} - export type OwnerMutationIntent = "remove_owner" | "demote_owner"; export function assertOwnerMutationAllowed(input: { @@ -263,18 +241,3 @@ export function assertOwnerMutationAllowed(input: { }); } } - -/** - * CMS-44 close work (March 4) will replace this with DB-backed checks around - * membership/grant mutation flows. - */ -export type OwnerInvariantStore = { - countActiveOwners: () => Promise; -}; - -export async function assertOwnerInvariantFromStore( - store: OwnerInvariantStore, -): Promise { - const activeOwnerCount = await store.countActiveOwners(); - assertOwnerInvariant({ activeOwnerCount }); -} diff --git a/apps/studio-review/README.md b/apps/studio-review/README.md deleted file mode 100644 index 1b0db00d..00000000 --- a/apps/studio-review/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Studio Review App - -Private Next.js review surface for MDCMS Studio. - -## Purpose - -- Provide deterministic Studio PR preview routes without the full Compose stack -- Exercise the real `@mdcms/studio` shell against a review-only bootstrap and runtime asset subtree -- Keep mock data and review scenarios isolated from the production example app - -## Local Run - -From the workspace root: - -```bash -bun run studio:review:dev -``` - -This command: - -1. Builds review runtime artifacts into `apps/studio-review/.generated/runtime` -2. Starts a workspace package watch for `@mdcms/shared`, `@mdcms/cli`, and `@mdcms/studio` -3. Keeps review runtime artifacts in sync while Studio runtime sources change -4. Starts the review app on `http://127.0.0.1:3000` - -## Scenario Routes - -| Route | Description | -| --------------------------------------- | -------------------- | -| `/review/editor/admin` | Editor role view | -| `/review/editor/admin/content/post/:id` | Editor document view | -| `/review/owner/admin` | Owner role view | -| `/review/owner/admin/schema` | Owner schema view | -| `/review/viewer/admin` | Viewer role view | -| `/review/schema-error/admin/schema` | Schema error state | - -## Notes - -- This is repo-internal tooling and does not change production Studio contracts. -- Review API responses live under the app-local `/review-api/:scenario/api/v1/*` subtree. -- The `apps/studio-example` app remains the real host-app integration target. diff --git a/apps/studio-review/app/layout.tsx b/apps/studio-review/app/layout.tsx deleted file mode 100644 index 9ae51352..00000000 --- a/apps/studio-review/app/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { ReactNode } from "react"; - -export const metadata = { - title: "MDCMS Studio Review", - description: "Private review app for deterministic Studio preview scenarios.", -}; - -export default function RootLayout({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} diff --git a/apps/studio-review/app/page.test.tsx b/apps/studio-review/app/page.test.tsx deleted file mode 100644 index 72c73c0a..00000000 --- a/apps/studio-review/app/page.test.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { renderToStaticMarkup } from "react-dom/server"; - -import HomePage from "./page"; - -test("review home page links to scenario-based Studio routes", () => { - const markup = renderToStaticMarkup(); - - assert.match(markup, /\/review\/editor\/admin/); - assert.match(markup, /\/review\/owner\/admin/); - assert.match(markup, /Studio Review/); -}); diff --git a/apps/studio-review/app/page.tsx b/apps/studio-review/app/page.tsx deleted file mode 100644 index da1595b7..00000000 --- a/apps/studio-review/app/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import Link from "next/link"; - -const reviewScenarios = [ - { - id: "editor", - title: "Editor Document Review", - description: - "Standard editor permissions with the document route loaded for visual review.", - href: "/review/editor/admin/content/post/11111111-1111-4111-8111-111111111111", - }, - { - id: "owner", - title: "Owner Navigation Review", - description: - "Full management capabilities with schema and settings surfaces visible.", - href: "/review/owner/admin", - }, - { - id: "viewer", - title: "Viewer Access Review", - description: - "Read-only shell state for validating restricted navigation and controls.", - href: "/review/viewer/admin", - }, -] as const; - -export default function HomePage() { - return ( -
-

Studio Review

-

Private deterministic preview routes for Studio PR review.

-
    - {reviewScenarios.map((scenario) => ( -
  • - {scenario.title} -

    {scenario.description}

    -
  • - ))} -
-
- ); -} diff --git a/apps/studio-review/app/review-api/[scenario]/api/v1/actions/[id]/route.ts b/apps/studio-review/app/review-api/[scenario]/api/v1/actions/[id]/route.ts deleted file mode 100644 index 506d40d6..00000000 --- a/apps/studio-review/app/review-api/[scenario]/api/v1/actions/[id]/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { getReviewAction } from "../../../../../../../review/actions"; - -function createErrorEnvelope( - code: string, - message: string, - statusCode: number, -) { - return { - status: "error" as const, - code, - message, - statusCode, - timestamp: new Date().toISOString(), - }; -} - -export async function GET( - _request: Request, - context: { params: Promise<{ scenario: string; id: string }> }, -) { - const { scenario, id } = await context.params; - const action = getReviewAction(scenario, id); - - if (!action) { - return Response.json( - createErrorEnvelope("ACTION_NOT_FOUND", "Review action not found.", 404), - { - status: 404, - }, - ); - } - - return Response.json(action); -} diff --git a/apps/studio-review/app/review-api/[scenario]/api/v1/actions/route.ts b/apps/studio-review/app/review-api/[scenario]/api/v1/actions/route.ts deleted file mode 100644 index 21e238fe..00000000 --- a/apps/studio-review/app/review-api/[scenario]/api/v1/actions/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { listReviewActions } from "../../../../../../review/actions"; - -export async function GET( - _request: Request, - context: { params: Promise<{ scenario: string }> }, -) { - const { scenario } = await context.params; - return Response.json(listReviewActions(scenario)); -} diff --git a/apps/studio-review/app/review-api/[scenario]/api/v1/auth/session/route.ts b/apps/studio-review/app/review-api/[scenario]/api/v1/auth/session/route.ts deleted file mode 100644 index 0b8394ab..00000000 --- a/apps/studio-review/app/review-api/[scenario]/api/v1/auth/session/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -export async function GET() { - return Response.json({ - data: { - session: { - id: "review-session-001", - userId: "review-user-001", - email: "reviewer@mdcms.local", - issuedAt: "2026-04-01T09:00:00.000Z", - expiresAt: "2099-01-01T00:00:00.000Z", - }, - csrfToken: "review-csrf-token", - }, - }); -} diff --git a/apps/studio-review/app/review-api/[scenario]/api/v1/content/[documentId]/publish/route.ts b/apps/studio-review/app/review-api/[scenario]/api/v1/content/[documentId]/publish/route.ts deleted file mode 100644 index 4b6f8b31..00000000 --- a/apps/studio-review/app/review-api/[scenario]/api/v1/content/[documentId]/publish/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { getReviewContentDocumentRecord } from "../../../../../../../../review/content-documents"; - -export async function POST( - _request: Request, - context: { params: Promise<{ scenario: string; documentId: string }> }, -) { - const { scenario, documentId } = await context.params; - const selected = getReviewContentDocumentRecord(scenario, documentId); - - if (!selected) { - const envelope = { - status: "error" as const, - code: "NOT_FOUND", - message: "Review document not found.", - statusCode: 404, - timestamp: new Date().toISOString(), - }; - - return Response.json(envelope, { - status: 404, - }); - } - - return Response.json({ - data: { - ...selected.document, - hasUnpublishedChanges: false, - version: selected.document.version + 1, - publishedVersion: selected.document.version + 1, - updatedAt: "2026-04-05T13:00:00.000Z", - }, - }); -} diff --git a/apps/studio-review/app/review-api/[scenario]/api/v1/content/[documentId]/route.ts b/apps/studio-review/app/review-api/[scenario]/api/v1/content/[documentId]/route.ts deleted file mode 100644 index d4bd0189..00000000 --- a/apps/studio-review/app/review-api/[scenario]/api/v1/content/[documentId]/route.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { getReviewContentDocumentRecord } from "../../../../../../../review/content-documents"; - -function notFoundResponse() { - const envelope = { - status: "error" as const, - code: "NOT_FOUND", - message: "Review document not found.", - statusCode: 404, - timestamp: new Date().toISOString(), - }; - - return Response.json(envelope, { - status: 404, - }); -} - -export async function GET( - _request: Request, - context: { params: Promise<{ scenario: string; documentId: string }> }, -) { - const { scenario, documentId } = await context.params; - const selected = getReviewContentDocumentRecord(scenario, documentId); - - if (!selected) { - return notFoundResponse(); - } - - return Response.json({ - data: selected.document, - }); -} - -export async function PUT( - request: Request, - context: { params: Promise<{ scenario: string; documentId: string }> }, -) { - const { scenario, documentId } = await context.params; - const selected = getReviewContentDocumentRecord(scenario, documentId); - - if (!selected) { - return notFoundResponse(); - } - - const payload = (await request.json()) as { - body?: string; - frontmatter?: Record; - }; - - return Response.json({ - data: { - ...selected.document, - body: payload.body ?? selected.document.body, - frontmatter: payload.frontmatter ?? selected.document.frontmatter, - updatedAt: "2026-04-05T12:30:00.000Z", - hasUnpublishedChanges: true, - draftRevision: selected.document.draftRevision + 1, - }, - }); -} diff --git a/apps/studio-review/app/review-api/[scenario]/api/v1/content/[documentId]/versions/[version]/route.ts b/apps/studio-review/app/review-api/[scenario]/api/v1/content/[documentId]/versions/[version]/route.ts deleted file mode 100644 index 227c3078..00000000 --- a/apps/studio-review/app/review-api/[scenario]/api/v1/content/[documentId]/versions/[version]/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { getReviewContentDocumentRecord } from "../../../../../../../../../review/content-documents"; - -export async function GET( - _request: Request, - context: { - params: Promise<{ scenario: string; documentId: string; version: string }>; - }, -) { - const { scenario, documentId, version } = await context.params; - const selected = getReviewContentDocumentRecord(scenario, documentId); - - if (!selected) { - const envelope = { - status: "error" as const, - code: "NOT_FOUND", - message: "Review document not found.", - statusCode: 404, - timestamp: new Date().toISOString(), - }; - - return Response.json(envelope, { - status: 404, - }); - } - - const versionNumber = Number.parseInt(version, 10); - const match = selected.versions.find( - (entry) => entry.version === versionNumber, - ); - - if (!match) { - const envelope = { - status: "error" as const, - code: "NOT_FOUND", - message: "Review document version not found.", - statusCode: 404, - timestamp: new Date().toISOString(), - }; - - return Response.json(envelope, { - status: 404, - }); - } - - return Response.json({ - data: match, - }); -} diff --git a/apps/studio-review/app/review-api/[scenario]/api/v1/content/[documentId]/versions/route.ts b/apps/studio-review/app/review-api/[scenario]/api/v1/content/[documentId]/versions/route.ts deleted file mode 100644 index a04f1011..00000000 --- a/apps/studio-review/app/review-api/[scenario]/api/v1/content/[documentId]/versions/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { getReviewContentDocumentRecord } from "../../../../../../../../review/content-documents"; - -export async function GET( - _request: Request, - context: { params: Promise<{ scenario: string; documentId: string }> }, -) { - const { scenario, documentId } = await context.params; - const selected = getReviewContentDocumentRecord(scenario, documentId); - - if (!selected) { - const envelope = { - status: "error" as const, - code: "NOT_FOUND", - message: "Review document not found.", - statusCode: 404, - timestamp: new Date().toISOString(), - }; - - return Response.json(envelope, { - status: 404, - }); - } - - return Response.json({ - data: selected.versions.map((version) => ({ - documentId: version.documentId, - translationGroupId: version.translationGroupId, - project: version.project, - environment: version.environment, - version: version.version, - path: version.path, - type: version.type, - locale: version.locale, - format: version.format, - publishedAt: version.publishedAt, - publishedBy: version.publishedBy, - ...(version.changeSummary - ? { changeSummary: version.changeSummary } - : {}), - })), - pagination: { - total: selected.versions.length, - limit: selected.versions.length, - offset: 0, - hasMore: false, - }, - }); -} diff --git a/apps/studio-review/app/review-api/[scenario]/api/v1/content/route.ts b/apps/studio-review/app/review-api/[scenario]/api/v1/content/route.ts deleted file mode 100644 index 24565d6a..00000000 --- a/apps/studio-review/app/review-api/[scenario]/api/v1/content/route.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { listReviewContentDocuments } from "../../../../../../review/content-documents"; - -export async function GET( - request: Request, - context: { params: Promise<{ scenario: string }> }, -) { - const { scenario } = await context.params; - const url = new URL(request.url); - const typeFilter = url.searchParams.get("type"); - const publishedFilter = url.searchParams.get("published"); - const sortField = url.searchParams.get("sort"); - const sortOrder = url.searchParams.get("order") ?? "asc"; - const limit = Math.min( - Math.max(parseInt(url.searchParams.get("limit") ?? "20", 10) || 20, 1), - 100, - ); - const offset = Math.max( - parseInt(url.searchParams.get("offset") ?? "0", 10) || 0, - 0, - ); - - let docs = listReviewContentDocuments(scenario); - - if (typeFilter) { - docs = docs.filter((d) => d.type === typeFilter); - } - - if (publishedFilter === "true") { - docs = docs.filter((d) => d.publishedVersion !== null); - } else if (publishedFilter === "false") { - docs = docs.filter((d) => d.publishedVersion === null); - } - - if (sortField === "updatedAt") { - docs = [...docs].sort((a, b) => { - const cmp = - new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(); - return sortOrder === "desc" ? -cmp : cmp; - }); - } - - const total = docs.length; - const paged = docs.slice(offset, offset + limit); - - return Response.json({ - data: paged, - pagination: { - total, - limit, - offset, - hasMore: offset + limit < total, - }, - }); -} diff --git a/apps/studio-review/app/review-api/[scenario]/api/v1/environments/[id]/route.ts b/apps/studio-review/app/review-api/[scenario]/api/v1/environments/[id]/route.ts deleted file mode 100644 index e710cdbc..00000000 --- a/apps/studio-review/app/review-api/[scenario]/api/v1/environments/[id]/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { RuntimeError } from "@mdcms/shared"; - -import { deleteReviewEnvironment } from "../../../../../../../review/environments"; - -function toErrorResponse(error: unknown): Response { - if (error instanceof RuntimeError) { - return Response.json( - { - status: "error", - code: error.code, - message: error.message, - statusCode: error.statusCode, - timestamp: new Date().toISOString(), - }, - { - status: error.statusCode, - }, - ); - } - - return Response.json( - { - status: "error", - code: "INTERNAL_ERROR", - message: "Review environment route failed.", - statusCode: 500, - timestamp: new Date().toISOString(), - }, - { - status: 500, - }, - ); -} - -export async function DELETE( - _request: Request, - context: { params: Promise<{ scenario: string; id: string }> }, -) { - try { - const { scenario, id } = await context.params; - - return Response.json({ - data: deleteReviewEnvironment(scenario, id), - }); - } catch (error) { - return toErrorResponse(error); - } -} diff --git a/apps/studio-review/app/review-api/[scenario]/api/v1/environments/route.test.ts b/apps/studio-review/app/review-api/[scenario]/api/v1/environments/route.test.ts deleted file mode 100644 index 686972c0..00000000 --- a/apps/studio-review/app/review-api/[scenario]/api/v1/environments/route.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import assert from "node:assert/strict"; - -import { test } from "bun:test"; - -import { GET, POST } from "./route"; - -test("environment review GET returns environments with readiness metadata", async () => { - const response = await GET( - new Request("http://localhost/review-api/owner/api/v1/environments"), - { - params: Promise.resolve({ - scenario: "owner", - }), - }, - ); - - const payload = (await response.json()) as { - data: Array<{ name: string }>; - meta: { definitionsStatus: string }; - }; - - assert.equal(response.status, 200); - assert.equal(payload.meta.definitionsStatus, "ready"); - assert.deepEqual( - payload.data.map((environment) => environment.name), - ["production", "staging"], - ); -}); - -test("environment review POST returns 400 for malformed json bodies", async () => { - const response = await POST( - new Request("http://localhost/review-api/owner/api/v1/environments", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: "{", - }), - { - params: Promise.resolve({ - scenario: "owner", - }), - }, - ); - - const payload = (await response.json()) as { - code?: string; - statusCode?: number; - }; - - assert.equal(response.status, 400); - assert.equal(payload.code, "INVALID_INPUT"); - assert.equal(payload.statusCode, 400); -}); diff --git a/apps/studio-review/app/review-api/[scenario]/api/v1/environments/route.ts b/apps/studio-review/app/review-api/[scenario]/api/v1/environments/route.ts deleted file mode 100644 index a8f012d5..00000000 --- a/apps/studio-review/app/review-api/[scenario]/api/v1/environments/route.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { RuntimeError } from "@mdcms/shared"; - -import { - createReviewEnvironment, - listReviewEnvironments, -} from "../../../../../../review/environments"; - -function toErrorResponse(error: unknown): Response { - if (error instanceof RuntimeError) { - return Response.json( - { - status: "error", - code: error.code, - message: error.message, - statusCode: error.statusCode, - timestamp: new Date().toISOString(), - }, - { - status: error.statusCode, - }, - ); - } - - return Response.json( - { - status: "error", - code: "INTERNAL_ERROR", - message: "Review environment route failed.", - statusCode: 500, - timestamp: new Date().toISOString(), - }, - { - status: 500, - }, - ); -} - -async function parseCreateEnvironmentRequest( - request: Request, -): Promise<{ name?: string }> { - try { - return (await request.json()) as { name?: string }; - } catch (error) { - if ( - error instanceof SyntaxError || - (error as Error)?.name === "SyntaxError" - ) { - throw new RuntimeError({ - code: "INVALID_INPUT", - message: "Request body must be valid JSON.", - statusCode: 400, - }); - } - - throw error; - } -} - -export async function GET( - _request: Request, - context: { params: Promise<{ scenario: string }> }, -) { - try { - const { scenario } = await context.params; - - return Response.json(listReviewEnvironments(scenario)); - } catch (error) { - return toErrorResponse(error); - } -} - -export async function POST( - request: Request, - context: { params: Promise<{ scenario: string }> }, -) { - try { - const { scenario } = await context.params; - const payload = await parseCreateEnvironmentRequest(request); - - return Response.json({ - data: createReviewEnvironment(scenario, payload), - }); - } catch (error) { - return toErrorResponse(error); - } -} diff --git a/apps/studio-review/app/review-api/[scenario]/api/v1/me/capabilities/route.ts b/apps/studio-review/app/review-api/[scenario]/api/v1/me/capabilities/route.ts deleted file mode 100644 index a32d07f6..00000000 --- a/apps/studio-review/app/review-api/[scenario]/api/v1/me/capabilities/route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { getReviewScenario } from "../../../../../../../review/scenarios"; - -export async function GET( - _request: Request, - context: { params: Promise<{ scenario: string }> }, -) { - const { scenario } = await context.params; - const selected = getReviewScenario(scenario); - - return Response.json({ - data: { - project: selected.document.project, - environment: selected.document.environment, - capabilities: selected.capabilities, - }, - }); -} diff --git a/apps/studio-review/app/review-api/[scenario]/api/v1/schema/route.ts b/apps/studio-review/app/review-api/[scenario]/api/v1/schema/route.ts deleted file mode 100644 index 2a313fe4..00000000 --- a/apps/studio-review/app/review-api/[scenario]/api/v1/schema/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { getReviewScenario } from "../../../../../../review/scenarios"; - -function createErrorEnvelope( - code: string, - message: string, - statusCode: number, -) { - return { - status: "error" as const, - code, - message, - statusCode, - timestamp: new Date().toISOString(), - }; -} - -export async function GET( - _request: Request, - context: { params: Promise<{ scenario: string }> }, -) { - const { scenario } = await context.params; - const selected = getReviewScenario(scenario); - - if (selected.schema.mode === "error") { - const envelope = createErrorEnvelope( - "SCHEMA_ROUTE_REQUEST_FAILED", - "Schema registry is unavailable for this review scenario.", - 503, - ); - - return Response.json(envelope, { - status: 503, - }); - } - - return Response.json({ - data: selected.schema.entries, - }); -} diff --git a/apps/studio-review/app/review-api/[scenario]/api/v1/studio/assets/[buildId]/[file]/route.ts b/apps/studio-review/app/review-api/[scenario]/api/v1/studio/assets/[buildId]/[file]/route.ts deleted file mode 100644 index b6f994c3..00000000 --- a/apps/studio-review/app/review-api/[scenario]/api/v1/studio/assets/[buildId]/[file]/route.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { readReviewRuntimeAsset } from "../../../../../../../../../review/runtime-artifacts"; - -function createErrorEnvelope( - code: string, - message: string, - statusCode: number, -) { - return { - status: "error" as const, - code, - message, - statusCode, - timestamp: new Date().toISOString(), - }; -} - -function getContentType(fileName: string): string { - if (fileName.endsWith(".css")) { - return "text/css; charset=utf-8"; - } - - return "text/javascript; charset=utf-8"; -} - -export async function GET( - _request: Request, - context: { params: Promise<{ buildId: string; file: string }> }, -) { - const { buildId, file } = await context.params; - - try { - const body = await readReviewRuntimeAsset({ - buildId, - fileName: file, - }); - const payload = Buffer.from(body); - - return new Response(payload, { - status: 200, - headers: { - "content-type": getContentType(file), - "cache-control": "public, max-age=300", - }, - }); - } catch (error) { - const message = - error instanceof Error ? error.message : "Runtime asset not found."; - const envelope = createErrorEnvelope( - "STUDIO_RUNTIME_ASSET_NOT_FOUND", - message, - 404, - ); - - return Response.json(envelope, { - status: 404, - }); - } -} diff --git a/apps/studio-review/app/review-api/[scenario]/api/v1/studio/bootstrap/route.ts b/apps/studio-review/app/review-api/[scenario]/api/v1/studio/bootstrap/route.ts deleted file mode 100644 index 0b4e9679..00000000 --- a/apps/studio-review/app/review-api/[scenario]/api/v1/studio/bootstrap/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - readReviewRuntimeBootstrapManifest, - scopeReviewRuntimeManifestToScenario, -} from "../../../../../../../review/runtime-artifacts"; - -function createErrorEnvelope( - code: string, - message: string, - statusCode: number, -) { - return { - status: "error" as const, - code, - message, - statusCode, - timestamp: new Date().toISOString(), - }; -} - -export async function GET( - _request: Request, - context: { params: Promise<{ scenario: string }> }, -) { - const { scenario } = await context.params; - - try { - const manifest = scopeReviewRuntimeManifestToScenario( - await readReviewRuntimeBootstrapManifest(), - scenario, - ); - - return Response.json({ - data: { - status: "ready", - source: "active", - manifest, - }, - }); - } catch (error) { - const message = - error instanceof Error ? error.message : "Review runtime is unavailable."; - const envelope = createErrorEnvelope( - "STUDIO_RUNTIME_UNAVAILABLE", - message, - 500, - ); - - return Response.json(envelope, { - status: 500, - }); - } -} diff --git a/apps/studio-review/app/review/[scenario]/admin/[[...path]]/page.test.tsx b/apps/studio-review/app/review/[scenario]/admin/[[...path]]/page.test.tsx deleted file mode 100644 index d108e1bd..00000000 --- a/apps/studio-review/app/review/[scenario]/admin/[[...path]]/page.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { resolve } from "node:path"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { AdminStudioClient } from "../admin-studio-client"; -import { resolveStudioReviewAppRoot } from "../resolve-studio-review-app-root"; -import { - createReviewScenarioServerUrl, - resolveReviewRequestOrigin, -} from "../studio-config"; -import AdminReviewPage from "./page"; - -test("resolveStudioReviewAppRoot is stable from the workspace root", () => { - assert.equal( - resolveStudioReviewAppRoot("/workspace"), - resolve("/workspace", "apps/studio-review"), - ); -}); - -test("resolveStudioReviewAppRoot does not duplicate the app path", () => { - assert.equal( - resolveStudioReviewAppRoot("/workspace/apps/studio-review"), - "/workspace/apps/studio-review", - ); -}); - -test("review admin page prepares scenario-scoped Studio config", async () => { - const element = await AdminReviewPage({ - params: Promise.resolve({ - scenario: "editor", - path: ["content", "post", "11111111-1111-4111-8111-111111111111"], - }), - }); - - assert.equal(element.type, AdminStudioClient); - assert.equal(element.props.scenario, "editor"); - assert.equal(element.props.basePath, "/review/editor/admin"); - assert.ok(Array.isArray(element.props.preparedComponents)); - assert.equal(element.props.preparedComponents.length, 3); -}); - -test("resolveReviewRequestOrigin prefers forwarded Vercel headers", () => { - const requestHeaders = new Headers({ - "x-forwarded-proto": "https", - "x-forwarded-host": "mdcms-studio-review.vercel.app", - host: "ignored.example.com", - }); - - assert.equal( - resolveReviewRequestOrigin(requestHeaders), - "https://mdcms-studio-review.vercel.app", - ); -}); - -test("createReviewScenarioServerUrl scopes review api routes to a provided origin", () => { - assert.equal( - createReviewScenarioServerUrl({ - scenario: "editor", - origin: "https://mdcms-studio-review.vercel.app", - }), - "https://mdcms-studio-review.vercel.app/review-api/editor", - ); -}); - -test("AdminStudioClient renders the provided server url into the Studio shell", () => { - const markup = renderToStaticMarkup( - , - ); - - assert.match( - markup, - /data-mdcms-server-url="https:\/\/mdcms-studio-review\.vercel\.app\/review-api\/editor"/, - ); - assert.doesNotMatch(markup, /127\.0\.0\.1:4273/); -}); diff --git a/apps/studio-review/app/review/[scenario]/admin/[[...path]]/page.tsx b/apps/studio-review/app/review/[scenario]/admin/[[...path]]/page.tsx deleted file mode 100644 index d8581022..00000000 --- a/apps/studio-review/app/review/[scenario]/admin/[[...path]]/page.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { resolve } from "node:path"; - -import { prepareStudioConfig } from "@mdcms/studio/runtime"; -import { headers } from "next/headers"; - -import config from "../../../../../mdcms.config"; -import { studioReviewServerUrl } from "../../../../../lib/review-studio-config"; -import { AdminStudioClient } from "../admin-studio-client"; -import { resolveStudioReviewAppRoot } from "../resolve-studio-review-app-root"; -import { - createReviewScenarioServerUrl, - extractPreparedStudioComponentMetadata, - resolveReviewRequestOrigin, -} from "../studio-config"; - -async function resolveCurrentRequestOrigin(): Promise { - try { - return resolveReviewRequestOrigin(await headers()); - } catch { - return studioReviewServerUrl; - } -} - -export default async function AdminReviewPage(props: { - params: Promise<{ scenario: string; path?: string[] }>; -}) { - const { scenario } = await props.params; - const appRoot = resolveStudioReviewAppRoot(); - const preparedConfig = await prepareStudioConfig(config, { - cwd: appRoot, - tsconfigPath: resolve(appRoot, "tsconfig.json"), - }); - const origin = await resolveCurrentRequestOrigin(); - - return ( - - ); -} diff --git a/apps/studio-review/app/review/[scenario]/admin/admin-studio-client.tsx b/apps/studio-review/app/review/[scenario]/admin/admin-studio-client.tsx deleted file mode 100644 index 46a8b575..00000000 --- a/apps/studio-review/app/review/[scenario]/admin/admin-studio-client.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client"; - -import { Studio, type MdcmsConfig } from "@mdcms/studio"; - -import { - createClientStudioConfig, - type PreparedStudioComponentMetadata, -} from "./studio-config"; - -export function AdminStudioClient(props: { - scenario: string; - basePath: string; - serverUrl: string; - preparedComponents: PreparedStudioComponentMetadata[]; - documentRouteMetadata?: MdcmsConfig["_documentRouteMetadata"]; -}) { - const config = createClientStudioConfig({ - scenario: props.scenario, - serverUrl: props.serverUrl, - preparedComponents: props.preparedComponents, - documentRouteMetadata: props.documentRouteMetadata ?? undefined, - }); - - return ( - - ); -} diff --git a/apps/studio-review/app/review/[scenario]/admin/resolve-studio-review-app-root.ts b/apps/studio-review/app/review/[scenario]/admin/resolve-studio-review-app-root.ts deleted file mode 100644 index 5330a433..00000000 --- a/apps/studio-review/app/review/[scenario]/admin/resolve-studio-review-app-root.ts +++ /dev/null @@ -1 +0,0 @@ -export { resolveStudioReviewAppRoot } from "../../../../review/app-root"; diff --git a/apps/studio-review/app/review/[scenario]/admin/studio-config.ts b/apps/studio-review/app/review/[scenario]/admin/studio-config.ts deleted file mode 100644 index 85e69a51..00000000 --- a/apps/studio-review/app/review/[scenario]/admin/studio-config.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { MdcmsConfig } from "@mdcms/studio"; -import { prepareStudioConfig } from "@mdcms/studio/runtime"; - -import { - studioReviewEnvironment, - studioReviewMdxComponents, - studioReviewProject, - studioReviewServerUrl, -} from "../../../../lib/review-studio-config"; - -type PreparedStudioConfig = Awaited>; -type PreparedStudioComponent = NonNullable[number]; -type PreparedDocumentRouteMetadata = NonNullable< - MdcmsConfig["_documentRouteMetadata"] ->; - -export type PreparedStudioComponentMetadata = Pick< - PreparedStudioComponent, - "name" | "extractedProps" ->; - -export function extractPreparedStudioComponentMetadata( - config: PreparedStudioConfig, -): PreparedStudioComponentMetadata[] { - return ( - config.components?.map((component) => ({ - name: component.name, - ...(component.extractedProps !== undefined - ? { extractedProps: component.extractedProps } - : {}), - })) ?? [] - ); -} - -type HeaderReader = Pick; - -function readRequestHeader( - headers: HeaderReader, - name: string, -): string | undefined { - const value = headers.get(name); - - if (value === null) { - return undefined; - } - - const firstValue = value.split(",")[0]?.trim(); - - return firstValue && firstValue.length > 0 ? firstValue : undefined; -} - -export function resolveReviewRequestOrigin(headers: HeaderReader): string { - const proto = readRequestHeader(headers, "x-forwarded-proto") ?? "http"; - const host = - readRequestHeader(headers, "x-forwarded-host") ?? - readRequestHeader(headers, "host"); - - if (!host) { - return studioReviewServerUrl; - } - - return `${proto}://${host}`; -} - -export function createReviewScenarioServerUrl(input: { - scenario: string; - origin: string; -}): string { - return new URL(`/review-api/${input.scenario}`, input.origin).href; -} - -export function createClientStudioConfig(input: { - scenario: string; - serverUrl: string; - preparedComponents: PreparedStudioComponentMetadata[]; - documentRouteMetadata?: PreparedDocumentRouteMetadata; -}): MdcmsConfig { - const extractedPropsByName = new Map( - input.preparedComponents.map((component) => [ - component.name, - component.extractedProps, - ]), - ); - const clientComponents = [ - ...studioReviewMdxComponents, - ] as PreparedStudioComponent[]; - - return { - project: studioReviewProject, - environment: studioReviewEnvironment, - serverUrl: input.serverUrl, - ...(input.documentRouteMetadata - ? { _documentRouteMetadata: input.documentRouteMetadata } - : {}), - components: clientComponents.map((component) => { - const extractedProps = extractedPropsByName.get(component.name); - - return extractedProps === undefined - ? component - : { - ...component, - extractedProps, - }; - }), - }; -} diff --git a/apps/studio-review/components/mdx/Callout.tsx b/apps/studio-review/components/mdx/Callout.tsx deleted file mode 100644 index 606c2cf4..00000000 --- a/apps/studio-review/components/mdx/Callout.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { ReactNode } from "react"; - -type CalloutProps = { - tone: "info" | "warning" | "success"; - title?: string; - children?: ReactNode; -}; - -const TONE_STYLES: Record< - CalloutProps["tone"], - { border: string; background: string; badge: string; title: string } -> = { - info: { - border: "#2563eb", - background: - "linear-gradient(180deg, rgba(239, 246, 255, 0.96), rgba(219, 234, 254, 0.92))", - badge: "#dbeafe", - title: "#1d4ed8", - }, - warning: { - border: "#d97706", - background: - "linear-gradient(180deg, rgba(255, 251, 235, 0.98), rgba(254, 243, 199, 0.92))", - badge: "#fef3c7", - title: "#b45309", - }, - success: { - border: "#059669", - background: - "linear-gradient(180deg, rgba(236, 253, 245, 0.98), rgba(209, 250, 229, 0.92))", - badge: "#d1fae5", - title: "#047857", - }, -}; - -export function Callout({ tone, title, children }: CalloutProps) { - const normalizedTone = tone ?? "info"; - const style = TONE_STYLES[normalizedTone] ?? TONE_STYLES.info; - - return ( - - ); -} diff --git a/apps/studio-review/components/mdx/Chart.tsx b/apps/studio-review/components/mdx/Chart.tsx deleted file mode 100644 index 7c0ee320..00000000 --- a/apps/studio-review/components/mdx/Chart.tsx +++ /dev/null @@ -1,125 +0,0 @@ -type ChartProps = { - data: number[]; - type: "bar" | "line" | "pie"; - title?: string; - color?: string; -}; - -const DEFAULT_COLOR = "#2563eb"; - -export function Chart({ data, type, title, color }: ChartProps) { - const values = data?.length ? data : [24, 48, 72]; - const peak = Math.max(...values, 1); - const accent = color?.trim() || DEFAULT_COLOR; - const kind = type ?? "bar"; - - return ( -
-
-
-

- Embedded chart -

-

- {title?.trim() || "Quarterly momentum"} -

-
- - {kind} - -
- -
- {values.map((value, index) => { - const barHeight = `${Math.max((value / peak) * 100, 12)}%`; - - return ( -
-
- - {value} - -
- ); - })} -
-
- ); -} diff --git a/apps/studio-review/components/mdx/PricingTable.editor.tsx b/apps/studio-review/components/mdx/PricingTable.editor.tsx deleted file mode 100644 index ad1933ff..00000000 --- a/apps/studio-review/components/mdx/PricingTable.editor.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import type { ChangeEvent } from "react"; - -import type { PropsEditorComponent } from "@mdcms/studio"; - -import type { PricingTableProps } from "./PricingTable"; - -type PricingTier = NonNullable[number]; - -const controlLabelStyles = { - display: "grid", - gap: "6px", - fontSize: "12px", - fontWeight: 600, - color: "#334155", -} as const; - -const controlStyles = { - width: "100%", - borderRadius: "10px", - border: "1px solid #cbd5e1", - padding: "10px 12px", - fontSize: "14px", - color: "#0f172a", - background: "#fff", -} as const; - -const buttonStyles = { - borderRadius: "999px", - border: "1px solid #cbd5e1", - background: "#f8fafc", - color: "#0f172a", - padding: "8px 12px", - fontSize: "12px", - fontWeight: 600, -} as const; - -export function getPricingTableEditorTiers( - value: Partial, -): PricingTier[] { - if (value.tiers === undefined) { - return [{ name: "", price: "", description: "" }]; - } - - return [...value.tiers]; -} - -const PricingTableEditor: PropsEditorComponent = ({ - value, - onChange, - readOnly, -}) => { - const tiers = getPricingTableEditorTiers(value); - - function handleTitleChange(event: ChangeEvent) { - onChange({ - ...value, - title: event.currentTarget.value, - }); - } - - function updateTier( - index: number, - key: keyof PricingTier, - nextValue: string, - ) { - const nextTiers = tiers.map((tier, tierIndex) => - tierIndex === index ? { ...tier, [key]: nextValue } : tier, - ); - - onChange({ - ...value, - tiers: nextTiers, - }); - } - - function addTier() { - onChange({ - ...value, - tiers: [ - ...tiers, - { - name: "", - price: "", - description: "", - }, - ], - }); - } - - function removeTier(index: number) { - const nextTiers = tiers.filter((_, tierIndex) => tierIndex !== index); - - onChange({ - ...value, - tiers: nextTiers, - }); - } - - return ( -
- - -
- {tiers.map((tier, index) => ( -
- - Tier {index + 1} - - - - - - -