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
+ }
+}