From 0af523ab33404dda23b51ea5781c3f38d2e41642 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Wed, 3 Jun 2026 06:49:35 -0500 Subject: [PATCH 1/7] fix(help-center): enforce public category visibility --- .../help-center-article.query.test.ts | 39 +++++++++++++-- .../help-center-article.service.test.ts | 49 +++++++++++++++++++ .../help-center/help-center.article.query.ts | 45 ++++++++++++++++- .../help-center.article.service.ts | 12 +++++ 4 files changed, 140 insertions(+), 5 deletions(-) diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.query.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.query.test.ts index 4b43db60f..298295dbe 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.query.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.query.test.ts @@ -34,7 +34,13 @@ vi.mock('@/lib/server/db', () => ({ }), })), }, - helpCenterCategories: { id: 'id', slug: 'slug', name: 'name' }, + helpCenterCategories: { + id: 'id', + slug: 'slug', + name: 'name', + isPublic: 'is_public', + deletedAt: 'deleted_at', + }, helpCenterArticles: { id: 'id', slug: 'slug', @@ -71,6 +77,7 @@ vi.mock('@/lib/server/db', () => ({ })) let listArticles: typeof import('../help-center.article.query').listArticles +let listPublicArticles: typeof import('../help-center.article.query').listPublicArticles let listPublicArticlesForCategory: typeof import('../help-center.article.query').listPublicArticlesForCategory beforeEach(async () => { @@ -78,6 +85,7 @@ beforeEach(async () => { const mod = await import('../help-center.article.query') listArticles = mod.listArticles + listPublicArticles = mod.listPublicArticles listPublicArticlesForCategory = mod.listPublicArticlesForCategory }) @@ -107,7 +115,8 @@ describe('listPublicArticlesForCategory', () => { const orderByMock = vi.fn().mockResolvedValue(mockArticles) const whereMock = vi.fn().mockReturnValue({ orderBy: orderByMock }) const leftJoinMock = vi.fn().mockReturnValue({ where: whereMock }) - const fromMock = vi.fn().mockReturnValue({ leftJoin: leftJoinMock }) + const innerJoinMock = vi.fn().mockReturnValue({ leftJoin: leftJoinMock }) + const fromMock = vi.fn().mockReturnValue({ innerJoin: innerJoinMock }) vi.mocked(db.select).mockReturnValueOnce({ from: fromMock } as never) const result = await listPublicArticlesForCategory('category_1') @@ -126,7 +135,8 @@ describe('listPublicArticlesForCategory', () => { const orderByMock = vi.fn().mockResolvedValue([]) const whereMock = vi.fn().mockReturnValue({ orderBy: orderByMock }) const leftJoinMock = vi.fn().mockReturnValue({ where: whereMock }) - const fromMock = vi.fn().mockReturnValue({ leftJoin: leftJoinMock }) + const innerJoinMock = vi.fn().mockReturnValue({ leftJoin: leftJoinMock }) + const fromMock = vi.fn().mockReturnValue({ innerJoin: innerJoinMock }) vi.mocked(db.select).mockReturnValueOnce({ from: fromMock } as never) const result = await listPublicArticlesForCategory('category_1') @@ -134,6 +144,29 @@ describe('listPublicArticlesForCategory', () => { }) }) + +describe('listPublicArticles', () => { + it('returns no articles when the requested category is not public', async () => { + mockCategoryFindMany.mockResolvedValue([{ id: 'category_public' }]) + + const result = await listPublicArticles({ categoryId: 'category_hidden' }) + + expect(result).toEqual({ items: [], nextCursor: null, hasMore: false }) + expect(mockArticleFindMany).not.toHaveBeenCalled() + }) + + it('restricts public article listings to public category IDs', async () => { + const { inArray } = await import('@/lib/server/db') + mockCategoryFindMany.mockResolvedValue([{ id: 'category_public' }]) + mockArticleFindMany.mockResolvedValue([]) + + await listPublicArticles({ limit: 20 }) + + expect(mockArticleFindMany).toHaveBeenCalled() + expect(inArray).toHaveBeenCalledWith('category_id', ['category_public']) + }) +}) + describe('listArticles with showDeleted option', () => { it('returns deleted articles within the 30-day window', async () => { const recentDeletedAt = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000) diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts index fd9642715..02b930524 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts @@ -15,6 +15,7 @@ function createUpdateChain() { updateWhereCalls.push(args) return chain }) + chain.catch = vi.fn().mockResolvedValue(undefined) chain.returning = vi.fn().mockResolvedValue([ { id: 'article_1' as HelpCenterArticleId, @@ -79,6 +80,8 @@ vi.mock('@/lib/server/db', () => ({ id: 'id', slug: 'slug', name: 'name', + isPublic: 'is_public', + deletedAt: 'deleted_at', }, helpCenterArticles: { id: 'id', @@ -132,6 +135,7 @@ let unpublishArticle: typeof import('../help-center.article.service').unpublishA let deleteArticle: typeof import('../help-center.article.service').deleteArticle let restoreArticle: typeof import('../help-center.article.service').restoreArticle let recordArticleFeedback: typeof import('../help-center.article.service').recordArticleFeedback +let getPublicArticleBySlug: typeof import('../help-center.article.service').getPublicArticleBySlug beforeEach(async () => { vi.clearAllMocks() @@ -148,6 +152,7 @@ beforeEach(async () => { deleteArticle = mod.deleteArticle restoreArticle = mod.restoreArticle recordArticleFeedback = mod.recordArticleFeedback + getPublicArticleBySlug = mod.getPublicArticleBySlug }) describe('getArticleById', () => { @@ -195,6 +200,46 @@ describe('getArticleById', () => { }) }) + +describe('getPublicArticleBySlug', () => { + const publishedArticle = { + id: 'article_1' as HelpCenterArticleId, + slug: 'how-to-start', + title: 'How to Start', + content: 'Content here', + contentJson: null, + categoryId: 'category_1', + principalId: null, + publishedAt: new Date(), + viewCount: 5, + helpfulCount: 2, + notHelpfulCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + } + + it('returns a published article when its category is public', async () => { + mockArticleFindFirst.mockResolvedValue(publishedArticle) + mockCategoryFindFirst + .mockResolvedValueOnce({ id: 'category_1' }) + .mockResolvedValueOnce({ id: 'category_1', slug: 'getting-started', name: 'Getting Started' }) + mockPrincipalFindFirst.mockResolvedValue(null) + + const result = await getPublicArticleBySlug('how-to-start') + + expect(result.title).toBe('How to Start') + }) + + it('throws NotFoundError when the article category is not public', async () => { + mockArticleFindFirst.mockResolvedValue(publishedArticle) + mockCategoryFindFirst.mockResolvedValue(null) + + await expect(getPublicArticleBySlug('how-to-start')).rejects.toMatchObject({ + code: 'ARTICLE_NOT_FOUND', + }) + }) +}) + describe('createArticle', () => { it('creates article with generated slug', async () => { const { db } = await import('@/lib/server/db') @@ -317,6 +362,7 @@ describe('createArticle', () => { const { db } = await import('@/lib/server/db') const chain: Record = {} chain.values = vi.fn(() => chain) + chain.catch = vi.fn().mockResolvedValue(undefined) chain.returning = vi.fn().mockResolvedValue([ { id: 'article_new' as HelpCenterArticleId, @@ -482,6 +528,7 @@ describe('updateArticle authorId validation', () => { const chain: Record = {} chain.set = vi.fn(() => chain) chain.where = vi.fn(() => chain) + chain.catch = vi.fn().mockResolvedValue(undefined) chain.returning = vi.fn().mockResolvedValue([ { id: 'article_1' as HelpCenterArticleId, @@ -523,6 +570,7 @@ describe('updateArticle authorId validation', () => { const chain: Record = {} chain.set = vi.fn(() => chain) chain.where = vi.fn(() => chain) + chain.catch = vi.fn().mockResolvedValue(undefined) chain.returning = vi.fn().mockResolvedValue([ { id: 'article_1' as HelpCenterArticleId, @@ -641,6 +689,7 @@ describe('restoreArticle', () => { return chain }) chain.where = vi.fn().mockReturnValue(chain) + chain.catch = vi.fn().mockResolvedValue(undefined) chain.returning = vi.fn().mockResolvedValue([ { id: 'article_1' as HelpCenterArticleId, diff --git a/apps/web/src/lib/server/domains/help-center/help-center.article.query.ts b/apps/web/src/lib/server/domains/help-center/help-center.article.query.ts index f3b07d264..83657f5ac 100644 --- a/apps/web/src/lib/server/domains/help-center/help-center.article.query.ts +++ b/apps/web/src/lib/server/domains/help-center/help-center.article.query.ts @@ -31,9 +31,12 @@ import type { // Article Queries // ============================================================================ -export async function listArticles(params: ListArticlesParams): Promise { +export async function listArticles( + params: ListArticlesParams & { categoryIds?: HelpCenterCategoryId[] } +): Promise { const { categoryId, + categoryIds: categoryIdsFilter, status = 'all', search, cursor, @@ -54,6 +57,10 @@ export async function listArticles(params: ListArticlesParams): Promise { + const categories = await db.query.helpCenterCategories.findMany({ + where: and(eq(helpCenterCategories.isPublic, true), isNull(helpCenterCategories.deletedAt)), + columns: { id: true }, + }) + return categories.map((category) => category.id as HelpCenterCategoryId) +} + export async function listPublicArticles(params: { categoryId?: string search?: string cursor?: string limit?: number }): Promise { - return listArticles({ ...params, status: 'published' }) + const publicCategoryIds = await listPublicCategoryIds() + + if (params.categoryId && !publicCategoryIds.includes(params.categoryId as HelpCenterCategoryId)) { + return { items: [], nextCursor: null, hasMore: false } + } + + if (!params.categoryId && publicCategoryIds.length === 0) { + return { items: [], nextCursor: null, hasMore: false } + } + + return listArticles({ + ...params, + categoryIds: params.categoryId ? undefined : publicCategoryIds, + status: 'published', + }) } export async function listPublicArticlesForCategory(categoryId: string) { + const now = new Date() + return db .select({ id: helpCenterArticles.id, @@ -205,11 +236,15 @@ export async function listPublicArticlesForCategory(categoryId: string) { authorAvatarUrl: principal.avatarUrl, }) .from(helpCenterArticles) + .innerJoin(helpCenterCategories, eq(helpCenterCategories.id, helpCenterArticles.categoryId)) .leftJoin(principal, eq(principal.id, helpCenterArticles.principalId)) .where( and( eq(helpCenterArticles.categoryId, categoryId as HelpCenterCategoryId), + eq(helpCenterCategories.isPublic, true), + isNull(helpCenterCategories.deletedAt), isNotNull(helpCenterArticles.publishedAt), + lte(helpCenterArticles.publishedAt, now), isNull(helpCenterArticles.deletedAt) ) ) @@ -219,6 +254,8 @@ export async function listPublicArticlesForCategory(categoryId: string) { export async function listPublicCategoryEditors(): Promise< Record> > { + const now = new Date() + const rows = await db .select({ categoryId: helpCenterArticles.categoryId, @@ -227,10 +264,14 @@ export async function listPublicCategoryEditors(): Promise< avatarUrl: principal.avatarUrl, }) .from(helpCenterArticles) + .innerJoin(helpCenterCategories, eq(helpCenterCategories.id, helpCenterArticles.categoryId)) .innerJoin(principal, eq(principal.id, helpCenterArticles.principalId)) .where( and( + eq(helpCenterCategories.isPublic, true), + isNull(helpCenterCategories.deletedAt), isNotNull(helpCenterArticles.publishedAt), + lte(helpCenterArticles.publishedAt, now), isNull(helpCenterArticles.deletedAt), inArray(principal.role, ['admin', 'member']) ) diff --git a/apps/web/src/lib/server/domains/help-center/help-center.article.service.ts b/apps/web/src/lib/server/domains/help-center/help-center.article.service.ts index bf1350bfc..3b98dd64c 100644 --- a/apps/web/src/lib/server/domains/help-center/help-center.article.service.ts +++ b/apps/web/src/lib/server/domains/help-center/help-center.article.service.ts @@ -99,6 +99,18 @@ export async function getPublicArticleBySlug(slug: string): Promise Date: Wed, 3 Jun 2026 06:50:42 -0500 Subject: [PATCH 2/7] fix(help-center): enforce authenticated access --- .../src/lib/server/functions/help-center.ts | 9 +++++++ apps/web/src/lib/server/help-center-access.ts | 27 +++++++++++++++++++ apps/web/src/routes/_portal/hc.tsx | 23 +++++++++++++--- .../v1/help/__tests__/categories.test.ts | 4 +++ .../api/public/v1/help/articles/$slug.ts | 2 ++ .../v1/help/articles/__tests__/$slug.test.ts | 4 +++ .../routes/api/public/v1/help/categories.ts | 2 ++ apps/web/src/routes/api/widget/kb-search.ts | 6 +++-- apps/web/src/routes/hc/sitemap[.]xml.ts | 7 ++--- 9 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/lib/server/help-center-access.ts diff --git a/apps/web/src/lib/server/functions/help-center.ts b/apps/web/src/lib/server/functions/help-center.ts index 6b9327517..311586531 100644 --- a/apps/web/src/lib/server/functions/help-center.ts +++ b/apps/web/src/lib/server/functions/help-center.ts @@ -55,6 +55,7 @@ import { } from '@/lib/shared/schemas/help-center' import { z } from 'zod' import { toIsoString, toIsoStringOrNull } from '@/lib/shared/utils' +import { requirePublicHelpCenterAccess } from '@/lib/server/help-center-access' // ============================================================================ // Helper: serialize article dates @@ -98,6 +99,7 @@ export const listCategoriesFn = createServerFn({ method: 'GET' }) export const listPublicCategoriesFn = createServerFn({ method: 'GET' }) .inputValidator(z.object({})) .handler(async () => { + await requirePublicHelpCenterAccess() const categories = await listPublicCategories() return categories.map(serializeCategory) }) @@ -113,6 +115,7 @@ export const getCategoryFn = createServerFn({ method: 'GET' }) export const getPublicCategoryBySlugFn = createServerFn({ method: 'GET' }) .inputValidator(getCategoryBySlugSchema) .handler(async ({ data }) => { + await requirePublicHelpCenterAccess() const category = await getCategoryBySlug(data.slug) return serializeCategory(category) }) @@ -189,6 +192,7 @@ export const restoreArticleFn = createServerFn({ method: 'POST' }) export const listPublicArticlesFn = createServerFn({ method: 'GET' }) .inputValidator(listPublicArticlesSchema) .handler(async ({ data }) => { + await requirePublicHelpCenterAccess() const result = await listPublicArticles(data) return { ...result, @@ -199,6 +203,7 @@ export const listPublicArticlesFn = createServerFn({ method: 'GET' }) export const listPublicArticlesForCategoryFn = createServerFn({ method: 'GET' }) .inputValidator(z.object({ categoryId: z.string() })) .handler(async ({ data }) => { + await requirePublicHelpCenterAccess() const articles = await listPublicArticlesForCategory(data.categoryId) return articles.map((a) => ({ ...a, @@ -209,6 +214,7 @@ export const listPublicArticlesForCategoryFn = createServerFn({ method: 'GET' }) export const listPublicCategoryEditorsFn = createServerFn({ method: 'GET' }) .inputValidator(z.object({})) .handler(async () => { + await requirePublicHelpCenterAccess() return listPublicCategoryEditors() }) @@ -223,6 +229,7 @@ export const getArticleFn = createServerFn({ method: 'GET' }) export const getPublicArticleBySlugFn = createServerFn({ method: 'GET' }) .inputValidator(getArticleBySlugSchema) .handler(async ({ data }) => { + await requirePublicHelpCenterAccess() const article = await getPublicArticleBySlug(data.slug) const { helpfulCount: _h, notHelpfulCount: _n, ...publicArticle } = serializeArticle(article) return publicArticle @@ -280,6 +287,7 @@ export const deleteArticleFn = createServerFn({ method: 'POST' }) export const recordArticleFeedbackFn = createServerFn({ method: 'POST' }) .inputValidator(articleFeedbackSchema) .handler(async ({ data }) => { + await requirePublicHelpCenterAccess() const auth = await getOptionalAuth() await recordArticleFeedback( data.articleId as HelpCenterArticleId, @@ -298,6 +306,7 @@ export const searchPublicArticlesFn = createServerFn({ method: 'GET' }) z.object({ query: z.string().min(1), limit: z.number().int().min(1).max(20).optional() }) ) .handler(async ({ data }) => { + await requirePublicHelpCenterAccess() const { hybridSearch } = await import('@/lib/server/domains/help-center/help-center-search.service') return hybridSearch(data.query, data.limit ?? 10) diff --git a/apps/web/src/lib/server/help-center-access.ts b/apps/web/src/lib/server/help-center-access.ts new file mode 100644 index 000000000..c66ab02e2 --- /dev/null +++ b/apps/web/src/lib/server/help-center-access.ts @@ -0,0 +1,27 @@ +import { getOptionalAuth } from '@/lib/server/functions/auth-helpers' +import type { HelpCenterConfig } from '@/lib/server/domains/settings' +import type { FeatureFlags } from '@/lib/server/domains/settings/settings.types' +import { NotFoundError, UnauthorizedError } from '@/lib/shared/errors' + +type HelpCenterAccessConfig = HelpCenterConfig & { + access?: 'public' | 'authenticated' +} + +export function requiresHelpCenterAuthentication(config: HelpCenterConfig | undefined): boolean { + return (config as HelpCenterAccessConfig | undefined)?.access === 'authenticated' +} + +export async function requirePublicHelpCenterAccess(): Promise { + const { getTenantSettings } = await import('@/lib/server/domains/settings/settings.service') + const settings = await getTenantSettings() + const flags = settings?.featureFlags as FeatureFlags | undefined + const helpCenterConfig = settings?.helpCenterConfig as HelpCenterConfig | undefined + + if (!flags?.helpCenter || !helpCenterConfig?.enabled) { + throw new NotFoundError('HELP_CENTER_NOT_FOUND', 'Help center is not available') + } + + if (requiresHelpCenterAuthentication(helpCenterConfig) && !(await getOptionalAuth())) { + throw new UnauthorizedError('Authentication required') + } +} diff --git a/apps/web/src/routes/_portal/hc.tsx b/apps/web/src/routes/_portal/hc.tsx index b613e5680..0e3b81632 100644 --- a/apps/web/src/routes/_portal/hc.tsx +++ b/apps/web/src/routes/_portal/hc.tsx @@ -1,6 +1,14 @@ -import { createFileRoute, notFound, Outlet } from '@tanstack/react-router' +import { createFileRoute, notFound, Outlet, redirect } from '@tanstack/react-router' import type { FeatureFlags, HelpCenterConfig } from '@/lib/shared/types/settings' +type HelpCenterAccessConfig = HelpCenterConfig & { + access?: 'public' | 'authenticated' +} + +function requiresAuthentication(config: HelpCenterConfig | undefined): boolean { + return (config as HelpCenterAccessConfig | undefined)?.access === 'authenticated' +} + export const Route = createFileRoute('/_portal/hc')({ beforeLoad: ({ context }) => { const { settings } = context @@ -10,14 +18,23 @@ export const Route = createFileRoute('/_portal/hc')({ const helpCenterConfig = settings?.helpCenterConfig as HelpCenterConfig | undefined if (!helpCenterConfig?.enabled) throw notFound() + + if (requiresAuthentication(helpCenterConfig) && !context.session?.user) { + throw redirect({ to: '/auth/login' }) + } }, loader: async ({ context }) => { const { settings } = context const helpCenterConfig = (settings?.helpCenterConfig as HelpCenterConfig | null) ?? null return { helpCenterConfig } }, - head: () => { - return { meta: [] } + head: ({ loaderData }) => { + const helpCenterConfig = loaderData?.helpCenterConfig ?? undefined + return { + meta: requiresAuthentication(helpCenterConfig) + ? [{ name: 'robots', content: 'noindex, nofollow' }] + : [], + } }, component: HelpCenterLayoutRoute, }) diff --git a/apps/web/src/routes/api/public/v1/help/__tests__/categories.test.ts b/apps/web/src/routes/api/public/v1/help/__tests__/categories.test.ts index 3737c7d14..e19ddb519 100644 --- a/apps/web/src/routes/api/public/v1/help/__tests__/categories.test.ts +++ b/apps/web/src/routes/api/public/v1/help/__tests__/categories.test.ts @@ -6,6 +6,10 @@ vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), })) +vi.mock('@/lib/server/help-center-access', () => ({ + requirePublicHelpCenterAccess: vi.fn(async () => undefined), +})) + vi.mock('@/lib/server/domains/help-center/help-center.category.service', () => ({ listPublicCategories: (...args: unknown[]) => mockListPublicCategories(...args), })) diff --git a/apps/web/src/routes/api/public/v1/help/articles/$slug.ts b/apps/web/src/routes/api/public/v1/help/articles/$slug.ts index ad2c7a66e..4ba095945 100644 --- a/apps/web/src/routes/api/public/v1/help/articles/$slug.ts +++ b/apps/web/src/routes/api/public/v1/help/articles/$slug.ts @@ -4,12 +4,14 @@ import { notFoundResponse, handleDomainError, } from '@/lib/server/domains/api/responses' +import { requirePublicHelpCenterAccess } from '@/lib/server/help-center-access' export const Route = createFileRoute('/api/public/v1/help/articles/$slug')({ server: { handlers: { GET: async ({ params }) => { try { + await requirePublicHelpCenterAccess() const { getPublicArticleBySlug } = await import('@/lib/server/domains/help-center/help-center.article.service') const article = await getPublicArticleBySlug(params.slug) diff --git a/apps/web/src/routes/api/public/v1/help/articles/__tests__/$slug.test.ts b/apps/web/src/routes/api/public/v1/help/articles/__tests__/$slug.test.ts index b1e9f9cdd..73ebd3ff1 100644 --- a/apps/web/src/routes/api/public/v1/help/articles/__tests__/$slug.test.ts +++ b/apps/web/src/routes/api/public/v1/help/articles/__tests__/$slug.test.ts @@ -6,6 +6,10 @@ vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), })) +vi.mock('@/lib/server/help-center-access', () => ({ + requirePublicHelpCenterAccess: vi.fn(async () => undefined), +})) + vi.mock('@/lib/server/domains/help-center/help-center.article.service', () => ({ getPublicArticleBySlug: (...args: unknown[]) => mockGetPublicArticleBySlug(...args), })) diff --git a/apps/web/src/routes/api/public/v1/help/categories.ts b/apps/web/src/routes/api/public/v1/help/categories.ts index 8424e2453..a16bfaca4 100644 --- a/apps/web/src/routes/api/public/v1/help/categories.ts +++ b/apps/web/src/routes/api/public/v1/help/categories.ts @@ -1,11 +1,13 @@ import { createFileRoute } from '@tanstack/react-router' import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' +import { requirePublicHelpCenterAccess } from '@/lib/server/help-center-access' export const Route = createFileRoute('/api/public/v1/help/categories')({ server: { handlers: { GET: async () => { try { + await requirePublicHelpCenterAccess() const { listPublicCategories } = await import('@/lib/server/domains/help-center/help-center.category.service') const categories = await listPublicCategories() diff --git a/apps/web/src/routes/api/widget/kb-search.ts b/apps/web/src/routes/api/widget/kb-search.ts index 31f6d8694..bdf6986a5 100644 --- a/apps/web/src/routes/api/widget/kb-search.ts +++ b/apps/web/src/routes/api/widget/kb-search.ts @@ -1,12 +1,14 @@ import { createFileRoute } from '@tanstack/react-router' -import { isFeatureEnabled } from '@/lib/server/domains/settings/settings.service' import { hybridSearch } from '@/lib/server/domains/help-center/help-center-search.service' +import { requirePublicHelpCenterAccess } from '@/lib/server/help-center-access' export const Route = createFileRoute('/api/widget/kb-search')({ server: { handlers: { GET: async ({ request }) => { - if (!(await isFeatureEnabled('helpCenter'))) { + try { + await requirePublicHelpCenterAccess() + } catch { return Response.json( { error: { code: 'NOT_FOUND', message: 'Knowledge base not found' } }, { status: 404, headers: corsHeaders() } diff --git a/apps/web/src/routes/hc/sitemap[.]xml.ts b/apps/web/src/routes/hc/sitemap[.]xml.ts index 8b3f27e5d..fb8c0e7d6 100644 --- a/apps/web/src/routes/hc/sitemap[.]xml.ts +++ b/apps/web/src/routes/hc/sitemap[.]xml.ts @@ -1,23 +1,24 @@ import { createFileRoute } from '@tanstack/react-router' import type { SitemapArticle } from '@/lib/shared/help-center-sitemap' +import { requirePublicHelpCenterAccess } from '@/lib/server/help-center-access' export const Route = createFileRoute('/hc/sitemap.xml')({ server: { handlers: { GET: async ({ request }) => { const [ - { isFeatureEnabled }, { listPublicCategories, listPublicArticles }, { buildHelpCenterSitemapUrls }, { renderSitemap }, ] = await Promise.all([ - import('@/lib/server/domains/settings/settings.service'), import('@/lib/server/domains/help-center/help-center.service'), import('@/lib/shared/help-center-sitemap'), import('@/lib/server/sitemap'), ]) - if (!(await isFeatureEnabled('helpCenter'))) { + try { + await requirePublicHelpCenterAccess() + } catch { return new Response('Not Found', { status: 404 }) } From 49f70df0d72f87af91242fb3ce571ffff7e5acde Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Wed, 3 Jun 2026 07:00:43 -0500 Subject: [PATCH 3/7] fix: enforce public help-center category visibility --- .../help-center-article.query.test.ts | 24 ++++++++++- .../help-center-article.service.test.ts | 30 +++++++++++++ .../help-center-category.service.test.ts | 28 ++++++++++++ .../help-center/help-center-search.service.ts | 3 ++ .../help-center/help-center.article.query.ts | 43 ++++++++++++++++++- .../help-center.article.service.ts | 12 ++++++ .../help-center.category.service.ts | 14 ++++++ .../src/lib/server/functions/help-center.ts | 4 +- 8 files changed, 153 insertions(+), 5 deletions(-) diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.query.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.query.test.ts index 4b43db60f..b52c31cf3 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.query.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.query.test.ts @@ -4,6 +4,7 @@ import type { HelpCenterArticleId } from '@opencoven-feedback/ids' const mockArticleFindFirst = vi.fn() const mockArticleFindMany = vi.fn() const mockCategoryFindMany = vi.fn() +const mockCategoryFindFirst = vi.fn() vi.mock('@/lib/server/db', () => ({ db: { @@ -13,6 +14,7 @@ vi.mock('@/lib/server/db', () => ({ findMany: (...args: unknown[]) => mockArticleFindMany(...args), }, helpCenterCategories: { + findFirst: (...args: unknown[]) => mockCategoryFindFirst(...args), findMany: (...args: unknown[]) => mockCategoryFindMany(...args), }, principal: { @@ -34,7 +36,13 @@ vi.mock('@/lib/server/db', () => ({ }), })), }, - helpCenterCategories: { id: 'id', slug: 'slug', name: 'name' }, + helpCenterCategories: { + id: 'id', + slug: 'slug', + name: 'name', + isPublic: 'is_public', + deletedAt: 'deleted_at', + }, helpCenterArticles: { id: 'id', slug: 'slug', @@ -71,13 +79,16 @@ vi.mock('@/lib/server/db', () => ({ })) let listArticles: typeof import('../help-center.article.query').listArticles +let listPublicArticles: typeof import('../help-center.article.query').listPublicArticles let listPublicArticlesForCategory: typeof import('../help-center.article.query').listPublicArticlesForCategory beforeEach(async () => { vi.clearAllMocks() + mockCategoryFindFirst.mockResolvedValue({ id: 'category_1' }) const mod = await import('../help-center.article.query') listArticles = mod.listArticles + listPublicArticles = mod.listPublicArticles listPublicArticlesForCategory = mod.listPublicArticlesForCategory }) @@ -134,6 +145,17 @@ describe('listPublicArticlesForCategory', () => { }) }) +describe('listPublicArticles', () => { + it('does not return articles when no public categories exist', async () => { + mockCategoryFindMany.mockResolvedValueOnce([]) + + const result = await listPublicArticles({ search: 'private' }) + + expect(result).toEqual({ items: [], nextCursor: null, hasMore: false }) + expect(mockArticleFindMany).not.toHaveBeenCalled() + }) +}) + describe('listArticles with showDeleted option', () => { it('returns deleted articles within the 30-day window', async () => { const recentDeletedAt = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000) diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts index fd9642715..435cf0b32 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts @@ -79,6 +79,8 @@ vi.mock('@/lib/server/db', () => ({ id: 'id', slug: 'slug', name: 'name', + isPublic: 'is_public', + deletedAt: 'deleted_at', }, helpCenterArticles: { id: 'id', @@ -125,6 +127,7 @@ vi.mock('@/lib/server/markdown-tiptap', () => ({ })) let getArticleById: typeof import('../help-center.article.service').getArticleById +let getPublicArticleBySlug: typeof import('../help-center.article.service').getPublicArticleBySlug let createArticle: typeof import('../help-center.article.service').createArticle let updateArticle: typeof import('../help-center.article.service').updateArticle let publishArticle: typeof import('../help-center.article.service').publishArticle @@ -141,6 +144,7 @@ beforeEach(async () => { const mod = await import('../help-center.article.service') getArticleById = mod.getArticleById + getPublicArticleBySlug = mod.getPublicArticleBySlug createArticle = mod.createArticle updateArticle = mod.updateArticle publishArticle = mod.publishArticle @@ -195,6 +199,32 @@ describe('getArticleById', () => { }) }) +describe('getPublicArticleBySlug', () => { + it('throws NotFoundError when the published article belongs to a private category', async () => { + mockArticleFindFirst.mockResolvedValue({ + id: 'article_1' as HelpCenterArticleId, + slug: 'private-article', + title: 'Private Article', + content: 'Private content', + contentJson: null, + categoryId: 'category_private', + principalId: 'principal_1', + publishedAt: new Date(), + viewCount: 0, + helpfulCount: 0, + notHelpfulCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + }) + mockCategoryFindFirst.mockResolvedValue(null) + + await expect(getPublicArticleBySlug('private-article')).rejects.toMatchObject({ + code: 'ARTICLE_NOT_FOUND', + }) + expect(updateSetCalls).toHaveLength(0) + }) +}) + describe('createArticle', () => { it('creates article with generated slug', async () => { const { db } = await import('@/lib/server/db') diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-category.service.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-category.service.test.ts index 2c16b23a7..231a9efa4 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-category.service.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-category.service.test.ts @@ -119,6 +119,7 @@ let listCategories: typeof import('../help-center.category.service').listCategor let listPublicCategories: typeof import('../help-center.category.service').listPublicCategories let getCategoryById: typeof import('../help-center.category.service').getCategoryById let getCategoryBySlug: typeof import('../help-center.category.service').getCategoryBySlug +let getPublicCategoryBySlug: typeof import('../help-center.category.service').getPublicCategoryBySlug let createCategory: typeof import('../help-center.category.service').createCategory let updateCategory: typeof import('../help-center.category.service').updateCategory let deleteCategory: typeof import('../help-center.category.service').deleteCategory @@ -135,6 +136,7 @@ beforeEach(async () => { listPublicCategories = mod.listPublicCategories getCategoryById = mod.getCategoryById getCategoryBySlug = mod.getCategoryBySlug + getPublicCategoryBySlug = mod.getPublicCategoryBySlug createCategory = mod.createCategory updateCategory = mod.updateCategory deleteCategory = mod.deleteCategory @@ -392,6 +394,32 @@ describe('getCategoryBySlug', () => { }) }) +describe('getPublicCategoryBySlug', () => { + it('returns public categories by slug', async () => { + mockCategoryFindFirst.mockResolvedValue({ + id: 'category_1' as HelpCenterCategoryId, + slug: 'getting-started', + name: 'Getting Started', + description: null, + isPublic: true, + position: 0, + createdAt: new Date(), + updatedAt: new Date(), + }) + + const result = await getPublicCategoryBySlug('getting-started') + expect(result.slug).toBe('getting-started') + }) + + it('throws NotFoundError when a category is not public', async () => { + mockCategoryFindFirst.mockResolvedValue(null) + + await expect(getPublicCategoryBySlug('private-category')).rejects.toMatchObject({ + code: 'CATEGORY_NOT_FOUND', + }) + }) +}) + describe('createCategory', () => { it('creates a category with auto-generated slug', async () => { const result = await createCategory({ name: 'Getting Started' }) diff --git a/apps/web/src/lib/server/domains/help-center/help-center-search.service.ts b/apps/web/src/lib/server/domains/help-center/help-center-search.service.ts index 66db7bcc9..dcf66245f 100644 --- a/apps/web/src/lib/server/domains/help-center/help-center-search.service.ts +++ b/apps/web/src/lib/server/domains/help-center/help-center-search.service.ts @@ -14,6 +14,7 @@ import { isNull, isNotNull, sql, + eq, } from '@/lib/server/db' import { generateKbEmbedding } from './help-center-embedding.service' @@ -103,6 +104,7 @@ async function hybridQuery( and( isNotNull(helpCenterArticles.publishedAt), isNull(helpCenterArticles.deletedAt), + eq(helpCenterCategories.isPublic, true), isNull(helpCenterCategories.deletedAt), sql`( ${helpCenterArticles.searchVector} @@ ${tsQuery} @@ -156,6 +158,7 @@ async function keywordOnlyQuery(query: string, limit: number): Promise { +type InternalListArticlesParams = ListArticlesParams & { publicOnly?: boolean } + +async function listPublicCategoryIds(): Promise { + const categories = await db.query.helpCenterCategories.findMany({ + where: and(eq(helpCenterCategories.isPublic, true), isNull(helpCenterCategories.deletedAt)), + columns: { id: true }, + }) + return categories.map((cat) => cat.id as HelpCenterCategoryId) +} + +export async function listArticles(params: InternalListArticlesParams): Promise { const { categoryId, status = 'all', @@ -40,6 +50,7 @@ export async function listArticles(params: ListArticlesParams): Promise { - return listArticles({ ...params, status: 'published' }) + return listArticles({ ...params, status: 'published', publicOnly: true }) } export async function listPublicArticlesForCategory(categoryId: string) { + const category = await db.query.helpCenterCategories.findFirst({ + where: and( + eq(helpCenterCategories.id, categoryId as HelpCenterCategoryId), + eq(helpCenterCategories.isPublic, true), + isNull(helpCenterCategories.deletedAt) + ), + columns: { id: true }, + }) + if (!category) return [] + return db .select({ id: helpCenterArticles.id, @@ -219,6 +254,9 @@ export async function listPublicArticlesForCategory(categoryId: string) { export async function listPublicCategoryEditors(): Promise< Record> > { + const publicCategoryIds = await listPublicCategoryIds() + if (publicCategoryIds.length === 0) return {} + const rows = await db .select({ categoryId: helpCenterArticles.categoryId, @@ -232,6 +270,7 @@ export async function listPublicCategoryEditors(): Promise< and( isNotNull(helpCenterArticles.publishedAt), isNull(helpCenterArticles.deletedAt), + inArray(helpCenterArticles.categoryId, publicCategoryIds), inArray(principal.role, ['admin', 'member']) ) ) diff --git a/apps/web/src/lib/server/domains/help-center/help-center.article.service.ts b/apps/web/src/lib/server/domains/help-center/help-center.article.service.ts index bf1350bfc..dc27873f1 100644 --- a/apps/web/src/lib/server/domains/help-center/help-center.article.service.ts +++ b/apps/web/src/lib/server/domains/help-center/help-center.article.service.ts @@ -99,6 +99,18 @@ export async function getPublicArticleBySlug(slug: string): Promise { + const category = await db.query.helpCenterCategories.findFirst({ + where: and( + eq(helpCenterCategories.slug, slug), + eq(helpCenterCategories.isPublic, true), + isNull(helpCenterCategories.deletedAt) + ), + }) + if (!category) { + throw new NotFoundError('CATEGORY_NOT_FOUND', `Category with slug "${slug}" not found`) + } + return category +} + export async function createCategory(input: CreateCategoryInput): Promise { const name = input.name?.trim() if (!name) throw new ValidationError('VALIDATION_ERROR', 'Name is required') diff --git a/apps/web/src/lib/server/functions/help-center.ts b/apps/web/src/lib/server/functions/help-center.ts index 6b9327517..9bac3603c 100644 --- a/apps/web/src/lib/server/functions/help-center.ts +++ b/apps/web/src/lib/server/functions/help-center.ts @@ -15,7 +15,7 @@ import { listPublicCategories, listPublicCategoryEditors, getCategoryById, - getCategoryBySlug, + getPublicCategoryBySlug, createCategory, updateCategory, deleteCategory, @@ -113,7 +113,7 @@ export const getCategoryFn = createServerFn({ method: 'GET' }) export const getPublicCategoryBySlugFn = createServerFn({ method: 'GET' }) .inputValidator(getCategoryBySlugSchema) .handler(async ({ data }) => { - const category = await getCategoryBySlug(data.slug) + const category = await getPublicCategoryBySlug(data.slug) return serializeCategory(category) }) From 00d35276bde783566c3fc039af885d065674bb5b Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 5 Jun 2026 16:49:30 -0700 Subject: [PATCH 4/7] fix(portal): restrict similar posts to public boards --- apps/web/src/lib/server/functions/public-posts.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/server/functions/public-posts.ts b/apps/web/src/lib/server/functions/public-posts.ts index b8693e8af..27427c73f 100644 --- a/apps/web/src/lib/server/functions/public-posts.ts +++ b/apps/web/src/lib/server/functions/public-posts.ts @@ -697,7 +697,7 @@ export const findSimilarPostsFn = createServerFn({ method: 'GET' }) .handler(async ({ data }): Promise => { console.log(`[fn:public-posts] findSimilarPostsFn: title="${data.title.slice(0, 30)}..."`) try { - const { db, posts, boards, postStatuses, and, isNull, desc, sql, inArray } = + const { db, posts, boards, postStatuses, and, eq, isNull, desc, sql, inArray } = await import('@/lib/server/db') const { generateEmbedding } = await import('@/lib/server/domains/embeddings/embedding.service') @@ -721,8 +721,10 @@ export const findSimilarPostsFn = createServerFn({ method: 'GET' }) ), }) .from(posts) + .innerJoin(boards, eq(posts.boardId, boards.id)) .where( and( + eq(boards.isPublic, true), isNull(posts.deletedAt), isNull(posts.canonicalPostId), sql`${posts.searchVector} @@ plainto_tsquery('english', ${searchQuery})` @@ -751,8 +753,10 @@ export const findSimilarPostsFn = createServerFn({ method: 'GET' }) score: sql`1 - (${posts.embedding} <=> ${vectorStr}::vector)`.as('score'), }) .from(posts) + .innerJoin(boards, eq(posts.boardId, boards.id)) .where( and( + eq(boards.isPublic, true), isNull(posts.deletedAt), isNull(posts.canonicalPostId), sql`${posts.embedding} IS NOT NULL`, @@ -831,7 +835,7 @@ export const findSimilarPostsFn = createServerFn({ method: 'GET' }) db .select({ id: boards.id, slug: boards.slug }) .from(boards) - .where(inArray(boards.id, boardIds)), + .where(and(inArray(boards.id, boardIds), eq(boards.isPublic, true))), ]) const statusMap = new Map(statusesResult.map((s) => [String(s.id), s])) From 81608420496a4b8a72781dd21fd88c85173e7447 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 5 Jun 2026 16:42:24 -0700 Subject: [PATCH 5/7] fix: harden help center MCP and JSON-LD --- apps/web/src/components/json-ld.tsx | 4 +++- apps/web/src/lib/server/mcp/tools.ts | 20 ++++++++++++++++--- .../__tests__/json-ld-serialization.test.ts | 16 +++++++++++++++ .../src/lib/shared/json-ld-serialization.ts | 14 +++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/lib/shared/__tests__/json-ld-serialization.test.ts create mode 100644 apps/web/src/lib/shared/json-ld-serialization.ts diff --git a/apps/web/src/components/json-ld.tsx b/apps/web/src/components/json-ld.tsx index a74cbea86..9db28b5ac 100644 --- a/apps/web/src/components/json-ld.tsx +++ b/apps/web/src/components/json-ld.tsx @@ -1,9 +1,11 @@ +import { serializeJsonLd } from '@/lib/shared/json-ld-serialization' + /** * JsonLd - Renders a &' + + const result = serializeJsonLd({ headline: payload }) + + expect(result).not.toContain('') + expect(result).not.toContain('