Implement CMS MCP

This commit is contained in:
corsacca 2026-04-29 13:06:54 +01:00
parent c85389f497
commit 127d89d084
39 changed files with 3800 additions and 495 deletions

8
.mcp.json Normal file
View file

@ -0,0 +1,8 @@
{
"mcpServers": {
"doxa-cms": {
"type": "http",
"url": "http://localhost:3033/mcp"
}
}
}

View file

@ -10,7 +10,7 @@
import { Node } from '@tiptap/core' import { Node } from '@tiptap/core'
const PRESERVED_DATA_ATTRS = [ export const PRESERVED_DATA_ATTRS = [
'data-width', 'data-width',
'data-width-sm', 'data-width-sm',
'data-width-md', 'data-width-md',

View file

@ -30,15 +30,18 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"fuse.js": "^7.3.0", "fuse.js": "^7.3.0",
"intl-tel-input": "^26.7.5", "intl-tel-input": "^26.7.5",
"jsdom": "^29.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"kysely": "^0.28.0", "kysely": "^0.28.0",
"kysely-postgres-js": "^2.0.0", "kysely-postgres-js": "^2.0.0",
"mapbox-gl": "^3.22.0", "mapbox-gl": "^3.22.0",
"markdown-it": "^14.1.1",
"nodemailer": "^7.0.6", "nodemailer": "^7.0.6",
"nodemailer-mailgun-transport": "^2.1.5", "nodemailer-mailgun-transport": "^2.1.5",
"nuxt": "^4.4.2", "nuxt": "^4.4.2",
"postgres": "^3.4.7", "postgres": "^3.4.7",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"turndown": "^7.2.4",
"vuedraggable": "^4.1.0", "vuedraggable": "^4.1.0",
"zod": "^3.23", "zod": "^3.23",
"zod-to-json-schema": "^3.23", "zod-to-json-schema": "^3.23",
@ -48,10 +51,11 @@
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/jsdom": "^28.0.1", "@types/jsdom": "^28.0.1",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/markdown-it": "^14.1.2",
"@types/nodemailer": "^7.0.1", "@types/nodemailer": "^7.0.1",
"@types/nodemailer-mailgun-transport": "^1.4.6", "@types/nodemailer-mailgun-transport": "^1.4.6",
"@types/turndown": "^5.0.6",
"eslint": "^10.2.0", "eslint": "^10.2.0",
"jsdom": "^29.0.2",
"sass": "^1.99.0", "sass": "^1.99.0",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vue-tsc": "^3.2.6", "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=="], "@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=="], "@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=="], "@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/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/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=="], "@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/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/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], "@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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], "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": ["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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
"ultrahtml": ["ultrahtml@1.6.0", "", {}, "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="],

View file

@ -123,6 +123,27 @@ export default defineNuxtConfig({
runtimeConfig: { runtimeConfig: {
appName: process.env.APP_TITLE || 'My App', 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 || '', databaseUrl: process.env.DATABASE_URL || '',
jwtSecret: process.env.JWT_SECRET || '', jwtSecret: process.env.JWT_SECRET || '',
mailgunApiKey: process.env.MAILGUN_API_KEY || '', mailgunApiKey: process.env.MAILGUN_API_KEY || '',

View file

@ -36,15 +36,18 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"fuse.js": "^7.3.0", "fuse.js": "^7.3.0",
"intl-tel-input": "^26.7.5", "intl-tel-input": "^26.7.5",
"jsdom": "^29.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"kysely": "^0.28.0", "kysely": "^0.28.0",
"kysely-postgres-js": "^2.0.0", "kysely-postgres-js": "^2.0.0",
"mapbox-gl": "^3.22.0", "mapbox-gl": "^3.22.0",
"markdown-it": "^14.1.1",
"nodemailer": "^7.0.6", "nodemailer": "^7.0.6",
"nodemailer-mailgun-transport": "^2.1.5", "nodemailer-mailgun-transport": "^2.1.5",
"nuxt": "^4.4.2", "nuxt": "^4.4.2",
"postgres": "^3.4.7", "postgres": "^3.4.7",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"turndown": "^7.2.4",
"vuedraggable": "^4.1.0", "vuedraggable": "^4.1.0",
"zod": "^3.23", "zod": "^3.23",
"zod-to-json-schema": "^3.23" "zod-to-json-schema": "^3.23"
@ -54,10 +57,11 @@
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/jsdom": "^28.0.1", "@types/jsdom": "^28.0.1",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/markdown-it": "^14.1.2",
"@types/nodemailer": "^7.0.1", "@types/nodemailer": "^7.0.1",
"@types/nodemailer-mailgun-transport": "^1.4.6", "@types/nodemailer-mailgun-transport": "^1.4.6",
"@types/turndown": "^5.0.6",
"eslint": "^10.2.0", "eslint": "^10.2.0",
"jsdom": "^29.0.2",
"sass": "^1.99.0", "sass": "^1.99.0",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vue-tsc": "^3.2.6" "vue-tsc": "^3.2.6"

View file

@ -1,33 +1,43 @@
// Admin: delete a category. Blocked (409) if any pages still reference // Admin: delete a category. Blocked (409) if any pages still reference
// it — the admin must reassign or delete those pages first. // 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 { requirePermission } from '../../../utils/rbac'
import { db } from '../../../utils/database' import { deleteCmsCategory } from '../../../services/cmsCategories'
import { deleteCategory } from '../../../database/categories' import { logEvent } from '../../../utils/activity-logger'
import { logDelete } from '../../../utils/activity-logger'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.write') const authUser = await requirePermission(event, 'pages.write')
const id = getRouterParam(event, 'id') const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' }) 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 { try {
await deleteCategory(id) const result = await deleteCmsCategory(id)
logDelete('categories', id, event, { slug: existing.slug }) if ('error' in result) {
return { ok: true }
} catch (e: any) {
if (e?.statusCode === 409) {
throw createError({ throw createError({
statusCode: 409, 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 throw e

View file

@ -1,11 +1,13 @@
// Admin: update a category — slug (cascades to every member page // Admin: update a category — slug (cascades to every member page
// slug), menu_order, and per-locale names. Returns the updated row. // 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 { requirePermission } from '../../../utils/rbac'
import { db } from '../../../utils/database' import {
import { updateCategory, purgeSlugs } from '../../../database/categories' updateCmsCategory,
import { logUpdate } from '../../../utils/activity-logger' applyCategoryInvalidations
} from '../../../services/cmsCategories'
import { logEvent } from '../../../utils/activity-logger'
import { ENABLED_LANGUAGE_CODES } from '../../../../config/languages' import { ENABLED_LANGUAGE_CODES } from '../../../../config/languages'
interface Body { interface Body {
@ -32,49 +34,32 @@ function normalizeTranslations(
} }
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.write') const authUser = await requirePermission(event, 'pages.write')
const id = getRouterParam(event, 'id') const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' }) if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
const body = await readBody<Body>(event) const body = await readBody<Body>(event)
const input: Parameters<typeof updateCategory>[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) const translations = normalizeTranslations(body?.translations)
if (translations) input.translations = translations
try { try {
const result = await updateCategory(id, input) const result = await updateCmsCategory({
await purgeSlugs(result.slugsToPurge) id,
logUpdate('categories', id, event, { slug: body?.slug != null ? String(body.slug) : undefined,
changes: Object.keys(input), menu_order: body?.menu_order !== undefined ? (Number(body.menu_order) || 0) : undefined,
slugsPurged: result.slugsToPurge.length 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 return result.category
} catch (e: any) { } catch (e: any) {
@ -82,7 +67,7 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 409, statusMessage: 'A category with that slug already exists' }) throw createError({ statusCode: 409, statusMessage: 'A category with that slug already exists' })
} }
if (e?.statusCode) { if (e?.statusCode) {
throw createError({ statusCode: e.statusCode, statusMessage: e.message }) throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message })
} }
throw e throw e
} }

View file

@ -1,15 +1,12 @@
// Admin: create a new category. Takes a slug and an optional set of // Admin: create a new CMS category. Slug must be unique against other
// per-locale names. Slug must be unique against other categories and // categories and must not collide with any uncategorized page slug.
// must not collide with any top-level (uncategorized) page slug — // All validation lives in the cmsCategories service.
// otherwise the public router can't tell `/contact` (a page) apart from
// `/contact` (a bare category URL).
import { defineEventHandler, readBody, createError } from 'h3' import { defineEventHandler, readBody, createError, getHeader } from 'h3'
import { requirePermission } from '../../../utils/rbac' import { requirePermission } from '../../../utils/rbac'
import { db } from '../../../utils/database' import { createCmsCategory } from '../../../services/cmsCategories'
import { createCategory } from '../../../database/categories'
import { logCreate } from '../../../utils/activity-logger'
import { ENABLED_LANGUAGE_CODES } from '../../../../config/languages' import { ENABLED_LANGUAGE_CODES } from '../../../../config/languages'
import { logEvent } from '../../../utils/activity-logger'
interface Body { interface Body {
slug?: string slug?: string
@ -37,55 +34,29 @@ function normalizeTranslations(
} }
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.write') const authUser = await requirePermission(event, 'pages.write')
const body = await readBody<Body>(event) const body = await readBody<Body>(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) 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 { try {
const category = await createCategory({ const category = await createCmsCategory({
slug, slug: body?.slug ?? '',
menu_order: Number.isFinite(body?.menu_order) ? Number(body.menu_order) : 0, menu_order: Number.isFinite(body?.menu_order) ? Number(body.menu_order) : 0,
translations 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 return category
} catch (e: any) { } catch (e: any) {
if (e?.code === '23505') { if (e?.statusCode) {
throw createError({ statusCode: 409, statusMessage: 'A category with that slug already exists' }) throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message })
} }
throw e throw e
} }

View file

@ -1,30 +1,34 @@
// Admin: delete a CMS page and (via cascade) all its translations. // 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 { requirePermission } from '../../../utils/rbac'
import { deletePage } from '../../../database/pages' import {
import { db } from '../../../utils/database' deleteCmsPage,
import { logDelete } from '../../../utils/activity-logger' applyPageInvalidations
import { purgeCmsPage, purgeCmsCategory } from '../../../utils/cmsCache' } from '../../../services/cmsPages'
import { logEvent } from '../../../utils/activity-logger'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.write') const authUser = await requirePermission(event, 'pages.write')
const id = getRouterParam(event, 'id') const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' }) if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
const existing = await db try {
.selectFrom('pages') const { slug, categoryId } = await deleteCmsPage(id)
.select(['id', 'slug', 'category_id']) logEvent({
.where('id', '=', id) eventType: 'DELETE',
.executeTakeFirst() tableName: 'pages',
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Page not found' }) recordId: id,
userId: authUser.userId,
await deletePage(id) userAgent: getHeader(event, 'user-agent') || undefined,
logDelete('pages', id, event, { slug: existing.slug }) metadata: { slug, source: 'admin-ui' }
await purgeCmsPage(existing.slug) })
// Removed entry leaves stale `children[]` on every surviving sibling. await applyPageInvalidations([slug], categoryId ? [categoryId] : [])
if (existing.category_id) { return { ok: true }
await purgeCmsCategory(existing.category_id, existing.slug) } catch (e: any) {
if (e?.statusCode) {
throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message })
}
throw e
} }
return { ok: true }
}) })

View file

@ -2,26 +2,18 @@
import { defineEventHandler, getRouterParam, createError } from 'h3' import { defineEventHandler, getRouterParam, createError } from 'h3'
import { requirePermission } from '../../../utils/rbac' import { requirePermission } from '../../../utils/rbac'
import { db } from '../../../utils/database' import { getCmsPage } from '../../../services/cmsPages'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.view') await requirePermission(event, 'pages.view')
const id = getRouterParam(event, 'id') const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' }) if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
const page = await db const found = await getCmsPage({ id })
.selectFrom('pages') if (!found) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
.selectAll()
.where('id', '=', id)
.executeTakeFirst()
if (!page) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
const translations = await db // Match the legacy admin contract: `{page, translations}`. The
.selectFrom('page_translations') // service additionally returns `category_slug`, but the Vue admin
.selectAll() // doesn't read that field — drop it from the response.
.where('page_id', '=', id) return { page: found.page, translations: found.translations }
.orderBy('locale', 'asc')
.execute()
return { page, translations }
}) })

View file

@ -2,16 +2,20 @@
// theme, custom_css). Changing `category_id` auto-rewrites the slug // theme, custom_css). Changing `category_id` auto-rewrites the slug
// prefix to stay under the new category (old URL 404s), and both the // prefix to stay under the new category (old URL 404s), and both the
// old and new cached URLs are purged. // 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 { requirePermission } from '../../../utils/rbac'
import { db } from '../../../utils/database' import {
import { setPageCategory } from '../../../database/pages' updateCmsPage,
import { logUpdate } from '../../../utils/activity-logger' applyPageInvalidations
import { purgeCmsPage } from '../../../utils/cmsCache' } from '../../../services/cmsPages'
import { logEvent } from '../../../utils/activity-logger'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.write') const authUser = await requirePermission(event, 'pages.write')
const id = getRouterParam(event, 'id') const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' }) if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
@ -23,125 +27,56 @@ export default defineEventHandler(async (event) => {
custom_css?: string | null custom_css?: string | null
}>(event) }>(event)
const existing = await db if (body?.theme !== undefined && !['default', 'green'].includes(body.theme)) {
.selectFrom('pages') throw createError({ statusCode: 400, statusMessage: 'theme must be one of: default, green' })
.selectAll()
.where('id', '=', id)
.executeTakeFirst()
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
const slugsToPurge = new Set<string>([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
}
} }
const set: Record<string, unknown> = {} // Service normalizes the slug; admin just forwards. Empty/whitespace
if (body?.slug != null) { // custom_css folds to null at this boundary (matches legacy admin
const slug = String(body.slug).trim().replace(/^\/+|\/+$/g, '') // behavior); a non-empty custom_css keeps its original form,
if (!slug || !/^[a-z0-9][a-z0-9/-]*$/.test(slug)) { // including any leading/trailing whitespace the author wrote.
throw createError({ statusCode: 400, statusMessage: 'slug must be lowercase letters, digits, dashes, and slashes' }) const slugIn = body?.slug != null ? String(body.slug) : undefined
} let customCssIn: string | null | undefined
if (body?.custom_css === undefined) {
if (currentCategoryId) { customCssIn = undefined
const category = await db } else if (body.custom_css == null) {
.selectFrom('categories') customCssIn = null
.select('slug') } else {
.where('id', '=', currentCategoryId) const raw = String(body.custom_css)
.executeTakeFirst() customCssIn = raw && raw.trim() ? raw : null
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
} }
try { try {
let updated = existing const result = await updateCmsPage({
if (Object.keys(set).length > 0) { id,
set.updated = new Date() slug: slugIn,
updated = await db category_id: body?.category_id === undefined ? undefined : (body.category_id ? String(body.category_id) : null),
.updateTable('pages') menu_order: body?.menu_order !== undefined ? (Number(body.menu_order) || 0) : undefined,
.set(set as any) theme: body?.theme as 'default' | 'green' | undefined,
.where('id', '=', id) custom_css: customCssIn
.returningAll() })
.executeTakeFirstOrThrow()
} else {
updated = await db
.selectFrom('pages')
.selectAll()
.where('id', '=', id)
.executeTakeFirstOrThrow()
}
slugsToPurge.add(updated.slug) if (Object.keys(result.changes).length > 0) {
// Fire-and-forget audit (matches legacy admin behavior; the audit
// Fields that bleed into a sibling's `children[]` array (slug + // runs after the data write commits).
// menu_order) or shift category membership need every member page logEvent({
// in the affected categories re-rendered. Collect the union of eventType: 'UPDATE',
// old + new category ids, then pull their slug rosters in one go. tableName: 'pages',
const categoriesToPurge = new Set<string>() recordId: id,
const slugChanged = updated.slug !== existing.slug userId: authUser.userId,
const orderChanged = updated.menu_order !== existing.menu_order userAgent: getHeader(event, 'user-agent') || undefined,
const movedCategory = updated.category_id !== existing.category_id metadata: { changes: result.changes, source: 'admin-ui' }
if (slugChanged || orderChanged || movedCategory) { })
if (existing.category_id) categoriesToPurge.add(existing.category_id)
if (updated.category_id) categoriesToPurge.add(updated.category_id)
} }
if (categoriesToPurge.size > 0) { await applyPageInvalidations(result.slugsToPurge, result.categoriesToPurge)
const siblings = await db return result.page
.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
} catch (e: any) { } catch (e: any) {
if (e?.code === '23505') { if (e?.code === '23505') {
throw createError({ statusCode: 409, statusMessage: 'A page with that slug already exists' }) 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 throw e
} }
}) })

View file

@ -1,11 +1,12 @@
// Admin: set a translation's status to published / draft. // 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 { requirePermission } from '../../../../utils/rbac'
import { setTranslationStatus, getPageSlug } from '../../../../database/pages' import {
import { ENABLED_LANGUAGE_CODES } from '../../../../../config/languages' setCmsTranslationStatus,
import { logUpdate } from '../../../../utils/activity-logger' applyTranslationInvalidations
import { purgeCmsPage } from '../../../../utils/cmsCache' } from '../../../../services/cmsTranslations'
import { logEvent } from '../../../../utils/activity-logger'
interface Body { interface Body {
locale?: string locale?: string
@ -13,26 +14,38 @@ interface Body {
} }
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.publish') const authUser = await requirePermission(event, 'pages.publish')
const id = getRouterParam(event, 'id') const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' }) if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
const body = await readBody<Body>(event) const body = await readBody<Body>(event)
const locale = body?.locale const locale = body?.locale
const status = body?.status const status = body?.status
if (!locale || !ENABLED_LANGUAGE_CODES.includes(locale)) { if (!locale) throw createError({ statusCode: 400, statusMessage: 'locale is required' })
throw createError({ statusCode: 400, statusMessage: 'locale is not enabled' })
}
if (status !== 'draft' && status !== 'published') { if (status !== 'draft' && status !== 'published') {
throw createError({ statusCode: 400, statusMessage: 'status must be draft or published' }) throw createError({ statusCode: 400, statusMessage: 'status must be draft or published' })
} }
await setTranslationStatus(id, locale, status) try {
logUpdate('page_translations', id, event, { const result = await setCmsTranslationStatus({ page_id: id, locale, status })
event: status === 'published' ? 'PUBLISH' : 'UNPUBLISH', logEvent({
locale eventType: 'UPDATE',
}) tableName: 'page_translations',
const slug = await getPageSlug(id) recordId: result.translation.id,
if (slug) await purgeCmsPage(slug, [locale]) userId: authUser.userId,
return { ok: true } 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
}
}) })

