From f96863fad17da2c1ae217966d9f2e2c4daeef4bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Wed, 15 Jul 2026 21:24:02 -0300 Subject: [PATCH] Add secured on-demand revalidation endpoint for posts cache Public post pages read through the `posts`-tagged unstable_cache wrappers, so their data is frozen at build time until revalidateTag('posts') runs in the deployed runtime. A direct-DB seed (pnpm db:seed:posts) writes past the Payload afterChange hooks (next/cache is stubbed), so nothing busts the tag in prod and seeded posts never surface publicly. Add POST /api/revalidate, guarded by a REVALIDATE_SECRET shared secret (constant-time compare), that calls revalidatePostsListing() to refresh the home page, all listings, and all article routes at once. Document the env var in .env.example; it must be set in Vercel for production. --- .env.example | 6 +++ src/app/(frontend)/api/revalidate/route.ts | 54 ++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 src/app/(frontend)/api/revalidate/route.ts diff --git a/.env.example b/.env.example index 6e780924..ee717268 100644 --- a/.env.example +++ b/.env.example @@ -10,5 +10,11 @@ PAYLOAD_SECRET=YOUR_SECRET_HERE BLOB_READ_WRITE_TOKEN=YOUR_BLOB_TOKEN_HERE 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`. +REVALIDATE_SECRET=YOUR_REVALIDATE_SECRET_HERE + # Note: the automated test suite ignores this file and reads .env.test instead, # and only ever runs against a database whose name ends with "_test". diff --git a/src/app/(frontend)/api/revalidate/route.ts b/src/app/(frontend)/api/revalidate/route.ts new file mode 100644 index 00000000..67626fd4 --- /dev/null +++ b/src/app/(frontend)/api/revalidate/route.ts @@ -0,0 +1,54 @@ +import { revalidatePostsListing } from '@/utilities/documents' +import { timingSafeEqual } from 'node:crypto' +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. + * + * Secured by the `REVALIDATE_SECRET` env var (set it in Vercel for prod). Call: + * curl -X POST "https:///api/revalidate" \ + * -H "x-revalidate-secret: $REVALIDATE_SECRET" + */ + +const SECRET_HEADER = 'x-revalidate-secret' + +const isValidSecret = (provided: string | null, expected: string): boolean => { + if (!provided) return false + + const providedBuffer = Buffer.from(provided) + const expectedBuffer = Buffer.from(expected) + + // `timingSafeEqual` requires equal-length buffers; unequal lengths never match. + if (providedBuffer.length !== expectedBuffer.length) return false + + return timingSafeEqual(providedBuffer, expectedBuffer) +} + +export async function POST(request: Request): Promise { + const expected = process.env.REVALIDATE_SECRET + + if (!expected) { + return NextResponse.json( + { revalidated: false, error: 'REVALIDATE_SECRET is not configured' }, + { status: 500 }, + ) + } + + const provided = + request.headers.get(SECRET_HEADER) ?? + request.headers.get('authorization')?.replace(/^Bearer\s+/i, '') ?? + null + + if (!isValidSecret(provided, expected)) { + return NextResponse.json({ revalidated: false, error: 'Unauthorized' }, { status: 401 }) + } + + revalidatePostsListing() + + return NextResponse.json({ revalidated: true, tag: 'posts', now: Date.now() }) +}