mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
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:
parent
387cd46fd3
commit
0460b08429
10 changed files with 181 additions and 29 deletions
|
|
@ -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.
|
||||
|
|
|
|||
10
AGENTS.md
10
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://<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`)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
38
src/utilities/revalidateRequest.ts
Normal file
38
src/utilities/revalidateRequest.ts
Normal 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 }
|
||||
}
|
||||
51
tests/unit/revalidateRequest.unit.spec.ts
Normal file
51
tests/unit/revalidateRequest.unit.spec.ts
Normal 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'))
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue