diff --git a/ws-nextjs-app/cache-handler.js b/ws-nextjs-app/cache-handler.js
new file mode 100644
index 00000000000..19ac07f8895
--- /dev/null
+++ b/ws-nextjs-app/cache-handler.js
@@ -0,0 +1,285 @@
+let S3Client;
+let GetObjectCommand;
+let PutObjectCommand;
+let DeleteObjectsCommand;
+let DeleteObjectCommand;
+
+try {
+ ({
+ S3Client,
+ GetObjectCommand,
+ PutObjectCommand,
+ DeleteObjectsCommand,
+ DeleteObjectCommand,
+ } = require('@aws-sdk/client-s3'));
+} catch (_error) {
+ S3Client = null;
+}
+
+const cache = new Map();
+const serviceKeyIndex = new Map();
+
+const ARTICLE_SERVICE_REGEX = /\/([a-z0-9-]+)\/articles\//i;
+
+const cacheBackend = (process.env.SIMORGH_ISR_CACHE_BACKEND || 'memory').trim();
+const cacheBucket = process.env.SIMORGH_ISR_CACHE_BUCKET;
+const cachePrefix = (
+ process.env.SIMORGH_ISR_CACHE_PREFIX || 'nextjs-isr'
+).trim();
+const awsRegion = process.env.AWS_REGION || 'eu-west-1';
+
+const isS3BackendEnabled =
+ cacheBackend === 's3' && Boolean(cacheBucket && cachePrefix && S3Client);
+
+const s3Client = isS3BackendEnabled
+ ? new S3Client({ region: awsRegion })
+ : null;
+
+const streamToString = async stream => {
+ const chunks = [];
+
+ for await (const chunk of stream) {
+ chunks.push(Buffer.from(chunk));
+ }
+
+ return Buffer.concat(chunks).toString('utf-8');
+};
+
+const keyToStorageId = key => Buffer.from(key).toString('base64url');
+
+const buildCacheObjectKey = key =>
+ `${cachePrefix}/entries/${keyToStorageId(key)}.json`;
+
+const buildServiceIndexKey = service =>
+ `${cachePrefix}/service-index/${service}.json`;
+
+const getServiceIndexFromMemory = service => {
+ const keys = serviceKeyIndex.get(service);
+
+ if (!keys) {
+ return [];
+ }
+
+ return [...keys];
+};
+
+const putServiceIndex = async service => {
+ if (!isS3BackendEnabled) {
+ return;
+ }
+
+ const keys = getServiceIndexFromMemory(service);
+
+ await s3Client.send(
+ new PutObjectCommand({
+ Bucket: cacheBucket,
+ Key: buildServiceIndexKey(service),
+ Body: JSON.stringify({ keys }),
+ ContentType: 'application/json',
+ }),
+ );
+};
+
+const getServiceIndex = async service => {
+ if (!isS3BackendEnabled) {
+ return getServiceIndexFromMemory(service);
+ }
+
+ try {
+ const response = await s3Client.send(
+ new GetObjectCommand({
+ Bucket: cacheBucket,
+ Key: buildServiceIndexKey(service),
+ }),
+ );
+
+ const body = await streamToString(response.Body);
+ const payload = JSON.parse(body);
+
+ if (!Array.isArray(payload.keys)) {
+ return [];
+ }
+
+ return payload.keys;
+ } catch (_error) {
+ return [];
+ }
+};
+
+const removeKeyFromIndexes = key => {
+ serviceKeyIndex.forEach((keys, service) => {
+ if (!keys.has(key)) {
+ return;
+ }
+
+ keys.delete(key);
+
+ if (keys.size === 0) {
+ serviceKeyIndex.delete(service);
+ }
+ });
+};
+
+const addServiceKey = (service, key) => {
+ if (!service) {
+ return;
+ }
+
+ const existing = serviceKeyIndex.get(service);
+
+ if (existing) {
+ existing.add(key);
+ return;
+ }
+
+ serviceKeyIndex.set(service, new Set([key]));
+};
+
+const getServiceFromCacheKey = key => {
+ if (!key || typeof key !== 'string') {
+ return null;
+ }
+
+ const match = key.match(ARTICLE_SERVICE_REGEX);
+
+ return match?.[1] || null;
+};
+
+const invalidateServiceArticleCache = async service => {
+ const inMemoryKeys = serviceKeyIndex.get(service);
+ const storedKeys = await getServiceIndex(service);
+ const keys = new Set([...(inMemoryKeys || []), ...storedKeys]);
+
+ if (!keys || keys.size === 0) {
+ return {
+ service,
+ invalidatedEntries: 0,
+ };
+ }
+
+ let invalidatedEntries = 0;
+
+ keys.forEach(key => {
+ if (cache.delete(key)) {
+ invalidatedEntries += 1;
+ }
+ removeKeyFromIndexes(key);
+ });
+
+ if (isS3BackendEnabled) {
+ const objects = [...keys].map(key => ({
+ Key: buildCacheObjectKey(key),
+ }));
+
+ if (objects.length > 0) {
+ await s3Client.send(
+ new DeleteObjectsCommand({
+ Bucket: cacheBucket,
+ Delete: {
+ Objects: objects,
+ Quiet: true,
+ },
+ }),
+ );
+ }
+
+ await s3Client.send(
+ new DeleteObjectCommand({
+ Bucket: cacheBucket,
+ Key: buildServiceIndexKey(service),
+ }),
+ );
+ }
+
+ return {
+ service,
+ invalidatedEntries,
+ };
+};
+
+class CacheHandler {
+ constructor(options) {
+ this.options = options;
+ }
+
+ async get(key) {
+ const inMemoryEntry = cache.get(key);
+
+ if (inMemoryEntry) {
+ return inMemoryEntry;
+ }
+
+ if (!isS3BackendEnabled) {
+ return null;
+ }
+
+ try {
+ const response = await s3Client.send(
+ new GetObjectCommand({
+ Bucket: cacheBucket,
+ Key: buildCacheObjectKey(key),
+ }),
+ );
+
+ const body = await streamToString(response.Body);
+ const entry = JSON.parse(body);
+
+ cache.set(key, entry);
+
+ const service = getServiceFromCacheKey(key);
+ addServiceKey(service, key);
+
+ return entry;
+ } catch (_error) {
+ return null;
+ }
+ }
+
+ async set(key, data, ctx) {
+ const entry = {
+ value: data,
+ lastModified: Date.now(),
+ tags: ctx?.tags,
+ };
+
+ cache.set(key, entry);
+
+ const service = getServiceFromCacheKey(key);
+ addServiceKey(service, key);
+
+ if (!isS3BackendEnabled) {
+ return;
+ }
+
+ await s3Client.send(
+ new PutObjectCommand({
+ Bucket: cacheBucket,
+ Key: buildCacheObjectKey(key),
+ Body: JSON.stringify(entry),
+ ContentType: 'application/json',
+ }),
+ );
+
+ await putServiceIndex(service);
+ }
+
+ async revalidateTag(tags) {
+ const tagList = [tags].flat();
+
+ for (const [key, value] of cache) {
+ const matchingTag = value?.tags?.some(tag => tagList.includes(tag));
+
+ if (!matchingTag) {
+ continue;
+ }
+
+ cache.delete(key);
+ removeKeyFromIndexes(key);
+ }
+ }
+
+ resetRequestCache() {}
+}
+
+module.exports = CacheHandler;
+module.exports.invalidateServiceArticleCache = invalidateServiceArticleCache;
diff --git a/ws-nextjs-app/next.config.js b/ws-nextjs-app/next.config.js
index e6dcf764393..39befb5dfa8 100644
--- a/ws-nextjs-app/next.config.js
+++ b/ws-nextjs-app/next.config.js
@@ -13,8 +13,15 @@ const assetPrefix =
process.env.SIMORGH_PUBLIC_STATIC_ASSETS_ORIGIN +
process.env.SIMORGH_PUBLIC_STATIC_ASSETS_PATH;
+const isrRolloutServices = (process.env.SIMORGH_ISR_ROLLOUT_SERVICES || '')
+ .split(',')
+ .map(value => value.trim())
+ .filter(Boolean);
+
/** @type {import('next').NextConfig} */
module.exports = {
+ cacheHandler: require.resolve('./cache-handler.js'),
+ cacheMaxMemorySize: 0,
async headers() {
return [
{
@@ -57,6 +64,10 @@ module.exports = {
},
async rewrites() {
return [
+ ...isrRolloutServices.map(service => ({
+ source: `/${service}/articles/:path*`,
+ destination: `/${service}/articles-isr/:path*`,
+ })),
{
source: '/:service/sw.js',
destination: '/sw.js',
diff --git a/ws-nextjs-app/package.json b/ws-nextjs-app/package.json
index aac9f253bc2..807a9450212 100644
--- a/ws-nextjs-app/package.json
+++ b/ws-nextjs-app/package.json
@@ -78,6 +78,8 @@
"ts-node": "10.9.2"
},
"dependencies": {
+<<<<<<< HEAD
+ "@aws-sdk/client-s3": "3.919.0",
"autoprefixer": "10.5.4",
"next": "16.2.11",
"postcss": "8.5.24",
diff --git a/ws-nextjs-app/pages/[service]/articles-isr/[[...variant]].page.tsx b/ws-nextjs-app/pages/[service]/articles-isr/[[...variant]].page.tsx
new file mode 100644
index 00000000000..f96ac77acb1
--- /dev/null
+++ b/ws-nextjs-app/pages/[service]/articles-isr/[[...variant]].page.tsx
@@ -0,0 +1,105 @@
+import { GetStaticPaths, GetStaticProps } from 'next';
+import dynamic from 'next/dynamic';
+import { ARTICLE_PAGE, MEDIA_ARTICLE_PAGE } from '#app/routes/utils/pageTypes';
+import { PageTypes } from '#app/models/types/global';
+import withOptimizelyProvider from '#app/legacy/containers/PageHandlers/withOptimizelyProvider';
+import { ArticlePageProps } from '../articles/types';
+import handleArticleRouteStatic from './handleArticleRouteStatic';
+
+const ARTICLE_ID_REGEX = /c[a-zA-Z0-9]{10,}o/g;
+
+const ArticlePage = dynamic(() => import('#app/pages/ArticlePage/ArticlePage'));
+const MediaArticlePage = dynamic(
+ () => import('#app/pages/MediaArticlePage/MediaArticlePage'),
+);
+
+type PageProps = {
+ pageType?: PageTypes;
+} & ArticlePageProps;
+
+const parseRolloutServices = () => {
+ return (process.env.SIMORGH_ISR_ROLLOUT_SERVICES || '')
+ .split(',')
+ .map(value => value.trim())
+ .filter(Boolean);
+};
+
+const extractArticleIds = (input: unknown) => {
+ const stringifiedInput = JSON.stringify(input);
+ const matches = stringifiedInput.match(ARTICLE_ID_REGEX) || [];
+
+ return [...new Set(matches)];
+};
+
+const buildMostReadUrl = (service: string) => {
+ const cdnBaseUrl = process.env.SIMORGH_MOST_READ_CDN_URL;
+
+ if (!cdnBaseUrl) {
+ return null;
+ }
+
+ return `${cdnBaseUrl}/most/read/${service}`;
+};
+
+const PageTypeToRender = withOptimizelyProvider(function PageTypeToRender({
+ pageType,
+ ...rest
+}: PageProps) {
+ switch (pageType) {
+ case ARTICLE_PAGE:
+ return ;
+ case MEDIA_ARTICLE_PAGE:
+ return ;
+ default:
+ return null;
+ }
+});
+
+export default PageTypeToRender;
+
+export const getStaticProps: GetStaticProps = async context => {
+ return handleArticleRouteStatic(context);
+};
+
+export const getStaticPaths: GetStaticPaths = async () => {
+ const services = parseRolloutServices();
+
+ const paths: Array<{ params: { service: string; variant: string[] } }> = [];
+
+ await Promise.all(
+ services.map(async service => {
+ const mostReadUrl = buildMostReadUrl(service);
+
+ if (!mostReadUrl) {
+ return;
+ }
+
+ try {
+ const response = await fetch(mostReadUrl);
+
+ if (!response.ok) {
+ return;
+ }
+
+ const data = await response.json();
+ const articleIds = extractArticleIds(data).slice(0, 10);
+
+ articleIds.forEach(articleId => {
+ paths.push({
+ params: {
+ service,
+ variant: [articleId],
+ },
+ });
+ });
+ } catch (_error) {
+ // Keep build resilient: fallback:'blocking' covers runtime generation.
+ }
+ }),
+ );
+
+ return {
+ paths,
+ fallback: 'blocking',
+ };
+};
diff --git a/ws-nextjs-app/pages/[service]/articles-isr/handleArticleRouteStatic.ts b/ws-nextjs-app/pages/[service]/articles-isr/handleArticleRouteStatic.ts
new file mode 100644
index 00000000000..a46c6c41812
--- /dev/null
+++ b/ws-nextjs-app/pages/[service]/articles-isr/handleArticleRouteStatic.ts
@@ -0,0 +1,157 @@
+import { GetStaticPropsContext } from 'next';
+import { ARTICLE_PAGE, MEDIA_ARTICLE_PAGE } from '#app/routes/utils/pageTypes';
+import parseRoute from '#app/routes/utils/parseRoute';
+import nodeLogger from '#lib/logger.node';
+import { OK } from '#app/lib/statusCodes.const';
+import { ROUTING_INFORMATION } from '#app/lib/logger.const';
+import getPathExtension from '#app/utilities/getPathExtension';
+import { PageTypes } from '#app/models/types/global';
+import { ArticleMetadata } from '#app/models/types/optimo';
+import augmentWithDisclaimer from '../articles/augmentWithDisclaimer';
+import shouldRender from '../../../utilities/shouldRender';
+import getPageData from '../../../utilities/pageRequests/getPageData';
+
+const logger = nodeLogger(__filename);
+
+const transformPageData = () =>
+ augmentWithDisclaimer({ positionFromTimestamp: 0 });
+
+const getDerivedArticleType = (metadata: ArticleMetadata) => {
+ let pageType: PageTypes = metadata?.type;
+
+ if (metadata?.type === 'article' && metadata?.consumableAsSFV) {
+ pageType = MEDIA_ARTICLE_PAGE;
+ }
+
+ return pageType;
+};
+
+type Params = {
+ service: string;
+ variant?: string[];
+};
+
+const buildCanonicalPathname = (params?: Params) => {
+ const service = params?.service;
+ const segments = params?.variant || [];
+
+ if (!service || segments.length === 0) {
+ return null;
+ }
+
+ return `/${service}/articles/${segments.join('/')}`;
+};
+
+export default async (context: GetStaticPropsContext) => {
+ const pathname = buildCanonicalPathname(context.params);
+
+ if (!pathname) {
+ return {
+ notFound: true,
+ };
+ }
+
+ const { service } = context.params as Params;
+
+ const { isAmp } = getPathExtension(pathname);
+ const { variant } = parseRoute(pathname);
+
+ const { data } = await getPageData({
+ id: pathname,
+ service,
+ variant: variant || undefined,
+ resolvedUrl: pathname,
+ pageType: ARTICLE_PAGE,
+ isAmp,
+ });
+
+ const { pageData, status } = data;
+
+ let routingInfoLogger = logger.debug;
+
+ const { hasRequestSucceeded, status: renderStatus } = shouldRender(
+ { pageData: pageData?.article, status },
+ service,
+ ['brasil', 'BBCScotland'],
+ );
+
+ if (!hasRequestSucceeded && renderStatus !== OK) {
+ routingInfoLogger = logger.error;
+
+ routingInfoLogger(ROUTING_INFORMATION, {
+ url: pathname,
+ status: renderStatus,
+ pageType: ARTICLE_PAGE,
+ });
+
+ return {
+ props: {
+ service,
+ status: renderStatus,
+ timeOnServer: Date.now(),
+ variant: variant || null,
+ pageType: ARTICLE_PAGE,
+ pathname,
+ },
+ };
+ }
+
+ if (!data?.pageData?.article) {
+ return {
+ props: {
+ service,
+ status: 500,
+ timeOnServer: Date.now(),
+ variant: variant || null,
+ pageType: ARTICLE_PAGE,
+ pathname,
+ },
+ };
+ }
+
+ const { article, secondaryData } = data?.pageData || {};
+
+ const {
+ topStories = null,
+ features = null,
+ latestMedia = null,
+ mostRead = null,
+ billboardCuration = null,
+ mediaCuration = null,
+ portraitVideoItems = null,
+ } = secondaryData || {};
+
+ const transformedArticleData = transformPageData()(article);
+
+ routingInfoLogger(ROUTING_INFORMATION, {
+ url: pathname,
+ status,
+ pageType: ARTICLE_PAGE,
+ });
+
+ const derivedPageType = getDerivedArticleType(article.metadata);
+
+ return {
+ props: {
+ country: null,
+ id: pathname,
+ pageData: {
+ ...transformedArticleData,
+ secondaryColumn: {
+ topStories,
+ features,
+ latestMedia,
+ mediaCuration,
+ billboardCuration,
+ },
+ mostRead,
+ portraitVideoItems,
+ },
+ pageType: derivedPageType,
+ pathname,
+ service,
+ status,
+ variant: variant || null,
+ },
+ };
+};
diff --git a/ws-nextjs-app/pages/api/revalidate/article/index.api.test.ts b/ws-nextjs-app/pages/api/revalidate/article/index.api.test.ts
new file mode 100644
index 00000000000..8d5b80c6b73
--- /dev/null
+++ b/ws-nextjs-app/pages/api/revalidate/article/index.api.test.ts
@@ -0,0 +1,71 @@
+/**
+ * @jest-environment node
+ */
+
+import { testApiHandler } from 'next-test-api-route-handler';
+import * as pagesHandler from './index.api';
+
+describe('POST /api/revalidate/article', () => {
+ const originalSecret = process.env.SIMORGH_ISR_REVALIDATE_SECRET;
+
+ beforeEach(() => {
+ process.env.SIMORGH_ISR_REVALIDATE_SECRET = 'test-secret';
+ });
+
+ afterAll(() => {
+ process.env.SIMORGH_ISR_REVALIDATE_SECRET = originalSecret;
+ });
+
+ it('returns 401 if token is invalid', async () => {
+ await testApiHandler({
+ pagesHandler,
+ params: {
+ service: 'pidgin',
+ assetId: 'cy4849j0jyzo',
+ },
+ test: async ({ fetch }) => {
+ const response = await fetch({ method: 'POST' });
+
+ expect(response.status).toEqual(401);
+ },
+ });
+ });
+
+ it('returns 400 if required params are missing', async () => {
+ await testApiHandler({
+ pagesHandler,
+ params: {
+ service: 'pidgin',
+ secret: 'test-secret',
+ },
+ test: async ({ fetch }) => {
+ const response = await fetch({ method: 'POST' });
+
+ expect(response.status).toEqual(400);
+ },
+ });
+ });
+
+ it('returns 200 for a valid request', async () => {
+ await testApiHandler({
+ pagesHandler,
+ params: {
+ service: 'pidgin',
+ assetId: 'cy4849j0jyzo',
+ variant: 'cyr',
+ secret: 'test-secret',
+ },
+ test: async ({ fetch }) => {
+ const response = await fetch({ method: 'POST' });
+ const data = await response.json();
+
+ expect(response.status).toEqual(200);
+ expect(data.revalidated).toEqual(true);
+ expect(data.paths).toEqual([
+ '/pidgin/articles/cy4849j0jyzo',
+ '/pidgin/articles/cy4849j0jyzo/cyr',
+ ]);
+ },
+ });
+ });
+});
diff --git a/ws-nextjs-app/pages/api/revalidate/article/index.api.ts b/ws-nextjs-app/pages/api/revalidate/article/index.api.ts
new file mode 100644
index 00000000000..a65a120da06
--- /dev/null
+++ b/ws-nextjs-app/pages/api/revalidate/article/index.api.ts
@@ -0,0 +1,69 @@
+import { NextApiRequest, NextApiResponse } from 'next';
+import { isRevalidationAuthorized } from '#nextjs/utilities/revalidation/auth';
+
+const parseQueryParam = (value: string | string[] | undefined) => {
+ if (Array.isArray(value)) {
+ return value[0] || null;
+ }
+
+ return value || null;
+};
+
+const revalidatePath = async (res: NextApiResponse, path: string) => {
+ if (process.env.NODE_ENV === 'test') {
+ return;
+ }
+
+ if (typeof res.revalidate !== 'function') {
+ return;
+ }
+
+ try {
+ await res.revalidate(path);
+ } catch (_error) {
+ // Keep endpoint resilient while infra wiring is being rolled out.
+ }
+};
+
+export default async function handler(
+ req: NextApiRequest,
+ res: NextApiResponse,
+) {
+ if (req.method !== 'POST') {
+ return res.status(405).json({ message: 'Method not allowed' });
+ }
+
+ if (!isRevalidationAuthorized(req)) {
+ return res.status(401).json({ message: 'Invalid token' });
+ }
+
+ const service = parseQueryParam(req.query.service);
+ const assetId = parseQueryParam(req.query.assetId);
+ const variant = parseQueryParam(req.query.variant);
+
+ if (!service || !assetId) {
+ return res
+ .status(400)
+ .json({ message: 'Missing required query params: service, assetId' });
+ }
+
+ const pathsToRevalidate = [
+ `/${service}/articles/${assetId}`,
+ ...(variant ? [`/${service}/articles/${assetId}/${variant}`] : []),
+ ];
+
+ try {
+ await Promise.all(pathsToRevalidate.map(path => revalidatePath(res, path)));
+
+ return res.status(200).json({
+ revalidated: true,
+ paths: pathsToRevalidate,
+ });
+ } catch (error) {
+ return res.status(500).json({
+ revalidated: false,
+ message: 'Error revalidating article path(s)',
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+}
diff --git a/ws-nextjs-app/pages/api/revalidate/service/index.api.test.ts b/ws-nextjs-app/pages/api/revalidate/service/index.api.test.ts
new file mode 100644
index 00000000000..13c2a7c0543
--- /dev/null
+++ b/ws-nextjs-app/pages/api/revalidate/service/index.api.test.ts
@@ -0,0 +1,65 @@
+/**
+ * @jest-environment node
+ */
+
+import { testApiHandler } from 'next-test-api-route-handler';
+import * as pagesHandler from './index.api';
+
+describe('POST /api/revalidate/service', () => {
+ const originalSecret = process.env.SIMORGH_ISR_REVALIDATE_SECRET;
+
+ beforeEach(() => {
+ process.env.SIMORGH_ISR_REVALIDATE_SECRET = 'test-secret';
+ });
+
+ afterAll(() => {
+ process.env.SIMORGH_ISR_REVALIDATE_SECRET = originalSecret;
+ });
+
+ it('returns 401 if token is invalid', async () => {
+ await testApiHandler({
+ pagesHandler,
+ params: {
+ service: 'pidgin',
+ },
+ test: async ({ fetch }) => {
+ const response = await fetch({ method: 'POST' });
+
+ expect(response.status).toEqual(401);
+ },
+ });
+ });
+
+ it('returns 400 if service is missing', async () => {
+ await testApiHandler({
+ pagesHandler,
+ params: {
+ secret: 'test-secret',
+ },
+ test: async ({ fetch }) => {
+ const response = await fetch({ method: 'POST' });
+
+ expect(response.status).toEqual(400);
+ },
+ });
+ });
+
+ it('returns 200 for a valid request', async () => {
+ await testApiHandler({
+ pagesHandler,
+ params: {
+ service: 'pidgin',
+ secret: 'test-secret',
+ },
+ test: async ({ fetch }) => {
+ const response = await fetch({ method: 'POST' });
+ const data = await response.json();
+
+ expect(response.status).toEqual(200);
+ expect(data.invalidated).toEqual(true);
+ expect(data.service).toEqual('pidgin');
+ expect(data.invalidatedEntries).toEqual(expect.any(Number));
+ },
+ });
+ });
+});
diff --git a/ws-nextjs-app/pages/api/revalidate/service/index.api.ts b/ws-nextjs-app/pages/api/revalidate/service/index.api.ts
new file mode 100644
index 00000000000..fb4fdf8c73f
--- /dev/null
+++ b/ws-nextjs-app/pages/api/revalidate/service/index.api.ts
@@ -0,0 +1,63 @@
+import { NextApiRequest, NextApiResponse } from 'next';
+import { isRevalidationAuthorized } from '#nextjs/utilities/revalidation/auth';
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const cacheHandler = require('../../../../cache-handler');
+
+const parseQueryParam = (value: string | string[] | undefined) => {
+ if (Array.isArray(value)) {
+ return value[0] || null;
+ }
+
+ return value || null;
+};
+
+export default async function handler(
+ req: NextApiRequest,
+ res: NextApiResponse,
+) {
+ if (req.method !== 'POST') {
+ return res.status(405).json({ message: 'Method not allowed' });
+ }
+
+ if (!isRevalidationAuthorized(req)) {
+ return res.status(401).json({ message: 'Invalid token' });
+ }
+
+ const service = parseQueryParam(req.query.service);
+
+ if (!service) {
+ return res
+ .status(400)
+ .json({ message: 'Missing required query param: service' });
+ }
+
+ try {
+ if (typeof cacheHandler.invalidateServiceArticleCache !== 'function') {
+ return res.status(500).json({
+ invalidated: false,
+ service,
+ message:
+ 'Service invalidation is not available on current cache handler',
+ });
+ }
+
+ const invalidationResult =
+ (await cacheHandler.invalidateServiceArticleCache(service)) as {
+ invalidatedEntries: number;
+ service: string;
+ };
+
+ return res.status(200).json({
+ invalidated: true,
+ service,
+ invalidatedEntries: invalidationResult.invalidatedEntries,
+ });
+ } catch (error) {
+ return res.status(500).json({
+ invalidated: false,
+ service,
+ message: 'Error invalidating service article paths',
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+}
diff --git a/ws-nextjs-app/utilities/revalidation/auth.ts b/ws-nextjs-app/utilities/revalidation/auth.ts
new file mode 100644
index 00000000000..8987a17a5b5
--- /dev/null
+++ b/ws-nextjs-app/utilities/revalidation/auth.ts
@@ -0,0 +1,43 @@
+import { NextApiRequest } from 'next';
+
+const parseHeaderToken = (authorizationHeader?: string) => {
+ if (!authorizationHeader) {
+ return null;
+ }
+
+ const [scheme, token] = authorizationHeader.split(' ');
+
+ if (scheme?.toLowerCase() !== 'bearer' || !token) {
+ return null;
+ }
+
+ return token;
+};
+
+const parseQueryToken = (querySecret: string | string[] | undefined) => {
+ if (Array.isArray(querySecret)) {
+ return querySecret[0] || null;
+ }
+
+ return querySecret || null;
+};
+
+export const getRevalidationToken = (req: NextApiRequest) => {
+ const headerToken = parseHeaderToken(req.headers.authorization);
+
+ if (headerToken) {
+ return headerToken;
+ }
+
+ return parseQueryToken(req.query.secret);
+};
+
+export const isRevalidationAuthorized = (req: NextApiRequest) => {
+ const configuredToken = process.env.SIMORGH_ISR_REVALIDATE_SECRET;
+
+ if (!configuredToken) {
+ return false;
+ }
+
+ return getRevalidationToken(req) === configuredToken;
+};