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.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-19 22:21:16 -03:00
parent 387cd46fd3
commit 0460b08429
10 changed files with 181 additions and 29 deletions

View file

@ -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.

View file

@ -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://<prod-domain>/api/revalidate" \
-H "x-revalidate-secret: $REVALIDATE_SECRET"
# Privacy policy global after Onda 0 migration/seed (`global_privacy-policy`)
curl -X POST "https://<prod-domain>/api/revalidate?tag=global_privacy-policy" \
-H "x-revalidate-secret: $REVALIDATE_SECRET"
```
- **Auth:** send the secret via `x-revalidate-secret` or `Authorization: Bearer <secret>`; 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`)

View file

@ -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://<prod-domain>/api/revalidate" -H "x-revalidate-secret: $REVALIDATE_SECRET"
# Privacy policy after Onda 0 provision (tag: global_privacy-policy)
curl -X POST "https://<prod-domain>/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.

View file

@ -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`.

View file

@ -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://<prod-domain>/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)

View file

@ -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)

View file

@ -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://<prod-domain>/api/revalidate" \
* -H "x-revalidate-secret: $REVALIDATE_SECRET"
* curl -X POST "https://<prod-domain>/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<ParseBodyTagResult> => {
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<NextResponse> {
const expected = process.env.REVALIDATE_SECRET
@ -48,7 +67,22 @@ export async function POST(request: Request): Promise<NextResponse> {
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() })
}

View file

@ -17,14 +17,14 @@ export async function getGlobal<Slug extends Global>(slug: Slug, depth?: number)
return global
}
const getTag = (slug: string) => `global_${slug}`
export const getGlobalCacheTag = (slug: string) => `global_${slug}`
type CachedGlobalFactory<Slug extends Global = Global> = () => Promise<Config['globals'][Slug]>
const cachedGlobalFactories = new Map<string, CachedGlobalFactory>()
export const getCachedGlobal = <Slug extends Global>(slug: Slug, depth?: number) => {
const tag = getTag(slug)
const tag = getGlobalCacheTag(slug)
const cacheKey = `${tag}:${depth ?? 0}`
const existing = cachedGlobalFactories.get(cacheKey) as CachedGlobalFactory<Slug> | undefined
if (existing) return existing
@ -36,4 +36,5 @@ export const getCachedGlobal = <Slug extends Global>(slug: Slug, depth?: number)
return factory
}
export const revalidateGlobal = <Slug extends Global>(slug: Slug) => revalidateTag(getTag(slug))
export const revalidateGlobal = <Slug extends Global>(slug: Slug) =>
revalidateTag(getGlobalCacheTag(slug))

View file

@ -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<string>(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 }
}

View file

@ -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'))
})
})