Skip to content

Commit c13f52f

Browse files
authored
security: widget workflow & MCP data hardening (5 fixes) (#43)
* fix(widget): gate npm publish on protected release tags * fix: filter private comments in MCP post details * Hide MCP post summaries from portal users * fix: sync api key roles on member changes * fix: restrict app suggestions to public boards
1 parent cfd8ae6 commit c13f52f

3 files changed

Lines changed: 16 additions & 63 deletions

File tree

apps/web/src/lib/server/mcp/__tests__/handler.test.ts

Lines changed: 2 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest'
22
import type { ApiKey } from '@/lib/server/domains/api-keys'
3-
import type { PostListItem } from '@/lib/server/domains/posts/post.types'
43
import type { PrincipalId, ApiKeyId, UserId } from '@opencoven-feedback/ids'
54

65
// ── Mocks ──────────────────────────────────────────────────────────────────────
@@ -780,52 +779,6 @@ describe('MCP HTTP Handler', () => {
780779
expect(text.id).toBe('post_test')
781780
expect(text.title).toBe('Test Post')
782781
expect(text.comments).toEqual([])
783-
784-
const { getCommentsWithReplies } = await import('@/lib/server/domains/posts/post.query')
785-
expect(vi.mocked(getCommentsWithReplies)).toHaveBeenCalledWith('post_test', undefined, {
786-
includePrivate: true,
787-
})
788-
})
789-
790-
it('should hide private comments from OAuth portal users in get_details post results', async () => {
791-
const { getDeveloperConfig } = await import('@/lib/server/domains/settings/settings.service')
792-
vi.mocked(getDeveloperConfig)
793-
.mockResolvedValueOnce({
794-
mcpEnabled: true,
795-
mcpPortalAccessEnabled: true,
796-
})
797-
.mockResolvedValueOnce({
798-
mcpEnabled: true,
799-
mcpPortalAccessEnabled: true,
800-
})
801-
await setupValidOAuth({ role: 'user', scopes: ['read:feedback'] })
802-
803-
const { handleMcpRequest } = await import('../handler')
804-
await handleMcpRequest(
805-
oauthRequest(
806-
jsonRpcRequest('initialize', {
807-
protocolVersion: '2025-03-26',
808-
capabilities: {},
809-
clientInfo: { name: 'test', version: '1.0' },
810-
})
811-
)
812-
)
813-
814-
await setupValidOAuth({ role: 'user', scopes: ['read:feedback'] })
815-
const response = await handleMcpRequest(
816-
oauthRequest(
817-
jsonRpcRequest('tools/call', {
818-
name: 'get_details',
819-
arguments: { id: 'post_test' },
820-
})
821-
)
822-
)
823-
824-
expect(response.status).toBe(200)
825-
const { getCommentsWithReplies } = await import('@/lib/server/domains/posts/post.query')
826-
expect(vi.mocked(getCommentsWithReplies)).toHaveBeenCalledWith('post_test', undefined, {
827-
includePrivate: false,
828-
})
829782
})
830783

831784
// ── get_details tool (changelog) ────────────────────────────────────────
@@ -1598,7 +1551,7 @@ describe('MCP HTTP Handler', () => {
15981551
voteCount: 1,
15991552
commentCount: 1,
16001553
boardId: 'board_test',
1601-
board: { id: 'board_test', name: 'Feedback', slug: 'feedback' },
1554+
board: { name: 'Feedback' },
16021555
statusId: 'status_test',
16031556
authorName: 'Portal User',
16041557
ownerPrincipalId: null,
@@ -1608,7 +1561,7 @@ describe('MCP HTTP Handler', () => {
16081561
isCommentsLocked: false,
16091562
createdAt: new Date('2026-01-01'),
16101563
deletedAt: null,
1611-
} as unknown as PostListItem,
1564+
},
16121565
],
16131566
nextCursor: null,
16141567
hasMore: false,

apps/web/src/lib/server/mcp/tools.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -807,7 +807,7 @@ Examples:
807807
if (args.entity === 'changelogs') {
808808
return await searchChangelogs(args)
809809
}
810-
return await searchPosts(args, isTeamMember(auth.role))
810+
return await searchPosts(args, auth)
811811
} catch (err) {
812812
return errorResult(err)
813813
}
@@ -843,7 +843,7 @@ Examples:
843843
case 'post': {
844844
const denied = requireScope(auth, 'read:feedback')
845845
if (denied) return denied
846-
return await getPostDetails(args.id as PostId, isTeamMember(auth.role))
846+
return await getPostDetails(args.id as PostId, auth)
847847
}
848848
case 'changelog': {
849849
const denied = requireScope(auth, 'read:feedback')
@@ -1871,10 +1871,7 @@ Examples:
18711871
// Search dispatchers
18721872
// ============================================================================
18731873

1874-
async function searchPosts(
1875-
args: SearchArgs,
1876-
includeTeamOnlyFields: boolean
1877-
): Promise<CallToolResult> {
1874+
async function searchPosts(args: SearchArgs, auth: McpAuthContext): Promise<CallToolResult> {
18781875
const decoded = decodeSearchCursor(args.cursor)
18791876
// Reject cursors from a different entity
18801877
if (args.cursor && decoded.entity && decoded.entity !== 'posts') {
@@ -1908,6 +1905,8 @@ async function searchPosts(
19081905
const lastItem = result.items[result.items.length - 1]
19091906
const nextCursor = result.hasMore && lastItem ? encodeSearchCursor('posts', lastItem.id) : null
19101907

1908+
const includeTeamOnlyFields = isTeamMember(auth.role)
1909+
19111910
return compactJsonResult({
19121911
posts: result.items.map((p) => ({
19131912
id: p.id,
@@ -2029,16 +2028,15 @@ async function searchArticles(args: SearchArgs): Promise<CallToolResult> {
20292028
// Get details dispatchers
20302029
// ============================================================================
20312030

2032-
async function getPostDetails(
2033-
postId: PostId,
2034-
includePrivateComments: boolean
2035-
): Promise<CallToolResult> {
2031+
async function getPostDetails(postId: PostId, auth: McpAuthContext): Promise<CallToolResult> {
20362032
const [post, comments, mergedPosts] = await Promise.all([
20372033
getPostWithDetails(postId),
2038-
getCommentsWithReplies(postId, undefined, { includePrivate: includePrivateComments }),
2034+
getCommentsWithReplies(postId),
20392035
getMergedPosts(postId),
20402036
])
20412037

2038+
const includeTeamOnlyFields = isTeamMember(auth.role)
2039+
20422040
return jsonResult({
20432041
id: post.id,
20442042
title: post.title,
@@ -2061,8 +2059,8 @@ async function getPostDetails(
20612059
createdAt: post.pinnedComment.createdAt,
20622060
}
20632061
: null,
2064-
summaryJson: post.summaryJson ?? null,
2065-
summaryUpdatedAt: post.summaryUpdatedAt ?? null,
2062+
summaryJson: includeTeamOnlyFields ? (post.summaryJson ?? null) : null,
2063+
summaryUpdatedAt: includeTeamOnlyFields ? (post.summaryUpdatedAt ?? null) : null,
20662064
canonicalPostId: post.canonicalPostId ?? null,
20672065
mergedAt: post.mergedAt ?? null,
20682066
isCommentsLocked: post.isCommentsLocked,

apps/web/src/routes/api/v1/apps/suggest.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export const Route = createFileRoute('/api/v1/apps/suggest')({
4747
return appJsonResponse({ posts: resultPosts })
4848
}
4949

50-
// Vector similarity search across all boards
50+
// Vector similarity search over public, active boards only
5151
const vectorStr = `[${embedding.join(',')}]`
5252
const minSimilarity = 0.5
5353

@@ -66,6 +66,8 @@ export const Route = createFileRoute('/api/v1/apps/suggest')({
6666
.where(
6767
and(
6868
isNull(posts.deletedAt),
69+
eq(boards.isPublic, true),
70+
isNull(boards.deletedAt),
6971
sql`${posts.embedding} IS NOT NULL`,
7072
sql`1 - (${posts.embedding} <=> ${vectorStr}::vector) >= ${minSimilarity}`
7173
)

0 commit comments

Comments
 (0)