View file

@ -4,15 +4,15 @@
// translation is upserted with the caller-chosen `status` (draft or // translation is upserted with the caller-chosen `status` (draft or
// published, default draft). `overwrite` controls whether to replace // published, default draft). `overwrite` controls whether to replace
// translations that already exist for a given locale. // 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 { requirePermission } from '../../../../utils/rbac'
import { db } from '../../../../utils/database' import { translatePage } from '../../../../services/cmsTranslate'
import { upsertTranslation, getPageSlug } from '../../../../database/pages' import { logEvent } from '../../../../utils/activity-logger'
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'
interface Body { interface Body {
sourceLocale?: string sourceLocale?: string
@ -22,93 +22,53 @@ interface Body {
} }
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.write') const authUser = await requirePermission(event, 'pages.write')
if (!isDeepLConfigured()) {
throw createError({ statusCode: 500, statusMessage: 'DEEPL_API_KEY is not configured' })
}
const id = getRouterParam(event, 'id') const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' }) if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
const body = await readBody<Body>(event) const body = await readBody<Body>(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 try {
.selectFrom('page_translations') const result = await translatePage({
.selectAll() page_id: id,
.where('page_id', '=', id) source_locale: body?.sourceLocale,
.where('locale', '=', sourceLocale) target_locales: body?.targetLocales ?? [],
.executeTakeFirst() overwrite: body?.overwrite,
if (!source) { status: body?.status
throw createError({ statusCode: 404, statusMessage: `No ${sourceLocale} translation to translate from` }) })
}
const results: Array<{ locale: string; skipped?: boolean; error?: string }> = [] logEvent({
eventType: 'UPDATE',
for (const target of targetLocales) { tableName: 'pages',
// Respect overwrite=false on existing non-draft translations so published content isn't silently replaced recordId: id,
if (!body?.overwrite) { userId: authUser.userId,
const existing = await db userAgent: getHeader(event, 'user-agent') || undefined,
.selectFrom('page_translations') metadata: {
.select(['id', 'status']) event: 'TRANSLATE',
.where('page_id', '=', id) source_locale: result.source_locale,
.where('locale', '=', target) // Reflect what the service actually attempted, not what the
.executeTakeFirst() // caller requested — the service silently filters out
if (existing) { // disabled/self-targeted locales before translating.
results.push({ locale: target, skipped: true }) target_locales: result.results.map(r => r.locale),
continue 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 } : {})
}))
} }
} catch (e: any) {
try { if (e?.statusCode) {
const textFields = [ throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message })
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' })
} }
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 }
}) })

