From 0460b084295a22d0710336c0ffe9e72733e9cb6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 19 Jul 2026 22:21:16 -0300 Subject: [PATCH] Add allowlisted global cache revalidation for Onda 0 F1. Extend POST /api/revalidate with posts and global_privacy-policy tags so direct-DB Onda 0 writes can bust /privacidade without an admin edit. --- .env.example | 8 ++-- AGENTS.md | 10 ++++- README.md | 6 ++- docs/plans/escala-dry-pos-onda0.md | 20 +++++---- docs/plans/onda-0.md | 11 ++++- scripts/seed-consent.mjs | 7 +++ src/app/(frontend)/api/revalidate/route.ts | 52 ++++++++++++++++++---- src/utilities/globals.ts | 7 +-- src/utilities/revalidateRequest.ts | 38 ++++++++++++++++ tests/unit/revalidateRequest.unit.spec.ts | 51 +++++++++++++++++++++ 10 files changed, 181 insertions(+), 29 deletions(-) create mode 100644 src/utilities/revalidateRequest.ts create mode 100644 tests/unit/revalidateRequest.unit.spec.ts diff --git a/.env.example b/.env.example index b89de944..80be71a7 100644 --- a/.env.example +++ b/.env.example @@ -16,10 +16,10 @@ BLOB_READ_WRITE_TOKEN=YOUR_BLOB_TOKEN_HERE # normalizes them to HTTP; otherwise this configured origin is the only fallback. NEXT_PUBLIC_SITE_URL=http://localhost:3000 -# Shared secret guarding POST /api/revalidate, which busts the `posts` cache tag -# on demand. Needed after a direct-DB seed (`pnpm db:seed:posts`) since that -# bypasses the Payload hooks that normally call revalidateTag. MUST be set in the -# Vercel project env for production. Generate with e.g. `openssl rand -hex 32`. +# Shared secret guarding POST /api/revalidate, which busts ISR cache tags on demand. +# Needed after direct-DB writes (e.g. `pnpm db:seed:posts`, Onda 0 migrations) since those +# bypass the deployed Payload hooks. Default tag is `posts`; allowlisted tags also include +# `global_privacy-policy` for /privacidade. MUST be set in the Vercel project env for production. Generate with e.g. `openssl rand -hex 32`. REVALIDATE_SECRET=YOUR_REVALIDATE_SECRET_HERE # Campaign password reset emails (Resend). Required in production for forgot-password. diff --git a/AGENTS.md b/AGENTS.md index 6b109c9d..646e6cf2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,16 +69,22 @@ The public news/publications system is backed by two collections (both in the `P Admin edits made through the deployed app self-revalidate via the `afterChange` hooks above. But any write that does **not** go through the deployed Payload runtime — direct SQL, `pnpm db:seed:posts`, a DB restore / `db:pull` — will **not** bust production's frozen `posts` cache. (Even when such a write triggers the hook, its `revalidateTag('posts')` runs in that CLI/local process and cannot touch the deployed server's cache.) The pages keep serving stale content until the tag is busted. -Bust it with the secured endpoint (`src/app/(frontend)/api/revalidate/route.ts`), which calls `revalidatePostsListing()`: +Bust it with the secured endpoint (`src/app/(frontend)/api/revalidate/route.ts`): ```bash +# News (`posts` tag — default) curl -X POST "https:///api/revalidate" \ -H "x-revalidate-secret: $REVALIDATE_SECRET" + +# Privacy policy global after Onda 0 migration/seed (`global_privacy-policy`) +curl -X POST "https:///api/revalidate?tag=global_privacy-policy" \ + -H "x-revalidate-secret: $REVALIDATE_SECRET" ``` - **Auth:** send the secret via `x-revalidate-secret` or `Authorization: Bearer `; it is compared to `REVALIDATE_SECRET` with a constant-time check. - `REVALIDATE_SECRET` must be set in the Vercel **production** env (and in your local env if you want to test the endpoint). It is documented in `.env.example`. -- **Responses:** `200 { revalidated: true, tag: 'posts' }` on success; `401` if the secret is missing/wrong; `500` if `REVALIDATE_SECRET` is not configured on the server. +- **Tag:** optional `?tag=` query or JSON body `{ "tag": "..." }` (query wins). Allowlist: `posts` (default), `global_privacy-policy`. Unknown tag → `400`. +- **Responses:** `200 { revalidated: true, tag: '...' }` on success; `401` if the secret is missing/wrong; `500` if `REVALIDATE_SECRET` is not configured on the server. - This POST is the required last step of the post-seed / direct-DB-change runbook. ## Campaign auth (`/campanha`) diff --git a/README.md b/README.md index f57aba68..e232d8d0 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,14 @@ Useful scripts: `pnpm db:start` / `pnpm db:stop` (local Postgres), `pnpm db:pull ### Content & cache revalidation -Public news pages (`/[type]`, `/[type]/[category]`, articles, and the home news list) are cached under a shared `posts` tag. Editing a post or tag in the deployed admin self-revalidates via `afterChange` hooks. Any write that bypasses the deployed runtime — a direct-DB seed (`pnpm db:seed:posts`), SQL, or a restore — does **not** bust production's cache, so afterwards call the secured endpoint: +Public news pages (`/[type]`, `/[type]/[category]`, articles, and the home news list) are cached under a shared `posts` tag. Editing a post or tag in the deployed admin self-revalidates via `afterChange` hooks. Any write that bypasses the deployed runtime — a direct-DB seed (`pnpm db:seed:posts`), SQL, Onda 0 migrations, or a restore — does **not** bust production's cache, so afterwards call the secured endpoint: ``` +# News (default tag: posts) curl -X POST "https:///api/revalidate" -H "x-revalidate-secret: $REVALIDATE_SECRET" + +# Privacy policy after Onda 0 provision (tag: global_privacy-policy) +curl -X POST "https:///api/revalidate?tag=global_privacy-policy" -H "x-revalidate-secret: $REVALIDATE_SECRET" ``` `REVALIDATE_SECRET` must be set in the Vercel production env (see `.env.example`). See the "Posts & Tags" section of `AGENTS.md` for the full model. diff --git a/docs/plans/escala-dry-pos-onda0.md b/docs/plans/escala-dry-pos-onda0.md index 8e7d6411..0717f1ef 100644 --- a/docs/plans/escala-dry-pos-onda0.md +++ b/docs/plans/escala-dry-pos-onda0.md @@ -1,6 +1,6 @@ # Escala e DRY pós-Onda 0 (Consent / privacidade / cache) -Status: registrado no roadmap (fases pendentes) +Status: Fase 1 entregue; fases 2–5 pendentes Atualizado em: 2026-07-19 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Onda 0+, fill-in de engenharia pós-MVP) Responsável: — @@ -39,8 +39,8 @@ Os três revisores do `/simplify` (quality / performance / reuse) deixaram débi ## Questões em aberto -- **Revalidate automático no fim da migration vs endpoint manual?** **Recomendação:** endpoint primeiro (paridade com `posts` seed); hook pós-migrate no build só se o time esquecer o passo em produção — documentar curl no [onda-0.md](onda-0.md). -- **Unificar seed CLI no caminho SQL?** **Recomendação:** manter Payload no CLI (valida hooks/admin) mas chamar `revalidateGlobal('privacy-policy')` ao final; migration continua SQL-only. +- **Revalidate automático no fim da migration vs endpoint manual?** **Resolvido (F1):** endpoint allowlisted + curl no runbook; migration continua SQL-only. +- **Unificar seed CLI no caminho SQL?** **Recomendação:** manter Payload no CLI (valida hooks/admin); bust via curl documentado — `revalidateGlobal` no CLI é no-op (`seed-loader`). - **Extrair `LexicalProse` agora ou com `Pages`?** **Recomendação:** adiar Fase 4 até o primeiro consumidor além de `/privacidade` + artigo — evitar abstração de uma página. ## Abordagem proposta @@ -55,12 +55,12 @@ flowchart TD F3 --> F5["Fase 5 — Footer link condicional"] ``` -### Fase 1 — Cache de globals pós-migration/seed +### Fase 1 — Cache de globals pós-migration/seed ✓ -- Estender `src/app/(frontend)/api/revalidate/route.ts`: aceitar `tag` query/body (`global_privacy-policy`, lista allowlist) ou segundo secret header; manter default `posts` para compatibilidade. -- Após `provisionOnda0ConsentAndPrivacy` em `scripts/seed-consent.mjs`, chamar `revalidateGlobal('privacy-policy')` via Local API helper ou fetch local ao endpoint (mesmo padrão documentado para post-seed). -- Atualizar checklist em [onda-0.md](onda-0.md) § Smoke: `curl` pós-deploy quando migration de dados rodar sem edit admin. -- Opcional: helper `revalidatePublicGlobals()` em `src/utilities/globals.ts` com tags conhecidas (`privacy-policy`, futuro `metadata`). +- `POST /api/revalidate` aceita `?tag=` ou body JSON `{ "tag": "..." }` com allowlist `posts` (default) e `global_privacy-policy`; query vence sobre body. +- `scripts/seed-consent.mjs` loga curl pós-sucesso (CLI não invalida cache do runtime deployado — `seed-loader` stubba `next/cache`). +- Checklist em [onda-0.md](onda-0.md) § Smoke produção: curl `global_privacy-policy` após migrate/seed sem edit admin. +- `getGlobalCacheTag` exportado de `src/utilities/globals.ts`; resolver testável em `src/utilities/revalidateRequest.ts`. ### Fase 2 — Chaves Consent únicas @@ -115,3 +115,7 @@ flowchart TD - `src/utilities/campaignConsent.ts` / `campaignInvite.ts` — chaves runtime - `tests/int/onda0Provision.int.spec.ts` / `campaignMigrationReconciliation.int.spec.ts` - AGENTS.md — Consent por chave estável, `revalidateGlobal`, naming + +## Revisões + +- **2026-07-19:** Fase 1 entregue — endpoint allowlisted (`posts`, `global_privacy-policy`), `getGlobalCacheTag` exportado, curl no runbook Onda 0 e lembrete no `seed:consent`. diff --git a/docs/plans/onda-0.md b/docs/plans/onda-0.md index 21afff4a..8ee5f151 100644 --- a/docs/plans/onda-0.md +++ b/docs/plans/onda-0.md @@ -72,9 +72,16 @@ Refusa host não-local sem `ALLOW_REMOTE_DB=true` (mesma família de guard que ` **Produção (pós-deploy):** -1. Confirmar env: `NEXT_PUBLIC_SITE_URL` HTTPS, `PAYLOAD_SECRET`, `DATABASE_URL`, `BLOB_*`. +1. Confirmar env: `NEXT_PUBLIC_SITE_URL` HTTPS, `PAYLOAD_SECRET`, `DATABASE_URL`, `BLOB_*`, `REVALIDATE_SECRET`. 2. Migrations aplicadas no build. -3. Smoke com **dados fictícios**; não importar CSV real nem convidar titulares reais. +3. Bustar cache de `/privacidade` (migration SQL não dispara `afterChange` no runtime deployado): + +```bash +curl -X POST "https:///api/revalidate?tag=global_privacy-policy" \ + -H "x-revalidate-secret: $REVALIDATE_SECRET" +``` + +4. Smoke com **dados fictícios**; não importar CSV real nem convidar titulares reais. ## Onboarding staff (sem PII de campo) diff --git a/scripts/seed-consent.mjs b/scripts/seed-consent.mjs index 7fb6bb30..aac4a006 100644 --- a/scripts/seed-consent.mjs +++ b/scripts/seed-consent.mjs @@ -30,6 +30,13 @@ const payload = await getPayload({ config }) try { await provisionOnda0ConsentAndPrivacy(payload) console.log('[seed:consent] OK — provisional Consent keys and privacy-policy global updated.') + console.log( + '[seed:consent] Bust deployed /privacidade cache (CLI cannot revalidate the server runtime):', + ) + console.log( + ' curl -X POST "$NEXT_PUBLIC_SITE_URL/api/revalidate?tag=global_privacy-policy" \\\n' + + ' -H "x-revalidate-secret: $REVALIDATE_SECRET"', + ) } catch (error) { console.error(`\n[seed:consent] ${error instanceof Error ? error.message : String(error)}\n`) process.exit(1) diff --git a/src/app/(frontend)/api/revalidate/route.ts b/src/app/(frontend)/api/revalidate/route.ts index 67626fd4..3a1adb3c 100644 --- a/src/app/(frontend)/api/revalidate/route.ts +++ b/src/app/(frontend)/api/revalidate/route.ts @@ -1,18 +1,19 @@ -import { revalidatePostsListing } from '@/utilities/documents' +import { resolveRevalidateTag } from '@/utilities/revalidateRequest' import { timingSafeEqual } from 'node:crypto' +import { revalidateTag } from 'next/cache' import { NextResponse } from 'next/server' /** * On-demand revalidation endpoint for content written straight to the database - * (e.g. `pnpm db:seed:posts`), which bypasses the Payload `afterChange` hooks - * that normally call `revalidateTag('posts')`. Because every public post read - * goes through the `posts`-tagged `unstable_cache` wrappers in - * `src/utilities/posts.ts`, a single `revalidateTag('posts')` here busts the - * home page, every listing route, and every article route at once. + * (e.g. `pnpm db:seed:posts`, Onda 0 migrations), which bypasses the Payload + * `afterChange` hooks that normally call `revalidateTag`. Public reads use + * `unstable_cache` tags — bust them here after direct-DB writes. * - * Secured by the `REVALIDATE_SECRET` env var (set it in Vercel for prod). Call: + * Secured by `REVALIDATE_SECRET` (set in Vercel for prod). Examples: * curl -X POST "https:///api/revalidate" \ * -H "x-revalidate-secret: $REVALIDATE_SECRET" + * curl -X POST "https:///api/revalidate?tag=global_privacy-policy" \ + * -H "x-revalidate-secret: $REVALIDATE_SECRET" */ const SECRET_HEADER = 'x-revalidate-secret' @@ -29,6 +30,24 @@ const isValidSecret = (provided: string | null, expected: string): boolean => { return timingSafeEqual(providedBuffer, expectedBuffer) } +type ParseBodyTagResult = { tag?: string } | { invalid: true } + +const parseBodyTag = async (request: Request): Promise => { + const contentType = request.headers.get('content-type') + if (!contentType?.includes('application/json')) return {} + + try { + const body: unknown = await request.json() + if (body && typeof body === 'object' && 'tag' in body) { + if (typeof body.tag !== 'string') return { invalid: true } + return { tag: body.tag } + } + return {} + } catch { + return { invalid: true } + } +} + export async function POST(request: Request): Promise { const expected = process.env.REVALIDATE_SECRET @@ -48,7 +67,22 @@ export async function POST(request: Request): Promise { return NextResponse.json({ revalidated: false, error: 'Unauthorized' }, { status: 401 }) } - revalidatePostsListing() + const url = new URL(request.url) + const queryTag = url.searchParams.get('tag') + const bodyResult = + queryTag != null && queryTag !== '' ? {} : await parseBodyTag(request) - return NextResponse.json({ revalidated: true, tag: 'posts', now: Date.now() }) + if ('invalid' in bodyResult) { + return NextResponse.json({ revalidated: false, error: 'Invalid JSON body' }, { status: 400 }) + } + + const resolved = resolveRevalidateTag(queryTag, bodyResult.tag) + + if (!resolved.ok) { + return NextResponse.json({ revalidated: false, error: resolved.error }, { status: 400 }) + } + + revalidateTag(resolved.tag) + + return NextResponse.json({ revalidated: true, tag: resolved.tag, now: Date.now() }) } diff --git a/src/utilities/globals.ts b/src/utilities/globals.ts index d9237090..e097aca3 100644 --- a/src/utilities/globals.ts +++ b/src/utilities/globals.ts @@ -17,14 +17,14 @@ export async function getGlobal(slug: Slug, depth?: number) return global } -const getTag = (slug: string) => `global_${slug}` +export const getGlobalCacheTag = (slug: string) => `global_${slug}` type CachedGlobalFactory = () => Promise const cachedGlobalFactories = new Map() export const getCachedGlobal = (slug: Slug, depth?: number) => { - const tag = getTag(slug) + const tag = getGlobalCacheTag(slug) const cacheKey = `${tag}:${depth ?? 0}` const existing = cachedGlobalFactories.get(cacheKey) as CachedGlobalFactory | undefined if (existing) return existing @@ -36,4 +36,5 @@ export const getCachedGlobal = (slug: Slug, depth?: number) return factory } -export const revalidateGlobal = (slug: Slug) => revalidateTag(getTag(slug)) +export const revalidateGlobal = (slug: Slug) => + revalidateTag(getGlobalCacheTag(slug)) diff --git a/src/utilities/revalidateRequest.ts b/src/utilities/revalidateRequest.ts new file mode 100644 index 00000000..ebcff788 --- /dev/null +++ b/src/utilities/revalidateRequest.ts @@ -0,0 +1,38 @@ +import { getGlobalCacheTag } from '@/utilities/globals' + +export const REVALIDATE_POSTS_TAG = 'posts' as const + +export const REVALIDATE_PRIVACY_POLICY_CACHE_TAG = getGlobalCacheTag('privacy-policy') + +const ALLOWED_REVALIDATE_TAGS = [REVALIDATE_POSTS_TAG, REVALIDATE_PRIVACY_POLICY_CACHE_TAG] as const + +export type AllowedRevalidateTag = (typeof ALLOWED_REVALIDATE_TAGS)[number] + +const allowedTagSet = new Set(ALLOWED_REVALIDATE_TAGS) + +export type ResolveRevalidateTagResult = + | { ok: true; tag: AllowedRevalidateTag } + | { ok: false; error: string } + +const isAllowedRevalidateTag = (tag: string): tag is AllowedRevalidateTag => + allowedTagSet.has(tag) + +export const resolveRevalidateTag = ( + queryTag: string | null | undefined, + bodyTag: string | null | undefined, +): ResolveRevalidateTagResult => { + const requested = queryTag ?? bodyTag + + if (requested == null || requested === '') { + return { ok: true, tag: REVALIDATE_POSTS_TAG } + } + + if (!isAllowedRevalidateTag(requested)) { + return { + ok: false, + error: `Unknown tag. Allowed: ${ALLOWED_REVALIDATE_TAGS.join(', ')}`, + } + } + + return { ok: true, tag: requested } +} diff --git a/tests/unit/revalidateRequest.unit.spec.ts b/tests/unit/revalidateRequest.unit.spec.ts new file mode 100644 index 00000000..779887a9 --- /dev/null +++ b/tests/unit/revalidateRequest.unit.spec.ts @@ -0,0 +1,51 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { getGlobalCacheTag } from '@/utilities/globals' +import { + REVALIDATE_POSTS_TAG, + REVALIDATE_PRIVACY_POLICY_CACHE_TAG, + resolveRevalidateTag, +} from '@/utilities/revalidateRequest' + +describe('resolveRevalidateTag', () => { + it('defaults to posts when no tag is provided', () => { + expect(resolveRevalidateTag(null, null)).toEqual({ ok: true, tag: REVALIDATE_POSTS_TAG }) + expect(resolveRevalidateTag(undefined, undefined)).toEqual({ + ok: true, + tag: REVALIDATE_POSTS_TAG, + }) + expect(resolveRevalidateTag('', '')).toEqual({ ok: true, tag: REVALIDATE_POSTS_TAG }) + }) + + it('accepts allowlisted tags from query or body', () => { + expect(resolveRevalidateTag('posts', null)).toEqual({ ok: true, tag: REVALIDATE_POSTS_TAG }) + expect(resolveRevalidateTag(null, 'posts')).toEqual({ ok: true, tag: REVALIDATE_POSTS_TAG }) + expect(resolveRevalidateTag(null, REVALIDATE_PRIVACY_POLICY_CACHE_TAG)).toEqual({ + ok: true, + tag: REVALIDATE_PRIVACY_POLICY_CACHE_TAG, + }) + }) + + it('prefers query tag over body tag', () => { + expect(resolveRevalidateTag('posts', REVALIDATE_PRIVACY_POLICY_CACHE_TAG)).toEqual({ + ok: true, + tag: REVALIDATE_POSTS_TAG, + }) + }) + + it('rejects unknown tags', () => { + const result = resolveRevalidateTag('global_metadata', null) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toContain('Unknown tag') + expect(result.error).toContain(REVALIDATE_POSTS_TAG) + expect(result.error).toContain(REVALIDATE_PRIVACY_POLICY_CACHE_TAG) + } + }) + + it('maps privacy-policy global slug to the shared cache tag helper', () => { + expect(REVALIDATE_PRIVACY_POLICY_CACHE_TAG).toBe(getGlobalCacheTag('privacy-policy')) + }) +})