diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c7d54a0968..8f35d639b8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -52,6 +52,7 @@ updates: - react - react-dom - react-inlinesvg + - swagger-ui-dist update-types: - minor - patch diff --git a/.github/workflows/dependency-impact.yml b/.github/workflows/dependency-impact.yml index 90d0faca24..7b620fbbb1 100644 --- a/.github/workflows/dependency-impact.yml +++ b/.github/workflows/dependency-impact.yml @@ -1,6 +1,6 @@ name: Dependency impact -# Advisory check for Dependabot npm updates: builds the merge base (A) and the +# Advisory check for npm updates: builds the merge base (A) and the # pull request (B), which differ only in dependencies, and compares the rendered # output and key pages in a headless browser. Never a required check. on: @@ -19,8 +19,6 @@ concurrency: jobs: compare: name: Advisory dependency impact - # The PR author, not github.actor: a maintainer re-running the job must not skip it. - if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }} runs-on: ubuntu-latest timeout-minutes: 45 diff --git a/.gitignore b/.gitignore index 9e480b6b01..b73add43aa 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ # Generated files .docusaurus +/generated-static/ .cache-loader # Misc diff --git a/README.md b/README.md index ffdee60baa..e027d9e5b1 100644 --- a/README.md +++ b/README.md @@ -59,12 +59,17 @@ This command starts a local development server and opens up a browser window. Ma ### Static Swagger UI distribution -`/api` serves the static Swagger UI distribution. Its exact source package, Yarn resolution, and -SHA-256 file hashes are recorded in `static/swagger-ui-vendor.json`. After intentionally changing -the exact `swagger-ui-dist` development dependency, run `yarn vendor:swagger-ui` and then -`yarn test:swagger-ui-vendor`. The vendor command updates only the published distribution assets; -`static/api.html` and `static/swagger-initializer.js` remain Netdata-specific configuration. The -vendor contract also records the title-only local accessibility overlay on its OAuth redirect page. +`/api` is the hand-written `static/api.html` page with Netdata's `static/swagger-initializer.js`. +The Swagger UI files it loads are not committed: `src/plugins/swagger-ui-assets.js` copies them +from the installed `swagger-ui-dist` package into the ignored `generated-static/swagger-ui/` +directory on every `yarn start` and `yarn build`, and Docusaurus publishes that directory next to +`static/`. The served version is therefore always the one locked in `yarn.lock`, and a +`swagger-ui-dist` update needs no manual step. The build fails if the package stops shipping a +listed file. + +Learn does not publish Swagger UI's `oauth2-redirect.html`, because the Netdata API defines no +OAuth2 security scheme. If one is added, add `oauth2-redirect.html` and `oauth2-redirect.js` to +the plugin's asset list; the site build gate requires the page to have a title. ### Dependency update authority @@ -89,11 +94,11 @@ owned by Docusaurus and Mermaid. ### Cloudflare Web Analytics The shared `docusaurus.config.js` script configuration installs one deferred module beacon on -each rendered page except the credential-handling `api.html` and `oauth2-redirect.html` artifacts. +each rendered page except the credential-handling `api.html` artifact. Its public site token identifies the existing Cloudflare analytics site. The payload sets `send.to` to `/cdn-cgi/rum` so production browser reports use the Learn origin's Cloudflare receiver. The build verifier requires exactly that token and destination and rejects additional payload -fields, duplicate beacons, and third-party code on the credential-handling artifacts. +fields, duplicate beacons, and third-party code on the credential-handling artifact. `send.to` is an undocumented option implemented by the unversioned Cloudflare beacon. It is a provider compatibility dependency that may change independently of Learn releases. The @@ -105,8 +110,8 @@ After deployment, verify a production page in a browser: the official beacon mus its actual reporting POST to `/cdn-cgi/rum` must complete successfully, and fresh analytics for the exact `learn.netdata.cloud` host must appear in Cloudflare. A loaded script, a successful OPTIONS request, or a successful POST without fresh provider data is insufficient for full -recovery acceptance. Test normal documentation and Ask Nedi pages, and confirm that `/api` and -`/oauth2-redirect.html` still load no third-party analytics. +recovery acceptance. Test normal documentation and Ask Nedi pages, and confirm that `/api` still +loads no third-party analytics. Local servers and Netlify deploy previews do not provide the production Cloudflare receiver at `/cdn-cgi/rum`; their failed reporting requests cannot validate production ingestion. Validate diff --git a/docusaurus.config.js b/docusaurus.config.js index 57bfcf8907..eece939ed1 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -1,5 +1,6 @@ const {themes: prismThemes} = require('prism-react-renderer'); const {isSitemapIncludedRoute} = require('./seo.config'); +const {SWAGGER_UI_STATIC_DIR} = require('./src/plugins/swagger-ui-assets'); // Extend Dracula with missing token type mappings for YAML, config files, etc. const draculaExtended = { @@ -22,6 +23,7 @@ module.exports = { baseUrl: '/', onBrokenLinks: 'warn', favicon: 'img/favicon-32x32.png', + staticDirectories: ['static', SWAGGER_UI_STATIC_DIR], organizationName: 'netdata', projectName: 'netdata', future: { @@ -182,6 +184,7 @@ module.exports = { // }, }, plugins: [ + require.resolve('./src/plugins/swagger-ui-assets'), [ "posthog-docusaurus", { diff --git a/package.json b/package.json index b02591f798..98b3dcaca5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "docusaurus": "docusaurus", "start": "docusaurus start", "build": "docusaurus build", - "build:netlify": "node scripts/verify-git-history.js && node scripts/verify-indexnow-vendor.js && node scripts/verify-swagger-ui-vendor.js && npm ci --prefix scripts/site-build-gate --ignore-scripts --no-audit && docusaurus build && node scripts/run-post-build-gates.mjs", + "build:netlify": "node scripts/verify-git-history.js && node scripts/verify-indexnow-vendor.js && npm ci --prefix scripts/site-build-gate --ignore-scripts --no-audit && docusaurus build && node scripts/run-post-build-gates.mjs", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", @@ -27,13 +27,12 @@ "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", "test": "vitest", - "test:run": "vitest run && npm run test:first-touch && npm run test:indexnow && npm run test:swagger-ui-vendor && npm run test:dependency-authority && npm run test:site-build-gate", + "test:run": "vitest run && npm run test:first-touch && npm run test:indexnow && npm run test:swagger-ui-assets && npm run test:dependency-authority && npm run test:site-build-gate", "test:first-touch": "node --test tests/first_touch_capture.test.mjs tests/first_touch_producer_baseline.test.mjs", "test:dependency-authority": "node --test tests/dependency_authority.test.js", "test:indexnow": "node --test tests/indexnow_plugin.test.js", - "test:swagger-ui-vendor": "node --test tests/swagger_ui_vendor.test.js", + "test:swagger-ui-assets": "node --test tests/swagger_ui_assets.test.js", "test:site-build-gate": "node --test tests/site_build_gate.test.js", - "vendor:swagger-ui": "node scripts/verify-swagger-ui-vendor.js --write", "test:coverage": "vitest run --coverage", "test:ui": "vitest --ui" }, @@ -69,7 +68,6 @@ "react-player": "^3.4.0", "remark-gfm": "^4.0.1", "saxes": "6.0.0", - "swagger-ui": "^5.33.1", "typescript": "^7.0.2", "webpack": "^5.111.1" }, diff --git a/scripts/verify-cloudflare-rum.js b/scripts/verify-cloudflare-rum.js index e959f9b9d9..664b5fbc6d 100644 --- a/scripts/verify-cloudflare-rum.js +++ b/scripts/verify-cloudflare-rum.js @@ -22,7 +22,7 @@ const REPRESENTATIVE_ROUTES = [ 'docs/collecting-metrics/collectors/index.html', 'docs/netdata-agent/installation/linux/index.html', ]; -const SENSITIVE_ROUTES = ['api.html', 'oauth2-redirect.html']; +const SENSITIVE_ROUTES = ['api.html']; function htmlFilesUnder(root) { const files = []; diff --git a/scripts/verify-swagger-ui-vendor.js b/scripts/verify-swagger-ui-vendor.js deleted file mode 100644 index cf59206900..0000000000 --- a/scripts/verify-swagger-ui-vendor.js +++ /dev/null @@ -1,270 +0,0 @@ -const crypto = require('node:crypto'); -const fs = require('node:fs'); -const path = require('node:path'); - -const repositoryRoot = path.resolve(__dirname, '..'); -const packageName = 'swagger-ui-dist'; -const contractPath = path.join(repositoryRoot, 'static', 'swagger-ui-vendor.json'); -const assetFilenames = Object.freeze([ - 'favicon-16x16.png', - 'favicon-32x32.png', - 'index.css', - 'oauth2-redirect.html', - 'oauth2-redirect.js', - 'swagger-ui-bundle.js', - 'swagger-ui-bundle.js.LICENSE.txt', - 'swagger-ui-bundle.js.map', - 'swagger-ui-es-bundle-core.js', - 'swagger-ui-es-bundle-core.js.LICENSE.txt', - 'swagger-ui-es-bundle-core.js.map', - 'swagger-ui-es-bundle.js', - 'swagger-ui-es-bundle.js.LICENSE.txt', - 'swagger-ui-es-bundle.js.map', - 'swagger-ui-standalone-preset.js', - 'swagger-ui-standalone-preset.js.LICENSE.txt', - 'swagger-ui-standalone-preset.js.map', - 'swagger-ui.css', - 'swagger-ui.css.map', - 'swagger-ui.js', - 'swagger-ui.js.map', -]); -const contractFields = Object.freeze([ - 'algorithm', - 'files', - 'integrity', - 'local_overrides', - 'package', - 'resolved', - 'schema_version', - 'version', -]); -const localOverrides = Object.freeze({ - 'oauth2-redirect.html': - 'Adds the non-empty document title required by the Learn static-site gate without changing OAuth redirect behavior.', -}); - -function sha256(contents) { - return crypto.createHash('sha256').update(contents).digest('hex'); -} - -function sha256File(filename) { - return sha256(fs.readFileSync(filename)); -} - -function readJson(filename) { - return JSON.parse(fs.readFileSync(filename, 'utf8')); -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function yarnMetadata(version) { - const lockfile = fs.readFileSync(path.join(repositoryRoot, 'yarn.lock'), 'utf8'); - const entry = new RegExp( - `^${escapeRegExp(packageName)}@${escapeRegExp(version)}:\\n((?:^[ \\t].*(?:\\n|$))*)`, - 'm', - ).exec(lockfile); - if (!entry) throw new Error(`Missing ${packageName}@${version} from yarn.lock`); - - const section = entry[1]; - const lockedVersion = /^ version "([^"]+)"$/m.exec(section)?.[1]; - const resolved = /^ resolved "([^"]+)"$/m.exec(section)?.[1]; - const integrity = /^ integrity (sha512-\S+)$/m.exec(section)?.[1]; - if (lockedVersion !== version || !resolved || !integrity) { - throw new Error(`Invalid ${packageName}@${version} yarn.lock entry`); - } - return {resolved, integrity}; -} - -function sourceContext() { - const rootPackage = readJson(path.join(repositoryRoot, 'package.json')); - const version = rootPackage.devDependencies?.[packageName]; - if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(version || '')) { - throw new Error(`${packageName} must be an exact root dev dependency`); - } - - const sourcePackagePath = require.resolve(`${packageName}/package.json`, {paths: [repositoryRoot]}); - const sourceDirectory = path.dirname(sourcePackagePath); - if (readJson(sourcePackagePath).version !== version) { - throw new Error(`Installed ${packageName} does not match package.json`); - } - return {version, sourceDirectory, ...yarnMetadata(version)}; -} - -function validateFileMap(files) { - if (!files || typeof files !== 'object' || Array.isArray(files)) { - throw new Error('Swagger UI vendor contract has no file map'); - } - const filenames = Object.keys(files).sort(); - if (JSON.stringify(filenames) !== JSON.stringify([...assetFilenames].sort())) { - throw new Error('Swagger UI vendor contract does not cover the active distribution'); - } - for (const [filename, expected] of Object.entries(files)) { - if (path.basename(filename) !== filename || !/^[0-9a-f]{64}$/.test(expected)) { - throw new Error(`Invalid Swagger UI vendor contract entry: ${filename}`); - } - } -} - -function validateLocalOverrides(overrides) { - if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) { - throw new Error('Swagger UI vendor contract has no local override map'); - } - if ( - JSON.stringify(Object.keys(overrides).sort()) !== - JSON.stringify(Object.keys(localOverrides).sort()) - ) { - throw new Error('Swagger UI vendor contract has unsupported local overrides'); - } - for (const [filename, reason] of Object.entries(localOverrides)) { - const override = overrides[filename]; - if ( - !override || - typeof override !== 'object' || - Array.isArray(override) || - override.reason !== reason || - !/^[0-9a-f]{64}$/.test(override.sha256 || '') - ) { - throw new Error(`Invalid Swagger UI local override: ${filename}`); - } - } -} - -function validateContract(contract, context) { - if (!contract || typeof contract !== 'object' || Array.isArray(contract)) { - throw new Error('Swagger UI vendor contract is not an object'); - } - if (JSON.stringify(Object.keys(contract).sort()) !== JSON.stringify(contractFields)) { - throw new Error('Swagger UI vendor contract has unsupported fields'); - } - if ( - contract.schema_version !== 2 || - contract.algorithm !== 'sha256' || - contract.package !== packageName || - contract.version !== context.version || - contract.resolved !== context.resolved || - contract.integrity !== context.integrity - ) { - throw new Error('Swagger UI vendor contract does not match the locked distribution'); - } - validateFileMap(contract.files); - validateLocalOverrides(contract.local_overrides); -} - -function publishedAsset(filename, source) { - if (filename !== 'oauth2-redirect.html') return source; - const sourceText = source.toString('utf8'); - const marker = '\n
'; - if (sourceText.split(marker).length !== 2) { - throw new Error(`Unexpected ${packageName} OAuth redirect document`); - } - return Buffer.from( - sourceText.replace( - marker, - '\n\n