View file

@ -1,14 +1,19 @@
// Admin: upsert a page's translation for a given locale. Saves as // Admin: upsert a page's translation for a given locale. Saves as
// draft unless `status: 'published'` is passed. All fields optional // draft unless `status: 'published'` is passed.
// except `title` and `body_json`. //
// 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 { requirePermission } from '../../../../../utils/rbac'
import { upsertTranslation } from '../../../../../database/pages' import {
import { ENABLED_LANGUAGE_CODES } from '../../../../../../config/languages' upsertCmsPageTranslation,
import { logUpdate } from '../../../../../utils/activity-logger' applyTranslationInvalidations
import { db } from '../../../../../utils/database' } from '../../../../../services/cmsTranslations'
import { purgeCmsPage, purgeCmsCategory } from '../../../../../utils/cmsCache' import { logEvent } from '../../../../../utils/activity-logger'
interface Body { interface Body {
title?: string title?: string
@ -22,57 +27,46 @@ interface Body {
} }
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.write') const authUser = await requirePermission(event, 'pages.write')
const id = getRouterParam(event, 'id') const id = getRouterParam(event, 'id')
const locale = getRouterParam(event, 'locale') const locale = getRouterParam(event, 'locale')
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' }) if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
if (!locale || !ENABLED_LANGUAGE_CODES.includes(locale)) { if (!locale) throw createError({ statusCode: 400, statusMessage: 'locale is required' })
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' })
const body = await readBody<Body>(event) const body = await readBody<Body>(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') { if (!body?.body_json || typeof body.body_json !== 'object') {
throw createError({ statusCode: 400, statusMessage: 'body_json is required' }) 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
}) })

View file

@ -1,74 +1,65 @@
// Admin: create a new CMS page (no translations). Returns the row. // Admin: create a new CMS page (no translations). Returns the row.
// Translation is added separately via PUT /api/admin/pages/[id]/translations/[locale]. // 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 { requirePermission } from '../../../utils/rbac'
import { createPage } from '../../../database/pages' import {
import { db } from '../../../utils/database' createCmsPage,
import { logCreate } from '../../../utils/activity-logger' applyPageInvalidations
import { purgeCmsCategory } from '../../../utils/cmsCache' } from '../../../services/cmsPages'
import { logEvent } from '../../../utils/activity-logger'
import type { Database } from '../../../database/schema'
export default defineEventHandler(async (event) => { 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 body = await readBody<{ slug?: string; category_id?: string | null; menu_order?: number }>(event)
if (!body?.slug || !String(body.slug).trim()) {
const slug = (body?.slug ?? '').trim().replace(/^\/+|\/+$/g, '')
if (!slug) {
throw createError({ statusCode: 400, statusMessage: 'slug is required' }) 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 category_id = body?.category_id ? String(body.category_id) : null
const menu_order = Number.isFinite(body?.menu_order) ? Number(body.menu_order) : 0 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 { try {
const page = await createPage({ slug, category_id, menu_order }) const result = await createCmsPage(
logCreate('pages', page.id, event, { slug, category_id, menu_order }) // Service normalizes the slug (trim + strip leading/trailing
// New member appears in every sibling's `children[]` — bust their // slashes); pass the raw body value through.
// cached responses so the sidebar picks it up on next visit. { slug: body?.slug ?? '', category_id, menu_order },
if (category_id) { async ({ pageId }, executor) => {
await purgeCmsCategory(category_id, slug) // Admin route never passes an inline translation, so
} // translationId is always null here — page audit only.
return page 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<Database>,
{ throwOnError: true }
)
}
)
await applyPageInvalidations(result.slugsToPurge, result.categoriesToPurge)
return result.page
} catch (e: any) { } 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') { if (e?.code === '23505') {
throw createError({ statusCode: 409, statusMessage: 'A page with that slug already exists' }) 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 throw e
} }
}) })

