diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..019a7476 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "doxa-cms": { + "type": "http", + "url": "http://localhost:3033/mcp" + } + } +} diff --git a/app/utils/tiptapDiv.ts b/app/utils/tiptapDiv.ts index 777415dc..311cd400 100644 --- a/app/utils/tiptapDiv.ts +++ b/app/utils/tiptapDiv.ts @@ -10,7 +10,7 @@ import { Node } from '@tiptap/core' -const PRESERVED_DATA_ATTRS = [ +export const PRESERVED_DATA_ATTRS = [ 'data-width', 'data-width-sm', 'data-width-md', diff --git a/bun.lock b/bun.lock index 3d01a82f..c297ec7e 100644 --- a/bun.lock +++ b/bun.lock @@ -30,15 +30,18 @@ "bcrypt": "^6.0.0", "fuse.js": "^7.3.0", "intl-tel-input": "^26.7.5", + "jsdom": "^29.1.0", "jsonwebtoken": "^9.0.2", "kysely": "^0.28.0", "kysely-postgres-js": "^2.0.0", "mapbox-gl": "^3.22.0", + "markdown-it": "^14.1.1", "nodemailer": "^7.0.6", "nodemailer-mailgun-transport": "^2.1.5", "nuxt": "^4.4.2", "postgres": "^3.4.7", "tailwindcss": "^4.2.2", + "turndown": "^7.2.4", "vuedraggable": "^4.1.0", "zod": "^3.23", "zod-to-json-schema": "^3.23", @@ -48,10 +51,11 @@ "@types/bcrypt": "^6.0.0", "@types/jsdom": "^28.0.1", "@types/jsonwebtoken": "^9.0.10", + "@types/markdown-it": "^14.1.2", "@types/nodemailer": "^7.0.1", "@types/nodemailer-mailgun-transport": "^1.4.6", + "@types/turndown": "^5.0.6", "eslint": "^10.2.0", - "jsdom": "^29.0.2", "sass": "^1.99.0", "typescript": "^6.0.2", "vue-tsc": "^3.2.6", @@ -413,6 +417,8 @@ "@mapbox/vector-tile": ["@mapbox/vector-tile@2.0.4", "", { "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^4.0.1" } }, "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg=="], + "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], + "@miyaneee/rollup-plugin-json5": ["@miyaneee/rollup-plugin-json5@1.2.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0", "json5": "^2.2.3" }, "peerDependencies": { "rollup": "^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0" } }, "sha512-JjTIaXZp9WzhUHpElrqPnl1AzBi/rvRs065F71+aTmlqvTMVkdbjZ8vfFl4nRlgJy+TPBw69ZK4pwFdmOAt4aA=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], @@ -971,6 +977,12 @@ "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], + "@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="], + + "@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="], + + "@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], @@ -987,6 +999,8 @@ "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + "@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="], + "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], @@ -1409,7 +1423,7 @@ "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], - "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], @@ -1723,7 +1737,7 @@ "jsdoc-type-pratt-parser": ["jsdoc-type-pratt-parser@7.2.0", "", {}, "sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw=="], - "jsdom": ["jsdom@29.0.2", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.5", "@asamuzakjp/dom-selector": "^7.0.6", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w=="], + "jsdom": ["jsdom@29.1.0", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -1795,6 +1809,8 @@ "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + "linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="], "listhen": ["listhen@1.9.1", "", { "dependencies": { "@parcel/watcher": "^2.5.6", "@parcel/watcher-wasm": "^2.5.6", "citty": "^0.2.2", "consola": "^3.4.2", "crossws": ">=0.2.0 <0.5.0", "defu": "^6.1.6", "get-port-please": "^3.2.0", "h3": "^1.15.11", "http-shutdown": "^1.2.2", "jiti": "^2.6.1", "mlly": "^1.8.2", "node-forge": "^1.4.0", "pathe": "^2.0.3", "std-env": "^4.0.0", "tinyclip": "^0.1.12", "ufo": "^1.6.3", "untun": "^0.1.3", "uqr": "^0.1.2" }, "bin": { "listen": "bin/listhen.mjs", "listhen": "bin/listhen.mjs" } }, "sha512-4EhoyVcXEpNlY5HJRSQpH7Rba94M8N2JmI62ePjl0lrJKXSfG0F1FAgHGxBoz/T3pe41sUEwkIRRIcaUL0/Ofw=="], @@ -1843,6 +1859,8 @@ "mapbox-gl": ["mapbox-gl@3.22.0", "", { "dependencies": { "@mapbox/mapbox-gl-supported": "^3.0.0", "@mapbox/point-geometry": "^1.1.0", "@mapbox/tiny-sdf": "^2.0.6", "@mapbox/unitbezier": "^0.0.1", "@mapbox/vector-tile": "^2.0.4", "@types/geojson": "^7946.0.16", "@types/geojson-vt": "^3.2.5", "@types/pbf": "^3.0.5", "@types/supercluster": "^7.1.3", "cheap-ruler": "^4.0.0", "csscolorparser": "~1.0.3", "earcut": "^3.0.1", "geojson-vt": "^4.0.2", "gl-matrix": "^3.4.4", "grid-index": "^1.1.0", "kdbush": "^4.0.2", "martinez-polygon-clipping": "^0.8.1", "murmurhash-js": "^1.0.0", "pbf": "^4.0.1", "potpack": "^2.0.0", "quickselect": "^3.0.0", "supercluster": "^8.0.1", "tinyqueue": "^3.0.0" } }, "sha512-ZIpF+oAMcQoDlvABmiRkHoydyBR9zI6CyDeVRa2/iyua0/B2+rPuIzoCV/CgN14P5F0HVk53GIZw220WSqJPyA=="], + "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], + "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], "martinez-polygon-clipping": ["martinez-polygon-clipping@0.8.1", "", { "dependencies": { "robust-predicates": "^2.0.4", "splaytree": "^0.1.4", "tinyqueue": "3.0.0" } }, "sha512-9PLLMzMPI6ihHox4Ns6LpVBLpRc7sbhULybZ/wyaY8sY3ECNe2+hxm1hA2/9bEEpRrdpjoeduBuZLg2aq1cSIQ=="], @@ -1851,6 +1869,8 @@ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], @@ -2123,6 +2143,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], @@ -2357,6 +2379,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "turndown": ["turndown@7.2.4", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], @@ -2367,6 +2391,8 @@ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], "ultrahtml": ["ultrahtml@1.6.0", "", {}, "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw=="], @@ -2645,8 +2671,6 @@ "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], - "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "eslint/find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], @@ -2705,6 +2729,8 @@ "nuxt/unimport": ["unimport@6.1.0", "", { "dependencies": { "acorn": "^8.16.0", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.2", "pathe": "^2.0.3", "picomatch": "^4.0.4", "pkg-types": "^2.3.0", "scule": "^1.3.0", "strip-literal": "^3.1.0", "tinyglobby": "^0.2.16", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1" } }, "sha512-ocgNKyiqj7Hw7oHt7A7D3za3fq28eShe1EloL6hsoQgn7CF51Y4CqFT9ISG3rEy0JpA8CCz/sY5h5OovOn62VQ=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "rollup-plugin-visualizer/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], diff --git a/nuxt.config.ts b/nuxt.config.ts index f9042565..d3fea9ea 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -123,6 +123,27 @@ export default defineNuxtConfig({ runtimeConfig: { appName: process.env.APP_TITLE || 'My App', + mcpServerName: process.env.MCP_SERVER_NAME || 'doxa-cms', + mcpServerVersion: process.env.MCP_SERVER_VERSION || '1.0.0', + // ── MCP layer config (consumed by base-code/nuxt-blueprints/layers/mcp) ── + // These keys are read at runtime by the MCP layer. If you rename + // them or change shape the layer silently falls back to defaults + // (no rate limiting, no read-scope classification) — guard them + // when upgrading the layer. + // mcpReadScopes: tools whose scope is in this list are exempt + // from the per-token writes bucket. `pages.view` is the only + // read scope today. + // mcpRateLimits: per-token writes bucket is raised from the + // layer default (20/min) to 60/min — a "publish 9 locales of + // 5 pages" Claude flow lands at 45 calls. + mcpReadScopes: ['pages.view'], + mcpRateLimits: { + writesPerToken: { limit: 60, windowMs: 60_000 } + }, + // OAuth layer reads this when DCR registrations omit `scope`. + // Curated to the three CMS scopes; excludes admin/users/roles/ + // settings even though those exist in PERMISSIONS. + oauthDcrDefaultScopes: ['pages.view', 'pages.write', 'pages.publish'], databaseUrl: process.env.DATABASE_URL || '', jwtSecret: process.env.JWT_SECRET || '', mailgunApiKey: process.env.MAILGUN_API_KEY || '', diff --git a/package.json b/package.json index 0dd667c5..c402f1d6 100644 --- a/package.json +++ b/package.json @@ -36,15 +36,18 @@ "bcrypt": "^6.0.0", "fuse.js": "^7.3.0", "intl-tel-input": "^26.7.5", + "jsdom": "^29.1.0", "jsonwebtoken": "^9.0.2", "kysely": "^0.28.0", "kysely-postgres-js": "^2.0.0", "mapbox-gl": "^3.22.0", + "markdown-it": "^14.1.1", "nodemailer": "^7.0.6", "nodemailer-mailgun-transport": "^2.1.5", "nuxt": "^4.4.2", "postgres": "^3.4.7", "tailwindcss": "^4.2.2", + "turndown": "^7.2.4", "vuedraggable": "^4.1.0", "zod": "^3.23", "zod-to-json-schema": "^3.23" @@ -54,10 +57,11 @@ "@types/bcrypt": "^6.0.0", "@types/jsdom": "^28.0.1", "@types/jsonwebtoken": "^9.0.10", + "@types/markdown-it": "^14.1.2", "@types/nodemailer": "^7.0.1", "@types/nodemailer-mailgun-transport": "^1.4.6", + "@types/turndown": "^5.0.6", "eslint": "^10.2.0", - "jsdom": "^29.0.2", "sass": "^1.99.0", "typescript": "^6.0.2", "vue-tsc": "^3.2.6" diff --git a/server/api/admin/categories/[id].delete.ts b/server/api/admin/categories/[id].delete.ts index 003fb537..973ff20f 100644 --- a/server/api/admin/categories/[id].delete.ts +++ b/server/api/admin/categories/[id].delete.ts @@ -1,33 +1,43 @@ // Admin: delete a category. Blocked (409) if any pages still reference // it — the admin must reassign or delete those pages first. -import { defineEventHandler, getRouterParam, createError } from 'h3' +import { defineEventHandler, getRouterParam, createError, getHeader } from 'h3' import { requirePermission } from '../../../utils/rbac' -import { db } from '../../../utils/database' -import { deleteCategory } from '../../../database/categories' -import { logDelete } from '../../../utils/activity-logger' +import { deleteCmsCategory } from '../../../services/cmsCategories' +import { logEvent } from '../../../utils/activity-logger' export default defineEventHandler(async (event) => { - await requirePermission(event, 'pages.write') + const authUser = await requirePermission(event, 'pages.write') const id = getRouterParam(event, 'id') if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' }) - const existing = await db - .selectFrom('categories') - .select(['id', 'slug']) - .where('id', '=', id) - .executeTakeFirst() - if (!existing) throw createError({ statusCode: 404, statusMessage: 'Category not found' }) - try { - await deleteCategory(id) - logDelete('categories', id, event, { slug: existing.slug }) - return { ok: true } - } catch (e: any) { - if (e?.statusCode === 409) { + const result = await deleteCmsCategory(id) + if ('error' in result) { throw createError({ statusCode: 409, - statusMessage: 'This category still has pages. Move or delete them first.' + statusMessage: 'This category still has pages. Move or delete them first.', + data: { + attached_page_count: result.attached_page_count, + sample_slugs: result.sample_slugs + } + }) + } + logEvent({ + eventType: 'DELETE', + tableName: 'categories', + recordId: id, + userId: authUser.userId, + userAgent: getHeader(event, 'user-agent') || undefined, + metadata: { slug: result.slug, source: 'admin-ui' } + }) + return { ok: true } + } catch (e: any) { + if (e?.statusCode) { + throw createError({ + statusCode: e.statusCode, + statusMessage: e.statusMessage || e.message, + data: e.data }) } throw e diff --git a/server/api/admin/categories/[id].patch.ts b/server/api/admin/categories/[id].patch.ts index b7167ae3..4318a00d 100644 --- a/server/api/admin/categories/[id].patch.ts +++ b/server/api/admin/categories/[id].patch.ts @@ -1,11 +1,13 @@ // Admin: update a category — slug (cascades to every member page // slug), menu_order, and per-locale names. Returns the updated row. -import { defineEventHandler, getRouterParam, readBody, createError } from 'h3' +import { defineEventHandler, getRouterParam, readBody, createError, getHeader } from 'h3' import { requirePermission } from '../../../utils/rbac' -import { db } from '../../../utils/database' -import { updateCategory, purgeSlugs } from '../../../database/categories' -import { logUpdate } from '../../../utils/activity-logger' +import { + updateCmsCategory, + applyCategoryInvalidations +} from '../../../services/cmsCategories' +import { logEvent } from '../../../utils/activity-logger' import { ENABLED_LANGUAGE_CODES } from '../../../../config/languages' interface Body { @@ -32,49 +34,32 @@ function normalizeTranslations( } export default defineEventHandler(async (event) => { - await requirePermission(event, 'pages.write') + const authUser = await requirePermission(event, 'pages.write') const id = getRouterParam(event, 'id') if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' }) const body = await readBody(event) - - const input: Parameters[1] = {} - if (body?.slug != null) { - const slug = String(body.slug).trim().replace(/^\/+|\/+$/g, '') - if (!slug || !/^[a-z0-9][a-z0-9-]*$/.test(slug)) { - throw createError({ - statusCode: 400, - statusMessage: 'Category slug must be lowercase letters, digits, and dashes (no slashes)' - }) - } - - const collidingPage = await db - .selectFrom('pages') - .select('id') - .where('slug', '=', slug) - .where('category_id', 'is', null) - .executeTakeFirst() - if (collidingPage) { - throw createError({ - statusCode: 409, - statusMessage: `A page already uses the slug "${slug}".` - }) - } - - input.slug = slug - } - if (body?.menu_order !== undefined) { - input.menu_order = Number(body.menu_order) || 0 - } const translations = normalizeTranslations(body?.translations) - if (translations) input.translations = translations try { - const result = await updateCategory(id, input) - await purgeSlugs(result.slugsToPurge) - logUpdate('categories', id, event, { - changes: Object.keys(input), - slugsPurged: result.slugsToPurge.length + const result = await updateCmsCategory({ + id, + slug: body?.slug != null ? String(body.slug) : undefined, + menu_order: body?.menu_order !== undefined ? (Number(body.menu_order) || 0) : undefined, + translations + }) + await applyCategoryInvalidations(result.slugsToPurge) + logEvent({ + eventType: 'UPDATE', + tableName: 'categories', + recordId: id, + userId: authUser.userId, + userAgent: getHeader(event, 'user-agent') || undefined, + metadata: { + changes: (['slug', 'menu_order', 'translations'] as const).filter(k => body?.[k] !== undefined), + slugsPurged: result.slugsToPurge.length, + source: 'admin-ui' + } }) return result.category } catch (e: any) { @@ -82,7 +67,7 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 409, statusMessage: 'A category with that slug already exists' }) } if (e?.statusCode) { - throw createError({ statusCode: e.statusCode, statusMessage: e.message }) + throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message }) } throw e } diff --git a/server/api/admin/categories/index.post.ts b/server/api/admin/categories/index.post.ts index f0ee8069..87098c35 100644 --- a/server/api/admin/categories/index.post.ts +++ b/server/api/admin/categories/index.post.ts @@ -1,15 +1,12 @@ -// Admin: create a new category. Takes a slug and an optional set of -// per-locale names. Slug must be unique against other categories and -// must not collide with any top-level (uncategorized) page slug — -// otherwise the public router can't tell `/contact` (a page) apart from -// `/contact` (a bare category URL). +// Admin: create a new CMS category. Slug must be unique against other +// categories and must not collide with any uncategorized page slug. +// All validation lives in the cmsCategories service. -import { defineEventHandler, readBody, createError } from 'h3' +import { defineEventHandler, readBody, createError, getHeader } from 'h3' import { requirePermission } from '../../../utils/rbac' -import { db } from '../../../utils/database' -import { createCategory } from '../../../database/categories' -import { logCreate } from '../../../utils/activity-logger' +import { createCmsCategory } from '../../../services/cmsCategories' import { ENABLED_LANGUAGE_CODES } from '../../../../config/languages' +import { logEvent } from '../../../utils/activity-logger' interface Body { slug?: string @@ -37,55 +34,29 @@ function normalizeTranslations( } export default defineEventHandler(async (event) => { - await requirePermission(event, 'pages.write') + const authUser = await requirePermission(event, 'pages.write') const body = await readBody(event) - const slug = (body?.slug ?? '').trim().replace(/^\/+|\/+$/g, '') - if (!slug) { - throw createError({ statusCode: 400, statusMessage: 'slug is required' }) - } - if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) { - throw createError({ - statusCode: 400, - statusMessage: 'Category slug must be lowercase letters, digits, and dashes (no slashes)' - }) - } - const translations = normalizeTranslations(body?.translations) - if (!translations.some(t => t.locale === 'en' && t.name)) { - throw createError({ - statusCode: 400, - statusMessage: 'An English name is required' - }) - } - - // Guard against collision with an uncategorized page that owns the - // same slug at the URL root (the public router would ambiguously - // resolve `/contact`). - const collidingPage = await db - .selectFrom('pages') - .select('id') - .where('slug', '=', slug) - .where('category_id', 'is', null) - .executeTakeFirst() - if (collidingPage) { - throw createError({ - statusCode: 409, - statusMessage: `A page already uses the slug "${slug}". Pick a different category slug or reassign that page.` - }) - } try { - const category = await createCategory({ - slug, + const category = await createCmsCategory({ + slug: body?.slug ?? '', menu_order: Number.isFinite(body?.menu_order) ? Number(body.menu_order) : 0, translations }) - logCreate('categories', category.id, event, { slug, translations: translations.length }) + logEvent({ + eventType: 'CREATE', + tableName: 'categories', + recordId: category.id, + userId: authUser.userId, + userAgent: getHeader(event, 'user-agent') || undefined, + metadata: { slug: category.slug, translations: translations.length, source: 'admin-ui' } + }) return category } catch (e: any) { - if (e?.code === '23505') { - throw createError({ statusCode: 409, statusMessage: 'A category with that slug already exists' }) + if (e?.statusCode) { + throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message }) } throw e } diff --git a/server/api/admin/pages/[id].delete.ts b/server/api/admin/pages/[id].delete.ts index a1fef80c..f8053ad0 100644 --- a/server/api/admin/pages/[id].delete.ts +++ b/server/api/admin/pages/[id].delete.ts @@ -1,30 +1,34 @@ // Admin: delete a CMS page and (via cascade) all its translations. -import { defineEventHandler, getRouterParam, createError } from 'h3' +import { defineEventHandler, getRouterParam, createError, getHeader } from 'h3' import { requirePermission } from '../../../utils/rbac' -import { deletePage } from '../../../database/pages' -import { db } from '../../../utils/database' -import { logDelete } from '../../../utils/activity-logger' -import { purgeCmsPage, purgeCmsCategory } from '../../../utils/cmsCache' +import { + deleteCmsPage, + applyPageInvalidations +} from '../../../services/cmsPages' +import { logEvent } from '../../../utils/activity-logger' export default defineEventHandler(async (event) => { - await requirePermission(event, 'pages.write') + const authUser = await requirePermission(event, 'pages.write') const id = getRouterParam(event, 'id') if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' }) - const existing = await db - .selectFrom('pages') - .select(['id', 'slug', 'category_id']) - .where('id', '=', id) - .executeTakeFirst() - if (!existing) throw createError({ statusCode: 404, statusMessage: 'Page not found' }) - - await deletePage(id) - logDelete('pages', id, event, { slug: existing.slug }) - await purgeCmsPage(existing.slug) - // Removed entry leaves stale `children[]` on every surviving sibling. - if (existing.category_id) { - await purgeCmsCategory(existing.category_id, existing.slug) + try { + const { slug, categoryId } = await deleteCmsPage(id) + logEvent({ + eventType: 'DELETE', + tableName: 'pages', + recordId: id, + userId: authUser.userId, + userAgent: getHeader(event, 'user-agent') || undefined, + metadata: { slug, source: 'admin-ui' } + }) + await applyPageInvalidations([slug], categoryId ? [categoryId] : []) + return { ok: true } + } catch (e: any) { + if (e?.statusCode) { + throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message }) + } + throw e } - return { ok: true } }) diff --git a/server/api/admin/pages/[id].get.ts b/server/api/admin/pages/[id].get.ts index 548c9b13..71fe3b57 100644 --- a/server/api/admin/pages/[id].get.ts +++ b/server/api/admin/pages/[id].get.ts @@ -2,26 +2,18 @@ import { defineEventHandler, getRouterParam, createError } from 'h3' import { requirePermission } from '../../../utils/rbac' -import { db } from '../../../utils/database' +import { getCmsPage } from '../../../services/cmsPages' export default defineEventHandler(async (event) => { await requirePermission(event, 'pages.view') const id = getRouterParam(event, 'id') if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' }) - const page = await db - .selectFrom('pages') - .selectAll() - .where('id', '=', id) - .executeTakeFirst() - if (!page) throw createError({ statusCode: 404, statusMessage: 'Page not found' }) + const found = await getCmsPage({ id }) + if (!found) throw createError({ statusCode: 404, statusMessage: 'Page not found' }) - const translations = await db - .selectFrom('page_translations') - .selectAll() - .where('page_id', '=', id) - .orderBy('locale', 'asc') - .execute() - - return { page, translations } + // Match the legacy admin contract: `{page, translations}`. The + // service additionally returns `category_slug`, but the Vue admin + // doesn't read that field — drop it from the response. + return { page: found.page, translations: found.translations } }) diff --git a/server/api/admin/pages/[id].patch.ts b/server/api/admin/pages/[id].patch.ts index cdf78fe5..020d4145 100644 --- a/server/api/admin/pages/[id].patch.ts +++ b/server/api/admin/pages/[id].patch.ts @@ -2,16 +2,20 @@ // theme, custom_css). Changing `category_id` auto-rewrites the slug // prefix to stay under the new category (old URL 404s), and both the // old and new cached URLs are purged. +// +// All slug-rewrite, validation, and cache fan-out logic lives in the +// cmsPages service; the route stays thin. -import { defineEventHandler, getRouterParam, readBody, createError } from 'h3' +import { defineEventHandler, getRouterParam, readBody, createError, getHeader } from 'h3' import { requirePermission } from '../../../utils/rbac' -import { db } from '../../../utils/database' -import { setPageCategory } from '../../../database/pages' -import { logUpdate } from '../../../utils/activity-logger' -import { purgeCmsPage } from '../../../utils/cmsCache' +import { + updateCmsPage, + applyPageInvalidations +} from '../../../services/cmsPages' +import { logEvent } from '../../../utils/activity-logger' export default defineEventHandler(async (event) => { - await requirePermission(event, 'pages.write') + const authUser = await requirePermission(event, 'pages.write') const id = getRouterParam(event, 'id') if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' }) @@ -23,125 +27,56 @@ export default defineEventHandler(async (event) => { custom_css?: string | null }>(event) - const existing = await db - .selectFrom('pages') - .selectAll() - .where('id', '=', id) - .executeTakeFirst() - if (!existing) throw createError({ statusCode: 404, statusMessage: 'Page not found' }) - - const slugsToPurge = new Set([existing.slug]) - let currentCategoryId = existing.category_id - - // Category moves are handled via setPageCategory so the slug prefix - // is rewritten in the same transaction. Skip if unchanged. - if (body?.category_id !== undefined) { - const nextCategoryId = body.category_id ? String(body.category_id) : null - if (nextCategoryId !== existing.category_id) { - const { page: moved, slugsToPurge: moveSlugs } = await setPageCategory(id, nextCategoryId) - moveSlugs.forEach(s => slugsToPurge.add(s)) - currentCategoryId = moved.category_id - } + if (body?.theme !== undefined && !['default', 'green'].includes(body.theme)) { + throw createError({ statusCode: 400, statusMessage: 'theme must be one of: default, green' }) } - const set: Record = {} - if (body?.slug != null) { - const slug = String(body.slug).trim().replace(/^\/+|\/+$/g, '') - if (!slug || !/^[a-z0-9][a-z0-9/-]*$/.test(slug)) { - throw createError({ statusCode: 400, statusMessage: 'slug must be lowercase letters, digits, dashes, and slashes' }) - } - - if (currentCategoryId) { - const category = await db - .selectFrom('categories') - .select('slug') - .where('id', '=', currentCategoryId) - .executeTakeFirst() - if (category && !slug.startsWith(`${category.slug}/`)) { - throw createError({ - statusCode: 400, - statusMessage: `Slug must start with "${category.slug}/"` - }) - } - } else { - const collidingCategory = await db - .selectFrom('categories') - .select('id') - .where('slug', '=', slug) - .executeTakeFirst() - if (collidingCategory) { - throw createError({ - statusCode: 409, - statusMessage: `"${slug}" is already used by a category.` - }) - } - } - - set.slug = slug - } - if (body?.menu_order !== undefined) { - set.menu_order = Number(body.menu_order) || 0 - } - if (body?.theme !== undefined) { - const theme = String(body.theme) - if (!['default', 'green'].includes(theme)) { - throw createError({ statusCode: 400, statusMessage: 'theme must be one of: default, green' }) - } - set.theme = theme - } - if (body?.custom_css !== undefined) { - const raw = body.custom_css == null ? null : String(body.custom_css) - set.custom_css = raw && raw.trim() ? raw : null + // Service normalizes the slug; admin just forwards. Empty/whitespace + // custom_css folds to null at this boundary (matches legacy admin + // behavior); a non-empty custom_css keeps its original form, + // including any leading/trailing whitespace the author wrote. + const slugIn = body?.slug != null ? String(body.slug) : undefined + let customCssIn: string | null | undefined + if (body?.custom_css === undefined) { + customCssIn = undefined + } else if (body.custom_css == null) { + customCssIn = null + } else { + const raw = String(body.custom_css) + customCssIn = raw && raw.trim() ? raw : null } try { - let updated = existing - if (Object.keys(set).length > 0) { - set.updated = new Date() - updated = await db - .updateTable('pages') - .set(set as any) - .where('id', '=', id) - .returningAll() - .executeTakeFirstOrThrow() - } else { - updated = await db - .selectFrom('pages') - .selectAll() - .where('id', '=', id) - .executeTakeFirstOrThrow() - } + const result = await updateCmsPage({ + id, + slug: slugIn, + category_id: body?.category_id === undefined ? undefined : (body.category_id ? String(body.category_id) : null), + menu_order: body?.menu_order !== undefined ? (Number(body.menu_order) || 0) : undefined, + theme: body?.theme as 'default' | 'green' | undefined, + custom_css: customCssIn + }) - slugsToPurge.add(updated.slug) - - // Fields that bleed into a sibling's `children[]` array (slug + - // menu_order) or shift category membership need every member page - // in the affected categories re-rendered. Collect the union of - // old + new category ids, then pull their slug rosters in one go. - const categoriesToPurge = new Set() - const slugChanged = updated.slug !== existing.slug - const orderChanged = updated.menu_order !== existing.menu_order - const movedCategory = updated.category_id !== existing.category_id - if (slugChanged || orderChanged || movedCategory) { - if (existing.category_id) categoriesToPurge.add(existing.category_id) - if (updated.category_id) categoriesToPurge.add(updated.category_id) + if (Object.keys(result.changes).length > 0) { + // Fire-and-forget audit (matches legacy admin behavior; the audit + // runs after the data write commits). + logEvent({ + eventType: 'UPDATE', + tableName: 'pages', + recordId: id, + userId: authUser.userId, + userAgent: getHeader(event, 'user-agent') || undefined, + metadata: { changes: result.changes, source: 'admin-ui' } + }) } - if (categoriesToPurge.size > 0) { - const siblings = await db - .selectFrom('pages') - .select('slug') - .where('category_id', 'in', Array.from(categoriesToPurge)) - .execute() - for (const { slug } of siblings) slugsToPurge.add(slug) - } - - logUpdate('pages', id, event, { changes: { ...set, category_id: updated.category_id } }) - await Promise.all(Array.from(slugsToPurge).map(s => purgeCmsPage(s))) - return updated + await applyPageInvalidations(result.slugsToPurge, result.categoriesToPurge) + return result.page } catch (e: any) { if (e?.code === '23505') { throw createError({ statusCode: 409, statusMessage: 'A page with that slug already exists' }) } + if (e?.statusCode) { + throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message }) + } throw e } }) diff --git a/server/api/admin/pages/[id]/publish.post.ts b/server/api/admin/pages/[id]/publish.post.ts index 49269fe3..972b38da 100644 --- a/server/api/admin/pages/[id]/publish.post.ts +++ b/server/api/admin/pages/[id]/publish.post.ts @@ -1,11 +1,12 @@ // Admin: set a translation's status to published / draft. -import { defineEventHandler, getRouterParam, readBody, createError } from 'h3' +import { defineEventHandler, getRouterParam, readBody, createError, getHeader } from 'h3' import { requirePermission } from '../../../../utils/rbac' -import { setTranslationStatus, getPageSlug } from '../../../../database/pages' -import { ENABLED_LANGUAGE_CODES } from '../../../../../config/languages' -import { logUpdate } from '../../../../utils/activity-logger' -import { purgeCmsPage } from '../../../../utils/cmsCache' +import { + setCmsTranslationStatus, + applyTranslationInvalidations +} from '../../../../services/cmsTranslations' +import { logEvent } from '../../../../utils/activity-logger' interface Body { locale?: string @@ -13,26 +14,38 @@ interface Body { } export default defineEventHandler(async (event) => { - await requirePermission(event, 'pages.publish') + const authUser = await requirePermission(event, 'pages.publish') const id = getRouterParam(event, 'id') if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' }) const body = await readBody(event) const locale = body?.locale const status = body?.status - if (!locale || !ENABLED_LANGUAGE_CODES.includes(locale)) { - throw createError({ statusCode: 400, statusMessage: 'locale is not enabled' }) - } + if (!locale) throw createError({ statusCode: 400, statusMessage: 'locale is required' }) if (status !== 'draft' && status !== 'published') { throw createError({ statusCode: 400, statusMessage: 'status must be draft or published' }) } - await setTranslationStatus(id, locale, status) - logUpdate('page_translations', id, event, { - event: status === 'published' ? 'PUBLISH' : 'UNPUBLISH', - locale - }) - const slug = await getPageSlug(id) - if (slug) await purgeCmsPage(slug, [locale]) - return { ok: true } + try { + const result = await setCmsTranslationStatus({ page_id: id, locale, status }) + logEvent({ + eventType: 'UPDATE', + tableName: 'page_translations', + recordId: result.translation.id, + userId: authUser.userId, + userAgent: getHeader(event, 'user-agent') || undefined, + metadata: { + event: status === 'published' ? 'PUBLISH' : 'UNPUBLISH', + locale, + source: 'admin-ui' + } + }) + await applyTranslationInvalidations(result.pageSlug, result.categoryId, locale) + return { ok: true } + } catch (e: any) { + if (e?.statusCode) { + throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message }) + } + throw e + } }) diff --git a/server/api/admin/pages/[id]/translate.post.ts b/server/api/admin/pages/[id]/translate.post.ts index cbefed73..31c72eb2 100644 --- a/server/api/admin/pages/[id]/translate.post.ts +++ b/server/api/admin/pages/[id]/translate.post.ts @@ -4,15 +4,15 @@ // translation is upserted with the caller-chosen `status` (draft or // published, default draft). `overwrite` controls whether to replace // translations that already exist for a given locale. +// +// All workflow logic — DeepL calls, body walking, upsert with +// validation, per-locale cache purge — lives in cmsTranslate.translatePage. +// This route stays thin. -import { defineEventHandler, getRouterParam, readBody, createError } from 'h3' +import { defineEventHandler, getRouterParam, readBody, createError, getHeader } from 'h3' import { requirePermission } from '../../../../utils/rbac' -import { db } from '../../../../utils/database' -import { upsertTranslation, getPageSlug } from '../../../../database/pages' -import { translateTexts, translateTiptapContent, isDeepLConfigured } from '../../../../utils/deepl' -import { ENABLED_LANGUAGE_CODES } from '../../../../../config/languages' -import { logUpdate } from '../../../../utils/activity-logger' -import { purgeCmsPage } from '../../../../utils/cmsCache' +import { translatePage } from '../../../../services/cmsTranslate' +import { logEvent } from '../../../../utils/activity-logger' interface Body { sourceLocale?: string @@ -22,93 +22,53 @@ interface Body { } export default defineEventHandler(async (event) => { - await requirePermission(event, 'pages.write') - if (!isDeepLConfigured()) { - throw createError({ statusCode: 500, statusMessage: 'DEEPL_API_KEY is not configured' }) - } - + const authUser = await requirePermission(event, 'pages.write') const id = getRouterParam(event, 'id') if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' }) const body = await readBody(event) - const sourceLocale = body?.sourceLocale ?? 'en' - const targetLocales = (body?.targetLocales ?? []).filter(l => ENABLED_LANGUAGE_CODES.includes(l) && l !== sourceLocale) - if (targetLocales.length === 0) { - throw createError({ statusCode: 400, statusMessage: 'targetLocales is empty' }) - } - const status: 'draft' | 'published' = body?.status === 'published' ? 'published' : 'draft' - const source = await db - .selectFrom('page_translations') - .selectAll() - .where('page_id', '=', id) - .where('locale', '=', sourceLocale) - .executeTakeFirst() - if (!source) { - throw createError({ statusCode: 404, statusMessage: `No ${sourceLocale} translation to translate from` }) - } + try { + const result = await translatePage({ + page_id: id, + source_locale: body?.sourceLocale, + target_locales: body?.targetLocales ?? [], + overwrite: body?.overwrite, + status: body?.status + }) - const results: Array<{ locale: string; skipped?: boolean; error?: string }> = [] - - for (const target of targetLocales) { - // Respect overwrite=false on existing non-draft translations so published content isn't silently replaced - if (!body?.overwrite) { - const existing = await db - .selectFrom('page_translations') - .select(['id', 'status']) - .where('page_id', '=', id) - .where('locale', '=', target) - .executeTakeFirst() - if (existing) { - results.push({ locale: target, skipped: true }) - continue + logEvent({ + eventType: 'UPDATE', + tableName: 'pages', + recordId: id, + userId: authUser.userId, + userAgent: getHeader(event, 'user-agent') || undefined, + metadata: { + event: 'TRANSLATE', + source_locale: result.source_locale, + // Reflect what the service actually attempted, not what the + // caller requested — the service silently filters out + // disabled/self-targeted locales before translating. + target_locales: result.results.map(r => r.locale), + results: result.results, + source: 'admin-ui' } + }) + + // Match the legacy admin response shape so the Vue UI doesn't + // need to change. Map service's {ok, skipped, error} to the + // wire shape the editor expects: {locale, skipped?, error?}. + return { + results: result.results.map(r => ({ + locale: r.locale, + ...(r.skipped ? { skipped: true } : {}), + ...(r.error ? { error: r.error } : {}) + })) } - - try { - const textFields = [ - source.title ?? '', - source.excerpt ?? '', - source.meta_title ?? '', - source.meta_description ?? '' - ] - const [translatedTitle, translatedExcerpt, translatedMetaTitle, translatedMetaDescription] - = await translateTexts(textFields, target, sourceLocale) - const translatedBody = await translateTiptapContent(source.body_json as any, target, sourceLocale) - - await upsertTranslation({ - page_id: id, - locale: target, - title: translatedTitle || source.title, - body_json: translatedBody as any, - excerpt: translatedExcerpt || null, - featured_image: source.featured_image, - meta_title: translatedMetaTitle || null, - meta_description: translatedMetaDescription || null, - og_image: source.og_image, - status - }) - results.push({ locale: target }) - } catch (e: any) { - console.error(`[DeepL] translate page ${id} → ${target} failed:`, e?.message || e) - results.push({ locale: target, error: e?.message || 'translation failed' }) + } catch (e: any) { + if (e?.statusCode) { + throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message }) } + throw e } - - logUpdate('pages', id, event, { - event: 'TRANSLATE', - source_locale: sourceLocale, - target_locales: targetLocales, - results - }) - - // Only purge locales whose translations actually got written (skipped - // ones didn't change; errored ones didn't commit). - const purgedLocales = results.filter(r => !r.skipped && !r.error).map(r => r.locale) - if (purgedLocales.length > 0) { - const slug = await getPageSlug(id) - if (slug) await purgeCmsPage(slug, purgedLocales) - } - - return { results } }) diff --git a/server/api/admin/pages/[id]/translations/[locale].put.ts b/server/api/admin/pages/[id]/translations/[locale].put.ts index c9574630..d7d7c94c 100644 --- a/server/api/admin/pages/[id]/translations/[locale].put.ts +++ b/server/api/admin/pages/[id]/translations/[locale].put.ts @@ -1,14 +1,19 @@ // Admin: upsert a page's translation for a given locale. Saves as -// draft unless `status: 'published'` is passed. All fields optional -// except `title` and `body_json`. +// draft unless `status: 'published'` is passed. +// +// Calls upsertCmsPageTranslation, which now applies tiptapValidate +// (the XSS allowlist) and the lossy-overwrite guard. Admin clients +// only ever pass body_json (no markdown), so the lossy guard is +// effectively inert here — it only fires when body_markdown is the +// input branch. -import { defineEventHandler, getRouterParam, readBody, createError } from 'h3' +import { defineEventHandler, getRouterParam, readBody, createError, getHeader } from 'h3' import { requirePermission } from '../../../../../utils/rbac' -import { upsertTranslation } from '../../../../../database/pages' -import { ENABLED_LANGUAGE_CODES } from '../../../../../../config/languages' -import { logUpdate } from '../../../../../utils/activity-logger' -import { db } from '../../../../../utils/database' -import { purgeCmsPage, purgeCmsCategory } from '../../../../../utils/cmsCache' +import { + upsertCmsPageTranslation, + applyTranslationInvalidations +} from '../../../../../services/cmsTranslations' +import { logEvent } from '../../../../../utils/activity-logger' interface Body { title?: string @@ -22,57 +27,46 @@ interface Body { } export default defineEventHandler(async (event) => { - await requirePermission(event, 'pages.write') + const authUser = await requirePermission(event, 'pages.write') const id = getRouterParam(event, 'id') const locale = getRouterParam(event, 'locale') if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' }) - if (!locale || !ENABLED_LANGUAGE_CODES.includes(locale)) { - throw createError({ statusCode: 400, statusMessage: 'locale is not enabled' }) - } - - const page = await db - .selectFrom('pages') - .select(['id', 'slug', 'category_id']) - .where('id', '=', id) - .executeTakeFirst() - if (!page) throw createError({ statusCode: 404, statusMessage: 'Page not found' }) + if (!locale) throw createError({ statusCode: 400, statusMessage: 'locale is required' }) const body = await readBody(event) - const title = (body?.title ?? '').trim() - if (!title) throw createError({ statusCode: 400, statusMessage: 'title is required' }) if (!body?.body_json || typeof body.body_json !== 'object') { throw createError({ statusCode: 400, statusMessage: 'body_json is required' }) } - if (body.status && body.status !== 'draft' && body.status !== 'published') { - throw createError({ statusCode: 400, statusMessage: 'status must be draft or published' }) + + try { + const result = await upsertCmsPageTranslation({ + page_id: id, + locale, + title: (body.title ?? '').trim(), + body_json: body.body_json, + excerpt: body.excerpt ?? null, + featured_image: body.featured_image ?? null, + meta_title: body.meta_title ?? null, + meta_description: body.meta_description ?? null, + og_image: body.og_image ?? null, + status: body.status + }) + + logEvent({ + eventType: 'UPDATE', + tableName: 'page_translations', + recordId: result.translation.id, + userId: authUser.userId, + userAgent: getHeader(event, 'user-agent') || undefined, + metadata: { page_id: id, locale, status: result.translation.status, source: 'admin-ui' } + }) + + await applyTranslationInvalidations(result.pageSlug, result.categoryId, locale) + return result.translation + } catch (e: any) { + if (e?.statusCode) { + throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message }) + } + throw e } - - const translation = await upsertTranslation({ - page_id: id, - locale, - title, - body_json: body.body_json, - excerpt: body.excerpt ?? null, - featured_image: body.featured_image ?? null, - meta_title: body.meta_title ?? null, - meta_description: body.meta_description ?? null, - og_image: body.og_image ?? null, - status: body.status - }) - - logUpdate('page_translations', translation.id, event, { - page_id: id, - locale, - status: translation.status - }) - - await purgeCmsPage(page.slug, [locale]) - // Title/excerpt/featured_image show up in every sibling's `children[]` - // array (used for the sidebar nav). Without a category there are no - // siblings to bust, so skip the query for standalone pages. - if (page.category_id) { - await purgeCmsCategory(page.category_id, page.slug) - } - - return translation }) diff --git a/server/api/admin/pages/index.post.ts b/server/api/admin/pages/index.post.ts index 9d295355..85e8390c 100644 --- a/server/api/admin/pages/index.post.ts +++ b/server/api/admin/pages/index.post.ts @@ -1,74 +1,65 @@ // Admin: create a new CMS page (no translations). Returns the row. // Translation is added separately via PUT /api/admin/pages/[id]/translations/[locale]. +// +// Validation, slug rules, transactional audit, and cache invalidation +// are owned by the cmsPages service. The admin route stays thin: parse +// + auth + dispatch + project the response shape Vue expects. -import { defineEventHandler, readBody, createError } from 'h3' +import { defineEventHandler, readBody, createError, getHeader } from 'h3' +import type { Kysely } from 'kysely' import { requirePermission } from '../../../utils/rbac' -import { createPage } from '../../../database/pages' -import { db } from '../../../utils/database' -import { logCreate } from '../../../utils/activity-logger' -import { purgeCmsCategory } from '../../../utils/cmsCache' +import { + createCmsPage, + applyPageInvalidations +} from '../../../services/cmsPages' +import { logEvent } from '../../../utils/activity-logger' +import type { Database } from '../../../database/schema' export default defineEventHandler(async (event) => { - await requirePermission(event, 'pages.write') + const authUser = await requirePermission(event, 'pages.write') const body = await readBody<{ slug?: string; category_id?: string | null; menu_order?: number }>(event) - - const slug = (body?.slug ?? '').trim().replace(/^\/+|\/+$/g, '') - if (!slug) { + if (!body?.slug || !String(body.slug).trim()) { throw createError({ statusCode: 400, statusMessage: 'slug is required' }) } - if (!/^[a-z0-9][a-z0-9/-]*$/.test(slug)) { - throw createError({ statusCode: 400, statusMessage: 'slug must be lowercase letters, digits, dashes, and slashes' }) - } const category_id = body?.category_id ? String(body.category_id) : null const menu_order = Number.isFinite(body?.menu_order) ? Number(body.menu_order) : 0 - // When a category is picked, the slug must live under its prefix so - // public URL resolution stays consistent. Uncategorized pages can - // have any slug shape, but they can't collide with a category slug — - // that would make `/contact` ambiguous. - if (category_id) { - const category = await db - .selectFrom('categories') - .select(['slug']) - .where('id', '=', category_id) - .executeTakeFirst() - if (!category) { - throw createError({ statusCode: 400, statusMessage: 'Category not found' }) - } - if (!slug.startsWith(`${category.slug}/`)) { - throw createError({ - statusCode: 400, - statusMessage: `Slug must start with "${category.slug}/"` - }) - } - } else { - const collidingCategory = await db - .selectFrom('categories') - .select('id') - .where('slug', '=', slug) - .executeTakeFirst() - if (collidingCategory) { - throw createError({ - statusCode: 409, - statusMessage: `"${slug}" is already used by a category.` - }) - } - } - try { - const page = await createPage({ slug, category_id, menu_order }) - logCreate('pages', page.id, event, { slug, category_id, menu_order }) - // New member appears in every sibling's `children[]` — bust their - // cached responses so the sidebar picks it up on next visit. - if (category_id) { - await purgeCmsCategory(category_id, slug) - } - return page + const result = await createCmsPage( + // Service normalizes the slug (trim + strip leading/trailing + // slashes); pass the raw body value through. + { slug: body?.slug ?? '', category_id, menu_order }, + async ({ pageId }, executor) => { + // Admin route never passes an inline translation, so + // translationId is always null here — page audit only. + await logEvent( + { + eventType: 'CREATE', + tableName: 'pages', + recordId: pageId, + userId: authUser.userId, + userAgent: getHeader(event, 'user-agent') || undefined, + metadata: { slug: body.slug, category_id, menu_order, source: 'admin-ui' } + }, + executor as Kysely, + { throwOnError: true } + ) + } + ) + await applyPageInvalidations(result.slugsToPurge, result.categoriesToPurge) + return result.page } catch (e: any) { + // Translate H3-shaped service errors back to the admin route's + // legacy createError contract. The service's `err()` helper already + // attaches statusCode/statusMessage so most cases pass through; + // we just need the unique-violation special case. if (e?.code === '23505') { throw createError({ statusCode: 409, statusMessage: 'A page with that slug already exists' }) } + if (e?.statusCode) { + throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message }) + } throw e } }) diff --git a/server/api/admin/upload/finalize-image.post.ts b/server/api/admin/upload/finalize-image.post.ts new file mode 100644 index 00000000..43d9b9ce --- /dev/null +++ b/server/api/admin/upload/finalize-image.post.ts @@ -0,0 +1,33 @@ +// Admin: validate a presigned upload and return the public URL (step 2). +// Dispatches into the same cmsAssets.finalizeImageUpload service the +// MCP `finalize_image_upload` tool calls. + +import { defineEventHandler, readBody, createError } from 'h3' +import { requirePermission } from '../../../utils/rbac' +import { finalizeImageUpload } from '../../../services/cmsAssets' + +interface Body { + upload_token?: string + alt?: string +} + +export default defineEventHandler(async (event) => { + await requirePermission(event, 'pages.write') + const body = await readBody(event) + + if (!body?.upload_token) { + throw createError({ statusCode: 400, statusMessage: 'upload_token is required' }) + } + + try { + return await finalizeImageUpload({ + upload_token: body.upload_token, + alt: body.alt + }) + } catch (e: any) { + if (e?.statusCode) { + throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message }) + } + throw e + } +}) diff --git a/server/api/admin/upload/image.post.ts b/server/api/admin/upload/image.post.ts index f5e04c25..7f62eb67 100644 --- a/server/api/admin/upload/image.post.ts +++ b/server/api/admin/upload/image.post.ts @@ -1,14 +1,12 @@ // Admin: image upload endpoint. Accepts a multipart form-data POST with -// the file under field `file`, uploads to S3 via the existing storage -// util, and returns a signed URL + S3 key so the Tiptap editor can -// insert the image. Mirrors the contract campaigns-sever's -// useImageUpload composable expects. +// the file under field `file`, dispatches into the canonical +// cmsAssets.uploadImage() service (shared with the MCP upload_image +// tool), and returns the {url, key, filename} contract that +// campaigns-sever's useImageUpload composable expects. import { defineEventHandler, readMultipartFormData, createError } from 'h3' import { requirePermission } from '../../../utils/rbac' -import { uploadToS3, validateImageType, validateFileSize } from '../../../utils/storage' - -const MAX_IMAGE_MB = 10 +import { uploadImage } from '../../../services/cmsAssets' export default defineEventHandler(async (event) => { await requirePermission(event, 'pages.write') @@ -19,15 +17,17 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, statusMessage: 'file is required' }) } - const contentType = file.type || 'application/octet-stream' - if (!validateImageType(contentType)) { - throw createError({ statusCode: 415, statusMessage: 'Unsupported image type' }) + try { + const result = await uploadImage({ + buffer: file.data, + filename: file.filename, + declaredMimeType: file.type + }) + return { url: result.url, key: result.key, filename: result.filename } + } catch (e: any) { + if (e?.statusCode) { + throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message }) + } + throw e } - if (!validateFileSize(file.data.length, MAX_IMAGE_MB)) { - throw createError({ statusCode: 413, statusMessage: `Image exceeds ${MAX_IMAGE_MB}MB limit` }) - } - - const filename = file.filename || 'upload' - const result = await uploadToS3(file.data, filename, contentType, 'public') - return { url: result.url, key: result.key, filename: result.filename } }) diff --git a/server/api/admin/upload/prepare-image.post.ts b/server/api/admin/upload/prepare-image.post.ts new file mode 100644 index 00000000..55524587 --- /dev/null +++ b/server/api/admin/upload/prepare-image.post.ts @@ -0,0 +1,39 @@ +// Admin: get a presigned PUT URL for direct-to-S3 image upload (step 1). +// Mirrors the MCP `prepare_image_upload` tool; both call the same +// service. After PUTting the bytes, the client must call +// /api/admin/upload/finalize-image with the returned upload_token. + +import { defineEventHandler, readBody, createError } from 'h3' +import { requirePermission } from '../../../utils/rbac' +import { prepareImageUpload } from '../../../services/cmsAssets' + +interface Body { + filename?: string + mimeType?: string + byte_size?: number +} + +export default defineEventHandler(async (event) => { + await requirePermission(event, 'pages.write') + const body = await readBody(event) + + if (!body?.mimeType) { + throw createError({ statusCode: 400, statusMessage: 'mimeType is required' }) + } + if (!body?.byte_size || !Number.isFinite(body.byte_size) || body.byte_size <= 0) { + throw createError({ statusCode: 400, statusMessage: 'byte_size is required and must be positive' }) + } + + try { + return await prepareImageUpload({ + filename: body.filename, + mimeType: body.mimeType, + byteSize: body.byte_size + }) + } catch (e: any) { + if (e?.statusCode) { + throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message }) + } + throw e + } +}) diff --git a/server/mcp/resources/pages.ts b/server/mcp/resources/pages.ts new file mode 100644 index 00000000..9eeace4e --- /dev/null +++ b/server/mcp/resources/pages.ts @@ -0,0 +1,87 @@ +// cms://page/{id}/{locale} resource — exposes page translations as +// browseable read-only URIs. +// +// Why {id} instead of {slug}: the layer's URI matcher treats each +// {placeholder} as a single non-empty path segment, but CMS slugs +// can contain '/' (categorized pages start with "/"). Using +// the integer page id avoids the segment-count mismatch; the slug +// is surfaced in `name` for human navigation. +// +// resources/list is hard-capped at 500 entries today (the layer's +// resourceDef.list signature does not thread cursor/nextCursor). + +import { defineMcpResource } from '#mcp-layer' +import { db } from '../../utils/database' +import { tiptapToMarkdown } from '../../utils/tiptapToMarkdown' +import { detectLossy } from '../../utils/tiptapLossyDetector' + +const RESOURCE_LIST_CAP = 500 + +export const cmsPageResource = defineMcpResource({ + uriPattern: 'cms://page/{id}/{locale}', + scope: 'pages.view', + async list() { + const rows = await db + .selectFrom('page_translations') + .innerJoin('pages', 'pages.id', 'page_translations.page_id') + .select([ + 'page_translations.page_id as page_id', + 'page_translations.locale as locale', + 'page_translations.title as title', + 'page_translations.excerpt as excerpt', + 'page_translations.updated as updated', + 'pages.slug as slug' + ]) + .orderBy('page_translations.updated', 'desc') + .limit(RESOURCE_LIST_CAP + 1) + .execute() + + const capped = rows.length > RESOURCE_LIST_CAP + const out = rows.slice(0, RESOURCE_LIST_CAP).map((row, idx) => { + const isLastWhenCapped = capped && idx === RESOURCE_LIST_CAP - 1 + const baseDescription = `${row.slug} — ${row.excerpt ?? '(no excerpt)'}` + const description = isLastWhenCapped + ? `${baseDescription} — List capped at ${RESOURCE_LIST_CAP} — use list_pages for full enumeration.` + : baseDescription + return { + uri: `cms://page/${row.page_id}/${row.locale}`, + name: `${row.title} (${row.locale})`, + description, + mimeType: 'text/markdown' + } + }) + return out + }, + async read(uri) { + const match = /^cms:\/\/page\/([^/]+)\/([^/]+)$/.exec(uri) + if (!match) { + throw new Error(`Resource URI does not match cms://page/{id}/{locale}: ${uri}`) + } + const [, pageId, locale] = match + const t = await db + .selectFrom('page_translations') + .innerJoin('pages', 'pages.id', 'page_translations.page_id') + .select([ + 'page_translations.body_json as body_json', + 'page_translations.title as title', + 'pages.slug as slug', + 'page_translations.status as status' + ]) + .where('page_translations.page_id', '=', pageId!) + .where('page_translations.locale', '=', locale!) + .executeTakeFirst() + if (!t) { + throw new Error(`Resource not found: ${uri}`) + } + const lossy = detectLossy(t.body_json) + const markdown = tiptapToMarkdown(t.body_json) + const header = `# ${t.title}\n\nslug: ${t.slug}\nstatus: ${t.status}\nbody_is_lossy_in_markdown: ${lossy.body_is_lossy_in_markdown}\n${lossy.body_lossy_reasons.length > 0 ? `body_lossy_reasons: ${lossy.body_lossy_reasons.join(', ')}\n` : ''}\n---\n\n` + return { + contents: [{ + uri, + mimeType: 'text/markdown', + text: header + markdown + }] + } + } +}) diff --git a/server/mcp/schemas.ts b/server/mcp/schemas.ts new file mode 100644 index 00000000..764f7fe1 --- /dev/null +++ b/server/mcp/schemas.ts @@ -0,0 +1,209 @@ +// Zod input schemas for the MCP CMS tool surface. The schemas are +// also projected to JSON Schema by the layer's registry and emitted +// in tools/list, so the runtime parser and the advertised contract +// can't drift. +// +// All schemas use .strict() so unknown keys fail loudly — typos in +// MCP clients surface as validation errors instead of silent drops. + +import { z } from 'zod' + +const slugString = z.string().min(1).max(255) +const localeString = z.string().min(2).max(20) +const titleString = z.string().min(1).max(500) +const idString = z.string().min(1).max(64) + +// ── List pages ────────────────────────────────────────────────────── + +export const listPagesInput = z + .object({ + category_id: idString.nullish(), + status: z.enum(['draft', 'published', 'any']).optional(), + locale: localeString.optional(), + query: z.string().max(255).optional(), + limit: z.number().int().positive().max(200).optional(), + cursor: z.string().max(2048).optional() + }) + .strict() + .superRefine((v, ctx) => { + // status=draft|published only meaningful with a locale + if (v.status && v.status !== 'any' && !v.locale) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['status'], + message: 'status filter requires locale (use status=any to skip)' + }) + } + }) + +// ── Get page ──────────────────────────────────────────────────────── + +export const getPageInput = z + .object({ + id: idString.optional(), + slug: slugString.optional() + }) + .strict() + .refine(v => Boolean(v.id) !== Boolean(v.slug), { + message: 'exactly one of id or slug is required' + }) + +// ── Create page ───────────────────────────────────────────────────── + +const themeEnum = z.enum(['default', 'green']) +const statusEnum = z.enum(['draft', 'published']) + +const inlineTranslationInput = z + .object({ + locale: localeString, + title: titleString, + body_markdown: z.string().max(1024 * 1024).optional(), + body_json: z.record(z.unknown()).optional(), + excerpt: z.string().max(2048).nullish(), + featured_image: z.string().max(2048).nullish(), + meta_title: z.string().max(255).nullish(), + meta_description: z.string().max(500).nullish(), + og_image: z.string().max(2048).nullish(), + status: statusEnum.optional() + }) + .strict() + .refine(v => Boolean(v.body_markdown) !== Boolean(v.body_json), { + message: 'exactly one of body_markdown or body_json is required' + }) + +export const createPageInput = z + .object({ + slug: slugString, + category_id: idString.nullish(), + menu_order: z.number().int().min(0).optional(), + theme: themeEnum.optional(), + custom_css: z.string().max(16 * 1024).nullish(), + translation: inlineTranslationInput.optional() + }) + .strict() + +// ── Update page ───────────────────────────────────────────────────── + +export const updatePageInput = z + .object({ + id: idString, + slug: slugString.optional(), + category_id: idString.nullish(), + menu_order: z.number().int().min(0).optional(), + theme: themeEnum.optional(), + custom_css: z.string().max(16 * 1024).nullish() + }) + .strict() + +// ── Delete page ───────────────────────────────────────────────────── + +export const deletePageInput = z.object({ id: idString }).strict() + +// ── Translation ───────────────────────────────────────────────────── + +export const getPageTranslationInput = z + .object({ + page_id: idString, + locale: localeString + }) + .strict() + +export const upsertPageTranslationInput = z + .object({ + page_id: idString, + locale: localeString, + title: titleString, + body_markdown: z.string().max(1024 * 1024).optional(), + body_json: z.record(z.unknown()).optional(), + excerpt: z.string().max(2048).nullish(), + featured_image: z.string().max(2048).nullish(), + meta_title: z.string().max(255).nullish(), + meta_description: z.string().max(500).nullish(), + og_image: z.string().max(2048).nullish(), + status: statusEnum.optional(), + allow_lossy_overwrite: z.boolean().optional() + }) + .strict() + .refine(v => Boolean(v.body_markdown) !== Boolean(v.body_json), { + message: 'exactly one of body_markdown or body_json is required' + }) + +export const publishTranslationInput = z + .object({ + page_id: idString, + locale: localeString + }) + .strict() + +export const unpublishTranslationInput = publishTranslationInput + +// ── Categories ────────────────────────────────────────────────────── + +const categoryTranslationsInput = z + .array( + z + .object({ + locale: localeString, + name: z.string().min(1).max(255) + }) + .strict() + ) + .min(1) + +export const listCategoriesInput = z.object({}).strict() + +export const createCategoryInput = z + .object({ + slug: slugString, + menu_order: z.number().int().min(0).optional(), + translations: categoryTranslationsInput + }) + .strict() + +export const updateCategoryInput = z + .object({ + id: idString, + slug: slugString.optional(), + menu_order: z.number().int().min(0).optional(), + translations: categoryTranslationsInput.optional() + }) + .strict() + +export const deleteCategoryInput = z.object({ id: idString }).strict() + +// ── Translation ───────────────────────────────────────────────────── + +export const translateTextInput = z + .object({ + text: z.string().min(1).max(100 * 1024), + source_locale: localeString.optional(), + target_locales: z.array(localeString).min(1).max(20) + }) + .strict() + +export const translatePageInput = z + .object({ + page_id: idString, + source_locale: localeString.optional(), + target_locales: z.array(localeString).min(1).max(20), + overwrite: z.boolean().optional(), + status: z.enum(['draft', 'published']).optional() + }) + .strict() + +// ── Assets ────────────────────────────────────────────────────────── + +export const prepareImageUploadInput = z + .object({ + filename: z.string().min(1).max(255).optional(), + mimeType: z.string().regex(/^image\/(jpeg|png|webp)$/, 'mimeType must be image/jpeg, image/png, or image/webp'), + byte_size: z.number().int().positive().max(10 * 1024 * 1024) + }) + .strict() + +export const finalizeImageUploadInput = z + .object({ + upload_token: z.string().min(1).max(2048), + alt: z.string().max(500).optional() + }) + .strict() diff --git a/server/mcp/tools/assets.ts b/server/mcp/tools/assets.ts new file mode 100644 index 00000000..1b198469 --- /dev/null +++ b/server/mcp/tools/assets.ts @@ -0,0 +1,99 @@ +// Asset upload tools — two-step presigned PUT flow. +// +// Step 1: prepare_image_upload binds (mimeType, byte_size, key) into +// a presigned S3 PUT URL and an opaque HMAC-signed upload_token. The +// agent reads the file bytes and PUTs them directly to S3. +// +// Step 2: finalize_image_upload verifies the actual upload — checks +// Content-Length and Content-Type match what was bound, sniffs the +// first 12 bytes for the declared image's magic bytes, and returns +// the public final_url. If validation fails, the S3 object is +// deleted before the error returns. This brings parity with the +// admin multipart path's magic-byte defence (the Content-Type +// binding alone only constrains the response header S3 stores, not +// the payload itself). + +import { defineMcpTool, mcpLog } from '#mcp-layer' +import { prepareImageUploadInput, finalizeImageUploadInput } from '../schemas' +import { prepareImageUpload, finalizeImageUpload } from '../../services/cmsAssets' + +const PREPARE_DESCRIPTION = `Get a presigned URL to upload an image directly to S3 (step 1 of 2). + +Two-step flow: +1. Call \`prepare_image_upload\` with \`mimeType\`, \`byte_size\` (the exact byte length of the file you're about to upload), and an optional \`filename\` hint. Receive \`upload_url\`, \`upload_token\`, and \`required_headers\`. +2. Issue an HTTP PUT to \`upload_url\` with the raw image bytes as the body. **Set both \`Content-Type\` AND \`Content-Length\` headers to exactly the values in \`required_headers\`** — the presigned URL signs both, so any mismatch fails with \`SignatureDoesNotMatch\`. +3. After the PUT returns 200, call \`finalize_image_upload\` with the \`upload_token\` to validate the upload and receive the public \`url\` you embed in pages. + +The byte_size cap is 10 MB. The upload URL expires after \`expires_at\` (5 minutes from issue). The upload_token (returned alongside) is the only way to convert a successful PUT into a public URL — keep it for the finalize step. + +Why two steps: validating that the bytes you uploaded are actually the image you said they were (magic-byte sniff) requires a server-side check after the PUT lands. Skipping that check would let a malformed payload masquerade as an image.` + +export const prepareImageUploadTool = defineMcpTool({ + name: 'prepare_image_upload', + description: PREPARE_DESCRIPTION, + scope: 'pages.write', + input: prepareImageUploadInput, + async handler(input, ctx) { + const result = await prepareImageUpload({ + filename: input.filename, + mimeType: input.mimeType, + byteSize: input.byte_size + }) + + await mcpLog('CREATE', 'assets.presigned', result.key, ctx, { + mimeType: result.content_type, + byte_size: result.byte_size, + expires_at: result.expires_at + }) + + return { + content: [{ + type: 'text', + text: `PUT raw bytes to upload_url with Content-Type: ${result.content_type} and Content-Length: ${result.byte_size}. After 200, call finalize_image_upload with the upload_token.` + }], + structuredContent: result + } + } +}) + +const FINALIZE_DESCRIPTION = `Validate a presigned upload and return its public URL (step 2 of 2). + +Call this after a successful PUT to the URL returned by \`prepare_image_upload\`. Pass the \`upload_token\` you received from prepare; optionally include \`alt\` text to round-trip alongside the URL. + +The server verifies (in order): (1) the token signature; (2) the token has not expired; (3) the S3 object exists; (4) its byte size matches what was declared at prepare-time; (5) its Content-Type matches; (6) the first 12 bytes are real magic bytes for the declared image type. Any failure deletes the S3 object and returns an error. + +On success returns \`{url, key, mimeType, byteSize, alt}\`. Use \`url\` as the \`src\` on a Tiptap image node when calling \`upsert_page_translation\`.` + +export const finalizeImageUploadTool = defineMcpTool({ + name: 'finalize_image_upload', + description: FINALIZE_DESCRIPTION, + scope: 'pages.write', + input: finalizeImageUploadInput, + async handler(input, ctx) { + const result = await finalizeImageUpload({ + upload_token: input.upload_token, + alt: input.alt + }) + + await mcpLog('CREATE', 'assets', result.key, ctx, { + mimeType: result.mimeType, + byteSize: result.byteSize + }) + + return { + content: [{ + type: 'text', + text: `Validated upload: ${result.mimeType}, ${result.byteSize} bytes. URL: ${result.url}` + }], + structuredContent: { + url: result.url, + key: result.key, + mimeType: result.mimeType, + byteSize: result.byteSize, + alt: result.alt + } + } + } +}) + +export const ASSET_TOOLS = [prepareImageUploadTool, finalizeImageUploadTool] as const diff --git a/server/mcp/tools/categories.ts b/server/mcp/tools/categories.ts new file mode 100644 index 00000000..57409827 --- /dev/null +++ b/server/mcp/tools/categories.ts @@ -0,0 +1,137 @@ +// Category CRUD tools — list, create, update, delete. +// +// delete_category is destructive (rate-limit bucket applies) and +// refuses when pages are still attached, surfacing the count and +// up to five sample slugs in structuredContent so the client can +// build a helpful redirect/reassign UI. + +import { defineMcpTool, mcpLog } from '#mcp-layer' +import { + listCategoriesInput, + createCategoryInput, + updateCategoryInput, + deleteCategoryInput +} from '../schemas' +import { + listCmsCategories, + createCmsCategory, + updateCmsCategory, + deleteCmsCategory, + applyCategoryInvalidations +} from '../../services/cmsCategories' + +export const listCategoriesTool = defineMcpTool({ + name: 'list_categories', + description: 'List all CMS categories with their per-locale names, menu_order, and the number of pages currently attached.', + scope: 'pages.view', + input: listCategoriesInput, + async handler() { + const rows = await listCmsCategories() + return { + content: [{ type: 'text', text: `Found ${rows.length} categor${rows.length === 1 ? 'y' : 'ies'}.` }], + structuredContent: { + categories: rows.map(row => ({ + id: row.id, + slug: row.slug, + menu_order: row.menu_order, + page_count: row.page_count, + translations: row.translations.map(t => ({ + locale: t.locale, + name: t.name + })) + })) + } + } + } +}) + +export const createCategoryTool = defineMcpTool({ + name: 'create_category', + description: 'Create a new CMS category. Slug must be lowercase letters/digits/dashes (no slashes). Requires at least an English (locale "en") translation entry.', + scope: 'pages.write', + input: createCategoryInput, + async handler(input, ctx) { + const category = await createCmsCategory({ + slug: input.slug, + menu_order: input.menu_order, + translations: input.translations + }) + await mcpLog('CREATE', 'categories', category.id, ctx, { + slug: input.slug, + translation_count: input.translations.length + }) + return { + content: [{ type: 'text', text: `Created category "${category.slug}"` }], + structuredContent: { category } + } + } +}) + +export const updateCategoryTool = defineMcpTool({ + name: 'update_category', + description: 'Update a CMS category — slug, menu_order, or per-locale translations. Slug renames cascade to every member page and bust the affected cache entries.', + scope: 'pages.write', + input: updateCategoryInput, + async handler(input, ctx) { + const result = await updateCmsCategory({ + id: input.id, + slug: input.slug, + menu_order: input.menu_order, + translations: input.translations + }) + await mcpLog('UPDATE', 'categories', input.id, ctx, { + changes: Object.keys(input).filter(k => k !== 'id'), + slugs_purged: result.slugsToPurge.length + }) + await applyCategoryInvalidations(result.slugsToPurge) + return { + content: [{ type: 'text', text: `Updated category "${result.category.slug}"` }], + structuredContent: { category: result.category } + } + } +}) + +export const deleteCategoryTool = defineMcpTool({ + name: 'delete_category', + description: 'Delete a CMS category. Refuses if any pages are still attached — the structuredContent.attached_page_count and sample_slugs surface what is in the way. Reassign or delete those pages first. Destructive.', + scope: 'pages.write', + destructive: true, + input: deleteCategoryInput, + async handler(input, ctx) { + const result = await deleteCmsCategory(input.id) + if ('error' in result) { + const sc: { ok: boolean; error: string | null; attached_page_count: number; sample_slugs: string[] } = { + ok: false, + error: result.error, + attached_page_count: result.attached_page_count, + sample_slugs: result.sample_slugs + } + return { + content: [{ + type: 'text', + text: `Category has ${result.attached_page_count} attached page(s). Reassign or delete them first.` + }], + structuredContent: sc, + isError: true + } + } + await mcpLog('DELETE', 'categories', input.id, ctx, { slug: result.slug }) + const sc: { ok: boolean; error: string | null; attached_page_count: number; sample_slugs: string[] } = { + ok: true, + error: null, + attached_page_count: 0, + sample_slugs: [] + } + return { + content: [{ type: 'text', text: `Deleted category "${result.slug}"` }], + structuredContent: sc + } + } +}) + +export const CATEGORY_TOOLS = [ + listCategoriesTool, + createCategoryTool, + updateCategoryTool, + deleteCategoryTool +] as const diff --git a/server/mcp/tools/pages.ts b/server/mcp/tools/pages.ts new file mode 100644 index 00000000..54ff52b9 --- /dev/null +++ b/server/mcp/tools/pages.ts @@ -0,0 +1,218 @@ +// Page CRUD tools — list, get, create, update, delete. +// +// All tool names are snake_case per MCP convention; the server name +// `doxa-cms` disambiguates in the client's tool list. Scopes are +// RBAC permission strings (no `cms.*` namespace) per the project's +// permission registry. + +import { defineMcpTool, mcpLog } from '#mcp-layer' +import { + listPagesInput, + getPageInput, + createPageInput, + updatePageInput, + deletePageInput +} from '../schemas' +import { + listCmsPages, + getCmsPage, + createCmsPage, + updateCmsPage, + deleteCmsPage, + applyPageInvalidations +} from '../../services/cmsPages' +import { upsertCmsPageTranslation, applyTranslationInvalidations } from '../../services/cmsTranslations' +import { tiptapValidate } from '../../utils/tiptapValidate' +import { markdownToTiptap } from '../../utils/tiptapFromMarkdown' +import { tiptapToMarkdown } from '../../utils/tiptapToMarkdown' +import { detectLossy } from '../../utils/tiptapLossyDetector' + +export const listPagesTool = defineMcpTool({ + name: 'list_pages', + description: 'List CMS pages with their translations. Returns a paginated list ordered by menu_order then id; use the returned `nextCursor` to page through. Filters: category_id (or null for uncategorized), status (requires locale; use "any" to skip), locale, and a substring query against slug/title.', + scope: 'pages.view', + input: listPagesInput, + async handler(input) { + const result = await listCmsPages({ + category_id: input.category_id ?? undefined, + status: input.status, + locale: input.locale, + query: input.query, + limit: input.limit, + cursor: input.cursor + }) + return { + content: [{ type: 'text', text: `Found ${result.pages.length} page(s).` }], + structuredContent: result + } + } +}) + +export const getPageTool = defineMcpTool({ + name: 'get_page', + description: 'Fetch a single CMS page by id or slug, including all translations (any status). Each translation includes body_json (Tiptap JSON), a Markdown rendering, and a body_is_lossy_in_markdown flag.', + scope: 'pages.view', + input: getPageInput, + async handler(input) { + const found = await getCmsPage({ id: input.id, slug: input.slug }) + if (!found) { + return { + content: [{ type: 'text', text: 'Page not found' }], + isError: true + } + } + const translations = found.translations.map((t) => { + const lossy = detectLossy(t.body_json) + return { + id: t.id, + page_id: t.page_id, + locale: t.locale, + title: t.title, + body_json: t.body_json, + body_markdown: tiptapToMarkdown(t.body_json), + body_is_lossy_in_markdown: lossy.body_is_lossy_in_markdown, + body_lossy_reasons: lossy.body_lossy_reasons, + excerpt: t.excerpt, + featured_image: t.featured_image, + meta_title: t.meta_title, + meta_description: t.meta_description, + og_image: t.og_image, + status: t.status, + updated_at: new Date(t.updated as unknown as string).toISOString() + } + }) + return { + content: [{ type: 'text', text: `Page "${found.page.slug}" (${translations.length} translation(s))` }], + structuredContent: { + page: { + id: found.page.id, + slug: found.page.slug, + category_id: found.page.category_id, + category_slug: found.category_slug, + menu_order: found.page.menu_order, + theme: found.page.theme, + custom_css: found.page.custom_css + }, + translations + } + } + } +}) + +export const createPageTool = defineMcpTool({ + name: 'create_page', + description: 'Create a new CMS page shell, optionally with an inline initial translation. The page shell + translation are committed atomically. Slug rules: under a category the slug must start with "/"; uncategorized slugs cannot collide with category slugs. If translation.body_markdown is supplied it is converted to Tiptap JSON server-side; if translation.body_json is supplied it is validated against the schema/attribute allowlist.', + scope: 'pages.write', + input: createPageInput, + async handler(input, ctx) { + let inlineTranslation: Parameters[0]['translation'] + if (input.translation) { + // Zod's inlineTranslationInput.refine() already enforces exactly + // one of body_json / body_markdown, so we never reach this code + // path with neither set — pick whichever the schema admitted. + const body_json: Record = input.translation.body_json + ? (input.translation.body_json as Record) + : (markdownToTiptap(input.translation.body_markdown!) as unknown as Record) + tiptapValidate(body_json) + inlineTranslation = { + locale: input.translation.locale, + title: input.translation.title, + body_json, + excerpt: input.translation.excerpt ?? null, + featured_image: input.translation.featured_image ?? null, + meta_title: input.translation.meta_title ?? null, + meta_description: input.translation.meta_description ?? null, + og_image: input.translation.og_image ?? null, + status: input.translation.status + } + } + + const created = await createCmsPage( + { + slug: input.slug, + category_id: input.category_id ?? null, + menu_order: input.menu_order, + theme: input.theme, + custom_css: input.custom_css ?? null, + translation: inlineTranslation + }, + async ({ pageId, translationId }, executor) => { + await mcpLog('CREATE', 'pages', pageId, ctx, { slug: input.slug }, executor as never) + if (translationId && inlineTranslation) { + // Audit the translation row by its actual id so greps on + // record_id find it. Both inserts commit in the same tx, + // so the audit row's executor is honored. + await mcpLog('CREATE', 'page_translations', translationId, ctx, { + page_id: pageId, + locale: inlineTranslation.locale, + via: 'create_page' + }, executor as never) + } + } + ) + + await applyPageInvalidations(created.slugsToPurge, created.categoriesToPurge) + + return { + content: [{ type: 'text', text: `Created page "${created.page.slug}"` }], + structuredContent: { + page: created.page, + translation: created.translation + } + } + } +}) + +export const updatePageTool = defineMcpTool({ + name: 'update_page', + description: 'Update a page\'s metadata: slug, category_id, menu_order, theme, custom_css. Translation edits go through upsert_page_translation. Changing the category rewrites the slug prefix; cache for the old + new slug + categories is purged.', + scope: 'pages.write', + input: updatePageInput, + async handler(input, ctx) { + const result = await updateCmsPage({ + id: input.id, + slug: input.slug, + category_id: input.category_id ?? undefined, + menu_order: input.menu_order, + theme: input.theme, + custom_css: input.custom_css ?? undefined + }) + if (Object.keys(result.changes).length > 0) { + await mcpLog('UPDATE', 'pages', input.id, ctx, { changes: result.changes }) + } + await applyPageInvalidations(result.slugsToPurge, result.categoriesToPurge) + return { + content: [{ type: 'text', text: `Updated page "${result.page.slug}"` }], + structuredContent: { page: result.page, changes: result.changes } + } + } +}) + +export const deletePageTool = defineMcpTool({ + name: 'delete_page', + description: 'Delete a CMS page and (via cascade) all its translations. This is destructive — the operation is irreversible without a database restore.', + scope: 'pages.write', + destructive: true, + input: deletePageInput, + async handler(input, ctx) { + const { slug, categoryId } = await deleteCmsPage(input.id) + await mcpLog('DELETE', 'pages', input.id, ctx, { slug }) + await applyPageInvalidations([slug], categoryId ? [categoryId] : []) + return { + content: [{ type: 'text', text: `Deleted page "${slug}"` }], + structuredContent: { ok: true } + } + } +}) + +export const PAGE_TOOLS = [ + listPagesTool, + getPageTool, + createPageTool, + updatePageTool, + deletePageTool +] as const + +// Re-export so the translations.ts importer can pull the same tools +// helpers without repeating the import paths. +export { upsertCmsPageTranslation, applyTranslationInvalidations } diff --git a/server/mcp/tools/translate.ts b/server/mcp/tools/translate.ts new file mode 100644 index 00000000..5531aca0 --- /dev/null +++ b/server/mcp/tools/translate.ts @@ -0,0 +1,117 @@ +// DeepL text-translation tool. Generic primitive — Claude composes +// with upsert_page_translation when applying translations to pages. +// +// The configured DeepL glossary for each target locale is always +// applied (the translateText service consults config/languages.ts), +// keeping MCP-driven translations terminologically consistent with +// admin auto-translates. + +import { defineMcpTool, mcpLog } from '#mcp-layer' +import { translateTextInput, translatePageInput } from '../schemas' +import { translateText, translatePage } from '../../services/cmsTranslate' + +const DESCRIPTION = `Translate a piece of text into one or more locales using DeepL. + +Use this when you've authored a section in one language (typically English) and need versions in the site's other locales — for example, "translate this paragraph then add it to each page in every locale." + +Inputs: +- \`text\`: the source text. Plain text or markdown — any markup is preserved as-is in the output. +- \`source_locale\`: defaults to "en"; must be one of the site's enabled locales. +- \`target_locales\`: array of locale codes to translate into; each must be enabled and different from the source. + +Output: \`translations: [{locale, text, glossary_used}]\`. \`glossary_used\` is true when a per-locale terminology glossary was applied (consistent with admin auto-translate behavior). After this returns, embed each translated text into the corresponding locale's page via upsert_page_translation. + +Limits: 100 KB per call. For longer content, split into sections and translate each separately.` + +export const translateTextTool = defineMcpTool({ + name: 'translate_text', + description: DESCRIPTION, + scope: 'pages.write', + input: translateTextInput, + async handler(input, ctx) { + const result = await translateText({ + text: input.text, + source_locale: input.source_locale, + target_locales: input.target_locales + }) + + // Audit the call so the activity log records when DeepL spend + // happened on Claude's behalf. Don't include the full text in + // metadata — keep the audit row small and non-PII-leaky. + await mcpLog('CREATE', 'translations.cache', `${result.source_locale}->${result.translations.map(t => t.locale).join(',')}`, ctx, { + source_locale: result.source_locale, + target_locales: result.translations.map(t => t.locale), + char_count: input.text.length + }) + + return { + content: [{ + type: 'text', + text: `Translated ${input.text.length} chars from ${result.source_locale} → ${result.translations.length} locale(s).` + }], + structuredContent: { + source_locale: result.source_locale, + translations: result.translations + } + } + } +}) + +// ── Page-level translate ──────────────────────────────────────────── + +const PAGE_DESCRIPTION = `Translate an entire CMS page (title, excerpt, meta_title, meta_description, body) from one locale into one or more target locales using DeepL. + +Use this for the "make this page exist in all locales" workflow. For surgical edits — translating one new section and inserting it into existing translations — prefer translate_text composed with upsert_page_translation. + +Inputs: +- \`page_id\`: the CMS page id. +- \`source_locale\`: defaults to "en". Must be an enabled locale with an existing translation row. +- \`target_locales\`: array of enabled locale codes. Per-locale failures don't abort the batch — DeepL flaking on one locale lets the rest proceed. +- \`overwrite\`: defaults to false. When false, locales that already have a translation row are silently skipped (no DeepL call). Set true to replace. +- \`status\`: \`draft\` (default) or \`published\` for the new translation rows. + +Output: \`{source_locale, results: [{locale, ok? | skipped? | error?}]}\`. Each result entry reports one of three outcomes; \`ok\` means a row was written and the public cache was purged for that locale. + +Cost note: this triggers up to 5 DeepL calls per target locale (4 metadata strings batched into one call + 1 body call). A "translate to all 9 locales" run on a typical page is ~18 DeepL requests.` + +export const translatePageTool = defineMcpTool({ + name: 'translate_page', + description: PAGE_DESCRIPTION, + scope: 'pages.write', + input: translatePageInput, + async handler(input, ctx) { + const result = await translatePage({ + page_id: input.page_id, + source_locale: input.source_locale, + target_locales: input.target_locales, + overwrite: input.overwrite, + status: input.status + }) + + await mcpLog('UPDATE', 'pages', input.page_id, ctx, { + event: 'TRANSLATE', + source_locale: result.source_locale, + target_locales: input.target_locales, + results: result.results.map(r => ({ + locale: r.locale, + outcome: r.ok ? 'ok' : r.skipped ? 'skipped' : 'error' + })) + }) + + const okCount = result.results.filter(r => r.ok).length + const skipCount = result.results.filter(r => r.skipped).length + const errCount = result.results.filter(r => r.error).length + return { + content: [{ + type: 'text', + text: `Translated page from ${result.source_locale}: ${okCount} written, ${skipCount} skipped, ${errCount} errored.` + }], + structuredContent: { + source_locale: result.source_locale, + results: result.results + } + } + } +}) + +export const TRANSLATE_TOOLS = [translateTextTool, translatePageTool] as const diff --git a/server/mcp/tools/translations.ts b/server/mcp/tools/translations.ts new file mode 100644 index 00000000..63f08eab --- /dev/null +++ b/server/mcp/tools/translations.ts @@ -0,0 +1,187 @@ +// Page-translation tools: read, upsert, publish, unpublish. +// +// upsert_page_translation is the largest single tool — its description +// is load-bearing for Claude's Markdown-vs-JSON choice. The constant +// below is the source of truth for both the runtime parser context +// and the advertised description. + +import { defineMcpTool, mcpLog } from '#mcp-layer' +import { + getPageTranslationInput, + upsertPageTranslationInput, + publishTranslationInput, + unpublishTranslationInput +} from '../schemas' +import { + upsertCmsPageTranslation, + getCmsPageTranslation, + setCmsTranslationStatus, + applyTranslationInvalidations +} from '../../services/cmsTranslations' +import { tiptapToMarkdown } from '../../utils/tiptapToMarkdown' +import { detectLossy } from '../../utils/tiptapLossyDetector' +import { logEvent } from '../../utils/activity-logger' + +const UPSERT_DESCRIPTION = `Upsert a page translation for a given locale. Pass exactly one of body_markdown or body_json. + +Markdown vs. JSON — which to use: +- For most edits prefer body_markdown — it is easier to author and round-trips cleanly for plain text, headings, bold/italic, lists, links, code, and images without alignment. +- Use body_json when the existing translation's body is lossy in Markdown (the read tools and resources surface body_is_lossy_in_markdown plus body_lossy_reasons). On a translation whose current body is lossy, a Markdown overwrite is rejected with {error: "lossy_overwrite_blocked", body_lossy_reasons: [...]} unless allow_lossy_overwrite: true is also passed — that flag silently drops the lossy nodes/marks/attributes and persists the Markdown conversion. + +Custom node JSON (when editing body_json directly): +- Verse: {"type":"verse","attrs":{"reference":"John 3:16"},"content":[{"type":"paragraph","content":[{"type":"text","text":"For God so loved the world..."}]}]} +- UupgsList: {"type":"uupgsList","attrs":{"languageCode":"en","perPage":12,"useSelectCard":true}} +- GeneralResources: {"type":"generalResources","attrs":{"useDocuments":true,"layout":"grid"}} +- CmsImage with align: {"type":"image","attrs":{"src":"https://...","alt":"...","align":"center"}} + +Not allowed: inline HTML in Markdown is escaped to text; style/script/srcdoc/javascript: URLs reject. Tables in Markdown are preserved as literal text (no table extension is registered). + +Status defaults to draft. Publishing requires the pages.publish scope — use publish_translation.` + +export const getPageTranslationTool = defineMcpTool({ + name: 'get_page_translation', + description: 'Fetch a single translation by page id + locale. Returns body_json (Tiptap), a Markdown rendering, and the lossy flag/reasons.', + scope: 'pages.view', + input: getPageTranslationInput, + async handler(input) { + const t = await getCmsPageTranslation({ page_id: input.page_id, locale: input.locale }) + if (!t) { + return { + content: [{ type: 'text', text: 'Translation not found' }], + isError: true + } + } + const lossy = detectLossy(t.body_json) + return { + content: [{ type: 'text', text: `Translation "${t.title}" (${t.locale}, ${t.status})` }], + structuredContent: { + id: t.id, + page_id: t.page_id, + locale: t.locale, + title: t.title, + body_json: t.body_json, + body_markdown: tiptapToMarkdown(t.body_json), + body_is_lossy_in_markdown: lossy.body_is_lossy_in_markdown, + body_lossy_reasons: lossy.body_lossy_reasons, + excerpt: t.excerpt, + featured_image: t.featured_image, + meta_title: t.meta_title, + meta_description: t.meta_description, + og_image: t.og_image, + status: t.status, + updated_at: new Date(t.updated as unknown as string).toISOString() + } + } + } +}) + +export const upsertPageTranslationTool = defineMcpTool({ + name: 'upsert_page_translation', + description: UPSERT_DESCRIPTION, + scope: 'pages.write', + input: upsertPageTranslationInput, + async handler(input, ctx) { + const result = await upsertCmsPageTranslation({ + page_id: input.page_id, + locale: input.locale, + title: input.title, + body_markdown: input.body_markdown, + body_json: input.body_json as Record | undefined, + excerpt: input.excerpt ?? null, + featured_image: input.featured_image ?? null, + meta_title: input.meta_title ?? null, + meta_description: input.meta_description ?? null, + og_image: input.og_image ?? null, + status: input.status, + allow_lossy_overwrite: input.allow_lossy_overwrite + }) + + await mcpLog('UPDATE', 'page_translations', result.translation.id, ctx, { + page_id: input.page_id, + locale: input.locale, + status: result.translation.status, + via: 'upsert_page_translation' + }) + + if (result.lossyOverwriteApplied) { + await logEvent({ + eventType: 'mcp.lossy_overwrite', + tableName: 'page_translations', + recordId: result.translation.id, + userId: ctx.auth.userId, + metadata: { + source: 'mcp', + client_id: ctx.auth.clientId, + tool: 'upsert_page_translation', + locale: input.locale, + dropped_reasons: result.droppedReasons + } + }) + } + + await applyTranslationInvalidations(result.pageSlug, result.categoryId, input.locale) + + return { + content: [{ + type: 'text', + text: `Saved translation "${result.translation.title}" (${result.translation.locale}, ${result.translation.status})` + }], + structuredContent: { translation: result.translation } + } + } +}) + +export const publishTranslationTool = defineMcpTool({ + name: 'publish_translation', + description: 'Publish a translation. The body content is not changed — only the status flips to "published". Errors if the translation does not exist.', + scope: 'pages.publish', + input: publishTranslationInput, + async handler(input, ctx) { + const result = await setCmsTranslationStatus({ + page_id: input.page_id, + locale: input.locale, + status: 'published' + }) + await mcpLog('UPDATE', 'page_translations', result.translation.id, ctx, { + field: 'status', + to: 'published', + locale: input.locale + }) + await applyTranslationInvalidations(result.pageSlug, result.categoryId, input.locale) + return { + content: [{ type: 'text', text: `Published ${result.translation.locale} translation` }], + structuredContent: { translation: result.translation } + } + } +}) + +export const unpublishTranslationTool = defineMcpTool({ + name: 'unpublish_translation', + description: 'Unpublish a translation (status flips to "draft"). The body content is preserved.', + scope: 'pages.publish', + input: unpublishTranslationInput, + async handler(input, ctx) { + const result = await setCmsTranslationStatus({ + page_id: input.page_id, + locale: input.locale, + status: 'draft' + }) + await mcpLog('UPDATE', 'page_translations', result.translation.id, ctx, { + field: 'status', + to: 'draft', + locale: input.locale + }) + await applyTranslationInvalidations(result.pageSlug, result.categoryId, input.locale) + return { + content: [{ type: 'text', text: `Unpublished ${result.translation.locale} translation` }], + structuredContent: { translation: result.translation } + } + } +}) + +export const TRANSLATION_TOOLS = [ + getPageTranslationTool, + upsertPageTranslationTool, + publishTranslationTool, + unpublishTranslationTool +] as const diff --git a/server/plugins/10-mcp-cms.ts b/server/plugins/10-mcp-cms.ts new file mode 100644 index 00000000..af9c2bc6 --- /dev/null +++ b/server/plugins/10-mcp-cms.ts @@ -0,0 +1,26 @@ +// Registers all CMS MCP tools + the cms://page/* resource against +// the layer's registry. The numeric prefix 10- ensures this runs +// after the layer's 00-mcp-init.ts (which initializes nitroApp.mcpRegistry). + +import { getRegistry } from '#mcp-layer' +import type { McpToolDef } from '#mcp-layer' +import { PAGE_TOOLS } from '../mcp/tools/pages' +import { TRANSLATION_TOOLS } from '../mcp/tools/translations' +import { CATEGORY_TOOLS } from '../mcp/tools/categories' +import { ASSET_TOOLS } from '../mcp/tools/assets' +import { TRANSLATE_TOOLS } from '../mcp/tools/translate' +import { cmsPageResource } from '../mcp/resources/pages' + +export default defineNitroPlugin(() => { + const registry = getRegistry() + + // Each registry.register call is generic over the tool's I/O. The + // shared array's element type is a union, so we register each as + // an opaque McpToolDef — the registry only reads name/scope/etc. + const allTools = [...PAGE_TOOLS, ...TRANSLATION_TOOLS, ...CATEGORY_TOOLS, ...ASSET_TOOLS, ...TRANSLATE_TOOLS] + for (const tool of allTools) { + registry.register(tool as McpToolDef) + } + + registry.registerResource(cmsPageResource) +}) diff --git a/server/services/cmsAssets.ts b/server/services/cmsAssets.ts new file mode 100644 index 00000000..add33c16 --- /dev/null +++ b/server/services/cmsAssets.ts @@ -0,0 +1,338 @@ +// Asset-upload service. Two flows go through here: +// +// 1. uploadImage(): admin multipart route hands a decoded Buffer to +// the server, which sniffs magic bytes, validates type/size, and +// uploads to S3 in a single step. +// +// 2. prepareImageUpload() + finalizeImageUpload(): MCP agents bypass +// base64 encoding by getting a presigned S3 PUT URL, uploading +// bytes directly, then asking the server to validate the result. +// The presign signs Content-Type *and* Content-Length into the URL +// (caps size at the value the agent declared); finalize HEADs the +// object and reads the first 12 bytes via a Range GET to confirm +// the actual payload matches the declared MIME (defends against +// a non-image masquerading as an image past the Content-Type +// binding). On validation failure, finalize deletes the object +// before returning the error. + +import type { H3Error } from 'h3' +import { createHmac, randomBytes, timingSafeEqual } from 'crypto' +import { + uploadToS3, + validateImageType, + validateFileSize, + createPresignedUploadUrl, + getPublicUrl, + getObjectByteRange, + headObject, + deleteFromS3 +} from '../utils/storage' + +const MAX_IMAGE_MB = 10 +const MAX_IMAGE_BYTES = MAX_IMAGE_MB * 1024 * 1024 + +function err(statusCode: number, message: string): H3Error { + return Object.assign(new Error(message), { + statusCode, + statusMessage: message, + data: { message } + }) as unknown as H3Error +} + +// Sniff the image MIME type from the first few bytes. Defends against +// the client lying about Content-Type: an attacker can claim +// `image/jpeg` and ship a script; magic-byte checking is the only +// reliable signal. +// +// Recognises JPEG, PNG, WebP — the same set validateImageType() admits. +// Returns null when the bytes don't match any allowed type. +function sniffImageMime(buf: Buffer): string | null { + if (buf.length < 12) return null + + // JPEG: FF D8 FF + if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg' + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if ( + buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 + && buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a + ) return 'image/png' + + // WebP: 'RIFF' .... 'WEBP' + if ( + buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 + && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50 + ) return 'image/webp' + + return null +} + +export interface UploadImageInput { + buffer: Buffer + filename?: string + // The client-supplied MIME type. Only used as a hint; the actual + // type comes from sniffImageMime() and must match. + declaredMimeType?: string + alt?: string +} + +export interface UploadImageResult { + url: string + key: string + filename: string + mimeType: string + byteSize: number + alt?: string +} + +export async function uploadImage(input: UploadImageInput): Promise { + const { buffer } = input + if (!buffer || !Buffer.isBuffer(buffer) || buffer.length === 0) { + throw err(400, 'image bytes are required') + } + + if (!validateFileSize(buffer.length, MAX_IMAGE_MB)) { + throw err(413, `Image exceeds ${MAX_IMAGE_MB}MB limit`) + } + + const sniffed = sniffImageMime(buffer) + if (!sniffed) { + throw err(415, 'Unsupported image type — only JPEG, PNG, and WebP are accepted') + } + // Defensive double-check: if the client declared a different type, refuse. + // Catches accidental mismatches between Claude's content-block claim and + // the bytes; preserves the admin path's validateImageType() guarantee. + if (input.declaredMimeType && input.declaredMimeType !== sniffed && !validateImageType(input.declaredMimeType)) { + throw err(415, `Declared MIME type "${input.declaredMimeType}" does not match the file contents`) + } + + const filename = (input.filename || 'upload').replace(/[^\w.\-]+/g, '_') + const result = await uploadToS3(buffer, filename, sniffed, 'public') + + return { + url: result.url, + key: result.key, + filename: result.filename, + mimeType: sniffed, + byteSize: buffer.length, + alt: input.alt + } +} + +// ── Presigned PUT flow ────────────────────────────────────────────── + +const MIME_TO_EXT: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp' +} + +const PRESIGN_TTL_SECONDS = 5 * 60 +const TOKEN_TTL_SECONDS = 30 * 60 // longer than presign — the agent's + // PUT might race the URL expiry, so + // give the finalize step a wider + // window before the token itself + // becomes unverifiable. + +// HMAC-signed token binding key + mimeType + byteSize + expiry. The +// agent treats it as opaque; finalize verifies the signature before +// trusting any of the bound values. +// +// Using jwtSecret as the HMAC key (already required for the auth +// surface) with a domain-separator prefix so a leak in one direction +// doesn't help forge the other. If a dedicated assetUploadSecret is +// added later, swap the secret source — the signing format itself +// is stable. +function getSigningKey(): Buffer { + const secret = (useRuntimeConfig().jwtSecret as string) || '' + if (!secret) { + throw err(500, 'jwtSecret is not configured — cannot sign upload tokens') + } + return createHmac('sha256', `asset-upload:${secret}`).update('').digest() +} + +interface UploadTokenPayload { + k: string // s3 key + m: string // mimeType + s: number // byteSize + e: number // expires-at (unix seconds) +} + +function signUploadToken(payload: UploadTokenPayload): string { + const json = JSON.stringify(payload) + const body = Buffer.from(json, 'utf8').toString('base64url') + const sig = createHmac('sha256', getSigningKey()).update(body).digest('base64url') + return `${body}.${sig}` +} + +function verifyUploadToken(token: string): UploadTokenPayload { + const dot = token.indexOf('.') + if (dot < 1) throw err(400, 'malformed upload_token') + const body = token.slice(0, dot) + const sig = token.slice(dot + 1) + + const expected = createHmac('sha256', getSigningKey()).update(body).digest('base64url') + const sigBuf = Buffer.from(sig) + const expBuf = Buffer.from(expected) + if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) { + throw err(401, 'upload_token signature is invalid') + } + + let payload: UploadTokenPayload + try { + payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) + } catch { + throw err(400, 'upload_token payload is malformed') + } + if (typeof payload.k !== 'string' || typeof payload.m !== 'string' || typeof payload.s !== 'number' || typeof payload.e !== 'number') { + throw err(400, 'upload_token payload is incomplete') + } + if (Math.floor(Date.now() / 1000) > payload.e) { + throw err(401, 'upload_token has expired') + } + return payload +} + +export interface PrepareImageUploadInput { + filename?: string + mimeType: string + byteSize: number +} + +export interface PrepareImageUploadResult { + upload_url: string + upload_token: string + key: string + expires_at: string + required_headers: Record + byte_size: number + content_type: string +} + +// Generates an S3 key + a presigned PUT URL bound to the agent's +// declared mimeType + byteSize. The agent uploads bytes directly to +// S3 (no base64 roundtrip), then calls finalizeImageUpload() with +// the returned upload_token to validate the actual payload and +// receive the public final_url. +// +// The presigned URL won't accept a PUT whose Content-Length or +// Content-Type header doesn't match what was signed; this caps the +// upload size at the declared value (no runaway uploads to a public +// bucket) and binds the declared MIME to the response Content-Type +// S3 stores. Magic-byte validation of the actual payload is deferred +// to finalize. +export async function prepareImageUpload(input: PrepareImageUploadInput): Promise { + const mimeType = input.mimeType?.toLowerCase() + if (!mimeType || !validateImageType(mimeType)) { + throw err(415, 'mimeType must be image/jpeg, image/png, or image/webp') + } + const ext = MIME_TO_EXT[mimeType] + if (!ext) { + throw err(415, 'mimeType must be image/jpeg, image/png, or image/webp') + } + + const byteSize = Math.floor(input.byteSize) + if (!Number.isFinite(byteSize) || byteSize <= 0) { + throw err(400, 'byte_size must be a positive integer') + } + if (byteSize > MAX_IMAGE_BYTES) { + throw err(413, `byte_size exceeds ${MAX_IMAGE_MB}MB limit`) + } + + const random = randomBytes(16).toString('hex') + const key = `uploads/${random}.${ext}` + const expiresUnix = Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS + + const upload_url = await createPresignedUploadUrl(key, mimeType, byteSize, PRESIGN_TTL_SECONDS) + const upload_token = signUploadToken({ k: key, m: mimeType, s: byteSize, e: expiresUnix }) + + // PRESIGN_TTL is the more aggressive of the two — that's when the + // PUT itself stops working. Surface it as the visible expiry. + const presignExpiresAt = new Date(Date.now() + PRESIGN_TTL_SECONDS * 1000).toISOString() + + return { + upload_url, + upload_token, + key, + expires_at: presignExpiresAt, + required_headers: { + // The presign signs both into the URL; the client MUST send + // these exact headers in the PUT or S3 rejects with + // SignatureDoesNotMatch. + 'Content-Type': mimeType, + 'Content-Length': String(byteSize) + }, + byte_size: byteSize, + content_type: mimeType + } +} + +export interface FinalizeImageUploadInput { + upload_token: string + alt?: string +} + +export interface FinalizeImageUploadResult { + url: string + key: string + mimeType: string + byteSize: number + alt?: string +} + +// Verifies that a presigned upload completed correctly. Agent calls +// this after their PUT to convert the upload_token into a public URL. +// +// Validates, in order: +// 1. Token signature (HMAC against jwtSecret + domain separator). +// 2. Token has not expired. +// 3. The S3 object exists at the bound key. +// 4. The object's Content-Length matches the bound byteSize. +// 5. The object's Content-Type matches the bound mimeType. +// 6. The first 12 bytes are real image magic bytes for the +// declared MIME (defends against a non-image PUT'd with an +// image Content-Type header). +// +// On any failure: the S3 object is deleted (best-effort) and the +// error is thrown. On success: returns the public final_url. +export async function finalizeImageUpload(input: FinalizeImageUploadInput): Promise { + const payload = verifyUploadToken(input.upload_token) + + const head = await headObject(payload.k) + if (!head) { + throw err(404, 'no upload found for this token — did the PUT complete?') + } + + const bail = async (status: number, message: string) => { + // Best-effort delete; ignore failures so we surface the original + // validation error rather than a delete error. + try { await deleteFromS3(payload.k) } catch { /* ignore */ } + throw err(status, message) + } + + if (head.contentLength !== payload.s) { + return await bail(409, `uploaded byte size (${head.contentLength}) does not match declared (${payload.s})`) + } + if (head.contentType && head.contentType !== payload.m) { + return await bail(409, `uploaded Content-Type (${head.contentType}) does not match declared (${payload.m})`) + } + + // Magic-byte sniff. Range GET the first 12 bytes; sniffImageMime + // requires that exact length to recognise WebP. + const headerBytes = await getObjectByteRange(payload.k, 0, 11) + const sniffed = sniffImageMime(headerBytes) + if (!sniffed) { + return await bail(415, 'uploaded bytes are not a recognised image (JPEG, PNG, or WebP)') + } + if (sniffed !== payload.m) { + return await bail(415, `actual image type (${sniffed}) does not match declared (${payload.m})`) + } + + return { + url: getPublicUrl(payload.k), + key: payload.k, + mimeType: sniffed, + byteSize: payload.s, + alt: input.alt + } +} diff --git a/server/services/cmsCategories.ts b/server/services/cmsCategories.ts new file mode 100644 index 00000000..c2d642de --- /dev/null +++ b/server/services/cmsCategories.ts @@ -0,0 +1,201 @@ +// Category service. List/get reuse existing helpers; the writes +// implement the validation + cache-purge contract MCP and admin both +// rely on. Delete encodes the "refuse if pages still attached" rule +// from plans/mcp-project/mcp-project.md (count + sample-slugs surface +// on the structuredContent the tool returns). + +import type { H3Error } from 'h3' +import { sql } from 'kysely' +import { db } from '../utils/database' +import { ENABLED_LANGUAGE_CODES } from '~~/config/languages' +import { + listCategoriesWithTranslations as dbListCategoriesWithTranslations, + getCategory as dbGetCategory, + createCategory as dbCreateCategory, + updateCategory as dbUpdateCategory, + purgeSlugs +} from '../database/categories' +import type { Category, CategoryTranslation } from '../database/categories' + +function err(statusCode: number, message: string, extra?: Record): H3Error { + return Object.assign(new Error(message), { + statusCode, + statusMessage: message, + data: { message, ...extra } + }) as unknown as H3Error +} + +export interface CategoryListItem extends Category { + translations: CategoryTranslation[] + page_count: number +} + +export async function listCmsCategories(): Promise { + return dbListCategoriesWithTranslations() +} + +export async function getCmsCategory(id: string) { + return dbGetCategory(id) +} + +function validateCategorySlug(slug: string): string { + const cleaned = slug.trim().replace(/^\/+|\/+$/g, '') + if (!cleaned) throw err(400, 'slug is required') + if (!/^[a-z0-9][a-z0-9-]*$/.test(cleaned)) { + throw err(400, 'Category slug must be lowercase letters, digits, and dashes (no slashes)') + } + return cleaned +} + +function normalizeTranslations( + raw: Array<{ locale: string; name: string }> | undefined +): Array<{ locale: string; name: string }> { + if (!raw) return [] + return raw + .filter(t => t && typeof t.locale === 'string' && typeof t.name === 'string') + .filter(t => ENABLED_LANGUAGE_CODES.includes(t.locale)) + .map(t => ({ locale: t.locale, name: t.name.trim() })) +} + +export interface CreateCategoryInput { + slug: string + menu_order?: number + translations: Array<{ locale: string; name: string }> +} + +export async function createCmsCategory(input: CreateCategoryInput): Promise { + const slug = validateCategorySlug(input.slug) + const translations = normalizeTranslations(input.translations) + if (!translations.some(t => t.locale === 'en' && t.name)) { + throw err(400, 'An English name is required') + } + + // Block collision with an uncategorized page + const collidingPage = await db + .selectFrom('pages') + .select('id') + .where('slug', '=', slug) + .where('category_id', 'is', null) + .executeTakeFirst() + if (collidingPage) { + throw err(409, `A page already uses the slug "${slug}". Pick a different category slug or reassign that page.`) + } + + try { + return await dbCreateCategory({ + slug, + menu_order: input.menu_order ?? 0, + translations + }) + } catch (e: unknown) { + if ((e as { code?: string })?.code === '23505') { + throw err(409, 'A category with that slug already exists') + } + throw e + } +} + +export interface UpdateCategoryInput { + id: string + slug?: string + menu_order?: number + translations?: Array<{ locale: string; name: string }> +} + +export async function updateCmsCategory(input: UpdateCategoryInput): Promise<{ category: Category; slugsToPurge: string[] }> { + const args: Parameters[1] = {} + if (input.slug !== undefined) { + const slug = validateCategorySlug(input.slug) + const collidingPage = await db + .selectFrom('pages') + .select('id') + .where('slug', '=', slug) + .where('category_id', 'is', null) + .executeTakeFirst() + if (collidingPage) { + throw err(409, `A page already uses the slug "${slug}".`) + } + args.slug = slug + } + if (input.menu_order !== undefined) args.menu_order = input.menu_order + if (input.translations !== undefined) args.translations = normalizeTranslations(input.translations) + + try { + return await dbUpdateCategory(input.id, args) + } catch (e: unknown) { + if ((e as { code?: string })?.code === '23505') { + throw err(409, 'A category with that slug already exists') + } + throw e + } +} + +export interface DeleteCategoryError { + error: 'attached_pages_present' + attached_page_count: number + sample_slugs: string[] +} + +// Returns null on success, or a structured error payload when pages +// are still attached. Uses the FK constraint to handle the rare race +// between count and delete (see plans/mcp-project/mcp-project.md). +export async function deleteCmsCategory(id: string): Promise<{ ok: true; slug: string } | DeleteCategoryError> { + const existing = await db + .selectFrom('categories') + .select(['id', 'slug']) + .where('id', '=', id) + .executeTakeFirst() + if (!existing) throw err(404, 'Category not found') + + const result = await db.transaction().execute(async (tx) => { + const countRow = await tx + .selectFrom('pages') + .select(sql`COUNT(*)::int`.as('count')) + .where('category_id', '=', id) + .executeTakeFirstOrThrow() + const count = Number(countRow.count) + + if (count > 0) { + const sample = await tx + .selectFrom('pages') + .select('slug') + .where('category_id', '=', id) + .limit(5) + .execute() + return { + kind: 'attached' as const, + attached_page_count: count, + sample_slugs: sample.map(s => s.slug) + } + } + + try { + await tx.deleteFrom('categories').where('id', '=', id).execute() + return { kind: 'deleted' as const, slug: existing.slug } + } catch (e: unknown) { + if ((e as { code?: string })?.code === '23503') { + // FK violation: race window — a page was just attached. + return { + kind: 'attached' as const, + attached_page_count: 1, + sample_slugs: [] + } + } + throw e + } + }) + + if (result.kind === 'attached') { + return { + error: 'attached_pages_present', + attached_page_count: result.attached_page_count, + sample_slugs: result.sample_slugs + } + } + return { ok: true, slug: result.slug } +} + +export async function applyCategoryInvalidations(slugs: string[]): Promise { + if (slugs.length === 0) return + await purgeSlugs(slugs) +} diff --git a/server/services/cmsPages.ts b/server/services/cmsPages.ts new file mode 100644 index 00000000..a6cd8f68 --- /dev/null +++ b/server/services/cmsPages.ts @@ -0,0 +1,519 @@ +// Shared service for CMS page operations. Called by MCP tools today; +// admin routes will follow under a separate service-extraction PR +// (Phase P1/P4 in plans/mcp-project/mcp-project.md). +// +// Each operation is responsible for: validation, DB writes (in a +// transaction when more than one row changes), activity logging +// (post-commit unless inside a transaction), and cache invalidation +// (always post-commit). + +import { sql } from 'kysely' +import type { Kysely } from 'kysely' +import type { H3Error } from 'h3' +import { db } from '../utils/database' +import { purgeCmsPage, purgeCmsCategory } from '../utils/cmsCache' +import { ENABLED_LANGUAGE_CODES } from '~~/config/languages' +import type { Page, PageTranslation } from '../database/pages' +import type { Database } from '../database/schema' + +export interface PageActor { + userId: string + source: 'admin-ui' | 'mcp' + clientId?: string +} + +export interface PageListItem { + id: string + slug: string + category_id: string | null + category_slug: string | null + theme: 'default' | 'green' + menu_order: number + translations: Array<{ + locale: string + title: string + status: 'draft' | 'published' + updated_at: string + }> +} + +export interface ListPagesOptions { + category_id?: string | null + status?: 'draft' | 'published' | 'any' + locale?: string + query?: string + limit?: number + cursor?: string +} + +export interface ListPagesResult { + pages: PageListItem[] + nextCursor: string | null +} + +interface CursorState { + last_menu_order: number + last_id: string +} + +function encodeCursor(state: CursorState): string { + return Buffer.from(JSON.stringify(state)).toString('base64url') +} + +function decodeCursor(cursor: string): CursorState | null { + try { + const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) + if (typeof parsed?.last_menu_order !== 'number' || typeof parsed?.last_id !== 'string') { + return null + } + return parsed + } catch { + return null + } +} + +const DEFAULT_LIMIT = 50 +const MAX_LIMIT = 200 + +export async function listCmsPages(opts: ListPagesOptions = {}): Promise { + const limit = Math.min(MAX_LIMIT, Math.max(1, opts.limit ?? DEFAULT_LIMIT)) + const cursor = opts.cursor ? decodeCursor(opts.cursor) : null + + let q = db + .selectFrom('pages') + .leftJoin('categories', 'categories.id', 'pages.category_id') + .select([ + 'pages.id', + 'pages.slug', + 'pages.category_id', + 'pages.menu_order', + 'pages.theme', + 'categories.slug as category_slug' + ]) + + if (opts.category_id !== undefined) { + if (opts.category_id === null) { + q = q.where('pages.category_id', 'is', null) + } else { + q = q.where('pages.category_id', '=', opts.category_id) + } + } + if (opts.query) { + const pattern = `%${opts.query.toLowerCase()}%` + q = q.where(sql`lower(pages.slug) like ${pattern}`) + } + if (cursor) { + q = q.where(sql`(pages.menu_order, pages.id) > (${cursor.last_menu_order}, ${cursor.last_id})`) + } + + const rows = await q + .orderBy('pages.menu_order', 'asc') + .orderBy('pages.id', 'asc') + .limit(limit + 1) + .execute() + + const pageRows = rows.slice(0, limit) + const hasMore = rows.length > limit + const nextCursor = hasMore && pageRows.length > 0 + ? encodeCursor({ + last_menu_order: pageRows[pageRows.length - 1]!.menu_order, + last_id: pageRows[pageRows.length - 1]!.id + }) + : null + + if (pageRows.length === 0) return { pages: [], nextCursor: null } + + const pageIds = pageRows.map(r => r.id) + let tQuery = db + .selectFrom('page_translations') + .select(['page_id', 'locale', 'title', 'status', 'updated']) + .where('page_id', 'in', pageIds) + + if (opts.locale) { + tQuery = tQuery.where('locale', '=', opts.locale) + if (opts.status && opts.status !== 'any') { + tQuery = tQuery.where('status', '=', opts.status) + } + } + + let translations = await tQuery.execute() + + // Title substring match: requires a translation row to exist for + // the page (any locale). When a query is provided we also keep + // pages whose slug matched (already filtered above). + if (opts.query) { + const pattern = opts.query.toLowerCase() + translations = translations.filter(t => t.title.toLowerCase().includes(pattern)) + } + + const tByPage = new Map>() + for (const t of translations) { + const list = tByPage.get(t.page_id) ?? [] + list.push({ + locale: t.locale, + title: t.title, + status: t.status, + updated_at: new Date(t.updated as unknown as string).toISOString() + }) + tByPage.set(t.page_id, list) + } + + const out: PageListItem[] = pageRows.map(p => ({ + id: p.id, + slug: p.slug, + category_id: p.category_id, + category_slug: p.category_slug ?? null, + theme: p.theme as 'default' | 'green', + menu_order: p.menu_order, + translations: tByPage.get(p.id) ?? [] + })) + + return { pages: out, nextCursor } +} + +export interface FullPage { + page: Page + translations: PageTranslation[] + category_slug: string | null +} + +export async function getCmsPage(input: { id?: string; slug?: string }): Promise { + let pageQuery = db.selectFrom('pages').selectAll() + if (input.id) pageQuery = pageQuery.where('id', '=', input.id) + else if (input.slug) pageQuery = pageQuery.where('slug', '=', input.slug) + else return null + + const page = await pageQuery.executeTakeFirst() + if (!page) return null + + const [translations, category] = await Promise.all([ + db + .selectFrom('page_translations') + .selectAll() + .where('page_id', '=', page.id) + .orderBy('locale', 'asc') + .execute(), + page.category_id + ? db + .selectFrom('categories') + .select('slug') + .where('id', '=', page.category_id) + .executeTakeFirst() + : Promise.resolve(null) + ]) + + return { + page, + translations, + category_slug: category?.slug ?? null + } +} + +// Validates a slug shape and uniqueness. Throws an H3-shaped error so +// the MCP layer's mcpError mapper can translate to a clean response. +function err(statusCode: number, message: string): H3Error { + return Object.assign(new Error(message), { + statusCode, + statusMessage: message, + data: { message } + }) as unknown as H3Error +} + +// Normalize slug input: trim whitespace, strip leading/trailing +// slashes. Single source of truth — admin routes and MCP tools both +// pass raw input; services normalize before validating. Empty result +// passes through; the regex check downstream rejects empty strings. +export function normalizeSlugInput(raw: string | null | undefined): string { + return (raw ?? '').trim().replace(/^\/+|\/+$/g, '') +} + +// Pass an `executor` (a Kysely transaction handle) when validation +// is part of a larger atomic operation — the read-side category +// existence/prefix check and the page-slug-uniqueness check then +// run on the same connection as the subsequent insert/update, so +// a concurrent category rename or page-slug change can't slip +// between validation and write. +export async function validatePageSlug( + slug: string, + opts: { categoryId: string | null; excludePageId?: string }, + executor: Kysely = db +): Promise { + if (!/^[a-z0-9][a-z0-9/-]*$/.test(slug)) { + throw err(400, 'slug must be lowercase letters, digits, dashes, and slashes') + } + if (slug.endsWith('/')) { + throw err(400, 'slug cannot end with /') + } + if (opts.categoryId) { + const category = await executor + .selectFrom('categories') + .select(['slug']) + .where('id', '=', opts.categoryId) + .executeTakeFirst() + if (!category) throw err(400, 'Category not found') + if (!slug.startsWith(`${category.slug}/`)) { + throw err(400, `Slug must start with "${category.slug}/"`) + } + } else { + const collidingCategory = await executor + .selectFrom('categories') + .select('id') + .where('slug', '=', slug) + .executeTakeFirst() + if (collidingCategory) { + throw err(409, `"${slug}" is already used by a category.`) + } + } + + let pageQuery = executor + .selectFrom('pages') + .select('id') + .where('slug', '=', slug) + if (opts.excludePageId) { + pageQuery = pageQuery.where('id', '!=', opts.excludePageId) + } + const collidingPage = await pageQuery.executeTakeFirst() + if (collidingPage) { + throw err(409, 'A page with that slug already exists') + } +} + +export interface CreatePageInput { + slug: string + category_id?: string | null + menu_order?: number + theme?: 'default' | 'green' + custom_css?: string | null + translation?: { + locale: string + title: string + body_json: Record + excerpt?: string | null + featured_image?: string | null + meta_title?: string | null + meta_description?: string | null + og_image?: string | null + status?: 'draft' | 'published' + } +} + +// Creates a page (and optionally an inline initial translation) in a +// single transaction. The audit hook receives the transaction's +// executor; callers (admin routes, MCP tools) are expected to pass a +// `throwOnError: true` to logEvent (or use mcpLog, which does this +// automatically when an executor is provided) so an audit-insert +// failure inside the transaction rolls back the page insert. +export interface CreateCmsPageAuditContext { + pageId: string + // The translation row id, set only when an inline translation was + // created in the same transaction. Lets callers emit a separate + // CREATE event on page_translations keyed by the actual row id so + // audit-log greps on `record_id` find the translation. + translationId: string | null +} + +export async function createCmsPage( + input: CreatePageInput, + audit: (ctx: CreateCmsPageAuditContext, executor: unknown) => Promise +): Promise<{ page: Page; translation: PageTranslation | null; slugsToPurge: string[]; categoriesToPurge: string[] }> { + const slug = normalizeSlugInput(input.slug) + if (!slug) throw err(400, 'slug is required') + if (input.translation) { + if (!ENABLED_LANGUAGE_CODES.includes(input.translation.locale)) { + throw err(400, 'locale is not enabled') + } + } + // Use the normalized form everywhere downstream — input.slug never + // touches the DB after this point. + input = { ...input, slug } + + const result = await db.transaction().execute(async (tx) => { + // Validate the slug inside the transaction so the category-prefix + // check and slug-uniqueness check run on the same connection as + // the insert. Without this a concurrent category rename could + // change the prefix between validation and write. + await validatePageSlug(slug, { categoryId: input.category_id ?? null }, tx) + + const page = await tx + .insertInto('pages') + .values({ + slug: input.slug, + category_id: input.category_id ?? null, + menu_order: input.menu_order ?? 0, + ...(input.theme ? { theme: input.theme } : {}), + ...(input.custom_css !== undefined ? { custom_css: input.custom_css } : {}) + }) + .returningAll() + .executeTakeFirstOrThrow() + + let translation: PageTranslation | null = null + if (input.translation) { + const t = input.translation + translation = await tx + .insertInto('page_translations') + .values({ + page_id: page.id, + locale: t.locale, + title: t.title, + body_json: t.body_json, + excerpt: t.excerpt ?? null, + featured_image: t.featured_image ?? null, + meta_title: t.meta_title ?? null, + meta_description: t.meta_description ?? null, + og_image: t.og_image ?? null, + status: t.status ?? 'draft' + }) + .returningAll() + .executeTakeFirstOrThrow() + } + + await audit({ pageId: page.id, translationId: translation?.id ?? null }, tx) + + return { page, translation } + }) + + const slugsToPurge = [result.page.slug] + const categoriesToPurge: string[] = [] + if (result.page.category_id) categoriesToPurge.push(result.page.category_id) + + return { ...result, slugsToPurge, categoriesToPurge } +} + +export interface UpdatePageInput { + id: string + slug?: string + category_id?: string | null + menu_order?: number + theme?: 'default' | 'green' + custom_css?: string | null +} + +export interface UpdatePageResult { + page: Page + slugsToPurge: string[] + categoriesToPurge: string[] + changes: Record +} + +export async function updateCmsPage(input: UpdatePageInput): Promise { + // Wrap reads + slug validation + UPDATE in one transaction so a + // concurrent rename of the source or target category — or a + // concurrent slug change on another page — can't slip between the + // category-prefix calculation and the row write. The bare-UPDATE + // version that lived here briefly had a race window where the + // page would commit with a stale prefix relative to the category's + // current slug. + return await db.transaction().execute(async (tx) => { + const existing = await tx + .selectFrom('pages') + .selectAll() + .where('id', '=', input.id) + .executeTakeFirst() + if (!existing) throw err(404, 'Page not found') + + const slugsToPurge = new Set([existing.slug]) + const categoriesToPurge = new Set() + const changes: Record = {} + + let finalCategoryId = existing.category_id + if (input.category_id !== undefined) { + finalCategoryId = input.category_id ?? null + } + + let finalSlug = existing.slug + if (input.slug !== undefined) { + finalSlug = normalizeSlugInput(input.slug) + } else if (input.category_id !== undefined && finalCategoryId !== existing.category_id) { + // Category change without slug change: rewrite the prefix. + let leaf = existing.slug + if (existing.category_id) { + const oldCat = await tx + .selectFrom('categories') + .select('slug') + .where('id', '=', existing.category_id) + .executeTakeFirst() + if (oldCat && existing.slug.startsWith(`${oldCat.slug}/`)) { + leaf = existing.slug.slice(oldCat.slug.length + 1) + } + } + if (finalCategoryId) { + const newCat = await tx + .selectFrom('categories') + .select('slug') + .where('id', '=', finalCategoryId) + .executeTakeFirst() + if (!newCat) throw err(400, 'Category not found') + finalSlug = `${newCat.slug}/${leaf}` + } else { + finalSlug = leaf + } + } + + if (finalSlug !== existing.slug) { + await validatePageSlug(finalSlug, { categoryId: finalCategoryId, excludePageId: input.id }, tx) + changes.slug = finalSlug + } + if (finalCategoryId !== existing.category_id) { + changes.category_id = finalCategoryId + } + + if (input.menu_order !== undefined && input.menu_order !== existing.menu_order) { + changes.menu_order = input.menu_order + } + if (input.theme !== undefined && input.theme !== existing.theme) { + changes.theme = input.theme + } + if (input.custom_css !== undefined && input.custom_css !== existing.custom_css) { + changes.custom_css = input.custom_css + } + + if (Object.keys(changes).length === 0) { + return { + page: existing, + slugsToPurge: Array.from(slugsToPurge), + categoriesToPurge: Array.from(categoriesToPurge), + changes + } + } + + const updated = await tx + .updateTable('pages') + .set({ ...changes, updated: sql`now()` }) + .where('id', '=', input.id) + .returningAll() + .executeTakeFirstOrThrow() + + slugsToPurge.add(updated.slug) + if (updated.slug !== existing.slug || updated.menu_order !== existing.menu_order || updated.category_id !== existing.category_id) { + if (existing.category_id) categoriesToPurge.add(existing.category_id) + if (updated.category_id) categoriesToPurge.add(updated.category_id) + } + + return { + page: updated, + slugsToPurge: Array.from(slugsToPurge), + categoriesToPurge: Array.from(categoriesToPurge), + changes + } + }) +} + +export async function deleteCmsPage(id: string): Promise<{ slug: string; categoryId: string | null }> { + const existing = await db + .selectFrom('pages') + .select(['id', 'slug', 'category_id']) + .where('id', '=', id) + .executeTakeFirst() + if (!existing) throw err(404, 'Page not found') + + await db.deleteFrom('pages').where('id', '=', id).execute() + return { slug: existing.slug, categoryId: existing.category_id } +} + +export async function applyPageInvalidations(slugs: string[], categoryIds: string[]): Promise { + await Promise.all(slugs.map(s => purgeCmsPage(s))) + // Skip the slugs we just purged from the category-sibling fan-out; + // otherwise purgeCmsCategory enumerates every member (including the + // page we already touched) and re-purges keys for no benefit. + await Promise.all(categoryIds.map(id => purgeCmsCategory(id, slugs))) +} diff --git a/server/services/cmsTranslate.ts b/server/services/cmsTranslate.ts new file mode 100644 index 00000000..6bf2a513 --- /dev/null +++ b/server/services/cmsTranslate.ts @@ -0,0 +1,226 @@ +// DeepL text-translation service — the canonical primitive both the +// MCP translate_text tool and any future admin batch-translate flow +// share. +// +// Always uses the per-locale DeepL glossary configured in +// config/languages.ts (via translateTexts()'s built-in lookup), so +// the terminology stays consistent with admin auto-translates and +// with the translation/ glossaries repo. + +import type { H3Error } from 'h3' +import { translateTexts, translateTiptapContent, isDeepLConfigured } from '../utils/deepl' +import { ENABLED_LANGUAGE_CODES, getLanguage } from '~~/config/languages' +import { db } from '../utils/database' +import { + upsertCmsPageTranslation, + applyTranslationInvalidations +} from './cmsTranslations' + +function err(statusCode: number, message: string): H3Error { + return Object.assign(new Error(message), { + statusCode, + statusMessage: message, + data: { message } + }) as unknown as H3Error +} + +export interface TranslateTextInput { + text: string + source_locale?: string + target_locales: string[] +} + +export interface TranslateTextResultEntry { + locale: string + text: string + glossary_used: boolean +} + +export interface TranslateTextResult { + source_locale: string + translations: TranslateTextResultEntry[] +} + +const MAX_TEXT_BYTES = 100 * 1024 // DeepL's per-request soft limit; large bodies + // should be split client-side and translated piece by piece + +export async function translateText(input: TranslateTextInput): Promise { + if (!isDeepLConfigured()) { + throw err(503, 'DeepL is not configured on this server') + } + + const text = input.text ?? '' + if (typeof text !== 'string' || text.trim().length === 0) { + throw err(400, 'text is required') + } + if (Buffer.byteLength(text, 'utf8') > MAX_TEXT_BYTES) { + throw err(413, `text exceeds ${MAX_TEXT_BYTES} bytes — split into smaller pieces`) + } + + const source = input.source_locale ?? 'en' + if (!ENABLED_LANGUAGE_CODES.includes(source)) { + throw err(400, `source_locale "${source}" is not enabled`) + } + + if (!Array.isArray(input.target_locales) || input.target_locales.length === 0) { + throw err(400, 'target_locales must be a non-empty array') + } + for (const t of input.target_locales) { + if (!ENABLED_LANGUAGE_CODES.includes(t)) { + throw err(400, `target_locale "${t}" is not enabled`) + } + if (t === source) { + throw err(400, `target_locale "${t}" cannot equal source_locale`) + } + } + + // Dedupe — Claude might list the same locale twice + const targets = Array.from(new Set(input.target_locales)) + + // One DeepL call per target locale (DeepL only translates to a single + // target per request). The /translate endpoint applies the configured + // glossary automatically based on the target locale. + const results = await Promise.all( + targets.map(async (locale) => { + const out = await translateTexts([text], locale, source) + const lang = getLanguage(locale) + return { + locale, + text: out[0] ?? '', + glossary_used: Boolean(lang?.glossaryId) + } + }) + ) + + return { + source_locale: source, + translations: results + } +} + +// ── Page-level translate ──────────────────────────────────────────── + +export interface TranslatePageInput { + page_id: string + source_locale?: string + target_locales: string[] + // When false (default), skip locales that already have a translation row. + // When true, replace the existing row's body and metadata. + overwrite?: boolean + status?: 'draft' | 'published' +} + +export interface TranslatePageResultEntry { + locale: string + // Mutually exclusive: either ok=true (translation written), skipped=true + // (existing row preserved, no DeepL call), or error set (translation + // failed for this locale only — other locales still proceed). + ok?: true + skipped?: true + error?: string +} + +export interface TranslatePageResult { + source_locale: string + results: TranslatePageResultEntry[] +} + +// Translate a whole CMS page (title, excerpt, meta_title, +// meta_description, body_json) from a source locale into one or more +// targets. Per-locale failures don't abort the batch — DeepL flaking +// on one locale shouldn't block the others. +// +// Each successful translation is written via upsertCmsPageTranslation, +// so the standard validator + cache-invalidation path runs on every +// translated row (consistent with hand-edited translations). +export async function translatePage(input: TranslatePageInput): Promise { + if (!isDeepLConfigured()) { + throw err(503, 'DeepL is not configured on this server') + } + + const sourceLocale = input.source_locale ?? 'en' + if (!ENABLED_LANGUAGE_CODES.includes(sourceLocale)) { + throw err(400, `source_locale "${sourceLocale}" is not enabled`) + } + + if (!Array.isArray(input.target_locales) || input.target_locales.length === 0) { + throw err(400, 'target_locales must be a non-empty array') + } + const targets = Array.from(new Set( + input.target_locales.filter(l => ENABLED_LANGUAGE_CODES.includes(l) && l !== sourceLocale) + )) + if (targets.length === 0) { + throw err(400, 'target_locales contains no enabled non-source locales') + } + + const status: 'draft' | 'published' = input.status === 'published' ? 'published' : 'draft' + + const source = await db + .selectFrom('page_translations') + .selectAll() + .where('page_id', '=', input.page_id) + .where('locale', '=', sourceLocale) + .executeTakeFirst() + if (!source) { + throw err(404, `No ${sourceLocale} translation to translate from`) + } + + const results: TranslatePageResultEntry[] = [] + + for (const target of targets) { + if (!input.overwrite) { + const existing = await db + .selectFrom('page_translations') + .select('id') + .where('page_id', '=', input.page_id) + .where('locale', '=', target) + .executeTakeFirst() + if (existing) { + results.push({ locale: target, skipped: true }) + continue + } + } + + try { + const [translatedTitle, translatedExcerpt, translatedMetaTitle, translatedMetaDescription] + = await translateTexts([ + source.title ?? '', + source.excerpt ?? '', + source.meta_title ?? '', + source.meta_description ?? '' + ], target, sourceLocale) + + const translatedBody = await translateTiptapContent(source.body_json as never, target, sourceLocale) + + const upserted = await upsertCmsPageTranslation({ + page_id: input.page_id, + locale: target, + title: translatedTitle || source.title, + body_json: translatedBody as unknown as Record, + excerpt: translatedExcerpt || null, + featured_image: source.featured_image, + meta_title: translatedMetaTitle || null, + meta_description: translatedMetaDescription || null, + og_image: source.og_image, + status + }) + + // Per-locale cache purge after each successful upsert. This + // keeps the invalidation contract identical to admin's pre- + // unification behavior — locales that errored or were skipped + // never had their cache touched. + await applyTranslationInvalidations(upserted.pageSlug, upserted.categoryId, target) + + results.push({ locale: target, ok: true }) + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'translation failed' + console.error(`[cmsTranslate] page ${input.page_id} → ${target} failed:`, msg) + results.push({ locale: target, error: msg }) + } + } + + return { + source_locale: sourceLocale, + results + } +} diff --git a/server/services/cmsTranslations.ts b/server/services/cmsTranslations.ts new file mode 100644 index 00000000..5aa06add --- /dev/null +++ b/server/services/cmsTranslations.ts @@ -0,0 +1,218 @@ +// Page-translation service. Wraps the upsert / status flips that +// admin routes do today, so MCP tools and admin can produce identical +// side effects (validation, transactional writes, cache purges). + +import type { H3Error } from 'h3' +import { sql } from 'kysely' +import { db } from '../utils/database' +import { ENABLED_LANGUAGE_CODES } from '~~/config/languages' +import { purgeCmsPage, purgeCmsCategory } from '../utils/cmsCache' +import type { PageTranslation } from '../database/pages' +import { detectLossy } from '../utils/tiptapLossyDetector' +import { tiptapValidate } from '../utils/tiptapValidate' +import { markdownToTiptap } from '../utils/tiptapFromMarkdown' +import { isSafeHttpUrl as isSafeUrl } from '../utils/urlValidation' + +function err(statusCode: number, message: string, extra?: Record): H3Error { + return Object.assign(new Error(message), { + statusCode, + statusMessage: message, + data: { message, ...extra } + }) as unknown as H3Error +} + +export interface UpsertTranslationInput { + page_id: string + locale: string + title: string + body_markdown?: string + body_json?: Record + excerpt?: string | null + featured_image?: string | null + meta_title?: string | null + meta_description?: string | null + og_image?: string | null + status?: 'draft' | 'published' + allow_lossy_overwrite?: boolean +} + +export interface UpsertTranslationResult { + translation: PageTranslation + pageSlug: string + categoryId: string | null + // True iff the input came in as Markdown and the existing body was + // lossy-in-markdown but allow_lossy_overwrite was set — the caller + // should log an mcp.lossy_overwrite event with the dropped reasons. + lossyOverwriteApplied: boolean + droppedReasons: string[] +} + +const URL_BEARING_FIELDS: ReadonlyArray = [ + 'featured_image', 'og_image' +] + +export async function upsertCmsPageTranslation(input: UpsertTranslationInput): Promise { + if (!ENABLED_LANGUAGE_CODES.includes(input.locale)) { + throw err(400, 'locale is not enabled') + } + const title = (input.title ?? '').trim() + if (!title) throw err(400, 'title is required') + if (title.length > 500) throw err(400, 'title must be ≤ 500 chars') + + const hasMarkdown = typeof input.body_markdown === 'string' + const hasJson = input.body_json && typeof input.body_json === 'object' + if (!hasMarkdown && !hasJson) throw err(400, 'one of body_markdown or body_json is required') + if (hasMarkdown && hasJson) throw err(400, 'provide exactly one of body_markdown or body_json') + + const page = await db + .selectFrom('pages') + .select(['id', 'slug', 'category_id']) + .where('id', '=', input.page_id) + .executeTakeFirst() + if (!page) throw err(404, 'Page not found') + + const existing = await db + .selectFrom('page_translations') + .selectAll() + .where('page_id', '=', input.page_id) + .where('locale', '=', input.locale) + .executeTakeFirst() + + let droppedReasons: string[] = [] + let lossyOverwriteApplied = false + let body_json: Record + + if (hasJson) { + body_json = input.body_json as Record + tiptapValidate(body_json) + } else { + // Markdown path — refuse to overwrite a lossy body unless the + // operator acknowledges via allow_lossy_overwrite. + if (existing) { + const existingLossy = detectLossy(existing.body_json) + if (existingLossy.body_is_lossy_in_markdown) { + if (!input.allow_lossy_overwrite) { + throw err(409, 'lossy_overwrite_blocked', { + error: 'lossy_overwrite_blocked', + body_lossy_reasons: existingLossy.body_lossy_reasons + }) + } + droppedReasons = existingLossy.body_lossy_reasons + lossyOverwriteApplied = true + } + } + if ((input.body_markdown ?? '').length > 1024 * 1024) { + throw err(400, 'body_markdown must be ≤ 1 MB') + } + body_json = markdownToTiptap(input.body_markdown ?? '') as unknown as Record + tiptapValidate(body_json) + } + + // Field-size guards (post-validate so we don't leak sanitized + // structure on early bailouts) + if (input.excerpt && input.excerpt.length > 2048) throw err(400, 'excerpt must be ≤ 2 KB') + if (input.meta_title && input.meta_title.length > 255) throw err(400, 'meta_title must be ≤ 255 chars') + if (input.meta_description && input.meta_description.length > 500) throw err(400, 'meta_description must be ≤ 500 chars') + + for (const field of URL_BEARING_FIELDS) { + const v = input[field] + if (typeof v === 'string' && v && !isSafeUrl(v)) { + throw err(400, `${field} is not a safe URL`) + } + } + + const status = input.status + if (status && status !== 'draft' && status !== 'published') { + throw err(400, 'status must be draft or published') + } + + let translation: PageTranslation + if (existing) { + translation = await db + .updateTable('page_translations') + .set({ + title, + body_json: body_json as never, + excerpt: input.excerpt ?? null, + featured_image: input.featured_image ?? null, + meta_title: input.meta_title ?? null, + meta_description: input.meta_description ?? null, + og_image: input.og_image ?? null, + ...(status ? { status } : {}), + updated: sql`now()` + }) + .where('id', '=', existing.id) + .returningAll() + .executeTakeFirstOrThrow() + } else { + translation = await db + .insertInto('page_translations') + .values({ + page_id: input.page_id, + locale: input.locale, + title, + body_json: body_json as never, + excerpt: input.excerpt ?? null, + featured_image: input.featured_image ?? null, + meta_title: input.meta_title ?? null, + meta_description: input.meta_description ?? null, + og_image: input.og_image ?? null, + status: status ?? 'draft' + }) + .returningAll() + .executeTakeFirstOrThrow() + } + + return { + translation, + pageSlug: page.slug, + categoryId: page.category_id, + lossyOverwriteApplied, + droppedReasons + } +} + +export async function getCmsPageTranslation(input: { page_id: string; locale: string }): Promise { + const row = await db + .selectFrom('page_translations') + .selectAll() + .where('page_id', '=', input.page_id) + .where('locale', '=', input.locale) + .executeTakeFirst() + return row ?? null +} + +export async function setCmsTranslationStatus(input: { + page_id: string + locale: string + status: 'draft' | 'published' +}): Promise<{ translation: PageTranslation; pageSlug: string; categoryId: string | null }> { + if (!ENABLED_LANGUAGE_CODES.includes(input.locale)) { + throw err(400, 'locale is not enabled') + } + + const page = await db + .selectFrom('pages') + .select(['slug', 'category_id']) + .where('id', '=', input.page_id) + .executeTakeFirst() + if (!page) throw err(404, 'Page not found') + + const updated = await db + .updateTable('page_translations') + .set({ status: input.status, updated: sql`now()` }) + .where('page_id', '=', input.page_id) + .where('locale', '=', input.locale) + .returningAll() + .executeTakeFirst() + + if (!updated) throw err(404, 'Translation not found') + + return { translation: updated, pageSlug: page.slug, categoryId: page.category_id } +} + +export async function applyTranslationInvalidations(slug: string, categoryId: string | null, locale?: string): Promise { + await purgeCmsPage(slug, locale ? [locale] : undefined) + if (categoryId) await purgeCmsCategory(categoryId, slug) +} + diff --git a/server/utils/activity-logger.ts b/server/utils/activity-logger.ts index fe5f99b3..7022b66d 100644 --- a/server/utils/activity-logger.ts +++ b/server/utils/activity-logger.ts @@ -21,10 +21,18 @@ interface LogEventOptions { * transaction — useful when the audit row must commit atomically with the * operation it describes (e.g. granting the first user the admin role). * Defaults to the global db for fire-and-forget use from helpers. + * + * Pass `{ throwOnError: true }` when the caller needs an audit-row failure + * to roll back its surrounding transaction. The MCP layer's `mcpLog` sets + * this when an executor is provided so a failed audit insert tears down + * the page/translation write that depends on it. Default `false` preserves + * the existing fire-and-forget admin-side behavior — there is nothing to + * roll back outside a transaction. */ export async function logEvent( options: LogEventOptions, - executor: Kysely = db + executor: Kysely = db, + opts: { throwOnError?: boolean } = {} ): Promise { try { await executor @@ -41,8 +49,10 @@ export async function logEvent( }) .execute() } catch (error) { - // Log error but don't throw - logging should never break the main flow + // Log error but don't throw by default - logging should never break the + // main flow for fire-and-forget callers. console.error('Failed to log activity:', error) + if (opts.throwOnError) throw error } } diff --git a/server/utils/cmsCache.ts b/server/utils/cmsCache.ts index d2462665..92558e8d 100644 --- a/server/utils/cmsCache.ts +++ b/server/utils/cmsCache.ts @@ -32,18 +32,26 @@ export async function purgeCmsPage(slug: string, locales?: string[]) { // Purge every page in a category across all locales. Needed whenever a // field that leaks into a sibling's cached response changes — titles, // excerpts, featured images (all embedded in `children[]`) and -// menu_order (alters sibling sort order). `excludeSlug` lets the caller -// skip the page whose own entry was already purged independently. +// menu_order (alters sibling sort order). `excludeSlugs` lets the +// caller skip pages whose entries were already purged independently +// (avoids duplicate removeItem calls + lets `applyPageInvalidations` +// pass the page-being-modified's slug — and its old slug, on a +// category move — through). export async function purgeCmsCategory( categoryId: string, - excludeSlug?: string + excludeSlugs?: string | ReadonlyArray ): Promise { + const skip = new Set( + excludeSlugs === undefined + ? [] + : (typeof excludeSlugs === 'string' ? [excludeSlugs] : excludeSlugs) + ) const rows = await db .selectFrom('pages') .select('slug') .where('category_id', '=', categoryId) .execute() - const slugs = rows.map(r => r.slug).filter(s => s !== excludeSlug) + const slugs = rows.map(r => r.slug).filter(s => !skip.has(s)) await Promise.all(slugs.map(s => purgeCmsPage(s))) } diff --git a/server/utils/storage.ts b/server/utils/storage.ts index 97d7d3e2..d6226e97 100644 --- a/server/utils/storage.ts +++ b/server/utils/storage.ts @@ -1,4 +1,4 @@ -import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3' +import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' import { randomBytes } from 'crypto' @@ -173,6 +173,102 @@ export async function generateSignedUrl(key: string, expiresIn: number = SIGNED_ } } +/** + * Generate a presigned PUT URL for direct browser/agent uploads to S3. + * + * Both the Content-Type and the byte size (Content-Length) are signed + * into the URL — the uploading client must send headers matching both + * exactly or S3 rejects with SignatureDoesNotMatch. This caps the + * upload size at the value you sign in (defending against runaway + * uploads to a public bucket) and binds the declared MIME to the + * upload without our server having to handle the bytes. + * + * Returned URL only authorizes a single PUT to the specified key. + * After the upload completes, callers should still validate the + * actual bytes (e.g. magic-byte sniff via a Range GET) — Content-Type + * binding only constrains the response header S3 stores, not the + * payload itself. See finalizeImageUpload() in cmsAssets. + */ +export async function createPresignedUploadUrl( + key: string, + contentType: string, + contentLength: number, + expiresIn: number = 5 * 60 // 5 minutes +): Promise { + try { + const client = getS3Client() + const settings = getS3Settings() + + const command = new PutObjectCommand({ + Bucket: settings.bucketName, + Key: key, + ContentType: contentType, + ContentLength: contentLength + }) + + return await getSignedUrl(client, command, { + expiresIn, + // Explicitly tell the signer to include Content-Length in the + // signed-headers list. Without this the AWS SDK signs only the + // host + content-type by default for PUT presigns. + unhoistableHeaders: new Set(['content-length']) + }) + } catch (error: any) { + console.error('S3 presigned URL generation error:', error) + throw new Error(`Failed to generate presigned upload URL: ${error.message}`) + } +} + +/** + * Read a byte range from an S3 object. Used by finalizeImageUpload + * to sniff magic bytes after a presigned PUT — we never trust the + * agent's claimed Content-Type without verifying the payload. + */ +export async function getObjectByteRange( + key: string, + start: number, + end: number +): Promise { + const client = getS3Client() + const settings = getS3Settings() + const command = new GetObjectCommand({ + Bucket: settings.bucketName, + Key: key, + Range: `bytes=${start}-${end}` + }) + const result = await client.send(command) + if (!result.Body) throw new Error('S3 returned empty body') + // result.Body is a stream in Node — collect to buffer + const stream = result.Body as NodeJS.ReadableStream + const chunks: Buffer[] = [] + for await (const chunk of stream) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk as Uint8Array)) + } + return Buffer.concat(chunks) +} + +/** + * HEAD an S3 object to read its metadata (Content-Length, Content-Type). + * Returns null if the object doesn't exist. + */ +export async function headObject(key: string): Promise<{ contentLength: number; contentType: string | undefined } | null> { + const client = getS3Client() + const settings = getS3Settings() + try { + const result = await client.send(new HeadObjectCommand({ + Bucket: settings.bucketName, + Key: key + })) + return { + contentLength: Number(result.ContentLength ?? 0), + contentType: result.ContentType + } + } catch (e: any) { + if (e?.name === 'NotFound' || e?.$metadata?.httpStatusCode === 404) return null + throw e + } +} + /** * Validate file type for image uploads */ diff --git a/server/utils/tiptapFromMarkdown.ts b/server/utils/tiptapFromMarkdown.ts new file mode 100644 index 00000000..7408582a --- /dev/null +++ b/server/utils/tiptapFromMarkdown.ts @@ -0,0 +1,59 @@ +// Markdown → Tiptap JSON converter for the MCP write surface. +// +// Pipeline (see plans/mcp-project/mcp-project.md "Conversion pipeline"): +// 1. markdown-it (CommonMark + GFM strikethrough; tables disabled, +// no inline HTML) parses Markdown to sanitized HTML. +// 2. A per-call `new JSDOM(html)` exposes the HTML as a DOM. +// 3. prosemirror-model's DOMParser walks the DOM into a doc that +// conforms to the schema built from buildTiptapExtensions(). +// +// We deliberately do NOT call @tiptap/html's `generateJSON` for this +// direction: it instantiates happy-dom internally, which is broken +// against ProseMirror's `parseHTML().getAttrs(el)` selectors. +// Serialization (JSON → HTML) does still go through @tiptap/html +// because happy-dom's incompatibility is in the parser path, not +// the serializer. + +import MarkdownIt from 'markdown-it' +import { JSDOM } from 'jsdom' +import { getSchema } from '@tiptap/core' +import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model' +import { buildTiptapExtensions } from '~~/app/utils/tiptapExtensions' + +// Module-scope schema is deterministic — one registered extension set +// per process. Per-call DOM keeps state isolated across requests. +const SCHEMA = getSchema(buildTiptapExtensions()) + +// CommonMark + GFM strikethrough; tables disabled (no extension is +// registered for them; the parser would silently drop the rows). +// `html: false` escapes any inline HTML rather than passing it through — +// explicit anti-XSS choice, not a convenience. +const md = new MarkdownIt({ + html: false, + linkify: false, + breaks: false, + typographer: false +}) +md.disable(['table']) + +export interface TiptapDocJson { + type: 'doc' + content: Array> +} + +export function markdownToTiptap(markdown: string): TiptapDocJson { + const html = md.render(markdown ?? '') + const dom = new JSDOM(`${html}`) + const body = dom.window.document.body + // ProseMirror's DOMParser.parse(node) walks the node directly and + // does not consult globalThis.document, so no global shim is needed. + const doc = ProseMirrorDOMParser.fromSchema(SCHEMA).parse(body as unknown as HTMLElement) + const json = doc.toJSON() as TiptapDocJson + // ProseMirror returns an empty doc as `{type:'doc',content:[]}` — + // the validator rejects empty content, so wrap with an empty + // paragraph for blank input. + if (!Array.isArray(json.content) || json.content.length === 0) { + return { type: 'doc', content: [{ type: 'paragraph' }] } + } + return json +} diff --git a/server/utils/tiptapLossyDetector.ts b/server/utils/tiptapLossyDetector.ts new file mode 100644 index 00000000..7f12d9a4 --- /dev/null +++ b/server/utils/tiptapLossyDetector.ts @@ -0,0 +1,117 @@ +// Walks a Tiptap JSON document and reports whether converting it to +// Markdown would silently drop authored content. Used by: +// - upsert_page_translation's lossy-overwrite guard (read existing +// body, refuse a Markdown overwrite when the existing body uses +// anything in the lossy sets unless allow_lossy_overwrite is set) +// - get_page_translation read paths to surface a flag the client +// uses to decide whether to round-trip via Markdown or edit JSON +// +// The lists below match the registered Tiptap node/mark names — any +// new extension added to app/utils/tiptapExtensions.ts must update +// these. A drift vitest enforces sync. + +// Marks that have no Markdown round-trip (TextStyle is a structural +// container; Color contributes its `color` attribute to TextStyle +// rather than being a standalone mark). +export const LOSSY_MARKS: ReadonlySet = new Set([ + 'textStyle', + 'highlight', + 'subscript', + 'superscript', + 'underline' +]) + +// Custom block nodes with no Markdown representation. +export const LOSSY_NODES: ReadonlySet = new Set([ + 'youtube', + 'div', + 'verse', + 'uupgsList', + 'generalResources' +]) + +// Per-node attributes that, when set, cannot survive a Markdown +// round-trip. The key is the registered Tiptap node name (so `image`, +// not `CmsImage`). +export const LOSSY_NODE_ATTRS: Readonly>> = { + paragraph: ['textAlign'], + heading: ['textAlign'], + image: ['align'] +} + +// Maps detector finds to stable string keys that can be returned as +// `body_lossy_reasons` (machine-readable, lowercase snake_case). +const REASON_KEYS = { + textStyle: 'text_style_or_color', + highlight: 'highlight', + subscript: 'subscript', + superscript: 'superscript', + underline: 'underline', + youtube: 'youtube_embed', + div: 'div_wrapper', + verse: 'verse_block', + uupgsList: 'uupgs_list', + generalResources: 'general_resources', + 'paragraph.textAlign': 'text_align', + 'heading.textAlign': 'text_align', + 'image.align': 'image_align' +} as const + +interface JsonNode { + type?: string + attrs?: Record + content?: JsonNode[] + marks?: Array<{ type?: string; attrs?: Record }> +} + +export interface LossyResult { + body_is_lossy_in_markdown: boolean + body_lossy_reasons: string[] +} + +export function detectLossy(doc: unknown): LossyResult { + const reasons = new Set() + walk(doc as JsonNode, reasons) + return { + body_is_lossy_in_markdown: reasons.size > 0, + body_lossy_reasons: Array.from(reasons).sort() + } +} + +function walk(node: JsonNode | undefined | null, reasons: Set): void { + if (!node || typeof node !== 'object') return + + if (node.type) { + if (LOSSY_NODES.has(node.type)) { + const key = REASON_KEYS[node.type as keyof typeof REASON_KEYS] + if (key) reasons.add(key) + } + + const lossyAttrs = LOSSY_NODE_ATTRS[node.type] + if (lossyAttrs && node.attrs) { + for (const attr of lossyAttrs) { + const v = node.attrs[attr] + if (v !== null && v !== undefined && v !== '') { + const key = REASON_KEYS[`${node.type}.${attr}` as keyof typeof REASON_KEYS] + if (key) reasons.add(key) + } + } + } + } + + if (Array.isArray(node.marks)) { + for (const mark of node.marks) { + if (!mark || !mark.type) continue + if (LOSSY_MARKS.has(mark.type)) { + const key = REASON_KEYS[mark.type as keyof typeof REASON_KEYS] + if (key) reasons.add(key) + } + // textStyle with a color attribute always counts as lossy + // (the textStyle entry already covers it via LOSSY_MARKS). + } + } + + if (Array.isArray(node.content)) { + for (const child of node.content) walk(child, reasons) + } +} diff --git a/server/utils/tiptapToMarkdown.ts b/server/utils/tiptapToMarkdown.ts new file mode 100644 index 00000000..be793ddf --- /dev/null +++ b/server/utils/tiptapToMarkdown.ts @@ -0,0 +1,66 @@ +// Tiptap JSON → Markdown via @tiptap/html's generateHTML → turndown. +// Output is informational only (read paths and resources); never +// persisted to the body_json column. +// +// Lossy nodes/marks/attributes are dropped silently here. Callers +// that need to know whether the conversion lost authored content +// should run tiptapLossyDetector on the JSON first. + +import { generateHTML } from '@tiptap/html' +import TurndownService from 'turndown' +import { buildTiptapExtensions } from '~~/app/utils/tiptapExtensions' + +const EXTENSIONS = buildTiptapExtensions() + +const turndown = new TurndownService({ + headingStyle: 'atx', + bulletListMarker: '-', + codeBlockStyle: 'fenced', + emDelimiter: '_', + strongDelimiter: '**' +}) + +// Strip presentational wrappers Markdown can't represent — the +// surrounding text content survives as paragraphs. +turndown.addRule('strip-presentational', { + filter: ['span', 'u'] as TurndownService.Filter, + replacement: (content) => content +}) + +// Custom Tiptap nodes have HTML representations the editor renders +// but Markdown can't carry — drop the wrapper, keep nested text. +turndown.addRule('cms-custom-blocks', { + filter: (node) => { + const el = node as HTMLElement + if (!el || typeof el.getAttribute !== 'function') return false + const cls = el.getAttribute('class') || '' + return ( + el.tagName === 'DIV' && ( + cls.includes('doxa-uupgs-list-slot') + || cls.includes('doxa-verse') + || cls.includes('doxa-general-resources') + ) + ) + }, + replacement: (content) => content +}) + +export function tiptapToMarkdown(doc: unknown): string { + if (!doc || typeof doc !== 'object') return '' + const d = doc as { type?: string; content?: unknown[] } + if (d.type !== 'doc') return '' + if (!Array.isArray(d.content) || d.content.length === 0) return '' + let html: string + try { + html = generateHTML(doc as Parameters[0], EXTENSIONS) + } catch (err) { + console.error('[mcp] tiptapToMarkdown: generateHTML failed', err) + return '' + } + try { + return turndown.turndown(html) + } catch (err) { + console.error('[mcp] tiptapToMarkdown: turndown failed', err) + return '' + } +} diff --git a/server/utils/tiptapValidate.ts b/server/utils/tiptapValidate.ts new file mode 100644 index 00000000..2a06dbc5 --- /dev/null +++ b/server/utils/tiptapValidate.ts @@ -0,0 +1,340 @@ +// Validates a Tiptap JSON document against an explicit per-node / +// per-mark policy. MCP write inputs are treated as hostile — the body +// is later rendered to public HTML via renderTiptap.ts and injected +// into the page, so validation is the only line of defense against +// stored XSS and unknown-node smuggling. +// +// Policy buckets (see plans/mcp-project/mcp-project.md): +// - preserve: per-node allowlist passes attrs through unchanged +// - strip: unknown attrs are dropped and a warning is logged +// - reject: any rule violation throws (style outside the spacer +// exception, dangerous URL schemes, unknown node types, etc.) + +import { PRESERVED_DATA_ATTRS } from '~~/app/utils/tiptapDiv' +import { isSafeHttpUrl as isSafeUrl } from './urlValidation' + +// ── Allowlists ────────────────────────────────────────────────────── + +// Registered Tiptap node names (matches buildTiptapExtensions()). +export const NODE_NAMES: ReadonlySet = new Set([ + 'doc', + 'paragraph', + 'heading', + 'bulletList', + 'orderedList', + 'listItem', + 'blockquote', + 'codeBlock', + 'horizontalRule', + 'hardBreak', + 'text', + 'image', + 'youtube', + 'div', + 'verse', + 'uupgsList', + 'generalResources' +]) + +export const MARK_NAMES: ReadonlySet = new Set([ + 'bold', + 'italic', + 'strike', + 'code', + 'link', + 'textStyle', + 'highlight', + 'subscript', + 'superscript', + 'underline' +]) + +// CUBE-CSS primitives the editor and the WP scrape produce. Any other +// class on a div is rejected. +const DIV_CLASS_SAFELIST: ReadonlySet = new Set([ + 'wp-block-spacer', + 'card', + 'grid', + 'switcher', + 'stack', + 'cluster', + 'sidebar', + 'reel', + 'frame', + 'cover', + 'imposter', + 'icon', + 'wrapper', + 'box', + 'center', + 'resource-card' +]) + +// Per-node attribute allowlist. Only listed attrs are preserved; any +// other attr is stripped + logged. Values like `image.align` round- +// trip through here even though the lossy detector flags them — the +// validator is structural defense, the lossy flag is informational. +const NODE_ATTRS: Readonly>> = { + doc: [], + paragraph: ['textAlign'], + heading: ['level', 'textAlign'], + bulletList: [], + orderedList: ['start', 'type'], + listItem: [], + blockquote: [], + codeBlock: ['language'], + horizontalRule: [], + hardBreak: [], + text: [], + image: ['src', 'alt', 'title', 'align', 'width', 'height'], + youtube: ['src', 'width', 'height', 'start'], + div: ['class', 'style', ...PRESERVED_DATA_ATTRS], + verse: ['reference'], + uupgsList: [ + 'languageCode', 'selectUrl', 'researchUrl', 'initialSearchTerm', 't', + 'perPage', 'morePerPage', + 'useSelectCard', 'useHighlightedUUPGs', 'hideSeeAllLink', 'dontShowListOnLoad', 'randomizeList' + ], + generalResources: ['useDocuments', 'layout'] +} + +// Per-mark attribute allowlist. Mark attrs are validated with explicit +// per-attr checks below — entries here are just the structural set. +const MARK_ATTRS: Readonly>> = { + bold: [], + italic: [], + strike: [], + code: [], + link: ['href', 'target', 'rel', 'class', 'title'], + textStyle: ['color', 'fontFamily'], + highlight: ['color'], + subscript: [], + superscript: [], + underline: [] +} + +// ── URL allowlist ─────────────────────────────────────────────────── + +const NAMED_COLORS = new Set([ + 'black', 'white', 'red', 'green', 'blue', 'yellow', + 'orange', 'purple', 'pink', 'gray', 'grey', 'brown', + 'transparent', 'inherit', 'currentcolor' +]) + +// YouTube embeds: only the canonical embed/watch URLs. +function isSafeYoutubeUrl(value: unknown): boolean { + if (typeof value !== 'string') return false + try { + const parsed = new URL(value) + if (parsed.protocol !== 'https:') return false + const host = parsed.hostname.toLowerCase() + if (host === 'www.youtube.com' || host === 'youtube.com') { + return parsed.pathname.startsWith('/embed/') || parsed.pathname === '/watch' + } + if (host === 'youtu.be') return true + return false + } catch { + return false + } +} + +function isSafeColor(value: unknown): boolean { + if (typeof value !== 'string') return true // null is fine, missing is fine + const v = value.trim() + if (v === '') return true + if (/^#[0-9a-fA-F]{3,8}$/.test(v)) return true + if (NAMED_COLORS.has(v.toLowerCase())) return true + return false +} + +// ── Errors ────────────────────────────────────────────────────────── + +export class TiptapValidationError extends Error { + constructor(public reason: string, public path: string) { + super(`${reason} at ${path}`) + this.name = 'TiptapValidationError' + } +} + +interface JsonMark { + type?: string + attrs?: Record +} + +interface JsonNode { + type?: string + attrs?: Record + content?: JsonNode[] + marks?: JsonMark[] + text?: string +} + +// ── Walker ────────────────────────────────────────────────────────── + +export interface ValidateOptions { + // When true (the default), validation rejects unknown node types + // outright. When false, unknown nodes are stripped — used internally + // by the conversion pipeline for forward-compat. + rejectUnknownNodes?: boolean +} + +export function tiptapValidate(doc: unknown, opts: ValidateOptions = {}): asserts doc is { type: 'doc'; content: JsonNode[] } { + const reject = opts.rejectUnknownNodes ?? true + + if (!doc || typeof doc !== 'object') { + throw new TiptapValidationError('Document must be an object', '$') + } + const root = doc as JsonNode + if (root.type !== 'doc') { + throw new TiptapValidationError('Root node type must be "doc"', '$.type') + } + if (!Array.isArray(root.content) || root.content.length === 0) { + throw new TiptapValidationError('Document content must be a non-empty array', '$.content') + } + + for (let i = 0; i < root.content.length; i++) { + walkNode(root.content[i], `$.content[${i}]`, reject) + } +} + +function walkNode(node: JsonNode | undefined, path: string, rejectUnknown: boolean): void { + if (!node || typeof node !== 'object') { + throw new TiptapValidationError('Node must be an object', path) + } + const type = node.type + if (!type || typeof type !== 'string') { + throw new TiptapValidationError('Node missing type', path) + } + + if (!NODE_NAMES.has(type)) { + if (rejectUnknown) { + throw new TiptapValidationError(`Unknown node type "${type}"`, path) + } + return + } + + // Text nodes carry a non-empty `text` and optional marks; no content. + if (type === 'text') { + if (typeof node.text !== 'string' || node.text.length === 0) { + throw new TiptapValidationError('Text node requires non-empty text', path) + } + if (Array.isArray(node.marks)) { + for (let i = 0; i < node.marks.length; i++) { + walkMark(node.marks[i], `${path}.marks[${i}]`) + } + } + return + } + + // Validate per-node attributes + validateNodeAttrs(type, node.attrs, path) + + // Custom URL validation per node type + if (type === 'image') { + const src = node.attrs?.src + if (typeof src !== 'string' || !isSafeUrl(src)) { + throw new TiptapValidationError('image.src is not a safe URL', `${path}.attrs.src`) + } + } + if (type === 'youtube') { + const src = node.attrs?.src + if (typeof src !== 'string' || !isSafeYoutubeUrl(src)) { + throw new TiptapValidationError('youtube.src must be a YouTube embed URL', `${path}.attrs.src`) + } + } + + // Recurse into content + if (Array.isArray(node.content)) { + for (let i = 0; i < node.content.length; i++) { + walkNode(node.content[i], `${path}.content[${i}]`, rejectUnknown) + } + } +} + +function validateNodeAttrs(type: string, attrs: Record | undefined, path: string): void { + if (!attrs || typeof attrs !== 'object') return + const allowed = NODE_ATTRS[type] ?? [] + const allowedSet = new Set(allowed) + + for (const [key, value] of Object.entries(attrs)) { + // Tiptap declares optional attributes with `default: null`, so the + // attrs object always contains every declared key — null/undefined + // means "attribute not set," not "attribute present with null value." + // Treat these as absent; the per-key rules below assume a real value. + if (value === null || value === undefined) continue + + if (key === 'style') { + if (type !== 'div') { + throw new TiptapValidationError(`style attr not allowed on ${type}`, `${path}.attrs.style`) + } + // Div-with-style is only allowed when class === 'wp-block-spacer' + const cls = typeof attrs.class === 'string' ? attrs.class : '' + if (!/\bwp-block-spacer\b/.test(cls)) { + throw new TiptapValidationError('div.style only allowed on wp-block-spacer', `${path}.attrs.style`) + } + if (typeof value !== 'string' || !/^height:\s*\d+(\.\d+)?(px|em|rem|%)?\s*;?\s*$/.test(value)) { + throw new TiptapValidationError('div.style on wp-block-spacer must be height:', `${path}.attrs.style`) + } + continue + } + if (key === 'class' && type === 'div') { + if (typeof value !== 'string') { + throw new TiptapValidationError('div.class must be a string', `${path}.attrs.class`) + } + const classes = value.trim().split(/\s+/).filter(Boolean) + for (const cls of classes) { + if (!DIV_CLASS_SAFELIST.has(cls)) { + throw new TiptapValidationError(`div.class "${cls}" not in safelist`, `${path}.attrs.class`) + } + } + continue + } + if (!allowedSet.has(key)) { + // Strip + log (forward-compat with minor extension changes). + console.warn('[mcp] tiptapValidate: stripped unknown attr', { type, key, path }) + delete attrs[key] + continue + } + // Reject any script/srcdoc/on* slip-through (they shouldn't be on + // the allowlist anyway, but defense in depth). + if (key === 'srcdoc' || key.startsWith('on') || key === 'script') { + throw new TiptapValidationError(`forbidden attr ${key}`, `${path}.attrs.${key}`) + } + } +} + +function walkMark(mark: JsonMark | undefined, path: string): void { + if (!mark || typeof mark !== 'object') { + throw new TiptapValidationError('Mark must be an object', path) + } + const type = mark.type + if (!type || !MARK_NAMES.has(type)) { + throw new TiptapValidationError(`Unknown mark type "${type ?? ''}"`, `${path}.type`) + } + const allowed = MARK_ATTRS[type] ?? [] + const allowedSet = new Set(allowed) + if (mark.attrs && typeof mark.attrs === 'object') { + for (const [key, value] of Object.entries(mark.attrs)) { + // Same null/undefined semantics as node attrs — Tiptap's default-null + // pattern leaves the key in the JSON. Treat as absent. + if (value === null || value === undefined) continue + if (!allowedSet.has(key)) { + console.warn('[mcp] tiptapValidate: stripped unknown mark attr', { type, key, path }) + delete mark.attrs[key] + continue + } + // Per-attr validation + if (type === 'link') { + if (key === 'href' && !isSafeUrl(value)) { + throw new TiptapValidationError('link.href is not a safe URL', `${path}.attrs.href`) + } + if (key === 'target' && value !== null && value !== '_blank' && value !== '_self') { + throw new TiptapValidationError('link.target must be _blank or _self', `${path}.attrs.target`) + } + } + if ((type === 'textStyle' || type === 'highlight') && key === 'color' && !isSafeColor(value)) { + throw new TiptapValidationError(`${type}.color must be a safe color value`, `${path}.attrs.color`) + } + } + } +} diff --git a/server/utils/urlValidation.ts b/server/utils/urlValidation.ts new file mode 100644 index 00000000..22debed5 --- /dev/null +++ b/server/utils/urlValidation.ts @@ -0,0 +1,41 @@ +// Shared URL safety check, used by: +// - server/utils/tiptapValidate.ts (rejects unsafe link.href / image.src +// in body_json on every write) +// - server/services/cmsTranslations.ts (rejects unsafe featured_image / +// og_image on translation upsert) +// +// Single source of truth so the two surfaces don't drift. Any change +// to the policy (new accepted scheme, additional rejection) must +// happen here. + +const SAFE_SCHEMES = new Set(['https:', 'http:']) +const REJECTED_SCHEMES = new Set([ + 'javascript:', 'data:', 'vbscript:', 'file:', 'blob:' +]) + +// True when a URL string is safe to render. Accepts: +// - https:, http: (any host) +// - site-relative paths starting with / +// - anchors starting with # +// - empty string (treated as "no link") +// +// Rejected: javascript:, data:, vbscript:, file:, blob: (case-insensitive +// prefix match, defeats `JaVaScRiPt:` tricks); any other scheme; any +// string that fails URL parsing. +export function isSafeHttpUrl(value: unknown): boolean { + if (typeof value !== 'string') return false + const v = value.trim() + if (v === '') return true + if (v.startsWith('#')) return true + if (v.startsWith('/') && !v.startsWith('//')) return true + const lower = v.toLowerCase() + for (const bad of REJECTED_SCHEMES) { + if (lower.startsWith(bad)) return false + } + try { + const parsed = new URL(v) + return SAFE_SCHEMES.has(parsed.protocol) + } catch { + return false + } +}