diff --git a/README.md b/README.md index 2cdd5bb..637f83a 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,10 @@ 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.siteCache.purgeToken` | `''` (route off) | `NUXT_SITE_CACHE_PURGE_TOKEN` (server only; at least 32 characters) | +| `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 base URLs always come from runtime config, never from request input. In production the web server routes `/_faq/` to the SummerCMS binary. @@ -69,17 +72,24 @@ A site that route-caches its pages (for example `routeRules: { '/**': { swr: 300 ``` POST /api/__site__/cache/purge -Authorization: Bearer +Authorization: Bearer {"kind":"faq","slug":"shipping"} → {"purged": } ``` - 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_SITE_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. +- 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_SITE_CACHE_ALLOW_PROXIED=true` (`runtimeConfig.siteCache.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. +- 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` and `server/api/__site__/cache/purge.post.ts` byte for byte (a test pins their sha256), so a site that extends several of them gets one route, whichever copy Nitro keeps. +- 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. + +### Upgrading from 0.3 + +- Rename `NUXT_SITE_CACHE_PURGE_TOKEN` to `NUXT_CACHE_PURGE_TOKEN` and `NUXT_SITE_CACHE_ALLOW_PROXIED` to `NUXT_CACHE_PURGE_ALLOW_PROXIED` in the site's environment. The old names keep working through 0.4.x (a non-empty new token wins) and are removed in the next minor release. +- Why: nuxt-site-config publishes every `NUXT_SITE_*` variable in the page payload, so in a site with nuxt-site-config a token set under `NUXT_SITE_CACHE_PURGE_TOKEN` appeared in every page as `cachePurgeToken`. From 0.4 the layer strips both keys from the site config even under the old names. +- Rotate a token that was ever set under `NUXT_SITE_CACHE_PURGE_TOKEN`: earlier versions published it. Set the new value in the site's `NUXT_CACHE_PURGE_TOKEN` and in the backend's `site_cache.purge_token`, then restart both. ## API diff --git a/nuxt.config.ts b/nuxt.config.ts index 2eb7272..7606ef0 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -23,17 +23,29 @@ export default defineNuxtConfig({ // from request input. apiBase: 'http://127.0.0.1:8096/_faq/api/v1', }, - siteCache: { + cachePurge: { // Server-only bearer token of the shared site cache purge route - // POST /api/__site__/cache/purge (NUXT_SITE_CACHE_PURGE_TOKEN), the + // POST /api/__site__/cache/purge (NUXT_CACHE_PURGE_TOKEN), the // backend's site_cache.purge_token. Shorter than 32 characters, the - // route answers 404. + // 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: '', - // NUXT_SITE_CACHE_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, }, public: { diff --git a/package.json b/package.json index 13860ad..652c693 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vue-faq-layer", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "main": "./nuxt.config.ts", diff --git a/server/api/__site__/cache/purge.post.ts b/server/api/__site__/cache/purge.post.ts index 78a56ae..f19d42d 100644 --- a/server/api/__site__/cache/purge.post.ts +++ b/server/api/__site__/cache/purge.post.ts @@ -1,34 +1,38 @@ // 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 ` and the body -// {"kind":"","slug":""}; the answer is {"purged":}. The kind -// and slug only reach the log line. +// `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.siteCache.allowProxied (NUXT_SITE_CACHE_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. +// 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 } from '../../../lib/siteCachePurge.ts' +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 config = useRuntimeConfig(event) - const siteCache = config.siteCache as { purgeToken?: unknown; allowProxied?: unknown } | undefined + const { token, allowProxied } = resolveSitePurgeConfig(useRuntimeConfig(event)) const verdict = authorizeSitePurge({ - token: siteCache?.purgeToken, + token, authorization: getRequestHeader(event, 'authorization'), proxied: PROXY_HEADERS.some((name) => getRequestHeader(event, name) !== undefined), - allowProxied: siteCache?.allowProxied === true || siteCache?.allowProxied === 'true', + allowProxied, }) if (verdict === 'disabled' || verdict === 'proxied') { throw createError({ statusCode: 404, statusMessage: 'Not Found' }) diff --git a/server/lib/siteCachePurge.ts b/server/lib/siteCachePurge.ts index f3b5b74..a7e41d5 100644 --- a/server/lib/siteCachePurge.ts +++ b/server/lib/siteCachePurge.ts @@ -1,8 +1,11 @@ // Shared site cache purge: the authorization rules of the purge route -// POST /api/__site__/cache/purge, the purge of Nitro's route cache and the -// parsing of the purge request body. Pure TypeScript outside Nitro's and -// Nuxt's auto-import directories, so several layers can ship this file -// byte for byte, and `node --test` and the route import it directly. +// 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' @@ -21,16 +24,17 @@ const KIND_PATTERN = /^[a-z_]{1,40}$/ export type SitePurgeVerdict = 'ok' | 'disabled' | 'proxied' | 'unauthorized' export interface SitePurgeAuthInput { - /** The configured token (runtimeConfig.siteCache.purgeToken). */ + /** 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 /** - * runtimeConfig.siteCache.allowProxied: 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. + * 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 } @@ -96,3 +100,109 @@ export function parseSitePurgeEvent(body: unknown): SitePurgeEvent { 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 new file mode 100644 index 0000000..a35e319 --- /dev/null +++ b/server/plugins/site-cache-purge.ts @@ -0,0 +1,48 @@ +// 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/tests/sitePurge.test.ts b/tests/sitePurge.test.ts index 644128d..798d4e5 100644 --- a/tests/sitePurge.test.ts +++ b/tests/sitePurge.test.ts @@ -1,22 +1,30 @@ // Shared site cache purge: route authorization, the route-cache purge, the -// purge body parsing and the byte-identity of the files every layer ships. +// 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 two shared files. vue-journal-layer, vue-menu-layer and +// 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 = '0ea9f0f936134516872a2edf9b8e09feedddfa23e9da05c57d06c772193b260e' -const SHARED_ROUTE_SHA256 = '8f86226074abf10c2ecc1d181b6957e90ed8d900589a6372214f50cdbfcd532c' +const SHARED_LIB_SHA256 = 'da5e588b94de6a07f99f203d51cc953f497af886d0fad03709d67064a7b09b2a' +const SHARED_ROUTE_SHA256 = 'b52189cd8b48586ebeade602d2e23951a8b8156fcc319974a5b3b09f1b409d93' +const SHARED_PLUGIN_SHA256 = 'bd4e00888d3121ae9b07cf4f8e130dd54a4fc575ca0b42771d744c36949633a6' const TOKEN = 'a'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) @@ -27,6 +35,7 @@ function sha256(relative: string): string { 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', () => { @@ -139,7 +148,15 @@ test('sitePurge: purge event edge cases', () => { }) /** One call of the shared route with Nitro's auto-imports stubbed. */ -async function callRoute(opts: { token?: unknown; allowProxied?: unknown; headers?: Record; body?: unknown; bodyError?: boolean; keys?: string[] }) { +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 ?? []) @@ -148,7 +165,10 @@ async function callRoute(opts: { token?: unknown; allowProxied?: unknown; header g.setResponseHeader = (_e: unknown, name: string, value: string) => { responseHeaders[name] = value } - g.useRuntimeConfig = () => ({ siteCache: opts.token === undefined ? undefined : { purgeToken: opts.token, allowProxied: opts.allowProxied } }) + 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 () => { @@ -245,3 +265,174 @@ test('sitePurge: route accepts a proxied request with allowProxied and the right 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() +})