View file

@ -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<Body>(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
}
})

View file

@ -1,14 +1,12 @@
// Admin: image upload endpoint. Accepts a multipart form-data POST with // Admin: image upload endpoint. Accepts a multipart form-data POST with
// the file under field `file`, uploads to S3 via the existing storage // the file under field `file`, dispatches into the canonical
// util, and returns a signed URL + S3 key so the Tiptap editor can // cmsAssets.uploadImage() service (shared with the MCP upload_image
// insert the image. Mirrors the contract campaigns-sever's // tool), and returns the {url, key, filename} contract that
// useImageUpload composable expects. // campaigns-sever's useImageUpload composable expects.
import { defineEventHandler, readMultipartFormData, createError } from 'h3' import { defineEventHandler, readMultipartFormData, createError } from 'h3'
import { requirePermission } from '../../../utils/rbac' import { requirePermission } from '../../../utils/rbac'
import { uploadToS3, validateImageType, validateFileSize } from '../../../utils/storage' import { uploadImage } from '../../../services/cmsAssets'
const MAX_IMAGE_MB = 10
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.write') await requirePermission(event, 'pages.write')
@ -19,15 +17,17 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'file is required' }) throw createError({ statusCode: 400, statusMessage: 'file is required' })
} }
const contentType = file.type || 'application/octet-stream' try {
if (!validateImageType(contentType)) { const result = await uploadImage({
throw createError({ statusCode: 415, statusMessage: 'Unsupported image type' }) 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 }
}) })

