Implement CMS MCP
This commit is contained in:
parent
c85389f497
commit
127d89d084
39 changed files with 3800 additions and 495 deletions
8
.mcp.json
Normal file
8
.mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"doxa-cms": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:3033/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
import { Node } from '@tiptap/core'
|
||||
|
||||
const PRESERVED_DATA_ATTRS = [
|
||||
export const PRESERVED_DATA_ATTRS = [
|
||||
'data-width',
|
||||
'data-width-sm',
|
||||
'data-width-md',
|
||||
|
|
|
|||
36
bun.lock
36
bun.lock
|
|
@ -30,15 +30,18 @@
|
|||
"bcrypt": "^6.0.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"intl-tel-input": "^26.7.5",
|
||||
"jsdom": "^29.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"kysely": "^0.28.0",
|
||||
"kysely-postgres-js": "^2.0.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"nodemailer": "^7.0.6",
|
||||
"nodemailer-mailgun-transport": "^2.1.5",
|
||||
"nuxt": "^4.4.2",
|
||||
"postgres": "^3.4.7",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"turndown": "^7.2.4",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"zod": "^3.23",
|
||||
"zod-to-json-schema": "^3.23",
|
||||
|
|
@ -48,10 +51,11 @@
|
|||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/nodemailer": "^7.0.1",
|
||||
"@types/nodemailer-mailgun-transport": "^1.4.6",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"eslint": "^10.2.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"sass": "^1.99.0",
|
||||
"typescript": "^6.0.2",
|
||||
"vue-tsc": "^3.2.6",
|
||||
|
|
@ -413,6 +417,8 @@
|
|||
|
||||
"@mapbox/vector-tile": ["@mapbox/vector-tile@2.0.4", "", { "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^4.0.1" } }, "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg=="],
|
||||
|
||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||
|
||||
"@miyaneee/rollup-plugin-json5": ["@miyaneee/rollup-plugin-json5@1.2.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0", "json5": "^2.2.3" }, "peerDependencies": { "rollup": "^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0" } }, "sha512-JjTIaXZp9WzhUHpElrqPnl1AzBi/rvRs065F71+aTmlqvTMVkdbjZ8vfFl4nRlgJy+TPBw69ZK4pwFdmOAt4aA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
|
@ -971,6 +977,12 @@
|
|||
|
||||
"@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="],
|
||||
|
||||
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
|
||||
|
||||
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
|
||||
|
||||
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
|
||||
|
|
@ -987,6 +999,8 @@
|
|||
|
||||
"@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="],
|
||||
|
||||
"@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="],
|
||||
|
||||
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
||||
|
||||
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
||||
|
|
@ -1409,7 +1423,7 @@
|
|||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
|
||||
|
||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
|
||||
|
||||
|
|
@ -1723,7 +1737,7 @@
|
|||
|
||||
"jsdoc-type-pratt-parser": ["jsdoc-type-pratt-parser@7.2.0", "", {}, "sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw=="],
|
||||
|
||||
"jsdom": ["jsdom@29.0.2", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.5", "@asamuzakjp/dom-selector": "^7.0.6", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w=="],
|
||||
"jsdom": ["jsdom@29.1.0", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
|
|
@ -1795,6 +1809,8 @@
|
|||
|
||||
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||
|
||||
"linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
|
||||
|
||||
"linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="],
|
||||
|
||||
"listhen": ["listhen@1.9.1", "", { "dependencies": { "@parcel/watcher": "^2.5.6", "@parcel/watcher-wasm": "^2.5.6", "citty": "^0.2.2", "consola": "^3.4.2", "crossws": ">=0.2.0 <0.5.0", "defu": "^6.1.6", "get-port-please": "^3.2.0", "h3": "^1.15.11", "http-shutdown": "^1.2.2", "jiti": "^2.6.1", "mlly": "^1.8.2", "node-forge": "^1.4.0", "pathe": "^2.0.3", "std-env": "^4.0.0", "tinyclip": "^0.1.12", "ufo": "^1.6.3", "untun": "^0.1.3", "uqr": "^0.1.2" }, "bin": { "listen": "bin/listhen.mjs", "listhen": "bin/listhen.mjs" } }, "sha512-4EhoyVcXEpNlY5HJRSQpH7Rba94M8N2JmI62ePjl0lrJKXSfG0F1FAgHGxBoz/T3pe41sUEwkIRRIcaUL0/Ofw=="],
|
||||
|
|
@ -1843,6 +1859,8 @@
|
|||
|
||||
"mapbox-gl": ["mapbox-gl@3.22.0", "", { "dependencies": { "@mapbox/mapbox-gl-supported": "^3.0.0", "@mapbox/point-geometry": "^1.1.0", "@mapbox/tiny-sdf": "^2.0.6", "@mapbox/unitbezier": "^0.0.1", "@mapbox/vector-tile": "^2.0.4", "@types/geojson": "^7946.0.16", "@types/geojson-vt": "^3.2.5", "@types/pbf": "^3.0.5", "@types/supercluster": "^7.1.3", "cheap-ruler": "^4.0.0", "csscolorparser": "~1.0.3", "earcut": "^3.0.1", "geojson-vt": "^4.0.2", "gl-matrix": "^3.4.4", "grid-index": "^1.1.0", "kdbush": "^4.0.2", "martinez-polygon-clipping": "^0.8.1", "murmurhash-js": "^1.0.0", "pbf": "^4.0.1", "potpack": "^2.0.0", "quickselect": "^3.0.0", "supercluster": "^8.0.1", "tinyqueue": "^3.0.0" } }, "sha512-ZIpF+oAMcQoDlvABmiRkHoydyBR9zI6CyDeVRa2/iyua0/B2+rPuIzoCV/CgN14P5F0HVk53GIZw220WSqJPyA=="],
|
||||
|
||||
"markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
|
||||
|
||||
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
|
||||
|
||||
"martinez-polygon-clipping": ["martinez-polygon-clipping@0.8.1", "", { "dependencies": { "robust-predicates": "^2.0.4", "splaytree": "^0.1.4", "tinyqueue": "3.0.0" } }, "sha512-9PLLMzMPI6ihHox4Ns6LpVBLpRc7sbhULybZ/wyaY8sY3ECNe2+hxm1hA2/9bEEpRrdpjoeduBuZLg2aq1cSIQ=="],
|
||||
|
|
@ -1851,6 +1869,8 @@
|
|||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
|
@ -2123,6 +2143,8 @@
|
|||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
|
||||
|
||||
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
|
||||
|
||||
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
|
||||
|
|
@ -2357,6 +2379,8 @@
|
|||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"turndown": ["turndown@7.2.4", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="],
|
||||
|
|
@ -2367,6 +2391,8 @@
|
|||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
|
||||
|
||||
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
|
||||
|
||||
"ultrahtml": ["ultrahtml@1.6.0", "", {}, "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw=="],
|
||||
|
|
@ -2645,8 +2671,6 @@
|
|||
|
||||
"csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"eslint/find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
|
@ -2705,6 +2729,8 @@
|
|||
|
||||
"nuxt/unimport": ["unimport@6.1.0", "", { "dependencies": { "acorn": "^8.16.0", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.2", "pathe": "^2.0.3", "picomatch": "^4.0.4", "pkg-types": "^2.3.0", "scule": "^1.3.0", "strip-literal": "^3.1.0", "tinyglobby": "^0.2.16", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1" } }, "sha512-ocgNKyiqj7Hw7oHt7A7D3za3fq28eShe1EloL6hsoQgn7CF51Y4CqFT9ISG3rEy0JpA8CCz/sY5h5OovOn62VQ=="],
|
||||
|
||||
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||
|
||||
"rollup-plugin-visualizer/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
|
||||
|
|
|
|||
|
|
@ -123,6 +123,27 @@ export default defineNuxtConfig({
|
|||
|
||||
runtimeConfig: {
|
||||
appName: process.env.APP_TITLE || 'My App',
|
||||
mcpServerName: process.env.MCP_SERVER_NAME || 'doxa-cms',
|
||||
mcpServerVersion: process.env.MCP_SERVER_VERSION || '1.0.0',
|
||||
// ── MCP layer config (consumed by base-code/nuxt-blueprints/layers/mcp) ──
|
||||
// These keys are read at runtime by the MCP layer. If you rename
|
||||
// them or change shape the layer silently falls back to defaults
|
||||
// (no rate limiting, no read-scope classification) — guard them
|
||||
// when upgrading the layer.
|
||||
// mcpReadScopes: tools whose scope is in this list are exempt
|
||||
// from the per-token writes bucket. `pages.view` is the only
|
||||
// read scope today.
|
||||
// mcpRateLimits: per-token writes bucket is raised from the
|
||||
// layer default (20/min) to 60/min — a "publish 9 locales of
|
||||
// 5 pages" Claude flow lands at 45 calls.
|
||||
mcpReadScopes: ['pages.view'],
|
||||
mcpRateLimits: {
|
||||
writesPerToken: { limit: 60, windowMs: 60_000 }
|
||||
},
|
||||
// OAuth layer reads this when DCR registrations omit `scope`.
|
||||
// Curated to the three CMS scopes; excludes admin/users/roles/
|
||||
// settings even though those exist in PERMISSIONS.
|
||||
oauthDcrDefaultScopes: ['pages.view', 'pages.write', 'pages.publish'],
|
||||
databaseUrl: process.env.DATABASE_URL || '',
|
||||
jwtSecret: process.env.JWT_SECRET || '',
|
||||
mailgunApiKey: process.env.MAILGUN_API_KEY || '',
|
||||
|
|
|
|||
|
|
@ -36,15 +36,18 @@
|
|||
"bcrypt": "^6.0.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"intl-tel-input": "^26.7.5",
|
||||
"jsdom": "^29.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"kysely": "^0.28.0",
|
||||
"kysely-postgres-js": "^2.0.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"nodemailer": "^7.0.6",
|
||||
"nodemailer-mailgun-transport": "^2.1.5",
|
||||
"nuxt": "^4.4.2",
|
||||
"postgres": "^3.4.7",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"turndown": "^7.2.4",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"zod": "^3.23",
|
||||
"zod-to-json-schema": "^3.23"
|
||||
|
|
@ -54,10 +57,11 @@
|
|||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/nodemailer": "^7.0.1",
|
||||
"@types/nodemailer-mailgun-transport": "^1.4.6",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"eslint": "^10.2.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"sass": "^1.99.0",
|
||||
"typescript": "^6.0.2",
|
||||
"vue-tsc": "^3.2.6"
|
||||
|
|
|
|||
|
|
@ -1,33 +1,43 @@
|
|||
// Admin: delete a category. Blocked (409) if any pages still reference
|
||||
// it — the admin must reassign or delete those pages first.
|
||||
|
||||
import { defineEventHandler, getRouterParam, createError } from 'h3'
|
||||
import { defineEventHandler, getRouterParam, createError, getHeader } from 'h3'
|
||||
import { requirePermission } from '../../../utils/rbac'
|
||||
import { db } from '../../../utils/database'
|
||||
import { deleteCategory } from '../../../database/categories'
|
||||
import { logDelete } from '../../../utils/activity-logger'
|
||||
import { deleteCmsCategory } from '../../../services/cmsCategories'
|
||||
import { logEvent } from '../../../utils/activity-logger'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requirePermission(event, 'pages.write')
|
||||
const authUser = await requirePermission(event, 'pages.write')
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
|
||||
|
||||
const existing = await db
|
||||
.selectFrom('categories')
|
||||
.select(['id', 'slug'])
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst()
|
||||
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Category not found' })
|
||||
|
||||
try {
|
||||
await deleteCategory(id)
|
||||
logDelete('categories', id, event, { slug: existing.slug })
|
||||
return { ok: true }
|
||||
} catch (e: any) {
|
||||
if (e?.statusCode === 409) {
|
||||
const result = await deleteCmsCategory(id)
|
||||
if ('error' in result) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'This category still has pages. Move or delete them first.'
|
||||
statusMessage: 'This category still has pages. Move or delete them first.',
|
||||
data: {
|
||||
attached_page_count: result.attached_page_count,
|
||||
sample_slugs: result.sample_slugs
|
||||
}
|
||||
})
|
||||
}
|
||||
logEvent({
|
||||
eventType: 'DELETE',
|
||||
tableName: 'categories',
|
||||
recordId: id,
|
||||
userId: authUser.userId,
|
||||
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||
metadata: { slug: result.slug, source: 'admin-ui' }
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (e: any) {
|
||||
if (e?.statusCode) {
|
||||
throw createError({
|
||||
statusCode: e.statusCode,
|
||||
statusMessage: e.statusMessage || e.message,
|
||||
data: e.data
|
||||
})
|
||||
}
|
||||
throw e
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
// Admin: update a category — slug (cascades to every member page
|
||||
// slug), menu_order, and per-locale names. Returns the updated row.
|
||||
|
||||
import { defineEventHandler, getRouterParam, readBody, createError } from 'h3'
|
||||
import { defineEventHandler, getRouterParam, readBody, createError, getHeader } from 'h3'
|
||||
import { requirePermission } from '../../../utils/rbac'
|
||||
import { db } from '../../../utils/database'
|
||||
import { updateCategory, purgeSlugs } from '../../../database/categories'
|
||||
import { logUpdate } from '../../../utils/activity-logger'
|
||||
import {
|
||||
updateCmsCategory,
|
||||
applyCategoryInvalidations
|
||||
} from '../../../services/cmsCategories'
|
||||
import { logEvent } from '../../../utils/activity-logger'
|
||||
import { ENABLED_LANGUAGE_CODES } from '../../../../config/languages'
|
||||
|
||||
interface Body {
|
||||
|
|
@ -32,49 +34,32 @@ function normalizeTranslations(
|
|||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requirePermission(event, 'pages.write')
|
||||
const authUser = await requirePermission(event, 'pages.write')
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
|
||||
|
||||
const body = await readBody<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)
|
||||
if (translations) input.translations = translations
|
||||
|
||||
try {
|
||||
const result = await updateCategory(id, input)
|
||||
await purgeSlugs(result.slugsToPurge)
|
||||
logUpdate('categories', id, event, {
|
||||
changes: Object.keys(input),
|
||||
slugsPurged: result.slugsToPurge.length
|
||||
const result = await updateCmsCategory({
|
||||
id,
|
||||
slug: body?.slug != null ? String(body.slug) : undefined,
|
||||
menu_order: body?.menu_order !== undefined ? (Number(body.menu_order) || 0) : undefined,
|
||||
translations
|
||||
})
|
||||
await applyCategoryInvalidations(result.slugsToPurge)
|
||||
logEvent({
|
||||
eventType: 'UPDATE',
|
||||
tableName: 'categories',
|
||||
recordId: id,
|
||||
userId: authUser.userId,
|
||||
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||
metadata: {
|
||||
changes: (['slug', 'menu_order', 'translations'] as const).filter(k => body?.[k] !== undefined),
|
||||
slugsPurged: result.slugsToPurge.length,
|
||||
source: 'admin-ui'
|
||||
}
|
||||
})
|
||||
return result.category
|
||||
} catch (e: any) {
|
||||
|
|
@ -82,7 +67,7 @@ export default defineEventHandler(async (event) => {
|
|||
throw createError({ statusCode: 409, statusMessage: 'A category with that slug already exists' })
|
||||
}
|
||||
if (e?.statusCode) {
|
||||
throw createError({ statusCode: e.statusCode, statusMessage: e.message })
|
||||
throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message })
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
// Admin: create a new category. Takes a slug and an optional set of
|
||||
// per-locale names. Slug must be unique against other categories and
|
||||
// must not collide with any top-level (uncategorized) page slug —
|
||||
// otherwise the public router can't tell `/contact` (a page) apart from
|
||||
// `/contact` (a bare category URL).
|
||||
// Admin: create a new CMS category. Slug must be unique against other
|
||||
// categories and must not collide with any uncategorized page slug.
|
||||
// All validation lives in the cmsCategories service.
|
||||
|
||||
import { defineEventHandler, readBody, createError } from 'h3'
|
||||
import { defineEventHandler, readBody, createError, getHeader } from 'h3'
|
||||
import { requirePermission } from '../../../utils/rbac'
|
||||
import { db } from '../../../utils/database'
|
||||
import { createCategory } from '../../../database/categories'
|
||||
import { logCreate } from '../../../utils/activity-logger'
|
||||
import { createCmsCategory } from '../../../services/cmsCategories'
|
||||
import { ENABLED_LANGUAGE_CODES } from '../../../../config/languages'
|
||||
import { logEvent } from '../../../utils/activity-logger'
|
||||
|
||||
interface Body {
|
||||
slug?: string
|
||||
|
|
@ -37,55 +34,29 @@ function normalizeTranslations(
|
|||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requirePermission(event, 'pages.write')
|
||||
const authUser = await requirePermission(event, 'pages.write')
|
||||
const body = await readBody<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)
|
||||
if (!translations.some(t => t.locale === 'en' && t.name)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'An English name is required'
|
||||
})
|
||||
}
|
||||
|
||||
// Guard against collision with an uncategorized page that owns the
|
||||
// same slug at the URL root (the public router would ambiguously
|
||||
// resolve `/contact`).
|
||||
const collidingPage = await db
|
||||
.selectFrom('pages')
|
||||
.select('id')
|
||||
.where('slug', '=', slug)
|
||||
.where('category_id', 'is', null)
|
||||
.executeTakeFirst()
|
||||
if (collidingPage) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: `A page already uses the slug "${slug}". Pick a different category slug or reassign that page.`
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const category = await createCategory({
|
||||
slug,
|
||||
const category = await createCmsCategory({
|
||||
slug: body?.slug ?? '',
|
||||
menu_order: Number.isFinite(body?.menu_order) ? Number(body.menu_order) : 0,
|
||||
translations
|
||||
})
|
||||
logCreate('categories', category.id, event, { slug, translations: translations.length })
|
||||
logEvent({
|
||||
eventType: 'CREATE',
|
||||
tableName: 'categories',
|
||||
recordId: category.id,
|
||||
userId: authUser.userId,
|
||||
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||
metadata: { slug: category.slug, translations: translations.length, source: 'admin-ui' }
|
||||
})
|
||||
return category
|
||||
} catch (e: any) {
|
||||
if (e?.code === '23505') {
|
||||
throw createError({ statusCode: 409, statusMessage: 'A category with that slug already exists' })
|
||||
if (e?.statusCode) {
|
||||
throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message })
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,34 @@
|
|||
// Admin: delete a CMS page and (via cascade) all its translations.
|
||||
|
||||
import { defineEventHandler, getRouterParam, createError } from 'h3'
|
||||
import { defineEventHandler, getRouterParam, createError, getHeader } from 'h3'
|
||||
import { requirePermission } from '../../../utils/rbac'
|
||||
import { deletePage } from '../../../database/pages'
|
||||
import { db } from '../../../utils/database'
|
||||
import { logDelete } from '../../../utils/activity-logger'
|
||||
import { purgeCmsPage, purgeCmsCategory } from '../../../utils/cmsCache'
|
||||
import {
|
||||
deleteCmsPage,
|
||||
applyPageInvalidations
|
||||
} from '../../../services/cmsPages'
|
||||
import { logEvent } from '../../../utils/activity-logger'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requirePermission(event, 'pages.write')
|
||||
const authUser = await requirePermission(event, 'pages.write')
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
|
||||
|
||||
const existing = await db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'slug', 'category_id'])
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst()
|
||||
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
|
||||
|
||||
await deletePage(id)
|
||||
logDelete('pages', id, event, { slug: existing.slug })
|
||||
await purgeCmsPage(existing.slug)
|
||||
// Removed entry leaves stale `children[]` on every surviving sibling.
|
||||
if (existing.category_id) {
|
||||
await purgeCmsCategory(existing.category_id, existing.slug)
|
||||
}
|
||||
try {
|
||||
const { slug, categoryId } = await deleteCmsPage(id)
|
||||
logEvent({
|
||||
eventType: 'DELETE',
|
||||
tableName: 'pages',
|
||||
recordId: id,
|
||||
userId: authUser.userId,
|
||||
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||
metadata: { slug, source: 'admin-ui' }
|
||||
})
|
||||
await applyPageInvalidations([slug], categoryId ? [categoryId] : [])
|
||||
return { ok: true }
|
||||
} catch (e: any) {
|
||||
if (e?.statusCode) {
|
||||
throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message })
|
||||
}
|
||||
throw e
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,26 +2,18 @@
|
|||
|
||||
import { defineEventHandler, getRouterParam, createError } from 'h3'
|
||||
import { requirePermission } from '../../../utils/rbac'
|
||||
import { db } from '../../../utils/database'
|
||||
import { getCmsPage } from '../../../services/cmsPages'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requirePermission(event, 'pages.view')
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
|
||||
|
||||
const page = await db
|
||||
.selectFrom('pages')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst()
|
||||
if (!page) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
|
||||
const found = await getCmsPage({ id })
|
||||
if (!found) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
|
||||
|
||||
const translations = await db
|
||||
.selectFrom('page_translations')
|
||||
.selectAll()
|
||||
.where('page_id', '=', id)
|
||||
.orderBy('locale', 'asc')
|
||||
.execute()
|
||||
|
||||
return { page, translations }
|
||||
// Match the legacy admin contract: `{page, translations}`. The
|
||||
// service additionally returns `category_slug`, but the Vue admin
|
||||
// doesn't read that field — drop it from the response.
|
||||
return { page: found.page, translations: found.translations }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,16 +2,20 @@
|
|||
// theme, custom_css). Changing `category_id` auto-rewrites the slug
|
||||
// prefix to stay under the new category (old URL 404s), and both the
|
||||
// old and new cached URLs are purged.
|
||||
//
|
||||
// All slug-rewrite, validation, and cache fan-out logic lives in the
|
||||
// cmsPages service; the route stays thin.
|
||||
|
||||
import { defineEventHandler, getRouterParam, readBody, createError } from 'h3'
|
||||
import { defineEventHandler, getRouterParam, readBody, createError, getHeader } from 'h3'
|
||||
import { requirePermission } from '../../../utils/rbac'
|
||||
import { db } from '../../../utils/database'
|
||||
import { setPageCategory } from '../../../database/pages'
|
||||
import { logUpdate } from '../../../utils/activity-logger'
|
||||
import { purgeCmsPage } from '../../../utils/cmsCache'
|
||||
import {
|
||||
updateCmsPage,
|
||||
applyPageInvalidations
|
||||
} from '../../../services/cmsPages'
|
||||
import { logEvent } from '../../../utils/activity-logger'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requirePermission(event, 'pages.write')
|
||||
const authUser = await requirePermission(event, 'pages.write')
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
|
||||
|
||||
|
|
@ -23,125 +27,56 @@ export default defineEventHandler(async (event) => {
|
|||
custom_css?: string | null
|
||||
}>(event)
|
||||
|
||||
const existing = await db
|
||||
.selectFrom('pages')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst()
|
||||
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
|
||||
|
||||
const slugsToPurge = new Set<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> = {}
|
||||
if (body?.slug != null) {
|
||||
const slug = String(body.slug).trim().replace(/^\/+|\/+$/g, '')
|
||||
if (!slug || !/^[a-z0-9][a-z0-9/-]*$/.test(slug)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'slug must be lowercase letters, digits, dashes, and slashes' })
|
||||
}
|
||||
|
||||
if (currentCategoryId) {
|
||||
const category = await db
|
||||
.selectFrom('categories')
|
||||
.select('slug')
|
||||
.where('id', '=', currentCategoryId)
|
||||
.executeTakeFirst()
|
||||
if (category && !slug.startsWith(`${category.slug}/`)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `Slug must start with "${category.slug}/"`
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const collidingCategory = await db
|
||||
.selectFrom('categories')
|
||||
.select('id')
|
||||
.where('slug', '=', slug)
|
||||
.executeTakeFirst()
|
||||
if (collidingCategory) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: `"${slug}" is already used by a category.`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
set.slug = slug
|
||||
}
|
||||
if (body?.menu_order !== undefined) {
|
||||
set.menu_order = Number(body.menu_order) || 0
|
||||
}
|
||||
if (body?.theme !== undefined) {
|
||||
const theme = String(body.theme)
|
||||
if (!['default', 'green'].includes(theme)) {
|
||||
if (body?.theme !== undefined && !['default', 'green'].includes(body.theme)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'theme must be one of: default, green' })
|
||||
}
|
||||
set.theme = theme
|
||||
}
|
||||
if (body?.custom_css !== undefined) {
|
||||
const raw = body.custom_css == null ? null : String(body.custom_css)
|
||||
set.custom_css = raw && raw.trim() ? raw : null
|
||||
|
||||
// Service normalizes the slug; admin just forwards. Empty/whitespace
|
||||
// custom_css folds to null at this boundary (matches legacy admin
|
||||
// behavior); a non-empty custom_css keeps its original form,
|
||||
// including any leading/trailing whitespace the author wrote.
|
||||
const slugIn = body?.slug != null ? String(body.slug) : undefined
|
||||
let customCssIn: string | null | undefined
|
||||
if (body?.custom_css === undefined) {
|
||||
customCssIn = undefined
|
||||
} else if (body.custom_css == null) {
|
||||
customCssIn = null
|
||||
} else {
|
||||
const raw = String(body.custom_css)
|
||||
customCssIn = raw && raw.trim() ? raw : null
|
||||
}
|
||||
|
||||
try {
|
||||
let updated = existing
|
||||
if (Object.keys(set).length > 0) {
|
||||
set.updated = new Date()
|
||||
updated = await db
|
||||
.updateTable('pages')
|
||||
.set(set as any)
|
||||
.where('id', '=', id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow()
|
||||
} else {
|
||||
updated = await db
|
||||
.selectFrom('pages')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirstOrThrow()
|
||||
}
|
||||
const result = await updateCmsPage({
|
||||
id,
|
||||
slug: slugIn,
|
||||
category_id: body?.category_id === undefined ? undefined : (body.category_id ? String(body.category_id) : null),
|
||||
menu_order: body?.menu_order !== undefined ? (Number(body.menu_order) || 0) : undefined,
|
||||
theme: body?.theme as 'default' | 'green' | undefined,
|
||||
custom_css: customCssIn
|
||||
})
|
||||
|
||||
slugsToPurge.add(updated.slug)
|
||||
|
||||
// Fields that bleed into a sibling's `children[]` array (slug +
|
||||
// menu_order) or shift category membership need every member page
|
||||
// in the affected categories re-rendered. Collect the union of
|
||||
// old + new category ids, then pull their slug rosters in one go.
|
||||
const categoriesToPurge = new Set<string>()
|
||||
const slugChanged = updated.slug !== existing.slug
|
||||
const orderChanged = updated.menu_order !== existing.menu_order
|
||||
const movedCategory = updated.category_id !== existing.category_id
|
||||
if (slugChanged || orderChanged || movedCategory) {
|
||||
if (existing.category_id) categoriesToPurge.add(existing.category_id)
|
||||
if (updated.category_id) categoriesToPurge.add(updated.category_id)
|
||||
if (Object.keys(result.changes).length > 0) {
|
||||
// Fire-and-forget audit (matches legacy admin behavior; the audit
|
||||
// runs after the data write commits).
|
||||
logEvent({
|
||||
eventType: 'UPDATE',
|
||||
tableName: 'pages',
|
||||
recordId: id,
|
||||
userId: authUser.userId,
|
||||
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||
metadata: { changes: result.changes, source: 'admin-ui' }
|
||||
})
|
||||
}
|
||||
if (categoriesToPurge.size > 0) {
|
||||
const siblings = await db
|
||||
.selectFrom('pages')
|
||||
.select('slug')
|
||||
.where('category_id', 'in', Array.from(categoriesToPurge))
|
||||
.execute()
|
||||
for (const { slug } of siblings) slugsToPurge.add(slug)
|
||||
}
|
||||
|
||||
logUpdate('pages', id, event, { changes: { ...set, category_id: updated.category_id } })
|
||||
await Promise.all(Array.from(slugsToPurge).map(s => purgeCmsPage(s)))
|
||||
return updated
|
||||
await applyPageInvalidations(result.slugsToPurge, result.categoriesToPurge)
|
||||
return result.page
|
||||
} catch (e: any) {
|
||||
if (e?.code === '23505') {
|
||||
throw createError({ statusCode: 409, statusMessage: 'A page with that slug already exists' })
|
||||
}
|
||||
if (e?.statusCode) {
|
||||
throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message })
|
||||
}
|
||||
throw e
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
// Admin: set a translation's status to published / draft.
|
||||
|
||||
import { defineEventHandler, getRouterParam, readBody, createError } from 'h3'
|
||||
import { defineEventHandler, getRouterParam, readBody, createError, getHeader } from 'h3'
|
||||
import { requirePermission } from '../../../../utils/rbac'
|
||||
import { setTranslationStatus, getPageSlug } from '../../../../database/pages'
|
||||
import { ENABLED_LANGUAGE_CODES } from '../../../../../config/languages'
|
||||
import { logUpdate } from '../../../../utils/activity-logger'
|
||||
import { purgeCmsPage } from '../../../../utils/cmsCache'
|
||||
import {
|
||||
setCmsTranslationStatus,
|
||||
applyTranslationInvalidations
|
||||
} from '../../../../services/cmsTranslations'
|
||||
import { logEvent } from '../../../../utils/activity-logger'
|
||||
|
||||
interface Body {
|
||||
locale?: string
|
||||
|
|
@ -13,26 +14,38 @@ interface Body {
|
|||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requirePermission(event, 'pages.publish')
|
||||
const authUser = await requirePermission(event, 'pages.publish')
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
|
||||
|
||||
const body = await readBody<Body>(event)
|
||||
const locale = body?.locale
|
||||
const status = body?.status
|
||||
if (!locale || !ENABLED_LANGUAGE_CODES.includes(locale)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'locale is not enabled' })
|
||||
}
|
||||
if (!locale) throw createError({ statusCode: 400, statusMessage: 'locale is required' })
|
||||
if (status !== 'draft' && status !== 'published') {
|
||||
throw createError({ statusCode: 400, statusMessage: 'status must be draft or published' })
|
||||
}
|
||||
|
||||
await setTranslationStatus(id, locale, status)
|
||||
logUpdate('page_translations', id, event, {
|
||||
try {
|
||||
const result = await setCmsTranslationStatus({ page_id: id, locale, status })
|
||||
logEvent({
|
||||
eventType: 'UPDATE',
|
||||
tableName: 'page_translations',
|
||||
recordId: result.translation.id,
|
||||
userId: authUser.userId,
|
||||
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||
metadata: {
|
||||
event: status === 'published' ? 'PUBLISH' : 'UNPUBLISH',
|
||||
locale
|
||||
locale,
|
||||
source: 'admin-ui'
|
||||
}
|
||||
})
|
||||
const slug = await getPageSlug(id)
|
||||
if (slug) await purgeCmsPage(slug, [locale])
|
||||
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
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,15 +4,15 @@
|
|||
// translation is upserted with the caller-chosen `status` (draft or
|
||||
// published, default draft). `overwrite` controls whether to replace
|
||||
// translations that already exist for a given locale.
|
||||
//
|
||||
// All workflow logic — DeepL calls, body walking, upsert with
|
||||
// validation, per-locale cache purge — lives in cmsTranslate.translatePage.
|
||||
// This route stays thin.
|
||||
|
||||
import { defineEventHandler, getRouterParam, readBody, createError } from 'h3'
|
||||
import { defineEventHandler, getRouterParam, readBody, createError, getHeader } from 'h3'
|
||||
import { requirePermission } from '../../../../utils/rbac'
|
||||
import { db } from '../../../../utils/database'
|
||||
import { upsertTranslation, getPageSlug } from '../../../../database/pages'
|
||||
import { translateTexts, translateTiptapContent, isDeepLConfigured } from '../../../../utils/deepl'
|
||||
import { ENABLED_LANGUAGE_CODES } from '../../../../../config/languages'
|
||||
import { logUpdate } from '../../../../utils/activity-logger'
|
||||
import { purgeCmsPage } from '../../../../utils/cmsCache'
|
||||
import { translatePage } from '../../../../services/cmsTranslate'
|
||||
import { logEvent } from '../../../../utils/activity-logger'
|
||||
|
||||
interface Body {
|
||||
sourceLocale?: string
|
||||
|
|
@ -22,93 +22,53 @@ interface Body {
|
|||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requirePermission(event, 'pages.write')
|
||||
if (!isDeepLConfigured()) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'DEEPL_API_KEY is not configured' })
|
||||
}
|
||||
|
||||
const authUser = await requirePermission(event, 'pages.write')
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
|
||||
|
||||
const body = await readBody<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
|
||||
.selectFrom('page_translations')
|
||||
.selectAll()
|
||||
.where('page_id', '=', id)
|
||||
.where('locale', '=', sourceLocale)
|
||||
.executeTakeFirst()
|
||||
if (!source) {
|
||||
throw createError({ statusCode: 404, statusMessage: `No ${sourceLocale} translation to translate from` })
|
||||
}
|
||||
|
||||
const results: Array<{ locale: string; skipped?: boolean; error?: string }> = []
|
||||
|
||||
for (const target of targetLocales) {
|
||||
// Respect overwrite=false on existing non-draft translations so published content isn't silently replaced
|
||||
if (!body?.overwrite) {
|
||||
const existing = await db
|
||||
.selectFrom('page_translations')
|
||||
.select(['id', 'status'])
|
||||
.where('page_id', '=', id)
|
||||
.where('locale', '=', target)
|
||||
.executeTakeFirst()
|
||||
if (existing) {
|
||||
results.push({ locale: target, skipped: true })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const textFields = [
|
||||
source.title ?? '',
|
||||
source.excerpt ?? '',
|
||||
source.meta_title ?? '',
|
||||
source.meta_description ?? ''
|
||||
]
|
||||
const [translatedTitle, translatedExcerpt, translatedMetaTitle, translatedMetaDescription]
|
||||
= await translateTexts(textFields, target, sourceLocale)
|
||||
const translatedBody = await translateTiptapContent(source.body_json as any, target, sourceLocale)
|
||||
|
||||
await upsertTranslation({
|
||||
const result = await translatePage({
|
||||
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
|
||||
source_locale: body?.sourceLocale,
|
||||
target_locales: body?.targetLocales ?? [],
|
||||
overwrite: body?.overwrite,
|
||||
status: body?.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' })
|
||||
}
|
||||
}
|
||||
|
||||
logUpdate('pages', id, event, {
|
||||
logEvent({
|
||||
eventType: 'UPDATE',
|
||||
tableName: 'pages',
|
||||
recordId: id,
|
||||
userId: authUser.userId,
|
||||
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||
metadata: {
|
||||
event: 'TRANSLATE',
|
||||
source_locale: sourceLocale,
|
||||
target_locales: targetLocales,
|
||||
results
|
||||
source_locale: result.source_locale,
|
||||
// Reflect what the service actually attempted, not what the
|
||||
// caller requested — the service silently filters out
|
||||
// disabled/self-targeted locales before translating.
|
||||
target_locales: result.results.map(r => r.locale),
|
||||
results: result.results,
|
||||
source: 'admin-ui'
|
||||
}
|
||||
})
|
||||
|
||||
// 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)
|
||||
// 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) {
|
||||
if (e?.statusCode) {
|
||||
throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message })
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
return { results }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
// Admin: upsert a page's translation for a given locale. Saves as
|
||||
// draft unless `status: 'published'` is passed. All fields optional
|
||||
// except `title` and `body_json`.
|
||||
// draft unless `status: 'published'` is passed.
|
||||
//
|
||||
// Calls upsertCmsPageTranslation, which now applies tiptapValidate
|
||||
// (the XSS allowlist) and the lossy-overwrite guard. Admin clients
|
||||
// only ever pass body_json (no markdown), so the lossy guard is
|
||||
// effectively inert here — it only fires when body_markdown is the
|
||||
// input branch.
|
||||
|
||||
import { defineEventHandler, getRouterParam, readBody, createError } from 'h3'
|
||||
import { defineEventHandler, getRouterParam, readBody, createError, getHeader } from 'h3'
|
||||
import { requirePermission } from '../../../../../utils/rbac'
|
||||
import { upsertTranslation } from '../../../../../database/pages'
|
||||
import { ENABLED_LANGUAGE_CODES } from '../../../../../../config/languages'
|
||||
import { logUpdate } from '../../../../../utils/activity-logger'
|
||||
import { db } from '../../../../../utils/database'
|
||||
import { purgeCmsPage, purgeCmsCategory } from '../../../../../utils/cmsCache'
|
||||
import {
|
||||
upsertCmsPageTranslation,
|
||||
applyTranslationInvalidations
|
||||
} from '../../../../../services/cmsTranslations'
|
||||
import { logEvent } from '../../../../../utils/activity-logger'
|
||||
|
||||
interface Body {
|
||||
title?: string
|
||||
|
|
@ -22,35 +27,22 @@ interface Body {
|
|||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requirePermission(event, 'pages.write')
|
||||
const authUser = await requirePermission(event, 'pages.write')
|
||||
const id = getRouterParam(event, 'id')
|
||||
const locale = getRouterParam(event, 'locale')
|
||||
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
|
||||
if (!locale || !ENABLED_LANGUAGE_CODES.includes(locale)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'locale is not enabled' })
|
||||
}
|
||||
|
||||
const page = await db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'slug', 'category_id'])
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst()
|
||||
if (!page) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
|
||||
if (!locale) throw createError({ statusCode: 400, statusMessage: 'locale is required' })
|
||||
|
||||
const body = await readBody<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') {
|
||||
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' })
|
||||
}
|
||||
|
||||
const translation = await upsertTranslation({
|
||||
try {
|
||||
const result = await upsertCmsPageTranslation({
|
||||
page_id: id,
|
||||
locale,
|
||||
title,
|
||||
title: (body.title ?? '').trim(),
|
||||
body_json: body.body_json,
|
||||
excerpt: body.excerpt ?? null,
|
||||
featured_image: body.featured_image ?? null,
|
||||
|
|
@ -60,19 +52,21 @@ export default defineEventHandler(async (event) => {
|
|||
status: body.status
|
||||
})
|
||||
|
||||
logUpdate('page_translations', translation.id, event, {
|
||||
page_id: id,
|
||||
locale,
|
||||
status: translation.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 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)
|
||||
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
|
||||
}
|
||||
|
||||
return translation
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,74 +1,65 @@
|
|||
// Admin: create a new CMS page (no translations). Returns the row.
|
||||
// Translation is added separately via PUT /api/admin/pages/[id]/translations/[locale].
|
||||
//
|
||||
// Validation, slug rules, transactional audit, and cache invalidation
|
||||
// are owned by the cmsPages service. The admin route stays thin: parse
|
||||
// + auth + dispatch + project the response shape Vue expects.
|
||||
|
||||
import { defineEventHandler, readBody, createError } from 'h3'
|
||||
import { defineEventHandler, readBody, createError, getHeader } from 'h3'
|
||||
import type { Kysely } from 'kysely'
|
||||
import { requirePermission } from '../../../utils/rbac'
|
||||
import { createPage } from '../../../database/pages'
|
||||
import { db } from '../../../utils/database'
|
||||
import { logCreate } from '../../../utils/activity-logger'
|
||||
import { purgeCmsCategory } from '../../../utils/cmsCache'
|
||||
import {
|
||||
createCmsPage,
|
||||
applyPageInvalidations
|
||||
} from '../../../services/cmsPages'
|
||||
import { logEvent } from '../../../utils/activity-logger'
|
||||
import type { Database } from '../../../database/schema'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requirePermission(event, 'pages.write')
|
||||
const authUser = await requirePermission(event, 'pages.write')
|
||||
const body = await readBody<{ slug?: string; category_id?: string | null; menu_order?: number }>(event)
|
||||
|
||||
const slug = (body?.slug ?? '').trim().replace(/^\/+|\/+$/g, '')
|
||||
if (!slug) {
|
||||
if (!body?.slug || !String(body.slug).trim()) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'slug is required' })
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9/-]*$/.test(slug)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'slug must be lowercase letters, digits, dashes, and slashes' })
|
||||
}
|
||||
|
||||
const category_id = body?.category_id ? String(body.category_id) : null
|
||||
const menu_order = Number.isFinite(body?.menu_order) ? Number(body.menu_order) : 0
|
||||
|
||||
// When a category is picked, the slug must live under its prefix so
|
||||
// public URL resolution stays consistent. Uncategorized pages can
|
||||
// have any slug shape, but they can't collide with a category slug —
|
||||
// that would make `/contact` ambiguous.
|
||||
if (category_id) {
|
||||
const category = await db
|
||||
.selectFrom('categories')
|
||||
.select(['slug'])
|
||||
.where('id', '=', category_id)
|
||||
.executeTakeFirst()
|
||||
if (!category) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Category not found' })
|
||||
}
|
||||
if (!slug.startsWith(`${category.slug}/`)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `Slug must start with "${category.slug}/"`
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const collidingCategory = await db
|
||||
.selectFrom('categories')
|
||||
.select('id')
|
||||
.where('slug', '=', slug)
|
||||
.executeTakeFirst()
|
||||
if (collidingCategory) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: `"${slug}" is already used by a category.`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await createPage({ slug, category_id, menu_order })
|
||||
logCreate('pages', page.id, event, { slug, category_id, menu_order })
|
||||
// New member appears in every sibling's `children[]` — bust their
|
||||
// cached responses so the sidebar picks it up on next visit.
|
||||
if (category_id) {
|
||||
await purgeCmsCategory(category_id, slug)
|
||||
const result = await createCmsPage(
|
||||
// Service normalizes the slug (trim + strip leading/trailing
|
||||
// slashes); pass the raw body value through.
|
||||
{ slug: body?.slug ?? '', category_id, menu_order },
|
||||
async ({ pageId }, executor) => {
|
||||
// Admin route never passes an inline translation, so
|
||||
// translationId is always null here — page audit only.
|
||||
await logEvent(
|
||||
{
|
||||
eventType: 'CREATE',
|
||||
tableName: 'pages',
|
||||
recordId: pageId,
|
||||
userId: authUser.userId,
|
||||
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||
metadata: { slug: body.slug, category_id, menu_order, source: 'admin-ui' }
|
||||
},
|
||||
executor as Kysely<Database>,
|
||||
{ throwOnError: true }
|
||||
)
|
||||
}
|
||||
return page
|
||||
)
|
||||
await applyPageInvalidations(result.slugsToPurge, result.categoriesToPurge)
|
||||
return result.page
|
||||
} catch (e: any) {
|
||||
// Translate H3-shaped service errors back to the admin route's
|
||||
// legacy createError contract. The service's `err()` helper already
|
||||
// attaches statusCode/statusMessage so most cases pass through;
|
||||
// we just need the unique-violation special case.
|
||||
if (e?.code === '23505') {
|
||||
throw createError({ statusCode: 409, statusMessage: 'A page with that slug already exists' })
|
||||
}
|
||||
if (e?.statusCode) {
|
||||
throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message })
|
||||
}
|
||||
throw e
|
||||
}
|
||||
})
|
||||
|
|
|
|||
33
server/api/admin/upload/finalize-image.post.ts
Normal file
33
server/api/admin/upload/finalize-image.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
|
|
@ -1,14 +1,12 @@
|
|||
// Admin: image upload endpoint. Accepts a multipart form-data POST with
|
||||
// the file under field `file`, uploads to S3 via the existing storage
|
||||
// util, and returns a signed URL + S3 key so the Tiptap editor can
|
||||
// insert the image. Mirrors the contract campaigns-sever's
|
||||
// useImageUpload composable expects.
|
||||
// the file under field `file`, dispatches into the canonical
|
||||
// cmsAssets.uploadImage() service (shared with the MCP upload_image
|
||||
// tool), and returns the {url, key, filename} contract that
|
||||
// campaigns-sever's useImageUpload composable expects.
|
||||
|
||||
import { defineEventHandler, readMultipartFormData, createError } from 'h3'
|
||||
import { requirePermission } from '../../../utils/rbac'
|
||||
import { uploadToS3, validateImageType, validateFileSize } from '../../../utils/storage'
|
||||
|
||||
const MAX_IMAGE_MB = 10
|
||||
import { uploadImage } from '../../../services/cmsAssets'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requirePermission(event, 'pages.write')
|
||||
|
|
@ -19,15 +17,17 @@ export default defineEventHandler(async (event) => {
|
|||
throw createError({ statusCode: 400, statusMessage: 'file is required' })
|
||||
}
|
||||
|
||||
const contentType = file.type || 'application/octet-stream'
|
||||
if (!validateImageType(contentType)) {
|
||||
throw createError({ statusCode: 415, statusMessage: 'Unsupported image type' })
|
||||
}
|
||||
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')
|
||||
try {
|
||||
const result = await uploadImage({
|
||||
buffer: file.data,
|
||||
filename: file.filename,
|
||||
declaredMimeType: file.type
|
||||
})
|
||||
return { url: result.url, key: result.key, filename: result.filename }
|
||||
} catch (e: any) {
|
||||
if (e?.statusCode) {
|
||||
throw createError({ statusCode: e.statusCode, statusMessage: e.statusMessage || e.message })
|
||||
}
|
||||
throw e
|
||||
}
|
||||
})
|
||||
|
|
|
|||
39
server/api/admin/upload/prepare-image.post.ts
Normal file
39
server/api/admin/upload/prepare-image.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
87
server/mcp/resources/pages.ts
Normal file
87
server/mcp/resources/pages.ts
Normal 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
209
server/mcp/schemas.ts
Normal 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()
|
||||
99
server/mcp/tools/assets.ts
Normal file
99
server/mcp/tools/assets.ts
Normal 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
|
||||
137
server/mcp/tools/categories.ts
Normal file
137
server/mcp/tools/categories.ts
Normal 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
218
server/mcp/tools/pages.ts
Normal 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 }
|
||||
117
server/mcp/tools/translate.ts
Normal file
117
server/mcp/tools/translate.ts
Normal 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
|
||||
187
server/mcp/tools/translations.ts
Normal file
187
server/mcp/tools/translations.ts
Normal 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
|
||||
26
server/plugins/10-mcp-cms.ts
Normal file
26
server/plugins/10-mcp-cms.ts
Normal 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)
|
||||
})
|
||||
338
server/services/cmsAssets.ts
Normal file
338
server/services/cmsAssets.ts
Normal 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
|
||||
}
|
||||
}
|
||||
201
server/services/cmsCategories.ts
Normal file
201
server/services/cmsCategories.ts
Normal 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
519
server/services/cmsPages.ts
Normal 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)))
|
||||
}
|
||||
226
server/services/cmsTranslate.ts
Normal file
226
server/services/cmsTranslate.ts
Normal 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
|
||||
}
|
||||
}
|
||||
218
server/services/cmsTranslations.ts
Normal file
218
server/services/cmsTranslations.ts
Normal 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)
|
||||
}
|
||||
|
||||
|
|
@ -21,10 +21,18 @@ interface LogEventOptions {
|
|||
* transaction — useful when the audit row must commit atomically with the
|
||||
* operation it describes (e.g. granting the first user the admin role).
|
||||
* Defaults to the global db for fire-and-forget use from helpers.
|
||||
*
|
||||
* Pass `{ throwOnError: true }` when the caller needs an audit-row failure
|
||||
* to roll back its surrounding transaction. The MCP layer's `mcpLog` sets
|
||||
* this when an executor is provided so a failed audit insert tears down
|
||||
* the page/translation write that depends on it. Default `false` preserves
|
||||
* the existing fire-and-forget admin-side behavior — there is nothing to
|
||||
* roll back outside a transaction.
|
||||
*/
|
||||
export async function logEvent(
|
||||
options: LogEventOptions,
|
||||
executor: Kysely<Database> = db
|
||||
executor: Kysely<Database> = db,
|
||||
opts: { throwOnError?: boolean } = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
await executor
|
||||
|
|
@ -41,8 +49,10 @@ export async function logEvent(
|
|||
})
|
||||
.execute()
|
||||
} catch (error) {
|
||||
// Log error but don't throw - logging should never break the main flow
|
||||
// Log error but don't throw by default - logging should never break the
|
||||
// main flow for fire-and-forget callers.
|
||||
console.error('Failed to log activity:', error)
|
||||
if (opts.throwOnError) throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,18 +32,26 @@ export async function purgeCmsPage(slug: string, locales?: string[]) {
|
|||
// Purge every page in a category across all locales. Needed whenever a
|
||||
// field that leaks into a sibling's cached response changes — titles,
|
||||
// excerpts, featured images (all embedded in `children[]`) and
|
||||
// menu_order (alters sibling sort order). `excludeSlug` lets the caller
|
||||
// skip the page whose own entry was already purged independently.
|
||||
// menu_order (alters sibling sort order). `excludeSlugs` lets the
|
||||
// caller skip pages whose entries were already purged independently
|
||||
// (avoids duplicate removeItem calls + lets `applyPageInvalidations`
|
||||
// pass the page-being-modified's slug — and its old slug, on a
|
||||
// category move — through).
|
||||
export async function purgeCmsCategory(
|
||||
categoryId: string,
|
||||
excludeSlug?: string
|
||||
excludeSlugs?: string | ReadonlyArray<string>
|
||||
): Promise<void> {
|
||||
const skip = new Set<string>(
|
||||
excludeSlugs === undefined
|
||||
? []
|
||||
: (typeof excludeSlugs === 'string' ? [excludeSlugs] : excludeSlugs)
|
||||
)
|
||||
const rows = await db
|
||||
.selectFrom('pages')
|
||||
.select('slug')
|
||||
.where('category_id', '=', categoryId)
|
||||
.execute()
|
||||
const slugs = rows.map(r => r.slug).filter(s => s !== excludeSlug)
|
||||
const slugs = rows.map(r => r.slug).filter(s => !skip.has(s))
|
||||
await Promise.all(slugs.map(s => purgeCmsPage(s)))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'
|
||||
import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
|
|
@ -173,6 +173,102 @@ export async function generateSignedUrl(key: string, expiresIn: number = SIGNED_
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a presigned PUT URL for direct browser/agent uploads to S3.
|
||||
*
|
||||
* Both the Content-Type and the byte size (Content-Length) are signed
|
||||
* into the URL — the uploading client must send headers matching both
|
||||
* exactly or S3 rejects with SignatureDoesNotMatch. This caps the
|
||||
* upload size at the value you sign in (defending against runaway
|
||||
* uploads to a public bucket) and binds the declared MIME to the
|
||||
* upload without our server having to handle the bytes.
|
||||
*
|
||||
* Returned URL only authorizes a single PUT to the specified key.
|
||||
* After the upload completes, callers should still validate the
|
||||
* actual bytes (e.g. magic-byte sniff via a Range GET) — Content-Type
|
||||
* binding only constrains the response header S3 stores, not the
|
||||
* payload itself. See finalizeImageUpload() in cmsAssets.
|
||||
*/
|
||||
export async function createPresignedUploadUrl(
|
||||
key: string,
|
||||
contentType: string,
|
||||
contentLength: number,
|
||||
expiresIn: number = 5 * 60 // 5 minutes
|
||||
): Promise<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
|
||||
*/
|
||||
|
|
|
|||
59
server/utils/tiptapFromMarkdown.ts
Normal file
59
server/utils/tiptapFromMarkdown.ts
Normal 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
|
||||
}
|
||||
117
server/utils/tiptapLossyDetector.ts
Normal file
117
server/utils/tiptapLossyDetector.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
66
server/utils/tiptapToMarkdown.ts
Normal file
66
server/utils/tiptapToMarkdown.ts
Normal 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 ''
|
||||
}
|
||||
}
|
||||
340
server/utils/tiptapValidate.ts
Normal file
340
server/utils/tiptapValidate.ts
Normal 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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
server/utils/urlValidation.ts
Normal file
41
server/utils/urlValidation.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue