From cb321e9437cf3ef4e3a65b77a0e30f69d2e89df1 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 14:00:04 +0100 Subject: [PATCH] feat!: extend the SEO layer and drop the copied purge route - extend vue-seo-layer (link dependency during development); every page takes its head from the SEO plugin through it - remove the copied shared purge route, library, plugin and their test: POST /api/__site__/cache/purge comes from vue-seo-layer with the same contract and environment variables - the page-less playground sets seo.htmlSitemap false; the smoke points NUXT_SEO_API_BASE at the fixture API (fails open) BREAKING CHANGE: server/api/__site__/cache/purge.post.ts, server/lib/siteCachePurge.ts and server/plugins/site-cache-purge.ts are no longer in this layer, and the runtime keys cachePurge and siteCache are declared by vue-seo-layer. An app without pages sets seo.htmlSitemap false. --- .playground/nuxt.config.ts | 4 + README.md | 31 +- nuxt.config.ts | 40 +-- package.json | 3 + pnpm-lock.yaml | 195 ++++++----- server/api/__site__/cache/purge.post.ts | 48 --- server/lib/siteCachePurge.ts | 208 ----------- server/plugins/site-cache-purge.ts | 48 --- testing/smoke-ssr.mjs | 8 +- tests/sitePurge.test.ts | 438 ------------------------ 10 files changed, 132 insertions(+), 891 deletions(-) delete mode 100644 server/api/__site__/cache/purge.post.ts delete mode 100644 server/lib/siteCachePurge.ts delete mode 100644 server/plugins/site-cache-purge.ts delete mode 100644 tests/sitePurge.test.ts diff --git a/.playground/nuxt.config.ts b/.playground/nuxt.config.ts index c3d90d3..a776309 100644 --- a/.playground/nuxt.config.ts +++ b/.playground/nuxt.config.ts @@ -3,4 +3,8 @@ export default defineNuxtConfig({ extends: ['..'], compatibilityDate: '2025-07-15', + // An app without pages: vue-seo-layer's HTML sitemap pages would be its + // only routes and turn the router on, so every other path would answer + // 404. A site with pages keeps them. + seo: { htmlSitemap: false }, }) diff --git a/README.md b/README.md index 637f83a..3def302 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ pnpm add git+https://git.golem15.com/golem15/vue-faq-layer.git#v0.2.0 The layer is consumed with `extends: ['vue-faq-layer']`. It reads one route of the FAQ plugin's anonymous API, `GET /_faq/api/v1/faqs/{slug}`, during SSR and renders it with ``. Editors manage FAQs in the SummerCMS admin; the site needs no FAQ data of its own. +The layer extends [vue-seo-layer](https://git.golem15.com/golem15/vue-seo-layer), which sets every page's head from the SummerCMS SEO plugin and serves the shared site cache purge route; an app that extends vue-faq-layer gets it with no extra entry in `extends`. + The layer is neutral: it ships no site colours, no site copy and no Nuxt modules. Its look comes from `--faq-*` CSS custom properties, which an app overrides, and every component can be replaced by a file of the same name in the app. There is deliberately no component named `FaqSection`, so an app's own `FaqSection.vue` can wrap `FaqBlock`. ## Features @@ -18,7 +20,7 @@ The layer is neutral: it ships no site colours, no site copy and no Nuxt modules - **`FaqList`** and **`FaqItem`**: one native `
` per question in API order, the question in `` with a CSS chevron (no animation under `prefers-reduced-motion`), the answer below. All items start closed (`openFirst` opens the first). With `exclusive`, the items share a `name`, so the browser keeps one open at a time. - **HTML**: the description and answers are HTML the FAQ API sanitizes on output; the layer renders them with `v-html` and adds no client sanitizer. Questions are plain text. - **Empty states**: a missing FAQ (404), a failed request or a FAQ without items renders nothing, or the `#fallback` slot. There is never an empty heading. -- **Site cache purge**: the shared route `POST /api/__site__/cache/purge` drops the site's route cache when the backend saves a FAQ (or a menu or post, through the other core layers). See [Site cache purge](#site-cache-purge). +- **Site cache purge**: vue-seo-layer's shared route `POST /api/__site__/cache/purge` drops the site's route cache when the backend saves a FAQ (or a menu or post, through the other core layers). See [Site cache purge](#site-cache-purge). - **Rate limits**: server-side fetches forward the visitor's `x-forwarded-for` (also on route-cached renders), so the API's per-client throttle sees visitors, not the Nuxt server. ## Usage @@ -59,31 +61,20 @@ export default defineNuxtConfig({ | `runtimeConfig.faq.apiBase` | `http://127.0.0.1:8096/_faq/api/v1` | `NUXT_FAQ_API_BASE` (server only, used for SSR) | | `runtimeConfig.public.faq.apiBase` | `/_faq/api/v1` | `NUXT_PUBLIC_FAQ_API_BASE` (browser, same origin) | -| `runtimeConfig.cachePurge.token` | `''` (route off) | `NUXT_CACHE_PURGE_TOKEN` (server only; at least 32 characters) | -| `runtimeConfig.cachePurge.allowProxied` | `false` | `NUXT_CACHE_PURGE_ALLOW_PROXIED` (server only) | -| `runtimeConfig.siteCache.purgeToken` | `''` | Deprecated (one release): `NUXT_SITE_CACHE_PURGE_TOKEN`, the earlier name of `cachePurge.token`, read when that is empty | -| `runtimeConfig.siteCache.allowProxied` | `false` | Deprecated (one release): `NUXT_SITE_CACHE_ALLOW_PROXIED`, the earlier name of `cachePurge.allowProxied` | + +The purge settings (`runtimeConfig.cachePurge.token`, `NUXT_CACHE_PURGE_TOKEN`; `cachePurge.allowProxied`, `NUXT_CACHE_PURGE_ALLOW_PROXIED`; the deprecated `siteCache.*` names) and the SEO options (`seo.*`, `NUXT_SEO_API_BASE` and the rest) are vue-seo-layer's; see its README. The base URLs always come from runtime config, never from request input. In production the web server routes `/_faq/` to the SummerCMS binary. ## Site cache purge -A site that route-caches its pages (for example `routeRules: { '/**': { swr: 300 } }`) shows an admin edit only when the cache window ends. The layer ships the shared purge route that the SummerCMS backend calls after an admin save commits: +The purge route `POST /api/__site__/cache/purge` that the SummerCMS backend calls after an admin save commits lives in vue-seo-layer: one copy for every layer, which also empties the SEO layer's resolve cache. Its contract is unchanged (`Authorization: Bearer `, body `{"kind":"","slug":""}`, answer `{"purged": }`, 404 without a token of at least 32 characters, 401 for a wrong token, proxied requests refused unless `NUXT_CACHE_PURGE_ALLOW_PROXIED=true`). Set the token and the backend's `site_cache.purge_url` as vue-seo-layer's README describes. -``` -POST /api/__site__/cache/purge -Authorization: Bearer -{"kind":"faq","slug":"shipping"} -→ {"purged": } -``` +### Upgrading from 0.4 -- It removes every entry of Nitro's route cache (pages, their `_payload.json` and feeds) and logs `[site-cache] purge: …` with the kind and slug. -- Set `NUXT_CACHE_PURGE_TOKEN` to the backend's `site_cache.purge_token` (at least 32 characters) and point the backend's `site_cache.purge_url` at `http://127.0.0.1:/api/__site__/cache/purge`. Without a token of that length the route answers 404. -- It answers 404 to any request that came through a reverse proxy (`X-Forwarded-For`, `X-Real-IP` or `Forwarded`), so only the backend on the same machine reaches it, and 401 to a missing or wrong bearer token (compared in constant time). -- Behind a TLS-terminating proxy, for a backend that reaches the site at an https `site_cache.purge_url` on another host, the proxy adds those headers. Set `NUXT_CACHE_PURGE_ALLOW_PROXIED=true` (`runtimeConfig.cachePurge.allowProxied`, default `false`) and the route accepts proxied requests; the bearer token is then the only gate, so keep it long and secret. Unset, proxied requests stay refused. -- The layer adds the route rule `'/api/__site__/**': { cache: false }`; without it, a site-wide `swr` rule would answer the purge from the cache. -- vue-journal-layer, vue-menu-layer and vue-faq-layer ship the same `server/lib/siteCachePurge.ts`, `server/api/__site__/cache/purge.post.ts` and `server/plugins/site-cache-purge.ts` byte for byte (a test pins their sha256), so a site that extends several of them gets one route, whichever copy Nitro keeps. -- Never put a secret in a `NUXT_SITE_*` or `NUXT_PUBLIC_SITE_*` variable: nuxt-site-config (in a site that also uses `@nuxtjs/seo`, for example through vue-journal-layer) copies every such variable into the site config, which is published in every page's payload. The Nitro plugin `server/plugins/site-cache-purge.ts` removes `cachePurgeToken` and `cacheAllowProxied` from that site config on its `site-config:init` hook, whatever names the site uses, and logs one `[site-cache]` deprecation warning per process when a setting comes from an old name (variable names only, never a value). Without nuxt-site-config the plugin does nothing. +- The layer extends vue-seo-layer. Nothing is needed in the app's `extends`, but every page now resolves through the SEO plugin (`GET /_seo/api/v1/resolve`) and takes its head from the answer; set `NUXT_SEO_API_BASE` when the backend is not on `http://127.0.0.1:8096`, or `seo: { cms: false }` for a site without the SEO plugin. +- `server/api/__site__/cache/purge.post.ts`, `server/lib/siteCachePurge.ts` and `server/plugins/site-cache-purge.ts` are removed from this layer: the same route, library and plugin come from vue-seo-layer, with the same environment variables. +- An app without pages (only `app.vue`) sets `seo: { htmlSitemap: false }`: otherwise the SEO layer's HTML sitemap pages are its only routes, which turns the router on and makes every other path answer 404. ### Upgrading from 0.3 @@ -139,6 +130,6 @@ Commit, tag `vX.Y.Z` (annotated) and push the tag; apps pin `#vX.Y.Z` in their d ## Testing ```bash -pnpm test # node --test: the client and the site cache purge +pnpm test # node --test: the client pnpm build:playground && pnpm smoke:ssr # SSR of the playground against testing/fixture-api.mjs ``` diff --git a/nuxt.config.ts b/nuxt.config.ts index 7606ef0..0ddb676 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -4,7 +4,9 @@ // // Every path below is absolute and built from this file's directory: a // relative or ~ path in a layer config resolves against the consuming -// project, not the layer. The layer declares no Nuxt modules. +// project, not the layer. The layer declares no Nuxt modules; it extends +// vue-seo-layer, which owns the page head and the shared site cache purge +// route POST /api/__site__/cache/purge. import { fileURLToPath } from 'node:url' const currentDir = fileURLToPath(new URL('.', import.meta.url)) @@ -13,6 +15,9 @@ export default defineNuxtConfig({ compatibilityDate: '2025-07-15', devtools: { enabled: false }, + // Resolved from this layer's own node_modules. + extends: ['vue-seo-layer'], + // Tokens first, then the neutral styles that use them. The app's own css // array loads after these, so its token overrides win. css: [`${currentDir}app/assets/css/faq-tokens.css`, `${currentDir}app/assets/css/faq-base.css`], @@ -23,31 +28,6 @@ export default defineNuxtConfig({ // from request input. apiBase: 'http://127.0.0.1:8096/_faq/api/v1', }, - cachePurge: { - // Server-only bearer token of the shared site cache purge route - // POST /api/__site__/cache/purge (NUXT_CACHE_PURGE_TOKEN), the - // backend's site_cache.purge_token. Shorter than 32 characters, the - // route answers 404. Not under NUXT_SITE_: nuxt-site-config copies - // every NUXT_SITE_* variable into the site config, which is published - // in every page's payload. - token: '', - // NUXT_CACHE_PURGE_ALLOW_PROXIED: set it to true when the backend - // reaches the site through a TLS-terminating proxy (an https - // site_cache.purge_url on another host), which adds X-Forwarded-For, - // X-Real-IP or Forwarded. The route then accepts proxied requests and - // the bearer token is the only gate; unset, it answers 404 to any - // proxied request. - allowProxied: false, - }, - // Deprecated (one release): the earlier names of cachePurge.token - // (NUXT_SITE_CACHE_PURGE_TOKEN) and cachePurge.allowProxied - // (NUXT_SITE_CACHE_ALLOW_PROXIED), read when the new ones are unset. The - // layer's server/plugins/site-cache-purge.ts strips them from the site - // config and logs a deprecation warning. - siteCache: { - purgeToken: '', - allowProxied: false, - }, public: { faq: { // Same-origin base for browser fetches (NUXT_PUBLIC_FAQ_API_BASE). @@ -55,12 +35,4 @@ export default defineNuxtConfig({ }, }, }, - - // The shared site routes are never route-cached. Nitro wraps every - // handler matching an app rule such as '/**': { swr } in its cached - // handler, which keys by path, does not skip POST and hides request - // headers, so the purge route would answer from the cache without running. - routeRules: { - '/api/__site__/**': { cache: false }, - }, }) diff --git a/package.json b/package.json index 652c693..7d4bd4c 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "engines": { "node": ">=22.18" }, + "dependencies": { + "vue-seo-layer": "link:../vue-seo-layer" + }, "peerDependencies": { "nuxt": "4.4.8" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index baeff13..33c821b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,10 +7,14 @@ settings: importers: .: + dependencies: + vue-seo-layer: + specifier: link:../vue-seo-layer + version: link:../vue-seo-layer devDependencies: nuxt: specifier: 4.4.8 - version: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1)(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) + version: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) typescript: specifier: 5.9.3 version: 5.9.3 @@ -3350,20 +3354,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7': + '@babel/core@7.29.7(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helpers': 7.29.10 '@babel/parser': 7.29.9 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.10 + '@babel/traverse': 7.29.10(supports-color@10.2.2) '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -3390,41 +3394,41 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.2.2) '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.10 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) + '@babel/traverse': 7.29.10(supports-color@10.2.2) semver: 6.3.1 transitivePeerDependencies: - supports-color '@babel/helper-globals@7.29.7': {} - '@babel/helper-member-expression-to-functions@7.29.7': + '@babel/helper-member-expression-to-functions@7.29.7(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.10 + '@babel/traverse': 7.29.10(supports-color@10.2.2) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7': + '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.10 + '@babel/traverse': 7.29.10(supports-color@10.2.2) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.10 + '@babel/traverse': 7.29.10(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -3434,18 +3438,18 @@ snapshots: '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.2.2) '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.10 + '@babel/traverse': 7.29.10(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.10 + '@babel/traverse': 7.29.10(supports-color@10.2.2) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -3473,24 +3477,24 @@ snapshots: dependencies: '@babel/types': 8.0.6 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-typescript@7.29.9(@babel/core@7.29.7)': + '@babel/plugin-transform-typescript@7.29.9(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) transitivePeerDependencies: - supports-color @@ -3500,7 +3504,7 @@ snapshots: '@babel/parser': 7.29.9 '@babel/types': 7.29.8 - '@babel/traverse@7.29.10': + '@babel/traverse@7.29.10(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 @@ -3508,7 +3512,7 @@ snapshots: '@babel/parser': 7.29.9 '@babel/template': 7.29.7 '@babel/types': 7.29.8 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -3690,19 +3694,19 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 - '@kwsites/file-exists@1.1.1': + '@kwsites/file-exists@1.1.1(supports-color@10.2.2)': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color '@kwsites/promise-deferred@1.1.1': {} - '@mapbox/node-pre-gyp@2.0.3': + '@mapbox/node-pre-gyp@2.0.3(supports-color@10.2.2)': dependencies: consola: 3.4.2 detect-libc: 2.1.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) node-fetch: 2.7.0 nopt: 8.1.0 semver: 7.8.5 @@ -3733,7 +3737,7 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.3 - '@nuxt/cli@3.37.0(@nuxt/schema@4.4.8)(cac@6.7.14)(magicast@0.5.5)': + '@nuxt/cli@3.37.0(@nuxt/schema@4.4.8)(cac@6.7.14)(magicast@0.5.5)(supports-color@10.2.2)': dependencies: '@bomb.sh/tab': 0.0.19(cac@6.7.14)(citty@0.2.2) '@clack/prompts': 1.8.1 @@ -3741,7 +3745,7 @@ snapshots: citty: 0.2.2 confbox: 0.2.4 consola: 3.4.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) defu: 6.1.7 exsolve: 1.1.3 fuse.js: 7.5.0 @@ -3802,7 +3806,7 @@ snapshots: pkg-types: 2.3.3 semver: 7.8.5 - '@nuxt/devtools@3.4.2(@nuxt/schema@4.4.8)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(unplugin@3.4.0(esbuild@0.28.2)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3))': + '@nuxt/devtools@3.4.2(@nuxt/schema@4.4.8)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(supports-color@10.2.2)(unplugin@3.4.0(esbuild@0.28.2)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3))': dependencies: '@nuxt/devtools-kit': 3.4.2(@nuxt/schema@4.4.8)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(unplugin@3.4.0(esbuild@0.28.2)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) '@nuxt/devtools-wizard': 3.4.2 @@ -3828,11 +3832,11 @@ snapshots: perfect-debounce: 2.1.0 pkg-types: 2.3.3 semver: 7.8.5 - simple-git: 3.36.0 + simple-git: 3.36.0(supports-color@10.2.2) sirv: 3.0.2 structured-clone-es: 2.0.1 tinyglobby: 0.2.17 - unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1) + unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2)) vite: 7.3.7(jiti@2.7.0)(terser@5.51.2) vite-plugin-inspect: 11.4.1(@nuxt/kit@4.6.0(@nuxt/schema@4.4.8)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(unplugin@3.4.0(esbuild@0.28.2)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))))(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) vite-plugin-vue-tracer: 1.5.0(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3)) @@ -3930,7 +3934,7 @@ snapshots: - magic-string - unplugin - '@nuxt/nitro-server@4.4.8(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(magicast@0.5.5)(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1)(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(oxc-parser@0.133.0)(rollup@4.64.2)(srvx@0.11.22)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))': + '@nuxt/nitro-server@4.4.8(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(oxc-parser@0.133.0)(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))': dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 4.4.8(magicast@0.5.5) @@ -3947,8 +3951,8 @@ snapshots: impound: 1.2.0(esbuild@0.28.2)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) klona: 2.0.6 mocked-exports: 0.1.1 - nitropack: 2.13.4(oxc-parser@0.133.0)(srvx@0.11.22)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) - nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1)(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) + nitropack: 2.13.4(oxc-parser@0.133.0)(srvx@0.11.22)(supports-color@10.2.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) + nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) nypm: 0.6.10 ohash: 2.0.12 pathe: 2.0.3 @@ -3956,12 +3960,12 @@ snapshots: std-env: 4.3.0 ufo: 1.6.4 unctx: 2.5.0 - unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1) + unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2)) vue: 3.5.35(typescript@5.9.3) vue-bundle-renderer: 2.4.0 vue-devtools-stub: 0.1.0 optionalDependencies: - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -4026,12 +4030,12 @@ snapshots: rc9: 3.1.0 std-env: 4.3.0 - '@nuxt/vite-builder@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(magicast@0.5.5)(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1)(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(terser@5.51.2)(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3))': + '@nuxt/vite-builder@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(magicast@0.5.5)(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3))': dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.5) '@rollup/plugin-replace': 6.0.3(rollup@4.64.2) '@vitejs/plugin-vue': 6.0.9(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3)) - '@vitejs/plugin-vue-jsx': 5.1.6(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3)) + '@vitejs/plugin-vue-jsx': 5.1.6(supports-color@10.2.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3)) autoprefixer: 10.6.1(postcss@8.5.29) consola: 3.4.2 cssnano: 8.0.10(postcss@8.5.29) @@ -4044,7 +4048,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1)(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) + nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) nypm: 0.6.10 pathe: 2.0.3 pkg-types: 2.3.3 @@ -4053,13 +4057,14 @@ snapshots: std-env: 4.3.0 ufo: 1.6.4 unenv: 2.0.0-rc.24 + unplugin: 2.3.11 vite: 7.3.7(jiti@2.7.0)(terser@5.51.2) vite-node: 5.3.0(jiti@2.7.0)(terser@5.51.2) vite-plugin-checker: 0.14.5(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) vue: 3.5.35(typescript@5.9.3) vue-bundle-renderer: 2.4.0 optionalDependencies: - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) rollup-plugin-visualizer: 7.1.1(rollup@4.64.2) transitivePeerDependencies: - '@biomejs/biome' @@ -4467,9 +4472,9 @@ snapshots: unhead: 2.1.17 vue: 3.5.35(typescript@5.9.3) - '@vercel/nft@1.11.0(rollup@4.64.2)': + '@vercel/nft@1.11.0(rollup@4.64.2)(supports-color@10.2.2)': dependencies: - '@mapbox/node-pre-gyp': 2.0.3 + '@mapbox/node-pre-gyp': 2.0.3(supports-color@10.2.2) '@rollup/pluginutils': 5.4.0(rollup@4.64.2) acorn: 8.19.0 acorn-import-attributes: 1.9.5(acorn@8.19.0) @@ -4486,13 +4491,13 @@ snapshots: - rollup - supports-color - '@vitejs/plugin-vue-jsx@5.1.6(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3))': + '@vitejs/plugin-vue-jsx@5.1.6(supports-color@10.2.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3))': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@rolldown/pluginutils': 1.0.1 - '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7) + '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) vite: 7.3.7(jiti@2.7.0)(terser@5.51.2) vue: 3.5.35(typescript@5.9.3) transitivePeerDependencies: @@ -4516,27 +4521,27 @@ snapshots: '@vue/babel-helper-vue-transform-on@2.0.1': {} - '@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.7)': + '@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/template': 7.29.7 - '@babel/traverse': 7.29.10 + '@babel/traverse': 7.29.10(supports-color@10.2.2) '@babel/types': 7.29.8 '@vue/babel-helper-vue-transform-on': 2.0.1 - '@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.7) + '@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@vue/shared': 3.5.43 optionalDependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@vue/babel-plugin-resolve-type@2.0.1(@babel/core@7.29.7)': + '@vue/babel-plugin-resolve-type@2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/parser': 7.29.9 '@vue/compiler-sfc': 3.5.43 @@ -4999,9 +5004,11 @@ snapshots: db0@0.3.4: {} - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 deepmerge@4.3.1: {} @@ -5288,10 +5295,10 @@ snapshots: http-shutdown@1.2.2: {} - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -5328,11 +5335,11 @@ snapshots: ini@4.1.1: {} - ioredis@5.11.1: + ioredis@5.11.1(supports-color@10.2.2): dependencies: '@ioredis/commands': 1.10.0 cluster-key-slot: 1.1.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) denque: 2.1.0 redis-errors: 1.2.0 redis-parser: 3.0.0 @@ -5541,7 +5548,7 @@ snapshots: nanotar@0.3.0: {} - nitropack@2.13.4(oxc-parser@0.133.0)(srvx@0.11.22)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)): + nitropack@2.13.4(oxc-parser@0.133.0)(srvx@0.11.22)(supports-color@10.2.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.64.2) @@ -5551,7 +5558,7 @@ snapshots: '@rollup/plugin-node-resolve': 16.0.3(rollup@4.64.2) '@rollup/plugin-replace': 6.0.3(rollup@4.64.2) '@rollup/plugin-terser': 1.0.0(rollup@4.64.2) - '@vercel/nft': 1.11.0(rollup@4.64.2) + '@vercel/nft': 1.11.0(rollup@4.64.2)(supports-color@10.2.2) archiver: 7.0.1 c12: 3.3.4(magicast@0.5.5) chokidar: 5.0.0 @@ -5575,7 +5582,7 @@ snapshots: h3: 1.15.11 hookable: 5.5.3 httpxy: 0.5.5 - ioredis: 5.11.1 + ioredis: 5.11.1(supports-color@10.2.2) jiti: 2.7.0 klona: 2.0.6 knitwork: 1.3.0 @@ -5598,7 +5605,7 @@ snapshots: scule: 1.3.0 semver: 7.8.5 serve-placeholder: 2.0.2 - serve-static: 2.2.1 + serve-static: 2.2.1(supports-color@10.2.2) source-map: 0.7.6 std-env: 4.3.0 ufo: 1.6.4 @@ -5608,7 +5615,7 @@ snapshots: unenv: 2.0.0-rc.24 unimport: 6.5.0(esbuild@0.28.2)(oxc-parser@0.133.0)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) unplugin-utils: 0.3.2 - unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1) + unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2)) untyped: 2.0.0 unwasm: 0.5.3 youch: 4.1.1 @@ -5689,16 +5696,16 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1)(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)): + nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)): dependencies: '@dxup/nuxt': 0.4.1(magicast@0.5.5)(typescript@5.9.3) - '@nuxt/cli': 3.37.0(@nuxt/schema@4.4.8)(cac@6.7.14)(magicast@0.5.5) - '@nuxt/devtools': 3.4.2(@nuxt/schema@4.4.8)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(unplugin@3.4.0(esbuild@0.28.2)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3)) + '@nuxt/cli': 3.37.0(@nuxt/schema@4.4.8)(cac@6.7.14)(magicast@0.5.5)(supports-color@10.2.2) + '@nuxt/devtools': 3.4.2(@nuxt/schema@4.4.8)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(supports-color@10.2.2)(unplugin@3.4.0(esbuild@0.28.2)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3)) '@nuxt/kit': 4.4.8(magicast@0.5.5) - '@nuxt/nitro-server': 4.4.8(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(magicast@0.5.5)(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1)(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(oxc-parser@0.133.0)(rollup@4.64.2)(srvx@0.11.22)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) + '@nuxt/nitro-server': 4.4.8(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(oxc-parser@0.133.0)(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) '@nuxt/schema': 4.4.8 '@nuxt/telemetry': 2.9.1(@nuxt/kit@4.4.8(magicast@0.5.5)) - '@nuxt/vite-builder': 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(magicast@0.5.5)(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1)(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(terser@5.51.2)(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3)) + '@nuxt/vite-builder': 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(magicast@0.5.5)(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3)) '@unhead/vue': 2.1.17(vue@3.5.35(typescript@5.9.3)) '@vue/shared': 3.5.43 chokidar: 5.0.0 @@ -6276,9 +6283,9 @@ snapshots: semver@7.8.5: {} - send@1.2.1: + send@1.2.1(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -6300,12 +6307,12 @@ snapshots: dependencies: defu: 6.1.7 - serve-static@2.2.1: + serve-static@2.2.1(supports-color@10.2.2): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1 + send: 1.2.1(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -6323,13 +6330,13 @@ snapshots: signal-exit@4.1.0: {} - simple-git@3.36.0: + simple-git@3.36.0(supports-color@10.2.2): dependencies: - '@kwsites/file-exists': 1.1.1 + '@kwsites/file-exists': 1.1.1(supports-color@10.2.2) '@kwsites/promise-deferred': 1.1.1 '@simple-git/args-pathspec': 1.0.4 '@simple-git/argv-parser': 1.1.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -6600,7 +6607,7 @@ snapshots: escape-string-regexp: 5.0.0 ufo: 1.6.4 - unstorage@1.17.5(db0@0.3.4)(ioredis@5.11.1): + unstorage@1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2)): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -6612,7 +6619,7 @@ snapshots: ufo: 1.6.4 optionalDependencies: db0: 0.3.4 - ioredis: 5.11.1 + ioredis: 5.11.1(supports-color@10.2.2) untun@0.2.2: {} diff --git a/server/api/__site__/cache/purge.post.ts b/server/api/__site__/cache/purge.post.ts deleted file mode 100644 index f19d42d..0000000 --- a/server/api/__site__/cache/purge.post.ts +++ /dev/null @@ -1,48 +0,0 @@ -// POST /api/__site__/cache/purge: removes every Nitro route-cache entry -// (pages, _payload.json and feeds), so an admin save shows on the next page -// view. The backend calls it on loopback after the save commits, with -// `Authorization: Bearer ` -// (NUXT_CACHE_PURGE_TOKEN) and the body {"kind":"","slug":""}; -// the answer is {"purged":}. The kind and slug only reach the log line. -// -// 404 without a token of at least 32 characters, and for any request that -// came through a reverse proxy (X-Forwarded-For, X-Real-IP or Forwarded) -// unless runtimeConfig.cachePurge.allowProxied -// (NUXT_CACHE_PURGE_ALLOW_PROXIED) is true or "true": a backend that reaches -// the site through a TLS-terminating proxy opts in, and the bearer token is -// then the only gate. 401 for a missing or wrong bearer token. The -// '/api/__site__/**' route rule keeps this route out of the route cache. -// -// Deprecated for one release: siteCache.purgeToken -// (NUXT_SITE_CACHE_PURGE_TOKEN) and siteCache.allowProxied -// (NUXT_SITE_CACHE_ALLOW_PROXIED), read when the new names are unset (see -// resolveSitePurgeConfig). -// -// Shared: several layers ship this file byte for byte, and Nitro keeps one -// handler per route and method. - -import { authorizeSitePurge, parseSitePurgeEvent, purgeSiteRouteCache, resolveSitePurgeConfig } from '../../../lib/siteCachePurge.ts' - -const PROXY_HEADERS = ['x-forwarded-for', 'x-real-ip', 'forwarded'] - -export default defineEventHandler(async (event) => { - setResponseHeader(event, 'cache-control', 'no-store') - const { token, allowProxied } = resolveSitePurgeConfig(useRuntimeConfig(event)) - const verdict = authorizeSitePurge({ - token, - authorization: getRequestHeader(event, 'authorization'), - proxied: PROXY_HEADERS.some((name) => getRequestHeader(event, name) !== undefined), - allowProxied, - }) - if (verdict === 'disabled' || verdict === 'proxied') { - throw createError({ statusCode: 404, statusMessage: 'Not Found' }) - } - if (verdict === 'unauthorized') { - throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) - } - - const { kind, slug } = parseSitePurgeEvent(await readBody(event).catch(() => null)) - const purged = await purgeSiteRouteCache(useStorage('cache')) - console.info(`[site-cache] purge: removed ${purged} route cache entries (${kind} ${slug})`) - return { purged } -}) diff --git a/server/lib/siteCachePurge.ts b/server/lib/siteCachePurge.ts deleted file mode 100644 index a7e41d5..0000000 --- a/server/lib/siteCachePurge.ts +++ /dev/null @@ -1,208 +0,0 @@ -// Shared site cache purge: the authorization rules of the purge route -// POST /api/__site__/cache/purge, the purge of Nitro's route cache, the -// parsing of the purge request body, the purge settings read from runtime -// config (new and deprecated names) and the removal of the purge keys from -// nuxt-site-config's site config. Pure TypeScript outside Nitro's and Nuxt's -// auto-import directories: vue-journal-layer, vue-menu-layer and -// vue-faq-layer ship this file byte for byte, and `node --test`, the route -// and the Nitro plugin import it directly. - -import { createHash, timingSafeEqual } from 'node:crypto' - -/** The shortest purge token the route accepts; with a shorter one it is off. */ -export const MIN_SITE_PURGE_TOKEN_LENGTH = 32 - -/** The cache storage base of Nitro's route cache (routeRules cache and swr). */ -export const SITE_ROUTE_CACHE_BASE = 'nitro:routes' - -/** Longest slug the purge log line carries. */ -const MAX_SLUG_LENGTH = 200 - -/** A purge kind: lower-case letters and underscores, up to 40 characters. */ -const KIND_PATTERN = /^[a-z_]{1,40}$/ - -export type SitePurgeVerdict = 'ok' | 'disabled' | 'proxied' | 'unauthorized' - -export interface SitePurgeAuthInput { - /** The configured token (see resolveSitePurgeConfig). */ - token: unknown - /** The request's Authorization header, if any. */ - authorization?: string | null - /** True when the request carried X-Forwarded-For, X-Real-IP or Forwarded. */ - proxied: boolean - /** - * The allowProxied setting (see resolveSitePurgeConfig): when true a - * proxied request is not refused, and the bearer token is the only gate. - * For a backend that reaches the site through a TLS-terminating proxy, - * which adds forwarding headers. - */ - allowProxied?: boolean -} - -function digest(value: string): Buffer { - return createHash('sha256').update(value, 'utf8').digest() -} - -/** - * Decides a purge request: `disabled` when no token of at least - * MIN_SITE_PURGE_TOKEN_LENGTH characters is configured, `proxied` when the - * request came through a reverse proxy (checked before the token) unless - * allowProxied is true, `ok` when the Bearer value equals the token (compared - * in constant time over sha256 digests) and `unauthorized` otherwise. With - * allowProxied the token is the only gate, so it must stay long and secret. - */ -export function authorizeSitePurge({ token, authorization, proxied, allowProxied }: SitePurgeAuthInput): SitePurgeVerdict { - if (typeof token !== 'string' || token.length < MIN_SITE_PURGE_TOKEN_LENGTH) return 'disabled' - if (proxied && allowProxied !== true) return 'proxied' - const m = /^Bearer[ \t]+(\S+)[ \t]*$/i.exec(authorization ?? '') - if (!m) return 'unauthorized' - return timingSafeEqual(digest(m[1]), digest(token)) ? 'ok' : 'unauthorized' -} - -/** The part of a Nitro/unstorage storage the purge uses. */ -export interface SitePurgeStorage { - getKeys(base?: string): Promise | string[] - removeItem(key: string): Promise | unknown -} - -/** - * Removes every entry of Nitro's route cache (pages, their _payload.json and - * feeds) from storage, which is `useStorage('cache')`, and returns how many - * it removed. Other cache groups (cached functions, handlers) stay. - */ -export async function purgeSiteRouteCache(storage: SitePurgeStorage): Promise { - const keys = await storage.getKeys(SITE_ROUTE_CACHE_BASE) - let removed = 0 - for (const key of keys) { - if (!key.startsWith(SITE_ROUTE_CACHE_BASE + ':')) continue - await storage.removeItem(key) - removed++ - } - return removed -} - -export interface SitePurgeEvent { - kind: string - slug: string -} - -/** - * Reads the kind and slug of a purge request body for the log line: kind is - * kept when it is lower-case letters and underscores up to 40 characters - * (faq, menu, menu_entry, post, category, ...), else it is unknown; the slug - * loses control characters and is cut to 200 characters. - */ -export function parseSitePurgeEvent(body: unknown): SitePurgeEvent { - if (body === null || typeof body !== 'object' || Array.isArray(body)) return { kind: 'unknown', slug: '' } - const raw = body as Record - const kind = typeof raw.kind === 'string' && KIND_PATTERN.test(raw.kind) ? raw.kind : 'unknown' - // eslint-disable-next-line no-control-regex - const slug = typeof raw.slug === 'string' ? raw.slug.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, MAX_SLUG_LENGTH) : '' - return { kind, slug } -} - -/** - * The site config keys nuxt-site-config derives from the deprecated - * variables NUXT_SITE_CACHE_PURGE_TOKEN and NUXT_SITE_CACHE_ALLOW_PROXIED: it - * copies every NUXT_SITE_* variable into the site config, camel-cased, and - * Nuxt serializes the site config into every page's payload. - */ -export const SITE_CONFIG_PURGE_KEYS: readonly string[] = Object.freeze(['cachePurgeToken', 'cacheAllowProxied']) - -/** - * The globalThis flag that keeps the deprecation warning to one per process, - * however many layers ship the Nitro plugin. - */ -export const SITE_PURGE_WARNED = Symbol.for('summercms.siteCachePurge.deprecationWarned') - -export type SitePurgeDeprecated = 'token' | 'allowProxied' - -export interface SitePurgeConfig { - /** The token the route checks; authorizeSitePurge decides whether it is usable. */ - token: unknown - /** True when a proxied request may reach the token check. */ - allowProxied: boolean - /** The settings that came from a deprecated name. */ - deprecated: SitePurgeDeprecated[] -} - -function section(config: unknown, name: string): Record { - if (config === null || typeof config !== 'object') return {} - const value = (config as Record)[name] - return value !== null && typeof value === 'object' ? (value as Record) : {} -} - -const isTrue = (value: unknown) => value === true || value === 'true' - -/** - * Reads the purge settings from runtime config. The current names are - * `cachePurge.token` (NUXT_CACHE_PURGE_TOKEN) and `cachePurge.allowProxied` - * (NUXT_CACHE_PURGE_ALLOW_PROXIED). Deprecated for one release: - * `siteCache.purgeToken` (NUXT_SITE_CACHE_PURGE_TOKEN) and - * `siteCache.allowProxied` (NUXT_SITE_CACHE_ALLOW_PROXIED). A non-empty new - * token wins over the old one; allowProxied is on when either flag is true or - * "true". `deprecated` lists the settings taken from an old name. - */ -export function resolveSitePurgeConfig(config: unknown): SitePurgeConfig { - const current = section(config, 'cachePurge') - const old = section(config, 'siteCache') - const deprecated: SitePurgeDeprecated[] = [] - - let token: unknown = current.token - if (typeof token !== 'string' || token === '') { - if (typeof old.purgeToken === 'string' && old.purgeToken !== '') { - token = old.purgeToken - deprecated.push('token') - } else if (token === undefined) { - token = old.purgeToken - } - } - - let allowProxied = isTrue(current.allowProxied) - if (!allowProxied && isTrue(old.allowProxied)) { - allowProxied = true - deprecated.push('allowProxied') - } - return { token, allowProxied, deprecated } -} - -/** - * Removes SITE_CONFIG_PURGE_KEYS from every entry of a nuxt-site-config stack - * (the array of partial site configs it merges per request) and returns how - * many keys it removed. Anything that is not an array of objects is left - * alone and counts 0. - */ -export function redactSiteConfigStack(stack: unknown): number { - if (!Array.isArray(stack)) return 0 - let removed = 0 - for (const entry of stack) { - if (entry === null || typeof entry !== 'object') continue - for (const key of SITE_CONFIG_PURGE_KEYS) { - if (Object.prototype.hasOwnProperty.call(entry, key)) { - delete (entry as Record)[key] - removed++ - } - } - } - return removed -} - -const DEPRECATED_NAMES: Record = { - token: ['NUXT_SITE_CACHE_PURGE_TOKEN', 'NUXT_CACHE_PURGE_TOKEN'], - allowProxied: ['NUXT_SITE_CACHE_ALLOW_PROXIED', 'NUXT_CACHE_PURGE_ALLOW_PROXIED'], -} - -/** - * The one-line deprecation warning for the settings resolveSitePurgeConfig - * took from an old name, or '' when there are none. It names variables - * only, never a value. - */ -export function sitePurgeDeprecationMessage(deprecated: readonly SitePurgeDeprecated[]): string { - const names = [...new Set(deprecated)].filter((d) => d in DEPRECATED_NAMES).map((d) => `${DEPRECATED_NAMES[d][0]} -> ${DEPRECATED_NAMES[d][1]}`) - if (names.length === 0) return '' - return ( - `[site-cache] deprecated purge settings in use, rename them: ${names.join(', ')}. ` + - 'The old names work until the next minor release. Earlier layer versions published every NUXT_SITE_* value ' + - 'in the page payload through nuxt-site-config, so rotate a token that was ever set under NUXT_SITE_CACHE_PURGE_TOKEN.' - ) -} diff --git a/server/plugins/site-cache-purge.ts b/server/plugins/site-cache-purge.ts deleted file mode 100644 index a35e319..0000000 --- a/server/plugins/site-cache-purge.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Keeps the site cache purge settings out of the public site config. -// -// nuxt-site-config (pulled in by @nuxtjs/seo) copies every NUXT_SITE_* and -// NUXT_PUBLIC_SITE_* environment variable into the site config, camel-cased, -// and Nuxt serializes the site config into every page's payload (and into -// window.__NUXT_SITE_CONFIG__ on client-only pages). The purge token's -// earlier name NUXT_SITE_CACHE_PURGE_TOKEN therefore reached every page as -// cachePurgeToken, and NUXT_SITE_CACHE_ALLOW_PROXIED as cacheAllowProxied. -// The settings now live under NUXT_CACHE_PURGE_TOKEN and -// NUXT_CACHE_PURGE_ALLOW_PROXIED, outside that prefix. The old names keep -// working for one release, so this plugin also removes the two keys from -// every entry of the site config stack on nuxt-site-config's -// `site-config:init` hook, before anything reads it. The stripping stays -// with the new names too: a site that still sets an old variable, or sets -// both, must not publish it. -// -// When a setting comes from an old name, the plugin logs one deprecation -// warning per process (globalThis[SITE_PURGE_WARNED], so a site extending -// several layers that ship this file warns once). The warning names -// variables, never a value. -// -// Without nuxt-site-config the hook is never called and the plugin does -// nothing else. Shared: several layers ship this file byte for byte; -// running it more than once is harmless. - -import { SITE_PURGE_WARNED, redactSiteConfigStack, resolveSitePurgeConfig, sitePurgeDeprecationMessage } from '../lib/siteCachePurge.ts' - -interface SiteConfigInitContext { - siteConfig?: { stack?: unknown } -} - -type LooseHooks = { hook: (name: string, fn: (ctx: SiteConfigInitContext | undefined) => void) => unknown } - -export default defineNitroPlugin((nitroApp) => { - const g = globalThis as Record - if (!g[SITE_PURGE_WARNED]) { - const message = sitePurgeDeprecationMessage(resolveSitePurgeConfig(useRuntimeConfig()).deprecated) - if (message) { - g[SITE_PURGE_WARNED] = true - console.warn(message) - } - } - - // The hook name is untyped in an app without nuxt-site-config. - ;(nitroApp.hooks as unknown as LooseHooks).hook('site-config:init', (ctx) => { - redactSiteConfigStack(ctx?.siteConfig?.stack) - }) -}) diff --git a/testing/smoke-ssr.mjs b/testing/smoke-ssr.mjs index e630d14..f722f22 100644 --- a/testing/smoke-ssr.mjs +++ b/testing/smoke-ssr.mjs @@ -144,7 +144,13 @@ async function main() { let nuxt = null const failures = /** @type {string[]} */ ([]) try { - nuxt = await startNuxtServer({ outputDir: args.output, env: { [API_ENV]: `${api.url}${API_PREFIX}`, ...args.env } }) + // vue-seo-layer resolves every page through the SEO API; the fixture API + // has none and answers 404, so the layer renders without a CMS answer + // (fail open) and never reaches a backend on the default port. + nuxt = await startNuxtServer({ + outputDir: args.output, + env: { [API_ENV]: `${api.url}${API_PREFIX}`, NUXT_SEO_API_BASE: `${api.url}/_seo/api/v1`, ...args.env }, + }) for (const line of lines) { let check try { diff --git a/tests/sitePurge.test.ts b/tests/sitePurge.test.ts deleted file mode 100644 index 798d4e5..0000000 --- a/tests/sitePurge.test.ts +++ /dev/null @@ -1,438 +0,0 @@ -// Shared site cache purge: route authorization, the route-cache purge, the -// purge body parsing, the purge settings under their new and deprecated -// names, the site config stripping plugin and the byte-identity of the files -// every layer ships. -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' -import { - MIN_SITE_PURGE_TOKEN_LENGTH, - SITE_CONFIG_PURGE_KEYS, - SITE_PURGE_WARNED, - SITE_ROUTE_CACHE_BASE, - authorizeSitePurge, - parseSitePurgeEvent, - purgeSiteRouteCache, - redactSiteConfigStack, - resolveSitePurgeConfig, - sitePurgeDeprecationMessage, -} from '../server/lib/siteCachePurge.ts' - -// The sha256 of the three shared files. vue-journal-layer, vue-menu-layer and -// vue-faq-layer ship them byte for byte; a change lands in all three with -// new values here. -const SHARED_LIB_SHA256 = 'da5e588b94de6a07f99f203d51cc953f497af886d0fad03709d67064a7b09b2a' -const SHARED_ROUTE_SHA256 = 'b52189cd8b48586ebeade602d2e23951a8b8156fcc319974a5b3b09f1b409d93' -const SHARED_PLUGIN_SHA256 = 'bd4e00888d3121ae9b07cf4f8e130dd54a4fc575ca0b42771d744c36949633a6' - -const TOKEN = 'a'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) - -function sha256(relative: string): string { - return createHash('sha256').update(readFileSync(new URL(relative, import.meta.url))).digest('hex') -} - -test('sitePurge: the shared files match the pinned hashes', () => { - assert.equal(sha256('../server/lib/siteCachePurge.ts'), SHARED_LIB_SHA256, 'server/lib/siteCachePurge.ts') - assert.equal(sha256('../server/api/__site__/cache/purge.post.ts'), SHARED_ROUTE_SHA256, 'server/api/__site__/cache/purge.post.ts') - assert.equal(sha256('../server/plugins/site-cache-purge.ts'), SHARED_PLUGIN_SHA256, 'server/plugins/site-cache-purge.ts') -}) - -test('sitePurge: a missing or short token disables the route', () => { - for (const token of [undefined, null, '', 'short', 'b'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH - 1), 12345678901234567890, {}]) { - assert.equal(authorizeSitePurge({ token, authorization: `Bearer ${String(token)}`, proxied: false }), 'disabled', `token ${JSON.stringify(token)}`) - } - assert.equal(authorizeSitePurge({ token: '', authorization: 'Bearer x', proxied: true }), 'disabled') - assert.equal(MIN_SITE_PURGE_TOKEN_LENGTH, 32) -}) - -test('sitePurge: a proxied request is refused before the token is checked', () => { - assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true }), 'proxied') - assert.equal(authorizeSitePurge({ token: TOKEN, authorization: null, proxied: true }), 'proxied') -}) - -test('sitePurge: authorize ok with the token and unauthorized otherwise', () => { - assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: false }), 'ok') - assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `bearer ${TOKEN}`, proxied: false }), 'ok') - for (const authorization of [undefined, null, '', 'Bearer', `Basic ${TOKEN}`, `${TOKEN}`, `Bearer ${TOKEN}x`, `Bearer ${TOKEN.slice(1)}`, `Bearer ${TOKEN} extra`, 'Bearer ' + 'b'.repeat(32)]) { - assert.equal(authorizeSitePurge({ token: TOKEN, authorization, proxied: false }), 'unauthorized', `authorization ${JSON.stringify(authorization)}`) - } -}) - -/** A fake unstorage prefix storage over a plain key list. */ -function fakeStorage(keys: string[]) { - const live = new Set(keys) - const removed: string[] = [] - return { - removed, - live, - async getKeys(base?: string) { - return [...live].filter((k) => !base || k === base || k.startsWith(base + ':')) - }, - async removeItem(key: string) { - live.delete(key) - removed.push(key) - }, - } -} - -test('sitePurge: purge removes only nitro:routes: keys', async () => { - const routes = [ - `${SITE_ROUTE_CACHE_BASE}:_:faq.abc123.json`, - `${SITE_ROUTE_CACHE_BASE}:_:faq_payloadjson.def456.json`, - `${SITE_ROUTE_CACHE_BASE}:_:index.0a1b2c.json`, - ] - const others = ['nitro:functions:_:faq.1.json', 'nitro:handlers:_:api.2.json', 'nitro:routesx:_:near.3.json', 'other:routes:_:x.json'] - const storage = fakeStorage([...routes, ...others]) - assert.equal(SITE_ROUTE_CACHE_BASE, 'nitro:routes') - assert.equal(await purgeSiteRouteCache(storage), routes.length) - assert.deepEqual([...storage.removed].sort(), [...routes].sort()) - assert.deepEqual([...storage.live].sort(), [...others].sort()) - assert.equal(await purgeSiteRouteCache(storage), 0) -}) - -test('sitePurge: purge event kinds and slugs', () => { - for (const kind of ['faq', 'menu', 'menu_entry', 'post', 'category']) { - assert.deepEqual(parseSitePurgeEvent({ kind, slug: 'x' }), { kind, slug: 'x' }) - } - for (const kind of ['FAQ', 'menu-entry', 'a'.repeat(41), '', 7, null]) { - assert.deepEqual(parseSitePurgeEvent({ kind, slug: 'x' }), { kind: 'unknown', slug: 'x' }, JSON.stringify(kind)) - } - assert.deepEqual(parseSitePurgeEvent({ kind: 'faq', slug: 'a\nb\r\tc\u0000d\u007fe\u0085f' }), { kind: 'faq', slug: 'abcdef' }) - assert.equal(parseSitePurgeEvent({ kind: 'faq', slug: 'x'.repeat(500) }).slug.length, 200) - for (const body of [null, undefined, 'faq', 42, ['faq', 'x']]) { - assert.deepEqual(parseSitePurgeEvent(body), { kind: 'unknown', slug: '' }, JSON.stringify(body)) - } -}) - -test('sitePurge: ok in constant time over sha256 digests, whatever the bearer length', () => { - // timingSafeEqual on raw strings throws on a length mismatch; over digests - // every length compares in constant time and answers unauthorized. - for (const length of [1, 31, 33, 64, 4096]) { - assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${'a'.repeat(length)}`, proxied: false }), 'unauthorized', `length ${length}`) - } - const long = 'k'.repeat(200) - assert.equal(authorizeSitePurge({ token: long, authorization: `Bearer ${long}`, proxied: false }), 'ok') - assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer\t${TOKEN}\t`, proxied: false }), 'ok') - assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `BEARER ${TOKEN} `, proxied: false }), 'ok') - assert.equal(authorizeSitePurge({ token: TOKEN, authorization: ` Bearer ${TOKEN}`, proxied: false }), 'unauthorized') - assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}\n`, proxied: false }), 'unauthorized') -}) - -test('sitePurge: purge works with a synchronous storage and skips the bare base key', async () => { - const live = new Set([`${SITE_ROUTE_CACHE_BASE}:_:a.json`, SITE_ROUTE_CACHE_BASE, 'nitro:other:_:b.json']) - const removed: string[] = [] - const storage = { - getKeys: (base?: string) => [...live].filter((k) => !base || k === base || k.startsWith(base + ':')), - removeItem: (key: string) => { - live.delete(key) - removed.push(key) - }, - } - assert.equal(await purgeSiteRouteCache(storage), 1) - assert.deepEqual(removed, [`${SITE_ROUTE_CACHE_BASE}:_:a.json`]) - assert.ok(live.has(SITE_ROUTE_CACHE_BASE)) - assert.equal(await purgeSiteRouteCache({ getKeys: () => [], removeItem: () => assert.fail('nothing to remove') }), 0) -}) - -test('sitePurge: purge event edge cases', () => { - assert.deepEqual(parseSitePurgeEvent({ kind: 'a'.repeat(40), slug: '' }), { kind: 'a'.repeat(40), slug: '' }) - assert.deepEqual(parseSitePurgeEvent({ kind: 'faq' }), { kind: 'faq', slug: '' }) - assert.deepEqual(parseSitePurgeEvent({ kind: 'faq', slug: 42 }), { kind: 'faq', slug: '' }) - assert.deepEqual(parseSitePurgeEvent({ kind: 'post', slug: 'x', extra: 'ignored' }), { kind: 'post', slug: 'x' }) - assert.equal(parseSitePurgeEvent({ kind: 'faq', slug: 'y'.repeat(200) }).slug.length, 200) - // Control characters are stripped before the cut. - const slug = '\u0001'.repeat(50) + 'z'.repeat(200) - assert.equal(parseSitePurgeEvent({ kind: 'faq', slug }).slug, 'z'.repeat(200)) - assert.deepEqual(parseSitePurgeEvent({}), { kind: 'unknown', slug: '' }) -}) - -/** One call of the shared route with Nitro's auto-imports stubbed. */ -async function callRoute(opts: { - token?: unknown - allowProxied?: unknown - cachePurge?: { token?: unknown; allowProxied?: unknown } - headers?: Record - body?: unknown - bodyError?: boolean - keys?: string[] -}) { - const responseHeaders: Record = {} - const logs: string[] = [] - const live = new Set(opts.keys ?? []) - const g = globalThis as Record - g.defineEventHandler = (fn: unknown) => fn - g.setResponseHeader = (_e: unknown, name: string, value: string) => { - responseHeaders[name] = value - } - g.useRuntimeConfig = () => ({ - siteCache: opts.token === undefined ? undefined : { purgeToken: opts.token, allowProxied: opts.allowProxied }, - cachePurge: opts.cachePurge, - }) - g.getRequestHeader = (_e: unknown, name: string) => opts.headers?.[name] - g.createError = (input: { statusCode: number; statusMessage: string }) => Object.assign(new Error(input.statusMessage), input) - g.readBody = async () => { - if (opts.bodyError) throw new Error('invalid JSON') - return opts.body - } - g.useStorage = (base: string) => { - assert.equal(base, 'cache') - return { - getKeys: async (prefix?: string) => [...live].filter((k) => !prefix || k.startsWith(prefix + ':')), - removeItem: async (key: string) => { - live.delete(key) - }, - } - } - const info = console.info - console.info = (line: string) => { - logs.push(line) - } - try { - const route = (await import('../server/api/__site__/cache/purge.post.ts')).default as (e: unknown) => Promise - try { - return { result: await route({}), status: 200, responseHeaders, logs, live } - } catch (err) { - return { result: undefined, status: (err as { statusCode?: number }).statusCode, responseHeaders, logs, live } - } - } finally { - console.info = info - } -} - -test('sitePurge: route answers 404 when disabled or proxied and 401 for a wrong bearer', async () => { - const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`] - for (const token of [undefined, '', 'short']) { - const r = await callRoute({ token, headers: { authorization: `Bearer ${String(token)}` }, keys }) - assert.equal(r.status, 404, `token ${JSON.stringify(token)}`) - assert.equal(r.live.size, 1) - } - for (const name of ['x-forwarded-for', 'x-real-ip', 'forwarded']) { - const r = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}`, [name]: '203.0.113.9' }, keys }) - assert.equal(r.status, 404, name) - assert.equal(r.live.size, 1) - } - for (const authorization of [undefined, 'Bearer wrong', `Basic ${TOKEN}`]) { - const r = await callRoute({ token: TOKEN, headers: authorization ? { authorization } : {}, keys }) - assert.equal(r.status, 401, String(authorization)) - assert.equal(r.live.size, 1) - assert.equal(r.responseHeaders['cache-control'], 'no-store') - } -}) - -test('sitePurge: route purges, answers the count and logs kind and slug only', async () => { - const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`, `${SITE_ROUTE_CACHE_BASE}:_:faq.json`, 'nitro:functions:_:x.json'] - const r = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}` }, body: { kind: 'faq', slug: 'ship\nping' }, keys }) - assert.equal(r.status, 200) - assert.deepEqual(r.result, { purged: 2 }) - assert.deepEqual([...r.live], ['nitro:functions:_:x.json']) - assert.equal(r.responseHeaders['cache-control'], 'no-store') - assert.deepEqual(r.logs, ['[site-cache] purge: removed 2 route cache entries (faq shipping)']) - assert.ok(!r.logs.join('').includes(TOKEN)) - - const bad = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}` }, bodyError: true, keys: [] }) - assert.equal(bad.status, 200) - assert.deepEqual(bad.result, { purged: 0 }) - assert.deepEqual(bad.logs, ['[site-cache] purge: removed 0 route cache entries (unknown )']) -}) - -test('sitePurge: allowProxied lets a proxied request reach the token check', () => { - assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true, allowProxied: true }), 'ok') - assert.equal(authorizeSitePurge({ token: TOKEN, authorization: 'Bearer ' + 'b'.repeat(32), proxied: true, allowProxied: true }), 'unauthorized') - assert.equal(authorizeSitePurge({ token: TOKEN, authorization: null, proxied: true, allowProxied: true }), 'unauthorized') - assert.equal(authorizeSitePurge({ token: 'short', authorization: 'Bearer short', proxied: true, allowProxied: true }), 'disabled') - assert.equal(authorizeSitePurge({ token: '', authorization: 'Bearer x', proxied: true, allowProxied: true }), 'disabled') - // Without the flag, or with anything but true, a proxied request stays refused. - assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true, allowProxied: false }), 'proxied') - assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true }), 'proxied') -}) - -test('sitePurge: route accepts a proxied request with allowProxied and the right bearer', async () => { - const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`, `${SITE_ROUTE_CACHE_BASE}:_:faq.json`] - const headers = { authorization: `Bearer ${TOKEN}`, 'x-forwarded-for': '203.0.113.9', 'x-real-ip': '203.0.113.9', forwarded: 'for=203.0.113.9;proto=https' } - for (const allowProxied of [true, 'true']) { - const r = await callRoute({ token: TOKEN, allowProxied, headers, body: { kind: 'faq', slug: 'shipping' }, keys }) - assert.equal(r.status, 200, `allowProxied ${JSON.stringify(allowProxied)}`) - assert.deepEqual(r.result, { purged: 2 }) - assert.equal(r.live.size, 0) - } - const wrong = await callRoute({ token: TOKEN, allowProxied: true, headers: { ...headers, authorization: 'Bearer wrong' }, keys }) - assert.equal(wrong.status, 401) - assert.equal(wrong.live.size, 2) - for (const allowProxied of [false, undefined, 'false', 1]) { - const r = await callRoute({ token: TOKEN, allowProxied, headers, keys }) - assert.equal(r.status, 404, `allowProxied ${JSON.stringify(allowProxied)}`) - assert.equal(r.live.size, 2) - } -}) - -test('sitePurge: resolve config prefers a non-empty new token over the deprecated one', () => { - const A = 'n'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) - const B = 'o'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) - assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: A }, siteCache: { purgeToken: B } }), { token: A, allowProxied: false, deprecated: [] }) - assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: A, allowProxied: false }, siteCache: { purgeToken: '' } }), { token: A, allowProxied: false, deprecated: [] }) - assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: '' }, siteCache: { purgeToken: B } }), { token: B, allowProxied: false, deprecated: ['token'] }) - assert.deepEqual(resolveSitePurgeConfig({ siteCache: { purgeToken: B } }), { token: B, allowProxied: false, deprecated: ['token'] }) - assert.deepEqual(resolveSitePurgeConfig({ cachePurge: null, siteCache: { purgeToken: B } }), { token: B, allowProxied: false, deprecated: ['token'] }) - assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: '' }, siteCache: { purgeToken: '' } }), { token: '', allowProxied: false, deprecated: [] }) -}) - -test('sitePurge: resolve config turns allowProxied on for either flag, true or "true" only', () => { - for (const value of [true, 'true']) { - assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { allowProxied: value } }).allowProxied, true, `new ${JSON.stringify(value)}`) - assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { allowProxied: value } }).deprecated, []) - const old = resolveSitePurgeConfig({ siteCache: { allowProxied: value } }) - assert.equal(old.allowProxied, true, `old ${JSON.stringify(value)}`) - assert.deepEqual(old.deprecated, ['allowProxied']) - // The new flag on: the old one is not counted as in use. - assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { allowProxied: value }, siteCache: { allowProxied: value } }).deprecated, []) - } - for (const value of [false, undefined, 'false', 1, null, 'TRUE']) { - const r = resolveSitePurgeConfig({ cachePurge: { allowProxied: value }, siteCache: { allowProxied: value } }) - assert.equal(r.allowProxied, false, JSON.stringify(value)) - assert.deepEqual(r.deprecated, []) - } - const B = 'o'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) - assert.deepEqual(resolveSitePurgeConfig({ siteCache: { purgeToken: B, allowProxied: 'true' } }).deprecated, ['token', 'allowProxied']) -}) - -test('sitePurge: resolve config tolerates a missing or malformed config', () => { - for (const config of [undefined, null, 'x', 42, {}, { cachePurge: 'x', siteCache: 7 }]) { - assert.deepEqual(resolveSitePurgeConfig(config), { token: undefined, allowProxied: false, deprecated: [] }, JSON.stringify(config)) - } -}) - -test('sitePurge: route reads the new token, falls back to the deprecated one, and the new one wins', async () => { - const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`] - const NEW = 'n'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) - const r = await callRoute({ cachePurge: { token: TOKEN }, headers: { authorization: `Bearer ${TOKEN}` }, keys }) - assert.equal(r.status, 200) - assert.deepEqual(r.result, { purged: 1 }) - const old = await callRoute({ token: TOKEN, cachePurge: { token: '' }, headers: { authorization: `Bearer ${TOKEN}` }, keys }) - assert.equal(old.status, 200) - const both = { token: TOKEN, cachePurge: { token: NEW } } - assert.equal((await callRoute({ ...both, headers: { authorization: `Bearer ${NEW}` }, keys })).status, 200) - const refused = await callRoute({ ...both, headers: { authorization: `Bearer ${TOKEN}` }, keys }) - assert.equal(refused.status, 401) - assert.equal(refused.live.size, 1) - const short = await callRoute({ cachePurge: { token: 'short' }, headers: { authorization: 'Bearer short' }, keys }) - assert.equal(short.status, 404) -}) - -test('sitePurge: route lets a proxied request reach the token check with cachePurge.allowProxied', async () => { - const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`] - const headers = { authorization: `Bearer ${TOKEN}`, 'x-forwarded-for': '203.0.113.9' } - for (const allowProxied of [true, 'true']) { - const r = await callRoute({ cachePurge: { token: TOKEN, allowProxied }, headers, keys }) - assert.equal(r.status, 200, `allowProxied ${JSON.stringify(allowProxied)}`) - const wrong = await callRoute({ cachePurge: { token: TOKEN, allowProxied }, headers: { ...headers, authorization: 'Bearer wrong' }, keys }) - assert.equal(wrong.status, 401) - } - // The deprecated flag with the new token still opens it. - assert.equal((await callRoute({ token: '', allowProxied: true, cachePurge: { token: TOKEN }, headers, keys })).status, 200) - for (const allowProxied of [false, undefined, 'false', 1]) { - const r = await callRoute({ cachePurge: { token: TOKEN, allowProxied }, headers, keys }) - assert.equal(r.status, 404, `allowProxied ${JSON.stringify(allowProxied)}`) - } -}) - -test('sitePurge: redactSiteConfigStack removes the purge keys from every stack entry', () => { - assert.deepEqual([...SITE_CONFIG_PURGE_KEYS], ['cachePurgeToken', 'cacheAllowProxied']) - const stack: Record[] = [ - { url: 'https://acme.test', name: 'Acme', cachePurgeToken: TOKEN, cacheAllowProxied: 'true' }, - { _context: 'x' }, - ] - assert.equal(redactSiteConfigStack(stack), 2) - assert.deepEqual(stack, [{ url: 'https://acme.test', name: 'Acme' }, { _context: 'x' }]) - assert.equal(redactSiteConfigStack(stack), 0) - const spread = [{ cachePurgeToken: TOKEN }, { cachePurgeToken: TOKEN, indexable: true }] - assert.equal(redactSiteConfigStack(spread), 2) - assert.deepEqual(spread, [{}, { indexable: true }]) - for (const value of [undefined, null, 'stack', 7, {}, { cachePurgeToken: TOKEN }]) { - assert.equal(redactSiteConfigStack(value), 0, JSON.stringify(value)) - } - assert.equal(redactSiteConfigStack([null, undefined, 'x', 3]), 0) -}) - -test('sitePurge: the deprecation message names variables, never a value', () => { - assert.equal(sitePurgeDeprecationMessage([]), '') - const token = sitePurgeDeprecationMessage(['token']) - assert.match(token, /^\[site-cache\] /) - assert.ok(token.includes('NUXT_SITE_CACHE_PURGE_TOKEN -> NUXT_CACHE_PURGE_TOKEN')) - assert.ok(!token.includes('NUXT_SITE_CACHE_ALLOW_PROXIED')) - assert.match(token, /rotate/) - assert.ok(!token.includes('\n')) - const both = sitePurgeDeprecationMessage(['token', 'allowProxied']) - assert.ok(both.includes('NUXT_SITE_CACHE_PURGE_TOKEN -> NUXT_CACHE_PURGE_TOKEN')) - assert.ok(both.includes('NUXT_SITE_CACHE_ALLOW_PROXIED -> NUXT_CACHE_PURGE_ALLOW_PROXIED')) - assert.ok(sitePurgeDeprecationMessage(['allowProxied']).includes('NUXT_CACHE_PURGE_ALLOW_PROXIED')) -}) - -type SiteConfigHook = (ctx: unknown) => void - -/** Runs the shared Nitro plugin on a fake nitroApp with the given runtime config. */ -async function runPlugin(config: unknown) { - const g = globalThis as Record - g.defineNitroPlugin = (fn: unknown) => fn - g.useRuntimeConfig = () => config - const hooks: Record = {} - const nitroApp = { - hooks: { - hook(name: string, fn: SiteConfigHook) { - ;(hooks[name] ??= []).push(fn) - }, - }, - } - const warnings: string[] = [] - const warn = console.warn - console.warn = (line: string) => { - warnings.push(line) - } - try { - const plugin = (await import('../server/plugins/site-cache-purge.ts')).default as (app: unknown) => void - plugin(nitroApp) - } finally { - console.warn = warn - } - return { hooks, warnings } -} - -function resetWarned() { - delete (globalThis as Record)[SITE_PURGE_WARNED] -} - -test('sitePurge: plugin strips the purge keys on site-config:init', async () => { - resetWarned() - const { hooks, warnings } = await runPlugin({ cachePurge: { token: TOKEN } }) - assert.equal(hooks['site-config:init']?.length, 1) - assert.deepEqual(warnings, []) - const stack = [{ url: 'https://acme.test', cachePurgeToken: TOKEN, cacheAllowProxied: 'true' }, { _context: 'x' }] - hooks['site-config:init'][0]({ siteConfig: { stack }, event: {} }) - assert.deepEqual(stack, [{ url: 'https://acme.test' }, { _context: 'x' }]) - for (const ctx of [undefined, null, {}, { siteConfig: {} }, { siteConfig: { stack: 'x' } }]) { - hooks['site-config:init'][0](ctx) - } - resetWarned() -}) - -test('sitePurge: plugin warns once per process about a deprecated name, without the value', async () => { - resetWarned() - const config = { siteCache: { purgeToken: TOKEN, allowProxied: 'true' } } - const first = await runPlugin(config) - assert.equal(first.warnings.length, 1) - assert.ok(first.warnings[0].includes('NUXT_CACHE_PURGE_TOKEN')) - assert.ok(first.warnings[0].includes('NUXT_SITE_CACHE_PURGE_TOKEN')) - assert.ok(first.warnings[0].includes('NUXT_CACHE_PURGE_ALLOW_PROXIED')) - assert.ok(!first.warnings[0].includes(TOKEN)) - // Another layer's copy of the plugin in the same process stays quiet. - const second = await runPlugin(config) - assert.deepEqual(second.warnings, []) - assert.equal(second.hooks['site-config:init']?.length, 1) - resetWarned() - - const fresh = await runPlugin({ cachePurge: { token: TOKEN }, siteCache: { purgeToken: 'o'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) } }) - assert.deepEqual(fresh.warnings, [], 'the new name wins: no warning') - const none = await runPlugin({}) - assert.deepEqual(none.warnings, []) - resetWarned() -})