View file

@ -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<Body>(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
}
})

View file

@ -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 "<cat>/"). 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
}]
}
}
})

209
server/mcp/schemas.ts Normal file
View file

@ -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()

View file

@ -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

View file

@ -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

218
server/mcp/tools/pages.ts Normal file
View file

@ -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 "<category_slug>/"; 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<typeof createCmsPage>[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<string, unknown> = input.translation.body_json
? (input.translation.body_json as Record<string, unknown>)
: (markdownToTiptap(input.translation.body_markdown!) as unknown as Record<string, unknown>)
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 }

View file

@ -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

View file

@ -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<string, unknown> | 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

View file

@ -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<unknown, unknown>)
}
registry.registerResource(cmsPageResource)
})

View file

@ -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<UploadImageResult> {
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<string, string> = {
'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<string, string>
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<PrepareImageUploadResult> {
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<FinalizeImageUploadResult> {
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
}
}

View file

@ -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<string, unknown>): 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<CategoryListItem[]> {
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<Category> {
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<typeof dbUpdateCategory>[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<number>`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<void> {
if (slugs.length === 0) return
await purgeSlugs(slugs)
}

519
server/services/cmsPages.ts Normal file
View file

@ -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<ListPagesResult> {
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<boolean>`lower(pages.slug) like ${pattern}`)
}
if (cursor) {
q = q.where(sql<boolean>`(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<string, Array<PageListItem['translations'][number]>>()
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<FullPage | null> {
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<Database> = db
): Promise<void> {
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<string, unknown>
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<void>
): 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<string, unknown>
}
export async function updateCmsPage(input: UpdatePageInput): Promise<UpdatePageResult> {
// 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<string>([existing.slug])
const categoriesToPurge = new Set<string>()
const changes: Record<string, unknown> = {}
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<void> {
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)))
}

View file

@ -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<TranslateTextResult> {
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<TranslatePageResult> {
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<string, unknown>,
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
}
}

View file

@ -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<string, unknown>): 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<string, unknown>
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<keyof UpsertTranslationInput> = [
'featured_image', 'og_image'
]
export async function upsertCmsPageTranslation(input: UpsertTranslationInput): Promise<UpsertTranslationResult> {
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<string, unknown>
if (hasJson) {
body_json = input.body_json as Record<string, unknown>
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<string, unknown>
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<PageTranslation | null> {
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<void> {
await purgeCmsPage(slug, locale ? [locale] : undefined)
if (categoryId) await purgeCmsCategory(categoryId, slug)
}

View file

@ -21,10 +21,18 @@ interface LogEventOptions {
* transaction — useful when the audit row must commit atomically with the * transaction — useful when the audit row must commit atomically with the
* operation it describes (e.g. granting the first user the admin role). * operation it describes (e.g. granting the first user the admin role).
* Defaults to the global db for fire-and-forget use from helpers. * 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( export async function logEvent(
options: LogEventOptions, options: LogEventOptions,
executor: Kysely<Database> = db executor: Kysely<Database> = db,
opts: { throwOnError?: boolean } = {}
): Promise<void> { ): Promise<void> {
try { try {
await executor await executor
@ -41,8 +49,10 @@ export async function logEvent(
}) })
.execute() .execute()
} catch (error) { } 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) console.error('Failed to log activity:', error)
if (opts.throwOnError) throw error
} }
} }

View file

@ -32,18 +32,26 @@ export async function purgeCmsPage(slug: string, locales?: string[]) {
// Purge every page in a category across all locales. Needed whenever a // Purge every page in a category across all locales. Needed whenever a
// field that leaks into a sibling's cached response changes — titles, // field that leaks into a sibling's cached response changes — titles,
// excerpts, featured images (all embedded in `children[]`) and // excerpts, featured images (all embedded in `children[]`) and
// menu_order (alters sibling sort order). `excludeSlug` lets the caller // menu_order (alters sibling sort order). `excludeSlugs` lets the
// skip the page whose own entry was already purged independently. // 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( export async function purgeCmsCategory(
categoryId: string, categoryId: string,
excludeSlug?: string excludeSlugs?: string | ReadonlyArray<string>
): Promise<void> { ): Promise<void> {
const skip = new Set<string>(
excludeSlugs === undefined
? []
: (typeof excludeSlugs === 'string' ? [excludeSlugs] : excludeSlugs)
)
const rows = await db const rows = await db
.selectFrom('pages') .selectFrom('pages')
.select('slug') .select('slug')
.where('category_id', '=', categoryId) .where('category_id', '=', categoryId)
.execute() .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))) await Promise.all(slugs.map(s => purgeCmsPage(s)))
} }

