From d7ffdf6fca6feb5413a2e1a24d7539da452ea665 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:03:14 +0100 Subject: [PATCH 1/4] docs: open cow-fi CMS boundary plan for medium findings From 2223170d62c558c4260dc1c57688747981d6295f Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:19:22 +0100 Subject: [PATCH 2/4] fix: harden cow-fi CMS query boundaries --- .../app/(learn)/learn/[article]/page.tsx | 12 ++- .../(learn)/learn/topic/[topicSlug]/page.tsx | 13 ++- apps/cow-fi/app/actions.ts | 16 +-- apps/cow-fi/app/api/revalidate/route.ts | 37 ++++--- apps/cow-fi/services/cms/index.ts | 64 +++++------ apps/cow-fi/util/cmsValidation.test.ts | 99 +++++++++++++++++ apps/cow-fi/util/cmsValidation.ts | 102 ++++++++++++++++++ apps/cow-fi/util/queryParams.test.ts | 44 ++++++++ apps/cow-fi/util/queryParams.ts | 6 +- 9 files changed, 337 insertions(+), 56 deletions(-) create mode 100644 apps/cow-fi/util/cmsValidation.test.ts create mode 100644 apps/cow-fi/util/cmsValidation.ts create mode 100644 apps/cow-fi/util/queryParams.test.ts diff --git a/apps/cow-fi/app/(learn)/learn/[article]/page.tsx b/apps/cow-fi/app/(learn)/learn/[article]/page.tsx index d2e844cbdfb..84c2326622f 100644 --- a/apps/cow-fi/app/(learn)/learn/[article]/page.tsx +++ b/apps/cow-fi/app/(learn)/learn/[article]/page.tsx @@ -15,6 +15,7 @@ import type { Metadata } from 'next' import { ArticlePageComponent } from '@/components/ArticlePageComponent' import { FEATURED_ARTICLES_PAGE_SIZE } from '@/const/pagination' +import { isValidCmsSlug } from '@/util/cmsValidation' import { fetchArticleWithRetry } from '@/util/fetchHelpers' import { getPageMetadata } from '@/util/getPageMetadata' import { stripHtmlTags } from '@/util/stripHTMLTags' @@ -44,7 +45,12 @@ type Props = { export async function generateMetadata({ params }: Props): Promise { const articleSlug = (await params).article - if (!articleSlug) return {} + if (!articleSlug || !isValidCmsSlug(articleSlug)) { + return getPageMetadata({ + title: 'Article Not Found', + description: 'The requested article could not be found.', + }) + } try { const article = await getArticleBySlug(articleSlug) @@ -94,6 +100,10 @@ export async function generateStaticParams(): Promise<{ article: string }[]> { export default async function ArticlePage({ params }: Props): Promise { const articleSlug = (await params).article + if (!isValidCmsSlug(articleSlug)) { + return notFound() + } + try { const article = await fetchArticleWithRetry(articleSlug) diff --git a/apps/cow-fi/app/(learn)/learn/topic/[topicSlug]/page.tsx b/apps/cow-fi/app/(learn)/learn/topic/[topicSlug]/page.tsx index 0bee619770e..e8feb205ffa 100644 --- a/apps/cow-fi/app/(learn)/learn/topic/[topicSlug]/page.tsx +++ b/apps/cow-fi/app/(learn)/learn/topic/[topicSlug]/page.tsx @@ -13,6 +13,7 @@ import { import type { Metadata } from 'next' import { TopicPageComponent } from '@/components/TopicPageComponent' +import { isValidCmsSlug } from '@/util/cmsValidation' import { getPageMetadata } from '@/util/getPageMetadata' type Props = { @@ -29,7 +30,12 @@ export async function generateMetadata({ params }: Props): Promise { const { topicSlug } = await params - if (!topicSlug) return {} + if (!topicSlug || !isValidCmsSlug(topicSlug)) { + return getPageMetadata({ + absoluteTitle: 'Topic Not Found - Knowledge base', + description: 'The requested topic could not be found.', + }) + } const category = await getCategoryBySlug(topicSlug) const { name, description = '' } = category?.attributes || {} @@ -50,6 +56,11 @@ export async function generateStaticParams(): Promise<{ topicSlug: string }[]> { export default async function TopicPage({ params }: { params: Promise<{ topicSlug: string }> }): Promise { const { topicSlug } = await params + + if (!isValidCmsSlug(topicSlug)) { + notFound() + } + const category = await getCategoryBySlug(topicSlug) if (!category) { diff --git a/apps/cow-fi/app/actions.ts b/apps/cow-fi/app/actions.ts index 74befdc4a18..e481a3f88a3 100644 --- a/apps/cow-fi/app/actions.ts +++ b/apps/cow-fi/app/actions.ts @@ -1,21 +1,23 @@ 'use server' import { searchArticles as searchArticlesService } from '../services/cms' +import { normalizeSearchArticlesInput } from '../util/cmsValidation' /** * Server action to search for articles */ -export async function searchArticlesAction({ - searchTerm, - page = 0, - pageSize = 10, -}: { +type SearchArticlesActionResult = + | { success: true; data: Awaited> } + | { success: false; error: string } + +export async function searchArticlesAction(input: { searchTerm: string page?: number pageSize?: number -}) { +}): Promise { try { - const results = await searchArticlesService({ searchTerm, page, pageSize }) + const normalizedInput = normalizeSearchArticlesInput(input) + const results = await searchArticlesService(normalizedInput) return { success: true, data: results } } catch (error) { diff --git a/apps/cow-fi/app/api/revalidate/route.ts b/apps/cow-fi/app/api/revalidate/route.ts index 4de6a294923..7796ab75987 100644 --- a/apps/cow-fi/app/api/revalidate/route.ts +++ b/apps/cow-fi/app/api/revalidate/route.ts @@ -1,13 +1,27 @@ import { revalidateTag, revalidatePath } from 'next/cache' import { NextRequest, NextResponse } from 'next/server' +import { normalizeRevalidateRequest } from '../../../util/cmsValidation' + // Secret key for protecting the revalidation endpoint const REVALIDATE_SECRET = process.env.REVALIDATE_SECRET -export async function GET(request: NextRequest): Promise { - const secret = request.nextUrl.searchParams.get('secret') - const tag = request.nextUrl.searchParams.get('tag') || 'cms-content' - const path = request.nextUrl.searchParams.get('path') +function getSecretFromHeaders(request: NextRequest): string | null { + const authorization = request.headers.get('authorization') + + if (authorization?.startsWith('Bearer ')) { + return authorization.slice('Bearer '.length) + } + + return request.headers.get('x-revalidate-secret') +} + +export async function GET(): Promise { + return NextResponse.json({ message: 'Use POST for revalidation requests' }, { status: 405 }) +} + +export async function POST(request: NextRequest): Promise { + const secret = getSecretFromHeaders(request) // Validate that the secret is configured if (!REVALIDATE_SECRET) { @@ -21,6 +35,9 @@ export async function GET(request: NextRequest): Promise { } try { + const requestBody: unknown = await request.json() + const { path, tag } = normalizeRevalidateRequest(requestBody) + // Revalidate the tag for data freshness revalidateTag(tag) @@ -35,13 +52,7 @@ export async function GET(request: NextRequest): Promise { revalidatePath('/learn/[article]') // If a specific path was provided, revalidate it to update the route manifest - if (path) { - // Ensure the incoming path starts with a slash - const formattedPath = path.startsWith('/') ? path : `/${path}` - - // Revalidate the specific article page (e.g. /learn/my-new-article) - revalidatePath(formattedPath) - } + if (path) revalidatePath(path) return NextResponse.json({ revalidated: true, @@ -49,7 +60,7 @@ export async function GET(request: NextRequest): Promise { date: new Date().toISOString(), }) } catch (err) { - // If there was an error, return 500 - return NextResponse.json({ message: 'Error revalidating', error: (err as Error).message }, { status: 500 }) + const errorMessage = err instanceof Error ? err.message : 'Unknown revalidation error' + return NextResponse.json({ message: 'Error revalidating', error: errorMessage }, { status: 400 }) } } diff --git a/apps/cow-fi/services/cms/index.ts b/apps/cow-fi/services/cms/index.ts index c23ad75f115..51652bc4c1f 100644 --- a/apps/cow-fi/services/cms/index.ts +++ b/apps/cow-fi/services/cms/index.ts @@ -1,9 +1,9 @@ import { components } from '@cowprotocol/cms' import { getCmsClient } from '@cowprotocol/core' -import qs from 'qs' import { PaginationParam } from 'types' +import { isValidCmsSlug, normalizeSearchArticlesInput } from 'util/cmsValidation' import { toQueryParams } from 'util/queryParams' import { DEFAULT_PAGE_SIZE, clientAddons } from './config' @@ -183,38 +183,44 @@ export async function searchArticles({ page?: number pageSize?: number }): Promise { - const trimmedSearchTerm = searchTerm.trim() + const { + searchTerm: trimmedSearchTerm, + page: normalizedPage, + pageSize: normalizedPageSize, + } = normalizeSearchArticlesInput({ searchTerm, page, pageSize }) if (!trimmedSearchTerm) { - return { data: [], meta: { pagination: { page, pageSize, pageCount: 0, total: 0 } } } + return { + data: [], + meta: { pagination: { page: normalizedPage, pageSize: normalizedPageSize, pageCount: 0, total: 0 } }, + } } try { - // Build query parameters with explicit array indices const queryParams = { - 'filters[$or][0][title][$startsWithi]': trimmedSearchTerm, - 'filters[$or][1][title][$containsi]': trimmedSearchTerm, - 'filters[$or][2][description][$containsi]': trimmedSearchTerm, - 'pagination[page]': page, - 'pagination[pageSize]': pageSize, - 'sort[0]': 'title:asc', - 'populate[0]': 'cover', - 'populate[1]': 'blocks', - 'populate[2]': 'seo', - 'populate[3]': 'authorsBio', + filters: { + $or: [ + { title: { $startsWithi: trimmedSearchTerm } }, + { title: { $containsi: trimmedSearchTerm } }, + { description: { $containsi: trimmedSearchTerm } }, + ], + }, + pagination: { + page: normalizedPage, + pageSize: normalizedPageSize, + }, + sort: ['title:asc'], + populate: ['cover', 'blocks', 'seo', 'authorsBio'], publicationState: 'live', // Ensure published content } - // Manual query string construction for absolute clarity - const queryString = qs.stringify(queryParams, { - encodeValuesOnly: true, - arrayFormat: 'brackets', - encode: false, + const { data, error, response } = await client.GET('/articles', { + params: { + query: toQueryParams(queryParams), + }, + ...clientAddons, }) - const url = `/articles?${queryString}` - const { data, error, response } = await client.GET(url, clientAddons) - if (error) { console.error(`Search failed (${response.status}):`, error) throw new Error(`Search failed: ${error.message}`) @@ -285,6 +291,7 @@ async function getBySlugAux(slug: string, endpoint: '/pages'): Promise { if (!slug) throw new Error('Slug is required') // Fail fast - no silent failures per CMS architecture + if (!isValidCmsSlug(slug)) return null try { const entity = endpoint.slice(1, -1) @@ -296,15 +303,10 @@ async function getBySlugAux(slug: string, endpoint: '/categories' | '/articles' populate, } - const queryString = endpoint === '/pages' ? qs.stringify(queryParams, { encodeValuesOnly: true }) : null - - const { data, error } = - endpoint === '/pages' - ? await client.GET(`${endpoint}?${queryString}`, clientAddons) - : await client.GET(endpoint, { - params: { query: toQueryParams(queryParams) }, - ...clientAddons, - }) + const { data, error } = await client.GET(endpoint, { + params: { query: toQueryParams(queryParams) }, + ...clientAddons, + }) if (error) { console.error(`Error getting slug ${slug} for ${entity}`, error) diff --git a/apps/cow-fi/util/cmsValidation.test.ts b/apps/cow-fi/util/cmsValidation.test.ts new file mode 100644 index 00000000000..3cedc227088 --- /dev/null +++ b/apps/cow-fi/util/cmsValidation.test.ts @@ -0,0 +1,99 @@ +import { + CMS_REVALIDATE_TAG, + isAllowedRevalidatePath, + isValidCmsSlug, + normalizeRevalidateRequest, + normalizeSearchArticlesInput, +} from './cmsValidation' + +describe('cmsValidation', () => { + describe('isValidCmsSlug', () => { + it('accepts canonical lowercase hyphenated slugs', () => { + expect(isValidCmsSlug('aave-trade-breakdown')).toBe(true) + expect(isValidCmsSlug('ens')).toBe(true) + }) + + it('rejects malformed or delimiter-based slugs', () => { + expect(isValidCmsSlug('')).toBe(false) + expect(isValidCmsSlug('Aave')).toBe(false) + expect(isValidCmsSlug('../preview')).toBe(false) + expect(isValidCmsSlug('aave&publicationState=preview')).toBe(false) + }) + }) + + describe('normalizeSearchArticlesInput', () => { + it('trims valid input and clamps pagination to safe limits', () => { + expect( + normalizeSearchArticlesInput({ + searchTerm: ' cowswap ', + page: 500, + pageSize: 1_000, + }), + ).toEqual({ + searchTerm: 'cowswap', + page: 100, + pageSize: 100, + }) + }) + + it('returns an empty search safely', () => { + expect( + normalizeSearchArticlesInput({ + searchTerm: ' ', + }), + ).toEqual({ + searchTerm: '', + page: 0, + pageSize: 10, + }) + }) + + it('rejects invalid search payloads', () => { + expect(() => normalizeSearchArticlesInput('cowswap')).toThrow('Search input must be an object') + expect(() => normalizeSearchArticlesInput({ searchTerm: 1 })).toThrow('Search term must be a string') + expect(() => normalizeSearchArticlesInput({ searchTerm: 'cow', page: -1 })).toThrow( + 'Pagination parameters must be non-negative integers', + ) + expect(() => normalizeSearchArticlesInput({ searchTerm: 'a'.repeat(101) })).toThrow( + 'Search term must be at most 100 characters', + ) + }) + }) + + describe('revalidation allowlist', () => { + it('accepts only approved learn paths', () => { + expect(isAllowedRevalidatePath('/learn')).toBe(true) + expect(isAllowedRevalidatePath('/learn/articles')).toBe(true) + expect(isAllowedRevalidatePath('/learn/articles/2')).toBe(true) + expect(isAllowedRevalidatePath('/learn/topic/amm')).toBe(true) + expect(isAllowedRevalidatePath('/learn/aave-trade-breakdown')).toBe(true) + }) + + it('rejects paths outside the learn allowlist', () => { + expect(isAllowedRevalidatePath('/')).toBe(false) + expect(isAllowedRevalidatePath('/api/revalidate')).toBe(false) + expect(isAllowedRevalidatePath('/learn//double-slash')).toBe(false) + expect(isAllowedRevalidatePath('/learn/topic/Bad-Slug')).toBe(false) + }) + + it('normalizes and validates revalidation payloads', () => { + expect( + normalizeRevalidateRequest({ + tag: CMS_REVALIDATE_TAG, + path: 'learn/topic/amm', + }), + ).toEqual({ + tag: CMS_REVALIDATE_TAG, + path: '/learn/topic/amm', + }) + }) + + it('rejects invalid revalidation payloads', () => { + expect(() => normalizeRevalidateRequest(null)).toThrow('Revalidation body must be an object') + expect(() => normalizeRevalidateRequest({ tag: 'preview' })).toThrow('Unsupported revalidation tag "preview"') + expect(() => normalizeRevalidateRequest({ tag: CMS_REVALIDATE_TAG, path: '/admin' })).toThrow( + 'Unsupported revalidation path "/admin"', + ) + }) + }) +}) diff --git a/apps/cow-fi/util/cmsValidation.ts b/apps/cow-fi/util/cmsValidation.ts new file mode 100644 index 00000000000..03de1ba392f --- /dev/null +++ b/apps/cow-fi/util/cmsValidation.ts @@ -0,0 +1,102 @@ +const CMS_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ +const LEARN_REVALIDATE_PATH_PATTERN = + /^\/learn(?:\/(?:articles(?:\/\d+)?|topics|topic\/[a-z0-9]+(?:-[a-z0-9]+)*|[a-z0-9]+(?:-[a-z0-9]+)*))?$/ + +export const CMS_REVALIDATE_TAG = 'cms-content' +export const DEFAULT_SEARCH_PAGE = 0 +export const DEFAULT_SEARCH_PAGE_SIZE = 10 +export const MAX_SEARCH_PAGE = 100 +export const MAX_SEARCH_PAGE_SIZE = 100 +export const MAX_SEARCH_TERM_LENGTH = 100 + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function readOptionalNumber(value: unknown, fallback: number, max: number): number { + if (typeof value === 'undefined') return fallback + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + throw new Error('Pagination parameters must be non-negative integers') + } + + return Math.min(value, max) +} + +export function isValidCmsSlug(slug: string): boolean { + return CMS_SLUG_PATTERN.test(slug) +} + +export function isAllowedRevalidatePath(path: string): boolean { + return LEARN_REVALIDATE_PATH_PATTERN.test(path) +} + +export function normalizeCmsSlug(slug: string): string | null { + const trimmedSlug = slug.trim() + + return isValidCmsSlug(trimmedSlug) ? trimmedSlug : null +} + +export function normalizeSearchArticlesInput(input: unknown): { + searchTerm: string + page: number + pageSize: number +} { + if (!isRecord(input)) { + throw new Error('Search input must be an object') + } + + if (typeof input.searchTerm !== 'string') { + throw new Error('Search term must be a string') + } + + const searchTerm = input.searchTerm.trim() + + if (searchTerm.length === 0) { + return { + searchTerm, + page: DEFAULT_SEARCH_PAGE, + pageSize: DEFAULT_SEARCH_PAGE_SIZE, + } + } + + if (searchTerm.length > MAX_SEARCH_TERM_LENGTH) { + throw new Error(`Search term must be at most ${MAX_SEARCH_TERM_LENGTH} characters`) + } + + return { + searchTerm, + page: readOptionalNumber(input.page, DEFAULT_SEARCH_PAGE, MAX_SEARCH_PAGE), + pageSize: readOptionalNumber(input.pageSize, DEFAULT_SEARCH_PAGE_SIZE, MAX_SEARCH_PAGE_SIZE), + } +} + +export function normalizeRevalidateRequest(input: unknown): { + path: string | null + tag: string +} { + if (!isRecord(input)) { + throw new Error('Revalidation body must be an object') + } + + const tag = typeof input.tag === 'undefined' ? CMS_REVALIDATE_TAG : input.tag + + if (tag !== CMS_REVALIDATE_TAG) { + throw new Error(`Unsupported revalidation tag "${String(tag)}"`) + } + + if (typeof input.path === 'undefined') { + return { path: null, tag } + } + + if (typeof input.path !== 'string') { + throw new Error('Revalidation path must be a string') + } + + const path = input.path.startsWith('/') ? input.path : `/${input.path}` + + if (!isAllowedRevalidatePath(path)) { + throw new Error(`Unsupported revalidation path "${path}"`) + } + + return { path, tag } +} diff --git a/apps/cow-fi/util/queryParams.test.ts b/apps/cow-fi/util/queryParams.test.ts new file mode 100644 index 00000000000..20b2b9742dd --- /dev/null +++ b/apps/cow-fi/util/queryParams.test.ts @@ -0,0 +1,44 @@ +import { toQueryParams } from './queryParams' + +describe('toQueryParams', () => { + it('flattens nested Strapi query objects into stable key-value params', () => { + expect( + toQueryParams({ + filters: { + slug: { + $eq: 'aave-trade-breakdown', + }, + }, + pagination: { + page: 1, + pageSize: 2, + }, + populate: { + seo: { + fields: ['metaTitle', 'metaDescription'], + }, + }, + }), + ).toEqual({ + 'filters[slug][$eq]': 'aave-trade-breakdown', + 'pagination[page]': '1', + 'pagination[pageSize]': '2', + 'populate[seo][fields][0]': 'metaTitle', + 'populate[seo][fields][1]': 'metaDescription', + }) + }) + + it('keeps encoded delimiters inside values instead of creating new params', () => { + expect( + toQueryParams({ + filters: { + slug: { + $eq: 'amm&publicationState=preview', + }, + }, + }), + ).toEqual({ + 'filters[slug][$eq]': 'amm&publicationState=preview', + }) + }) +}) diff --git a/apps/cow-fi/util/queryParams.ts b/apps/cow-fi/util/queryParams.ts index 9baa9568214..37a5f868686 100644 --- a/apps/cow-fi/util/queryParams.ts +++ b/apps/cow-fi/util/queryParams.ts @@ -55,10 +55,10 @@ into this: * @returns the object in key value format */ export function toQueryParams(query: unknown): { [key: string]: string } { - const queryString = qs.stringify(query, { encode: false }) + const queryString = qs.stringify(query, { encodeValuesOnly: true }) + const searchParams = new URLSearchParams(queryString) - return queryString.split('&').reduce<{ [key: string]: string }>((acc, pair) => { - const [key, value] = pair.split('=') + return Array.from(searchParams.entries()).reduce<{ [key: string]: string }>((acc, [key, value]) => { acc[key] = value return acc }, {}) From 5138ef9f3bc6d4645d4502b400ddb8acd6699b8f Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:42:43 +0100 Subject: [PATCH 3/4] fix: reject malformed cow-fi CMS validator inputs --- apps/cow-fi/util/cmsValidation.test.ts | 11 +++++++++++ apps/cow-fi/util/cmsValidation.ts | 12 +++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/cow-fi/util/cmsValidation.test.ts b/apps/cow-fi/util/cmsValidation.test.ts index 3cedc227088..a0bb96c3460 100644 --- a/apps/cow-fi/util/cmsValidation.test.ts +++ b/apps/cow-fi/util/cmsValidation.test.ts @@ -48,6 +48,15 @@ describe('cmsValidation', () => { }) }) + it('rejects invalid pagination even for blank searches', () => { + expect(() => normalizeSearchArticlesInput({ searchTerm: ' ', page: -1 })).toThrow( + 'Pagination parameters must be non-negative integers', + ) + expect(() => normalizeSearchArticlesInput({ searchTerm: ' ', page: 'oops' })).toThrow( + 'Pagination parameters must be non-negative integers', + ) + }) + it('rejects invalid search payloads', () => { expect(() => normalizeSearchArticlesInput('cowswap')).toThrow('Search input must be an object') expect(() => normalizeSearchArticlesInput({ searchTerm: 1 })).toThrow('Search term must be a string') @@ -90,6 +99,8 @@ describe('cmsValidation', () => { it('rejects invalid revalidation payloads', () => { expect(() => normalizeRevalidateRequest(null)).toThrow('Revalidation body must be an object') + expect(() => normalizeRevalidateRequest([])).toThrow('Revalidation body must be an object') + expect(() => normalizeRevalidateRequest(new Date())).toThrow('Revalidation body must be an object') expect(() => normalizeRevalidateRequest({ tag: 'preview' })).toThrow('Unsupported revalidation tag "preview"') expect(() => normalizeRevalidateRequest({ tag: CMS_REVALIDATE_TAG, path: '/admin' })).toThrow( 'Unsupported revalidation path "/admin"', diff --git a/apps/cow-fi/util/cmsValidation.ts b/apps/cow-fi/util/cmsValidation.ts index 03de1ba392f..f4b354a32a5 100644 --- a/apps/cow-fi/util/cmsValidation.ts +++ b/apps/cow-fi/util/cmsValidation.ts @@ -10,7 +10,7 @@ export const MAX_SEARCH_PAGE_SIZE = 100 export const MAX_SEARCH_TERM_LENGTH = 100 function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null + return Object.prototype.toString.call(value) === '[object Object]' } function readOptionalNumber(value: unknown, fallback: number, max: number): number { @@ -50,12 +50,14 @@ export function normalizeSearchArticlesInput(input: unknown): { } const searchTerm = input.searchTerm.trim() + const page = readOptionalNumber(input.page, DEFAULT_SEARCH_PAGE, MAX_SEARCH_PAGE) + const pageSize = readOptionalNumber(input.pageSize, DEFAULT_SEARCH_PAGE_SIZE, MAX_SEARCH_PAGE_SIZE) if (searchTerm.length === 0) { return { searchTerm, - page: DEFAULT_SEARCH_PAGE, - pageSize: DEFAULT_SEARCH_PAGE_SIZE, + page, + pageSize, } } @@ -65,8 +67,8 @@ export function normalizeSearchArticlesInput(input: unknown): { return { searchTerm, - page: readOptionalNumber(input.page, DEFAULT_SEARCH_PAGE, MAX_SEARCH_PAGE), - pageSize: readOptionalNumber(input.pageSize, DEFAULT_SEARCH_PAGE_SIZE, MAX_SEARCH_PAGE_SIZE), + page, + pageSize, } } From 0920e11cd7c2a5ae42861c0ac41293158b012af4 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Sat, 6 Jun 2026 19:27:35 +0100 Subject: [PATCH 4/4] fix: return not-found metadata for missing cow-fi topics --- .../app/(learn)/learn/topic/[topicSlug]/page.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/cow-fi/app/(learn)/learn/topic/[topicSlug]/page.tsx b/apps/cow-fi/app/(learn)/learn/topic/[topicSlug]/page.tsx index e8feb205ffa..caf5dfe0382 100644 --- a/apps/cow-fi/app/(learn)/learn/topic/[topicSlug]/page.tsx +++ b/apps/cow-fi/app/(learn)/learn/topic/[topicSlug]/page.tsx @@ -38,7 +38,15 @@ export async function generateMetadata({ params }: Props): Promise { } const category = await getCategoryBySlug(topicSlug) - const { name, description = '' } = category?.attributes || {} + + if (!category || !category.attributes) { + return getPageMetadata({ + absoluteTitle: 'Topic Not Found - Knowledge base', + description: 'The requested topic could not be found.', + }) + } + + const { name, description = '' } = category.attributes return getPageMetadata({ absoluteTitle: `${name} - Knowledge base`,