Skip to content

Commit 4f0b422

Browse files
committed
fix(api): restore safe non-suspicious behavior
1 parent 4435a77 commit 4f0b422

13 files changed

Lines changed: 512 additions & 77 deletions

convex/httpApi.handlers.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,22 @@ describe('httpApi handlers', () => {
112112
})
113113
})
114114

115+
it('searchSkillsHttp prefers canonical nonSuspiciousOnly over legacy alias', async () => {
116+
const runAction = vi.fn().mockResolvedValue([])
117+
await __handlers.searchSkillsHandler(
118+
makeCtx({ runAction }),
119+
new Request(
120+
'https://example.com/api/search?q=test&nonSuspiciousOnly=false&nonSuspicious=1',
121+
),
122+
)
123+
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
124+
query: 'test',
125+
limit: undefined,
126+
highlightedOnly: undefined,
127+
nonSuspiciousOnly: undefined,
128+
})
129+
})
130+
115131
it('getSkillHttp validates slug', async () => {
116132
const response = await __handlers.getSkillHandler(
117133
makeCtx({ runQuery: vi.fn() }),

convex/httpApi.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { ActionCtx } from './_generated/server'
1212
import { httpAction } from './functions'
1313
import { requireApiTokenUser } from './lib/apiTokenAuth'
1414
import { corsHeaders, mergeHeaders } from './lib/httpHeaders'
15-
import { parseBooleanQueryParam } from './lib/httpUtils'
15+
import { parseBooleanQueryParam, resolveBooleanQueryParam } from './lib/httpUtils'
1616
import { publishVersionForUser } from './skills'
1717

1818
type SearchSkillEntry = {
@@ -47,9 +47,10 @@ async function searchSkillsHandler(ctx: ActionCtx, request: Request) {
4747
const limit = toOptionalNumber(url.searchParams.get('limit'))
4848
const approvedOnly = parseBooleanQueryParam(url.searchParams.get('approvedOnly'))
4949
const highlightedOnly = parseBooleanQueryParam(url.searchParams.get('highlightedOnly')) || approvedOnly
50-
const nonSuspiciousOnly =
51-
parseBooleanQueryParam(url.searchParams.get('nonSuspiciousOnly')) ||
52-
parseBooleanQueryParam(url.searchParams.get('nonSuspicious'))
50+
const nonSuspiciousOnly = resolveBooleanQueryParam(
51+
url.searchParams.get('nonSuspiciousOnly'),
52+
url.searchParams.get('nonSuspicious'),
53+
)
5354

5455
if (!query) return json({ results: [] })
5556

@@ -168,7 +169,7 @@ async function cliPublishHandler(ctx: ActionCtx, request: Request) {
168169
try {
169170
const { userId } = await requireApiTokenUser(ctx, request)
170171
const args = parsePublishBody(body)
171-
if (args.acceptLicenseTerms !== true) {
172+
if (!hasAcceptedLegacyLicenseTerms(args.acceptLicenseTerms)) {
172173
return text('MIT-0 license terms must be accepted to publish skills', 400)
173174
}
174175
const result = await publishVersionForUser(ctx, userId, args)
@@ -180,6 +181,10 @@ async function cliPublishHandler(ctx: ActionCtx, request: Request) {
180181
}
181182
}
182183

184+
function hasAcceptedLegacyLicenseTerms(acceptLicenseTerms: boolean | undefined) {
185+
return acceptLicenseTerms !== false
186+
}
187+
183188
export const cliPublishHttp = httpAction(cliPublishHandler)
184189

185190
async function cliSkillDeleteHandler(ctx: ActionCtx, request: Request, deleted: boolean) {

convex/httpApiV1.handlers.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,26 @@ describe('httpApiV1 handlers', () => {
265265
})
266266
})
267267

268+
it('search prefers canonical nonSuspiciousOnly over legacy alias', async () => {
269+
const runAction = vi.fn().mockResolvedValue([])
270+
const runMutation = vi.fn().mockResolvedValue(okRate())
271+
const response = await __handlers.searchSkillsV1Handler(
272+
makeCtx({ runAction, runMutation }),
273+
new Request(
274+
'https://example.com/api/v1/search?q=test&nonSuspiciousOnly=false&nonSuspicious=1',
275+
),
276+
)
277+
if (response.status !== 200) {
278+
throw new Error(await response.text())
279+
}
280+
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
281+
query: 'test',
282+
limit: undefined,
283+
highlightedOnly: undefined,
284+
nonSuspiciousOnly: undefined,
285+
})
286+
})
287+
268288
it('search rate limits', async () => {
269289
const runMutation = vi.fn().mockResolvedValue(blockedRate())
270290
const response = await __handlers.searchSkillsV1Handler(
@@ -623,6 +643,24 @@ describe('httpApiV1 handlers', () => {
623643
expect(response.status).toBe(200)
624644
})
625645

646+
it('lists skills prefers canonical nonSuspiciousOnly over legacy alias', async () => {
647+
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
648+
if ('sort' in args || 'cursor' in args || 'limit' in args) {
649+
expect(args.nonSuspiciousOnly).toBeUndefined()
650+
return { items: [], nextCursor: null }
651+
}
652+
return null
653+
})
654+
const runMutation = vi.fn().mockResolvedValue(okRate())
655+
const response = await __handlers.listSkillsV1Handler(
656+
makeCtx({ runQuery, runMutation }),
657+
new Request(
658+
'https://example.com/api/v1/skills?nonSuspiciousOnly=false&nonSuspicious=1',
659+
),
660+
)
661+
expect(response.status).toBe(200)
662+
})
663+
626664
it('get skill returns 404 when missing', async () => {
627665
const runQuery = vi.fn().mockResolvedValue(null)
628666
const runMutation = vi.fn().mockResolvedValue(okRate())

convex/httpApiV1/skillsV1.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { api, internal } from '../_generated/api'
22
import type { Doc, Id } from '../_generated/dataModel'
33
import type { ActionCtx } from '../_generated/server'
44
import { getOptionalApiTokenUserId, requireApiTokenUser } from '../lib/apiTokenAuth'
5-
import { parseBooleanQueryParam } from '../lib/httpUtils'
5+
import { parseBooleanQueryParam, resolveBooleanQueryParam } from '../lib/httpUtils'
66
import { applyRateLimit, parseBearerToken } from '../lib/httpRateLimit'
77
import { publishVersionForUser } from '../skills'
88
import {
@@ -327,9 +327,10 @@ export async function searchSkillsV1Handler(ctx: ActionCtx, request: Request) {
327327
const query = url.searchParams.get('q')?.trim() ?? ''
328328
const limit = toOptionalNumber(url.searchParams.get('limit'))
329329
const highlightedOnly = parseBooleanQueryParam(url.searchParams.get('highlightedOnly'))
330-
const nonSuspiciousOnly =
331-
parseBooleanQueryParam(url.searchParams.get('nonSuspiciousOnly')) ||
332-
parseBooleanQueryParam(url.searchParams.get('nonSuspicious'))
330+
const nonSuspiciousOnly = resolveBooleanQueryParam(
331+
url.searchParams.get('nonSuspiciousOnly'),
332+
url.searchParams.get('nonSuspicious'),
333+
)
333334

334335
if (!query) return json({ results: [] }, 200, rate.headers)
335336

@@ -408,9 +409,10 @@ export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
408409
const rawCursor = url.searchParams.get('cursor')?.trim() || undefined
409410
const sort = parseListSort(url.searchParams.get('sort'))
410411
const cursor = sort === 'trending' ? undefined : rawCursor
411-
const nonSuspiciousOnly =
412-
parseBooleanQueryParam(url.searchParams.get('nonSuspiciousOnly')) ||
413-
parseBooleanQueryParam(url.searchParams.get('nonSuspicious'))
412+
const nonSuspiciousOnly = resolveBooleanQueryParam(
413+
url.searchParams.get('nonSuspiciousOnly'),
414+
url.searchParams.get('nonSuspicious'),
415+
)
414416

415417
const result = (await ctx.runQuery(api.skills.listPublicPage, {
416418
limit,
@@ -846,7 +848,7 @@ export async function publishSkillV1Handler(ctx: ActionCtx, request: Request) {
846848
if (contentType.includes('application/json')) {
847849
const body = await request.json()
848850
const payload = parsePublishBody(body)
849-
if (payload.acceptLicenseTerms !== true) {
851+
if (!hasAcceptedLegacyLicenseTerms(payload.acceptLicenseTerms)) {
850852
return text('MIT-0 license terms must be accepted to publish skills', 400, rate.headers)
851853
}
852854
const result = await publishVersionForUser(ctx, userId, payload)
@@ -855,7 +857,7 @@ export async function publishSkillV1Handler(ctx: ActionCtx, request: Request) {
855857

856858
if (contentType.includes('multipart/form-data')) {
857859
const payload = await parseMultipartPublish(ctx, request)
858-
if (payload.acceptLicenseTerms !== true) {
860+
if (!hasAcceptedLegacyLicenseTerms(payload.acceptLicenseTerms)) {
859861
return text('MIT-0 license terms must be accepted to publish skills', 400, rate.headers)
860862
}
861863
const result = await publishVersionForUser(ctx, userId, payload)
@@ -869,6 +871,10 @@ export async function publishSkillV1Handler(ctx: ActionCtx, request: Request) {
869871
return text('Unsupported content type', 415, rate.headers)
870872
}
871873

874+
function hasAcceptedLegacyLicenseTerms(acceptLicenseTerms: boolean | undefined) {
875+
return acceptLicenseTerms !== false
876+
}
877+
872878
type TransferDecisionAction = 'accept' | 'reject' | 'cancel'
873879

874880
function transferErrorToResponse(error: unknown, headers: HeadersInit) {

convex/leaderboards.ts

Lines changed: 66 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { v } from 'convex/values'
22
import { internal } from './_generated/api'
3-
import type { Id } from './_generated/dataModel'
43
import { internalAction, internalMutation, internalQuery } from './functions'
54
import {
6-
buildTrendingLeaderboard,
7-
compareTrendingEntries,
5+
buildTrendingEntriesFromDailyRows,
6+
buildTrendingEntryCandidates,
87
getTrendingRange,
98
queryDailyStats,
10-
topN,
9+
takeTopNonSuspiciousTrendingEntries,
10+
takeTopTrendingEntries,
11+
TRENDING_LEADERBOARD_KIND,
12+
TRENDING_NON_SUSPICIOUS_LEADERBOARD_KIND,
1113
} from './lib/leaderboards'
1214

1315
const MAX_TRENDING_LIMIT = 200
@@ -26,9 +28,27 @@ export const getDailyStats = internalQuery({
2628
},
2729
})
2830

31+
export const filterTopNonSuspiciousTrendingEntries = internalQuery({
32+
args: {
33+
entries: v.array(
34+
v.object({
35+
skillId: v.id('skills'),
36+
score: v.number(),
37+
installs: v.number(),
38+
downloads: v.number(),
39+
}),
40+
),
41+
limit: v.number(),
42+
},
43+
handler: async (ctx, { entries, limit }) => {
44+
return takeTopNonSuspiciousTrendingEntries(ctx, entries, limit)
45+
},
46+
})
47+
2948
/** Writes the pre-computed leaderboard and prunes old entries. */
3049
export const writeTrendingLeaderboard = internalMutation({
3150
args: {
51+
kind: v.string(),
3252
items: v.array(
3353
v.object({
3454
skillId: v.id('skills'),
@@ -40,11 +60,11 @@ export const writeTrendingLeaderboard = internalMutation({
4060
startDay: v.number(),
4161
endDay: v.number(),
4262
},
43-
handler: async (ctx, { items, startDay, endDay }) => {
63+
handler: async (ctx, { kind, items, startDay, endDay }) => {
4464
const now = Date.now()
4565

4666
await ctx.db.insert('skillLeaderboards', {
47-
kind: 'trending',
67+
kind,
4868
generatedAt: now,
4969
rangeStartDay: startDay,
5070
rangeEndDay: endDay,
@@ -53,7 +73,7 @@ export const writeTrendingLeaderboard = internalMutation({
5373

5474
const recent = await ctx.db
5575
.query('skillLeaderboards')
56-
.withIndex('by_kind', (q) => q.eq('kind', 'trending'))
76+
.withIndex('by_kind', (q) => q.eq('kind', kind))
5777
.order('desc')
5878
.take(KEEP_LEADERBOARD_ENTRIES + 5)
5979

@@ -70,39 +90,32 @@ export const rebuildTrendingLeaderboardAction = internalAction({
7090
args: { limit: v.optional(v.number()) },
7191
handler: async (ctx, args): Promise<{ ok: true; count: number }> => {
7292
const limit = clampInt(args.limit ?? MAX_TRENDING_LIMIT, 1, MAX_TRENDING_LIMIT)
73-
const { startDay, endDay } = getTrendingRange(Date.now())
74-
93+
const now = Date.now()
94+
const { startDay, endDay } = getTrendingRange(now)
7595
const dayKeys = Array.from({ length: endDay - startDay + 1 }, (_, i) => startDay + i)
7696
const perDayRows = await Promise.all(
7797
dayKeys.map((day) => ctx.runQuery(internal.leaderboards.getDailyStats, { day })),
7898
)
79-
80-
const totals = new Map<string, { installs: number; downloads: number }>()
81-
for (const rows of perDayRows) {
82-
for (const row of rows) {
83-
const current = totals.get(row.skillId) ?? { installs: 0, downloads: 0 }
84-
current.installs += row.installs
85-
current.downloads += row.downloads
86-
totals.set(row.skillId, current)
87-
}
88-
}
89-
90-
const entries = Array.from(totals, ([skillId, t]) => ({
91-
skillId: skillId as Id<'skills'>,
92-
installs: t.installs,
93-
downloads: t.downloads,
94-
score: t.installs,
95-
}))
96-
97-
const items = topN(entries, limit, compareTrendingEntries).sort((a, b) =>
98-
compareTrendingEntries(b, a),
99+
const entries = buildTrendingEntriesFromDailyRows(perDayRows)
100+
const items = takeTopTrendingEntries(entries, limit)
101+
const nonSuspicious = await ctx.runQuery(
102+
internal.leaderboards.filterTopNonSuspiciousTrendingEntries,
103+
{ entries, limit },
99104
)
100105

101-
return await ctx.runMutation(internal.leaderboards.writeTrendingLeaderboard, {
106+
await ctx.runMutation(internal.leaderboards.writeTrendingLeaderboard, {
107+
kind: TRENDING_LEADERBOARD_KIND,
102108
items,
103109
startDay,
104110
endDay,
105111
})
112+
await ctx.runMutation(internal.leaderboards.writeTrendingLeaderboard, {
113+
kind: TRENDING_NON_SUSPICIOUS_LEADERBOARD_KIND,
114+
items: nonSuspicious,
115+
startDay,
116+
endDay,
117+
})
118+
return { ok: true as const, count: items.length }
106119
},
107120
})
108121

@@ -115,24 +128,37 @@ export const rebuildTrendingLeaderboardInternal = internalMutation({
115128
handler: async (ctx, args) => {
116129
const limit = clampInt(args.limit ?? MAX_TRENDING_LIMIT, 1, MAX_TRENDING_LIMIT)
117130
const now = Date.now()
118-
const { startDay, endDay, items } = await buildTrendingLeaderboard(ctx, { limit, now })
131+
const { startDay, endDay, entries } = await buildTrendingEntryCandidates(ctx, now)
132+
const items = takeTopTrendingEntries(entries, limit)
133+
const nonSuspicious = await takeTopNonSuspiciousTrendingEntries(ctx, entries, limit)
119134

120135
await ctx.db.insert('skillLeaderboards', {
121-
kind: 'trending',
136+
kind: TRENDING_LEADERBOARD_KIND,
122137
generatedAt: now,
123138
rangeStartDay: startDay,
124139
rangeEndDay: endDay,
125140
items,
126141
})
142+
await ctx.db.insert('skillLeaderboards', {
143+
kind: TRENDING_NON_SUSPICIOUS_LEADERBOARD_KIND,
144+
generatedAt: now,
145+
rangeStartDay: startDay,
146+
rangeEndDay: endDay,
147+
items: nonSuspicious,
148+
})
127149

128-
const recent = await ctx.db
129-
.query('skillLeaderboards')
130-
.withIndex('by_kind', (q) => q.eq('kind', 'trending'))
131-
.order('desc')
132-
.take(KEEP_LEADERBOARD_ENTRIES + 5)
133-
134-
for (const entry of recent.slice(KEEP_LEADERBOARD_ENTRIES)) {
135-
await ctx.db.delete(entry._id)
150+
for (const kind of [
151+
TRENDING_LEADERBOARD_KIND,
152+
TRENDING_NON_SUSPICIOUS_LEADERBOARD_KIND,
153+
]) {
154+
const entriesForKind = await ctx.db
155+
.query('skillLeaderboards')
156+
.withIndex('by_kind', (q) => q.eq('kind', kind))
157+
.order('desc')
158+
.take(KEEP_LEADERBOARD_ENTRIES + 5)
159+
for (const entry of entriesForKind.slice(KEEP_LEADERBOARD_ENTRIES)) {
160+
await ctx.db.delete(entry._id)
161+
}
136162
}
137163

138164
return { ok: true as const, count: items.length }

convex/lib/httpUtils.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it } from 'vitest'
2-
import { parseBooleanQueryParam } from './httpUtils'
2+
import {
3+
parseBooleanQueryParam,
4+
parseBooleanQueryParamOptional,
5+
resolveBooleanQueryParam,
6+
} from './httpUtils'
37

48
describe('parseBooleanQueryParam', () => {
59
it('returns true for true-like values', () => {
@@ -15,4 +19,17 @@ describe('parseBooleanQueryParam', () => {
1519
expect(parseBooleanQueryParam('0')).toBe(false)
1620
expect(parseBooleanQueryParam('yes')).toBe(false)
1721
})
22+
23+
it('supports optional parsing for precedence-sensitive callers', () => {
24+
expect(parseBooleanQueryParamOptional(null)).toBeUndefined()
25+
expect(parseBooleanQueryParamOptional('false')).toBe(false)
26+
expect(parseBooleanQueryParamOptional('1')).toBe(true)
27+
})
28+
29+
it('prefers the primary param over the legacy alias when both are present', () => {
30+
expect(resolveBooleanQueryParam('false', '1')).toBe(false)
31+
expect(resolveBooleanQueryParam('true', '0')).toBe(true)
32+
expect(resolveBooleanQueryParam(null, '1')).toBe(true)
33+
expect(resolveBooleanQueryParam(null, null)).toBeUndefined()
34+
})
1835
})

0 commit comments

Comments
 (0)