fix: raise new skill publish rate limit (#3030)

This commit is contained in:
Patrick Erichsen
2026-07-08 22:21:47 -07:00
committed by GitHub
parent 683a1bf5dd
commit dfeb1682ec
2 changed files with 17 additions and 46 deletions
+4 -4
View File
@@ -220,11 +220,11 @@ function emptyOwnerScopedAliasLookup(name: string) {
}
describe("skills anti-spam guards", () => {
it("blocks low-trust users after hourly new-skill cap", async () => {
it("blocks users after 200 new skills in 24 hours", async () => {
const now = Date.now();
const ownerSkills = Array.from({ length: 5 }, (_, i) => ({
const ownerSkills = Array.from({ length: 200 }, (_, i) => ({
_id: `skills:${i}`,
createdAt: now - i * 10_000,
createdAt: now - i * 60_000,
}));
const db = {
@@ -303,7 +303,7 @@ describe("skills anti-spam guards", () => {
await expect(
insertVersionHandler({ db } as never, createPublishArgs() as never),
).rejects.toThrow(/max 5 new skills per hour/i);
).rejects.toThrow(/max 200 new skills per 24 hours/i);
});
it("allows a legacy personal publish when another owner already uses the slug", async () => {
+13 -42
View File
@@ -201,15 +201,13 @@ const HARD_DELETE_LEADERBOARD_BATCH_SIZE = 25;
const BAN_USER_SKILLS_BATCH_SIZE = 25;
const MAX_REPORT_REASON_SAMPLE = 5;
const MAX_APPEAL_MESSAGE_LENGTH = 2_000;
const RATE_LIMIT_HOUR_MS = 60 * 60 * 1000;
const RATE_LIMIT_DAY_MS = 24 * RATE_LIMIT_HOUR_MS;
const RATE_LIMIT_DAY_MS = 24 * 60 * 60 * 1000;
const SLUG_RESERVATION_DAYS = 90;
const SLUG_RESERVATION_MS = SLUG_RESERVATION_DAYS * RATE_LIMIT_DAY_MS;
const UNPUBLISHED_SLUG_RESERVATION_DAYS = 30;
const UNPUBLISHED_SLUG_RESERVATION_MS = UNPUBLISHED_SLUG_RESERVATION_DAYS * RATE_LIMIT_DAY_MS;
const MAX_SKILL_SLUG_ALIASES_PER_SKILL = 5;
const MAX_SKILL_SLUG_ALIASES_PER_OWNER = 25;
const LOW_TRUST_ACCOUNT_AGE_MS = 30 * RATE_LIMIT_DAY_MS;
const MAX_MANUAL_OVERRIDE_NOTE_LENGTH = 1200;
const DEFAULT_STAFF_AUDIT_LOG_LIMIT = 10;
const MAX_STAFF_AUDIT_LOG_LIMIT = 50;
@@ -853,13 +851,8 @@ export const previewLatestSkillModerationInternal = internalQuery({
};
},
});
const TRUSTED_PUBLISHER_SKILL_THRESHOLD = 10;
const LOW_TRUST_BURST_THRESHOLD_PER_HOUR = 8;
const OWNER_ACTIVITY_SCAN_LIMIT = 500;
const NEW_SKILL_RATE_LIMITS = {
lowTrust: { perHour: 5, perDay: 20 },
trusted: { perHour: 20, perDay: 80 },
} as const;
const NEW_SKILL_DAILY_LIMIT = 200;
const SORT_INDEXES = {
recommended: "by_active_recommended_score",
@@ -906,9 +899,7 @@ function isUserId(value: Id<"users"> | null | undefined): value is Id<"users"> {
return typeof value === "string" && value.length > 0;
}
type OwnerTrustSignals = {
isLowTrust: boolean;
skillsLastHour: number;
type OwnerPublishActivity = {
skillsLastDay: number;
};
@@ -1462,53 +1453,33 @@ async function adjustGlobalPublicCountForSkillChange(
await adjustGlobalPublicSkillsCount(ctx, delta);
}
async function getOwnerTrustSignals(
async function getOwnerPublishActivity(
ctx: QueryCtx | MutationCtx,
owner: Doc<"users">,
ownerUserId: Id<"users">,
now: number,
): Promise<OwnerTrustSignals> {
): Promise<OwnerPublishActivity> {
const ownerSkills = await ctx.db
.query("skills")
.withIndex("by_owner", (q) => q.eq("ownerUserId", owner._id))
.withIndex("by_owner", (q) => q.eq("ownerUserId", ownerUserId))
.order("desc")
.take(OWNER_ACTIVITY_SCAN_LIMIT);
const hourThreshold = now - RATE_LIMIT_HOUR_MS;
const dayThreshold = now - RATE_LIMIT_DAY_MS;
let skillsLastHour = 0;
let skillsLastDay = 0;
for (const skill of ownerSkills) {
if (skill.createdAt >= dayThreshold) {
skillsLastDay += 1;
if (skill.createdAt >= hourThreshold) {
skillsLastHour += 1;
}
}
}
const accountCreatedAt = owner.createdAt ?? owner._creationTime;
const accountAgeMs = Math.max(0, now - accountCreatedAt);
const isLowTrust =
accountAgeMs < LOW_TRUST_ACCOUNT_AGE_MS ||
ownerSkills.length < TRUSTED_PUBLISHER_SKILL_THRESHOLD ||
skillsLastHour >= LOW_TRUST_BURST_THRESHOLD_PER_HOUR;
return { isLowTrust, skillsLastHour, skillsLastDay };
return { skillsLastDay };
}
function enforceNewSkillRateLimit(signals: OwnerTrustSignals) {
const limits = signals.isLowTrust
? NEW_SKILL_RATE_LIMITS.lowTrust
: NEW_SKILL_RATE_LIMITS.trusted;
if (signals.skillsLastHour >= limits.perHour) {
function enforceNewSkillRateLimit(activity: OwnerPublishActivity) {
if (activity.skillsLastDay >= NEW_SKILL_DAILY_LIMIT) {
throw new ConvexError(
`Rate limit: max ${limits.perHour} new skills per hour. Please wait before publishing more.`,
);
}
if (signals.skillsLastDay >= limits.perDay) {
throw new ConvexError(
`Rate limit: max ${limits.perDay} new skills per 24 hours. Please wait before publishing more.`,
`Rate limit: max ${NEW_SKILL_DAILY_LIMIT} new skills per 24 hours. Please wait before publishing more.`,
);
}
}
@@ -12071,8 +12042,8 @@ export const insertVersion = internalMutation({
});
if (!args.bypassNewSkillRateLimit) {
const ownerTrustSignals = await getOwnerTrustSignals(ctx, user, now);
enforceNewSkillRateLimit(ownerTrustSignals);
const ownerPublishActivity = await getOwnerPublishActivity(ctx, user._id, now);
enforceNewSkillRateLimit(ownerPublishActivity);
}
const forkOfSlug = args.forkOf?.slug.trim().toLowerCase() || "";