Skip to content

Commit 4e63f70

Browse files
jouwdanclaude
andauthored
fix: hold first posts for review, and promote into groups an operator made (#251)
Two bugs found while checking the deleted administrator reference against the code for MEI-124. Both are configuration that silently does nothing. `antispam.moderate_first_posts` asked whether the author bypasses moderation with `authorizer.can(actor, 'content.viewUnapproved')` and no target. That action is forum-scoped, so the call threw, the surrounding catch logged a warning and returned false, and the post was not held. It failed open for exactly the ordinary new members the setting exists to catch: administrators and awaiting-activation members never reached the throw. The line above it in reply-core already passed the scope. The target is now required, so no caller can omit it — both call sites had one in hand and the compiler found them. The catch that swallowed this is split in two: a threshold it cannot read leaves the feature off, which is what an unset threshold means anyway, while a bypass it cannot resolve holds the post. Holding a post that did not need holding is undone by a moderator; publishing spam is not. Promotion rules ranked an unranked group 0 and treated a lower rank as a demotion, so every rule into a group the operator created — Registered ranks 2 — was refused. That is every rule a board would write, since the seeded groups are guests, registered, moderators and administrators. The guard now compares only ranks it knows, which loses nothing: a candidate already in a protected group is skipped before the comparison, so protectedGroupIds is what keeps staff safe, and a real demotion between two ranked groups is still refused. promotion.test.ts passed throughout because its fixture ranks the custom group; the shipped guards cannot. The regression test is in packages/runtime against `defaultPromotionGuards()` itself. The antispam mock now throws for a forum-scoped action with no target, the way the real authorizer does. Without that it agreed with the bug. Co-authored-by: Claude <noreply@anthropic.com>
1 parent d869643 commit 4e63f70

6 files changed

Lines changed: 157 additions & 21 deletions

File tree

apps/community/src/server/antispam.test.ts

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,33 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
33
import type { Actor } from '@meith/authorization'
44
import { emptyPermissionSet, type PermissionSet } from '@meith/core'
55

6+
const FORUM_SCOPED = new Set(['content.viewUnapproved'])
7+
68
const state = vi.hoisted(() => ({
79
dataSource: 'postgres' as 'postgres' | 'fixture',
810
bypassesFlood: false,
911
counts: new Map<string, number>(),
1012
storeThrows: false,
13+
firstPostThreshold: 0,
1114
}))
1215

1316
vi.mock('./request-fingerprint', () => ({ countingPrefix: async () => null }))
14-
vi.mock('./settings', () => ({ getSettings: async () => ({ get: () => 0 }) }))
17+
vi.mock('./settings', () => ({
18+
getSettings: async () => ({ get: () => state.firstPostThreshold }),
19+
}))
1520

1621
vi.mock('./container', () => ({
1722
getContainer: () => ({
1823
dataSource: state.dataSource,
1924
authorizer: {
20-
can: () => state.bypassesFlood,
25+
can: (_actor: Actor, action: string, target?: unknown) => {
26+
if (FORUM_SCOPED.has(action) && target === undefined) {
27+
throw new Error(
28+
`Forum-scoped action "${action}" requires target.forum (resolved matrix).`,
29+
)
30+
}
31+
return state.bypassesFlood
32+
},
2133
globalLimit: (actor: Actor, key: keyof PermissionSet) => {
2234
const value = actor.global[key]
2335
return typeof value === 'number' ? value : 0
@@ -51,7 +63,7 @@ vi.mock('@meith/db', () => ({
5163
},
5264
}))
5365

54-
const { dailyLimitMessage, refused, spendDailyLimit } = await import('./antispam')
66+
const { dailyLimitMessage, holdsNewMember, refused, spendDailyLimit } = await import('./antispam')
5567

5668
function member(over: Partial<PermissionSet>, userId: number | null = 7): Actor {
5769
return {
@@ -179,3 +191,48 @@ describe('dailyLimitMessage', () => {
179191
).toBe('You have used your allowance of private messages for today. It resets in 2 hours.')
180192
})
181193
})
194+
195+
describe('holding a new member for review', () => {
196+
const FORUM_TARGET = { forum: { id: 1 } } as never
197+
198+
beforeEach(() => {
199+
state.firstPostThreshold = 0
200+
state.bypassesFlood = false
201+
})
202+
203+
it('does nothing while the setting is off', async () => {
204+
state.firstPostThreshold = 0
205+
await expect(
206+
holdsNewMember({ actor: member({}), postCount: 0, target: FORUM_TARGET }),
207+
).resolves.toBe(false)
208+
})
209+
210+
it('holds a member under the threshold', async () => {
211+
state.firstPostThreshold = 5
212+
await expect(
213+
holdsNewMember({ actor: member({}), postCount: 1, target: FORUM_TARGET }),
214+
).resolves.toBe(true)
215+
})
216+
217+
it('lets a member past the threshold through', async () => {
218+
state.firstPostThreshold = 5
219+
await expect(
220+
holdsNewMember({ actor: member({}), postCount: 9, target: FORUM_TARGET }),
221+
).resolves.toBe(false)
222+
})
223+
224+
it('lets someone who may see unapproved content through', async () => {
225+
state.firstPostThreshold = 5
226+
state.bypassesFlood = true
227+
await expect(
228+
holdsNewMember({ actor: member({}), postCount: 1, target: FORUM_TARGET }),
229+
).resolves.toBe(false)
230+
})
231+
232+
it('holds the post when it cannot tell who bypasses moderation', async () => {
233+
state.firstPostThreshold = 5
234+
await expect(
235+
holdsNewMember({ actor: member({}), postCount: 1, target: undefined as never }),
236+
).resolves.toBe(true)
237+
})
238+
})

apps/community/src/server/antispam.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
type RateLimitOutcome,
1717
subjectFor,
1818
} from '@meith/antispam'
19-
import type { Actor, NumericGlobalPermission } from '@meith/authorization'
19+
import type { Actor, NumericGlobalPermission, Target } from '@meith/authorization'
2020
import { env, logger } from '@meith/core'
2121
import { getDb, PostgresCaptchaQuestionRepository, PostgresRateLimitBucketStore } from '@meith/db'
2222
import type { SettingsSnapshot } from '@meith/settings'
@@ -280,29 +280,42 @@ export async function verifyChallenge(form: FormData): Promise<ChallengeVerdict>
280280
}
281281
}
282282

283+
const FIRST_POST_UNRESOLVED = 'could not resolve first-post moderation'
284+
285+
function bypassesModeration(actor: Actor, target: Target): boolean {
286+
try {
287+
return getContainer().authorizer.can(actor, 'content.viewUnapproved', target)
288+
} catch (error) {
289+
logger().warn({ err: String(error), stage: 'bypass', held: true }, FIRST_POST_UNRESOLVED)
290+
return false
291+
}
292+
}
293+
283294
export async function holdsNewMember(input: {
284295
readonly actor: Actor
285296
readonly postCount: number
297+
readonly target: Target
286298
readonly settings?: SettingsSnapshot
287299
}): Promise<boolean> {
300+
let threshold = 0
288301
try {
289302
const settings = input.settings ?? (await getSettings())
290-
const threshold = Number(settings.get('antispam.moderate_first_posts') ?? 0)
291-
if (threshold <= 0) return false
292-
293-
const { authorizer } = getContainer()
294-
return holdsForReview(
295-
{
296-
userId: input.actor.userId,
297-
postCount: input.postCount,
298-
bypassesModeration: authorizer.can(input.actor, 'content.viewUnapproved'),
299-
},
300-
{ threshold },
301-
)
303+
threshold = Number(settings.get('antispam.moderate_first_posts') ?? 0)
302304
} catch (error) {
303-
logger().warn({ err: String(error) }, 'could not resolve first-post moderation')
305+
logger().warn({ err: String(error), stage: 'threshold', held: false }, FIRST_POST_UNRESOLVED)
304306
return false
305307
}
308+
309+
if (threshold <= 0) return false
310+
311+
return holdsForReview(
312+
{
313+
userId: input.actor.userId,
314+
postCount: input.postCount,
315+
bypassesModeration: bypassesModeration(input.actor, input.target),
316+
},
317+
{ threshold },
318+
)
306319
}
307320

308321
export function antispamAvailable(): boolean {

apps/community/src/server/reply-core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ export async function submitReply(
131131
heldAsNewMember: await holdsNewMember({
132132
actor,
133133
postCount: profile.postCount,
134+
target: scope,
134135
settings,
135136
}),
136137
requiresApproval: scope.forum.requiresPostApproval === true,

apps/community/src/server/thread-core.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,12 @@ export async function submitThread(
126126
message: draft.body,
127127
prefixId: draft.prefixId,
128128
subscribe: input.subscribe ?? false,
129-
heldAsNewMember: await holdsNewMember({ actor, postCount: profile.postCount, settings }),
129+
heldAsNewMember: await holdsNewMember({
130+
actor,
131+
postCount: profile.postCount,
132+
target: scope,
133+
settings,
134+
}),
130135
requiresApproval: scope.forum.requiresThreadApproval === true,
131136
...(input.poll === undefined
132137
? {}

packages/groups/src/promotion.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ export function evaluatePromotions(
6262
now: Date = new Date(),
6363
): PromotionOutcome[] {
6464
const protectedIds = new Set(guards.protectedGroupIds)
65-
const rankOf = (groupId: number | null): number =>
66-
groupId === null ? 0 : (guards.rank?.get(groupId) ?? 0)
65+
const rankOf = (groupId: number | null): number | undefined =>
66+
groupId === null ? undefined : guards.rank?.get(groupId)
6767

6868
const active = [...rules]
6969
.filter((r) => r.enabled)
@@ -79,7 +79,9 @@ export function evaluatePromotions(
7979

8080
if (user.primaryGroupId === rule.toPrimaryGroupId) break
8181

82-
if (rankOf(rule.toPrimaryGroupId) < rankOf(user.primaryGroupId)) break
82+
const to = rankOf(rule.toPrimaryGroupId)
83+
const from = rankOf(user.primaryGroupId)
84+
if (to !== undefined && from !== undefined && to < from) break
8385

8486
outcomes.push({
8587
userId: user.userId,

packages/runtime/src/task-workers.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
22

33
import { MemoryQueue } from '@meith/drivers'
44
import type { OutboxReader, OutboxRecord } from '@meith/events'
5+
import type { PromotionCandidate, PromotionRule } from '@meith/groups'
6+
import { evaluatePromotions } from '@meith/groups'
57
import type { MarketplaceFeed } from '@meith/marketplace'
68

79
import { buildEventRegistry } from './event-handlers'
10+
import { SEED_GROUP } from './groups'
811
import { defaultPromotionGuards, taskWorkers } from './task-workers'
912

1013
const NOT_ABORTED = new AbortController().signal
@@ -403,3 +406,58 @@ describe('the marketplace catalog refresh', () => {
403406
expect('refreshMarketplaceCatalog' in workers).toBe(false)
404407
})
405408
})
409+
410+
describe('the guards a real board promotes under', () => {
411+
const CUSTOM_GROUP = 42
412+
413+
function candidate(primaryGroupId: number | null): PromotionCandidate {
414+
return {
415+
userId: 1,
416+
primaryGroupId,
417+
postCount: 500,
418+
reputation: 0,
419+
registeredAt: new Date('2020-01-01T00:00:00Z'),
420+
}
421+
}
422+
423+
function ruleInto(toPrimaryGroupId: number): PromotionRule {
424+
return {
425+
id: 1,
426+
title: 'Veteran',
427+
enabled: true,
428+
displayOrder: 0,
429+
toPrimaryGroupId,
430+
minPostCount: 100,
431+
}
432+
}
433+
434+
it('promotes a registered member into a group the operator made', () => {
435+
const outcomes = evaluatePromotions(
436+
[ruleInto(CUSTOM_GROUP)],
437+
[candidate(SEED_GROUP.registered)],
438+
defaultPromotionGuards(),
439+
)
440+
441+
expect(outcomes.map((outcome) => outcome.toPrimaryGroupId)).toEqual([CUSTOM_GROUP])
442+
})
443+
444+
it('still refuses a move down a rank it can see', () => {
445+
const outcomes = evaluatePromotions(
446+
[ruleInto(SEED_GROUP.guest)],
447+
[candidate(SEED_GROUP.registered)],
448+
defaultPromotionGuards(),
449+
)
450+
451+
expect(outcomes).toEqual([])
452+
})
453+
454+
it('still leaves a protected group alone', () => {
455+
const outcomes = evaluatePromotions(
456+
[ruleInto(CUSTOM_GROUP)],
457+
[candidate(SEED_GROUP.administrators)],
458+
defaultPromotionGuards(),
459+
)
460+
461+
expect(outcomes).toEqual([])
462+
})
463+
})

0 commit comments

Comments
 (0)