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.
This commit is contained in:
parent
bcb6c6c014
commit
f96863fad1
2 changed files with 60 additions and 0 deletions
|
|
@ -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".
|
||||
|
|
|
|||
54
src/app/(frontend)/api/revalidate/route.ts
Normal file
54
src/app/(frontend)/api/revalidate/route.ts
Normal file
|
|
@ -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://<prod-domain>/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<NextResponse> {
|
||||
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() })
|
||||
}
|
||||
Loading…
Reference in a new issue