diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f99d83c..f4ce24f 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1559,8 +1559,8 @@ paths: post: tags: [widgets] summary: Render an unsaved widget config to HTML (non-persisting) - description: 'Requires scope: read. Returns rendered HTML; does not create anything.' - x-required-scope: read + description: 'Requires scope: write (any POST needs write under the scope ladder); renders to HTML without persisting anything.' + x-required-scope: write requestBody: required: true content: diff --git a/frontend/vendor/README.md b/frontend/vendor/README.md new file mode 100644 index 0000000..3d3b4aa --- /dev/null +++ b/frontend/vendor/README.md @@ -0,0 +1,18 @@ +# Vendored front-end libraries + +Third-party libraries committed directly to the repo (not fetched from a CDN or built +from npm) so self-hosted / air-gapped instances work with no external dependency and no +build step. + +## redoc.standalone.js +- **Library:** Redoc — renders the OpenAPI reference served at `/docs`. +- **Version:** 2.3.9 +- **Source:** https://cdn.redoc.ly/redoc/v2.3.9/bundles/redoc.standalone.js +- **Why committed:** the API reference must render on offline instances — no CDN, no build step. +- **Regenerate / update:** + ```sh + curl -sL https://cdn.redoc.ly/redoc/v2.3.9/bundles/redoc.standalone.js \ + -o frontend/vendor/redoc.standalone.js + # drop the trailing sourcemap comment (the .map is intentionally not vendored) + sed -i '/sourceMappingURL=redoc.standalone.js.map/d' frontend/vendor/redoc.standalone.js + ``` diff --git a/frontend/vendor/redoc.standalone.js b/frontend/vendor/redoc.standalone.js index 1f14e14..6bb2319 100644 --- a/frontend/vendor/redoc.standalone.js +++ b/frontend/vendor/redoc.standalone.js @@ -1835,4 +1835,3 @@ font-style: normal; color: '#666'; `;var jw=Object.defineProperty,Tw=Object.getOwnPropertyDescriptor;class Nw extends r.PureComponent{constructor(e){super(e),this.activeItemRef=null,this.clear=()=>{this.setState({results:[],noResults:!1,term:"",activeItemIdx:-1}),this.props.marker.unmark()},this.handleKeyDown=e=>{if(27===e.keyCode&&this.clear(),40===e.keyCode&&(this.setState({activeItemIdx:Math.min(this.state.activeItemIdx+1,this.state.results.length-1)}),e.preventDefault()),38===e.keyCode&&(this.setState({activeItemIdx:Math.max(0,this.state.activeItemIdx-1)}),e.preventDefault()),13===e.keyCode){const e=this.state.results[this.state.activeItemIdx];if(e){const t=this.props.getItemById(e.meta);t&&this.props.onActivate(t)}}},this.search=e=>{const{minCharacterLengthToInitSearch:t}=this.context,r=e.target.value;r.lengththis.searchCallback(this.state.term))},this.state={results:[],noResults:!1,term:"",activeItemIdx:-1}}clearResults(e){this.setState({results:[],noResults:!1,term:e}),this.props.marker.unmark()}setResults(e,t){this.setState({results:e,noResults:0===e.length}),this.props.marker.mark(t)}searchCallback(e){this.props.search.search(e).then(t=>{this.setResults(t,e)})}render(){const{activeItemIdx:e}=this.state,t=this.state.results.filter(e=>this.props.getItemById(e.meta)).map(e=>({item:this.props.getItemById(e.meta),score:e.score})).sort((e,t)=>t.score-e.score);return r.createElement(_w,{role:"search"},this.state.term&&r.createElement(Cw,{onClick:this.clear},"×"),r.createElement($w,null),r.createElement(Pw,{value:this.state.term,onKeyDown:this.handleKeyDown,placeholder:"Search...","aria-label":"Search",type:"text",onChange:this.search}),t.length>0&&r.createElement(df,{options:{wheelPropagation:!1}},r.createElement(Aw,{"data-role":"search:results"},t.map((t,n)=>r.createElement(ow,{item:Object.create(t.item,{active:{value:n===e}}),onActivate:this.props.onActivate,withoutChildren:!0,key:t.item.id,"data-role":"search:result"})))),this.state.term&&this.state.noResults?r.createElement(Aw,{"data-role":"search:results"},hi("noResultsFound")):null)}}Nw.contextType=Os,((e,t,r)=>{for(var n,i=Tw(t,r),o=e.length-1;o>=0;o--)(n=e[o])&&(i=n(t,r,i)||i);i&&jw(t,r,i)})([js.bind,(0,js.debounce)(400)],Nw.prototype,"searchCallback");class Iw extends r.Component{componentDidMount(){this.props.store.onDidMount()}componentWillUnmount(){this.props.store.dispose()}render(){const{store:{spec:e,menu:t,options:n,search:i,marker:o}}=this.props,s=this.props.store;return r.createElement(hs,{theme:n.theme},r.createElement(wp,{value:s},r.createElement(_s,{value:n},r.createElement(Sw,{className:"redoc-wrap"},r.createElement(kw,{menu:t,className:"menu-content"},r.createElement(Gb,{info:e.info}),!n.disableSearch&&r.createElement(Nw,{search:i,marker:o,getItemById:t.getItemById,onActivate:t.activateAndScroll})||null,r.createElement(hw,{menu:t})),r.createElement(Ew,{className:"api-content"},r.createElement(qb,{store:s}),r.createElement(Gx,{items:t.items})),r.createElement(Ow,null)))))}}Iw.propTypes={store:Es.instanceOf(Lb).isRequired};var Rw=Object.defineProperty,Lw=Object.getOwnPropertySymbols,Dw=Object.prototype.hasOwnProperty,Mw=Object.prototype.propertyIsEnumerable,zw=(e,t,r)=>t in e?Rw(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,Fw=(e,t)=>{for(var r in t||(t={}))Dw.call(t,r)&&zw(e,r,t[r]);if(Lw)for(var r of Lw(t))Mw.call(t,r)&&zw(e,r,t[r]);return e};const Bw=function(e){const{spec:t,specUrl:i,options:o={},onLoaded:s}=e,a=Ei(o.hideLoading,!1),l=new _i(o);if(void 0!==l.nonce)try{n.nc=l.nonce}catch(e){}return r.createElement(vs,null,r.createElement(Sp,{spec:t?Fw({},t):void 0,specUrl:i,options:o,onLoaded:s},({loading:e,store:t})=>e?a?null:r.createElement(Ss,{color:l.theme.colors.primary.main}):r.createElement(Iw,{store:t})))};var Uw=Object.defineProperty,qw=Object.getOwnPropertySymbols,Vw=Object.prototype.hasOwnProperty,Ww=Object.prototype.propertyIsEnumerable,Hw=(e,t,r)=>t in e?Uw(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,Gw=(e,t)=>{for(var r in t||(t={}))Vw.call(t,r)&&Hw(e,r,t[r]);if(qw)for(var r of qw(t))Ww.call(t,r)&&Hw(e,r,t[r]);return e};zt({useProxies:"ifavailable"});const Yw="2.5.3",Kw="1b2591e";function Qw(e){const t=function(e){const t={},r=e.attributes;for(let e=0;et.toUpperCase()),i=t[e];r[n]="theme"===e?JSON.parse(i):i}return r}function Xw(e,t={},n=Xn("redoc"),i){if(null===n)throw new Error('"element" argument is not provided and tag is not found on the page');let s,a;"string"==typeof e?s=e:"object"==typeof e&&(a=e),(0,o.H)(n).render(r.createElement(Bw,{spec:a,onLoaded:i,specUrl:s,options:Gw(Gw({},t),Qw(n))},["Loading..."]))}function Jw(e=Xn("redoc")){e&&(0,o.H)(e).unmount()}function Zw(e,t=Xn("redoc"),n){const i=Lb.fromJS(e);setTimeout(()=>{(0,o.c)(t,r.createElement(Iw,{store:i}),{onRecoverableError:n})},0)}!function(){const e=Xn("redoc");if(!e)return;const t=e.getAttribute("spec-url");t&&Xw(t,{},e)}()}(),i}()}); -//# sourceMappingURL=redoc.standalone.js.map \ No newline at end of file diff --git a/server/package-lock.json b/server/package-lock.json index 8d95e82..625cb58 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -26,6 +26,7 @@ "uuid": "^14.0.0" }, "devDependencies": { + "js-yaml": "^4.2.0", "socket.io-client": "^4.8.3" } }, @@ -692,6 +693,13 @@ "streamx": "^2.15.0" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -2186,6 +2194,29 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", diff --git a/server/package.json b/server/package.json index 4befa71..e18dda6 100644 --- a/server/package.json +++ b/server/package.json @@ -27,6 +27,7 @@ "uuid": "^14.0.0" }, "devDependencies": { + "js-yaml": "^4.2.0", "socket.io-client": "^4.8.3" } } diff --git a/server/test/openapi-contract.test.js b/server/test/openapi-contract.test.js new file mode 100644 index 0000000..a2603c3 --- /dev/null +++ b/server/test/openapi-contract.test.js @@ -0,0 +1,52 @@ +'use strict'; + +// Contract tests for the published OpenAPI spec. The spec is the integrator-facing +// contract, so it must not drift from what the server actually enforces. These parse +// docs/openapi.yaml directly (no server needed) and are derived from the same +// config/api-surface.js the server mounts from. +// +// Born from a real self-review finding: POST /widgets/preview was documented as scope +// 'read' while the method-based tokenScopeGate enforces 'write' for any POST, so a +// read-token integrator following the docs would hit a surprise 403. This makes that +// class of drift fail CI forever after. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const yaml = require('js-yaml'); +const { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS } = require('../config/api-surface'); + +const spec = yaml.load(fs.readFileSync(path.join(__dirname, '..', '..', 'docs', 'openapi.yaml'), 'utf8')); +const METHODS = ['get', 'post', 'put', 'delete', 'patch', 'head']; +// Spec paths are written without the /api prefix (servers: [{ url: /api }]). +const PUBLIC_PREFIXES = PUBLIC_ROUTERS.map(r => r.path.replace(/^\/api/, '')); +const JWT_ONLY_PREFIXES = JWT_ONLY_ROUTERS.map(r => r.path.replace(/^\/api/, '')); +const underPrefix = (p, prefixes) => prefixes.some(pre => p === pre || p.startsWith(pre + '/')); + +test('openapi: every operation x-required-scope matches the method-based enforcement', () => { + // Mirrors tokenScopeGate (GET/HEAD -> read, mutations -> write) + requireScope('full') + // on the operational command route. Public render endpoints (security: []) carry no scope. + const mismatches = []; + for (const [p, ops] of Object.entries(spec.paths || {})) { + for (const [m, op] of Object.entries(ops)) { + if (!METHODS.includes(m) || !op || typeof op !== 'object') continue; + if (Array.isArray(op.security) && op.security.length === 0) continue; // unauthenticated render + const expected = (m === 'get' || m === 'head') ? 'read' : (p.includes('command') ? 'full' : 'write'); + if (op['x-required-scope'] !== expected) { + mismatches.push(`${m.toUpperCase()} ${p}: spec='${op['x-required-scope']}' enforcement='${expected}'`); + } + } + } + assert.deepEqual(mismatches, [], 'spec x-required-scope drifted from enforcement:\n' + mismatches.join('\n')); +}); + +test('openapi: every documented path is a token-reachable (public) router, never JWT-only', () => { + // The spec must never advertise a JWT-only / privileged route as part of the token + // surface (it would invite an integrator to call something their token can't reach). + const offenders = []; + for (const p of Object.keys(spec.paths || {})) { + if (underPrefix(p, JWT_ONLY_PREFIXES) || !underPrefix(p, PUBLIC_PREFIXES)) offenders.push(p); + } + assert.deepEqual(offenders, [], 'spec documents non-public paths:\n' + offenders.join('\n')); +});