View file

@ -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 { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { randomBytes } from 'crypto' 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<string> {
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<Buffer> {
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 * Validate file type for image uploads
*/ */

View file

@ -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<Record<string, unknown>>
}
export function markdownToTiptap(markdown: string): TiptapDocJson {
const html = md.render(markdown ?? '')
const dom = new JSDOM(`<!doctype html><html><body>${html}</body></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
}

View file

@ -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<string> = new Set([
'textStyle',
'highlight',
'subscript',
'superscript',
'underline'
])
// Custom block nodes with no Markdown representation.
export const LOSSY_NODES: ReadonlySet<string> = 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<Record<string, ReadonlyArray<string>>> = {
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<string, unknown>
content?: JsonNode[]
marks?: Array<{ type?: string; attrs?: Record<string, unknown> }>
}
export interface LossyResult {
body_is_lossy_in_markdown: boolean
body_lossy_reasons: string[]
}
export function detectLossy(doc: unknown): LossyResult {
const reasons = new Set<string>()
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<string>): 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)
}
}

View file

@ -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<typeof generateHTML>[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 ''
}
}

View file

@ -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<string> = new Set([
'doc',
'paragraph',
'heading',
'bulletList',
'orderedList',
'listItem',
'blockquote',
'codeBlock',
'horizontalRule',
'hardBreak',
'text',
'image',
'youtube',
'div',
'verse',
'uupgsList',
'generalResources'
])
export const MARK_NAMES: ReadonlySet<string> = 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<string> = 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<Record<string, ReadonlyArray<string>>> = {
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<Record<string, ReadonlyArray<string>>> = {
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<string, unknown>
}
interface JsonNode {
type?: string
attrs?: Record<string, unknown>
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<string, unknown> | 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:<n><unit>', `${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 ?? '<missing>'}"`, `${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`)
}
}
}
}

View file

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