feat(api): add moderation events and visibility actions (#3219)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-05 13:24:19 +02:00
1 parent 532e828fe6
commit 05257d6439
39 files changed
+2464 -88

No files matched your search

@@ -57,6 +57,15 @@ export function currentAccountChangeSource(): ChangeSource {
return sourceContext.getStore() ?? requestContext.getStore()?.source ?? 'other';
}
export function anonymousActivityMeta(): Meta {
const context = requestContext.getStore();
return {
...workerMeta(),
channel: context?.channel ?? processChannel,
request_id: context?.requestId ?? null,
};
}
export function workerMeta(): Meta {
return {ip: null, country: null, ua: null, locale: null, channel: 'worker', request_id: null};
}
@@ -4,9 +4,9 @@ export type Id = string;
export type Flags64 = string;
export type Channel = "stable" | "canary" | "worker" | "internal" | "import" | "other";
export type Meta = { ip: string | null, country: string | null, ua: string | null, locale: string | null, channel: Channel, request_id: string | null, };
export type Kind = "registration" | "email_changed" | "profile_updated" | "account_changed" | "admin_action" | "account_deleted" | "report_filed" | "email_bounced" | "action_outcome" | "login" | "session_started" | "guild_joined" | "dm_opened" | "message_created" | "message_updated" | "friend_request" | "http_errors" | "user_blocked";
export type Event = { v: number, id: string, at_ms: number, key: string, meta: Meta, } & ({ "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "message_updated", "data": MessageUpdated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked });
export type Body = { "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "message_updated", "data": MessageUpdated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked };
export type Kind = "registration" | "email_changed" | "profile_updated" | "account_changed" | "admin_action" | "account_deleted" | "report_filed" | "email_bounced" | "action_outcome" | "login" | "session_started" | "guild_joined" | "dm_opened" | "message_created" | "message_updated" | "friend_request" | "http_errors" | "user_blocked" | "guild_member_banned" | "guild_member_unbanned" | "report_resolved";
export type Event = { v: number, id: string, at_ms: number, key: string, meta: Meta, } & ({ "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "message_updated", "data": MessageUpdated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked } | { "kind": "guild_member_banned", "data": GuildMemberBanned } | { "kind": "guild_member_unbanned", "data": GuildMemberUnbanned } | { "kind": "report_resolved", "data": ReportResolved });
export type Body = { "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "message_updated", "data": MessageUpdated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked } | { "kind": "guild_member_banned", "data": GuildMemberBanned } | { "kind": "guild_member_unbanned", "data": GuildMemberUnbanned } | { "kind": "report_resolved", "data": ReportResolved };
export type Registration = { user_id: Id, method: RegMethod, email: string | null, username: string, username_user_chosen: boolean, global_name: string | null, locale: string | null, timezone: string | null, invite_code: string | null, flags: Flags64, };
export type RegMethod = "password" | "unclaimed" | "oauth" | "other";
export type EmailChanged = { user_id: Id, new_email: string, was_unclaimed: boolean, has_ever_purchased: boolean, };
@@ -32,8 +32,14 @@ export type ChannelType = "dm" | "group_dm" | "guild";
export type FriendRequest = { user_id: Id, target_id: Id, delivered: boolean, };
export type UserBlocked = { blocker_id: Id, blocked_id: Id, };
export type HttpErrors = { ip: string, window_ms: number, s401: number, s403: number, s404: number, s429: number, other_4xx: number, auth_failures: number, token_hashes: Array<string>, };
export type ActionEnvelope = { v: number, id: string, key: string, issued_at_ms: number, expires_at_ms: number, } & ({ "type": "set_account_limit", user_id: Id, on: boolean, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "limit_new_conversations", user_id: Id, on: boolean, until_ms: number, });
export type Action = { "type": "set_account_limit", user_id: Id, on: boolean, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "limit_new_conversations", user_id: Id, on: boolean, until_ms: number, };
export type GuildMemberBanned = { guild_id: Id, user_id: Id, moderator_id: Id, by: BanBy, guild_member_count: number, target_moderator: boolean, expires_at_ms: number | null, };
export type GuildMemberUnbanned = { guild_id: Id, user_id: Id, moderator_id: Id, by: BanBy, };
export type BanBy = "moderator" | "staff";
export type ReportResolved = { report_id: Id, reporter_id: Id, category: string, target_type: ReportTarget, reported_user_id: Id | null, outcome: ReportOutcome, resolved_by: ResolvedBy, };
export type ReportOutcome = "actioned" | "no_violation" | "duplicate" | "auto_resolved" | "unspecified";
export type ResolvedBy = "staff" | "system";
export type ActionEnvelope = { v: number, id: string, key: string, issued_at_ms: number, expires_at_ms: number, } & ({ "type": "set_account_limit", user_id: Id, on: boolean, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "limit_new_conversations", user_id: Id, on: boolean, until_ms: number, } | { "type": "hide_recent_messages", user_id: Id, since_ms: number, on: boolean, } | { "type": "hide_profile", user_id: Id, on: boolean, } | { "type": "delete_user_messages", user_id: Id, on: boolean, });
export type Action = { "type": "set_account_limit", user_id: Id, on: boolean, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "limit_new_conversations", user_id: Id, on: boolean, until_ms: number, } | { "type": "hide_recent_messages", user_id: Id, since_ms: number, on: boolean, } | { "type": "hide_profile", user_id: Id, on: boolean, } | { "type": "delete_user_messages", user_id: Id, on: boolean, };
export type ActionOutcome = { action_id: string, action_type: string, status: OutcomeStatus, detail: string | null, observed: Observed | null, user_id?: Id, };
export type OutcomeStatus = "applied" | "noop" | "conflict" | "expired" | "ineligible" | "exempt" | "unsupported" | "failed";
export type Observed = { flags: Flags64, deleted: boolean, };
@@ -71,6 +77,9 @@ export const EVENT_KINDS: ReadonlyArray<EventKind> = [
'friend_request',
'http_errors',
'user_blocked',
'guild_member_banned',
'guild_member_unbanned',
'report_resolved',
];
export const EVENT_MAJOR: Record<EventKind, number> = {
registration: 1,
@@ -91,6 +100,9 @@ export const EVENT_MAJOR: Record<EventKind, number> = {
friend_request: 1,
http_errors: 1,
user_blocked: 1,
guild_member_banned: 1,
guild_member_unbanned: 1,
report_resolved: 1,
};
export const EVENT_TTL: Record<EventKind, string> = {
registration: 'never',
@@ -111,6 +123,9 @@ export const EVENT_TTL: Record<EventKind, string> = {
friend_request: '259200',
http_errors: '3600',
user_blocked: '259200',
guild_member_banned: '3024000',
guild_member_unbanned: '3024000',
report_resolved: 'never',
};
export const EVENT_CLASS: Record<EventKind, 'fact' | 'signal'> = {
registration: 'fact',
@@ -131,6 +146,9 @@ export const EVENT_CLASS: Record<EventKind, 'fact' | 'signal'> = {
friend_request: 'signal',
http_errors: 'signal',
user_blocked: 'signal',
guild_member_banned: 'fact',
guild_member_unbanned: 'fact',
report_resolved: 'fact',
};
export const effectsConsumer = (p: number): string => `effects-${String(p).padStart(2, '0')}`;
export function keyToken(key: string): string {
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {GuildID, UserID} from '@app/api/BrandedTypes';
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
import {anonymousActivityMeta} from '@app/api/infrastructure/activity/ActivityMeta';
import type {BanBy, ReportOutcome, ReportTarget, ResolvedBy} from '@app/api/infrastructure/activity/Contract.generated';
import type {IARSubmission} from '@app/api/report/IReportRepository';
import {ReportType} from '@app/api/report/IReportRepository';
interface GuildBanFacts {
guildId: GuildID;
userId: UserID;
moderatorId: UserID;
by: BanBy;
memberCount: number;
targetModerator: boolean;
bannedAt: Date;
expiresAt: Date | null;
}
export async function emitGuildMemberBanned(facts: GuildBanFacts): Promise<void> {
const target = facts.userId.toString();
await emitActivity(
'guild_member_banned',
target,
{
guild_id: facts.guildId.toString(),
user_id: target,
moderator_id: facts.moderatorId.toString(),
by: facts.by,
guild_member_count: Math.max(0, Math.trunc(facts.memberCount)),
target_moderator: facts.targetModerator,
expires_at_ms: facts.expiresAt?.getTime() ?? null,
},
anonymousActivityMeta(),
`${facts.guildId}:${target}:${facts.bannedAt.getTime()}`,
);
}
export async function emitGuildMemberUnbanned(facts: {
guildId: GuildID;
userId: UserID;
moderatorId: UserID;
by: BanBy;
}): Promise<void> {
const target = facts.userId.toString();
await emitActivity(
'guild_member_unbanned',
target,
{
guild_id: facts.guildId.toString(),
user_id: target,
moderator_id: facts.moderatorId.toString(),
by: facts.by,
},
anonymousActivityMeta(),
);
}
const REPORT_TARGETS: Record<number, ReportTarget> = {
[ReportType.MESSAGE]: 'message',
[ReportType.USER]: 'user',
[ReportType.GUILD]: 'guild',
};
export async function emitReportResolved(
report: Pick<IARSubmission, 'reportId' | 'reporterId' | 'reportedUserId' | 'category' | 'reportType'>,
outcome: ReportOutcome,
resolvedBy: ResolvedBy,
): Promise<void> {
const key = report.reportedUserId ?? report.reporterId;
const targetType = REPORT_TARGETS[report.reportType];
if (key === null || targetType === undefined) return;
await emitActivity(
'report_resolved',
key.toString(),
{
report_id: report.reportId.toString(),
reporter_id: (report.reporterId ?? 0n).toString(),
category: report.category,
target_type: targetType,
reported_user_id: report.reportedUserId?.toString() ?? null,
outcome,
resolved_by: resolvedBy,
},
anonymousActivityMeta(),
report.reportId.toString(),
);
}
@@ -0,0 +1,172 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {addMemberRole, createGuild, createRole, setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
import {resetActivityEventsForTests, startActivityEvents} from '@app/api/infrastructure/activity/ActivityEvents';
import type {ActivityPublisher} from '@app/api/infrastructure/activity/ActivitySpool';
import type {Event} from '@app/api/infrastructure/activity/Contract.generated';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
class CapturingPublisher implements ActivityPublisher {
readonly events: Array<Event> = [];
async publish(_subject: string, payload: string): Promise<void> {
this.events.push(JSON.parse(payload) as Event);
}
of<K extends Event['kind']>(kind: K): Array<Extract<Event, {kind: K}>> {
return this.events.filter((event): event is Extract<Event, {kind: K}> => event.kind === kind);
}
}
describe('moderation activity events', () => {
let harness: ApiTestHarness;
let publisher: CapturingPublisher;
beforeEach(async () => {
harness = await createApiTestHarness();
publisher = new CapturingPublisher();
await startActivityEvents({publisher, kv: new MockKVProvider()});
});
afterEach(async () => {
resetActivityEventsForTests();
await harness?.shutdown();
});
async function ban(token: string, path: string): Promise<void> {
await createBuilder(harness, token).put(path).body({}).expect(HTTP_STATUS.NO_CONTENT).execute();
}
test('a moderator ban and unban publish facts about the target without addresses', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const target = members[0]!;
await ban(owner.token, `/guilds/${guild.id}/bans/${target.userId}`);
const [banned] = publisher.of('guild_member_banned');
expect(banned?.key).toBe(target.userId);
expect(banned?.data).toEqual({
guild_id: guild.id,
user_id: target.userId,
moderator_id: owner.userId,
by: 'moderator',
guild_member_count: 2,
target_moderator: false,
expires_at_ms: null,
});
expect(banned?.meta).toMatchObject({ip: null, country: null, ua: null, locale: null});
await createBuilder(harness, owner.token)
.delete(`/guilds/${guild.id}/bans/${target.userId}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const [unbanned] = publisher.of('guild_member_unbanned');
expect(unbanned?.data).toEqual({
guild_id: guild.id,
user_id: target.userId,
moderator_id: owner.userId,
by: 'moderator',
});
expect(unbanned?.meta.ip).toBeNull();
});
test('banning a member who can moderate the guild marks the target as a moderator', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const target = members[0]!;
const role = await createRole(harness, owner.token, guild.id, {
name: 'Mods',
permissions: Permissions.BAN_MEMBERS.toString(),
});
await addMemberRole(harness, owner.token, guild.id, target.userId, role.id);
await ban(owner.token, `/guilds/${guild.id}/bans/${target.userId}`);
expect(publisher.of('guild_member_banned')[0]?.data.target_moderator).toBe(true);
});
test('a staff ban says it came from staff', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
'guild:ban_member',
]);
const target = await createTestAccount(harness);
const guild = await createGuild(harness, admin.token, 'Staff ban guild');
await ban(admin.token, `/admin/guilds/${guild.id}/bans/${target.userId}`);
expect(publisher.of('guild_member_banned')[0]?.data).toMatchObject({
user_id: target.userId,
by: 'staff',
target_moderator: false,
});
});
describe('report resolution', () => {
let admin: TestAccount;
beforeEach(async () => {
admin = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
'report:resolve',
'user:temp_ban',
]);
});
async function fileReport(): Promise<{reporter: TestAccount; reported: TestAccount; reportId: string}> {
const reporter = await createTestAccount(harness);
const reported = await createTestAccount(harness);
const report = await createBuilder<{report_id: string}>(harness, reporter.token)
.post('/reports/user')
.body({user_id: reported.userId, category: 'harassment'})
.execute();
return {reporter, reported, reportId: report.report_id};
}
async function resolve(reportId: string, body: Record<string, unknown>): Promise<void> {
await createBuilder(harness, admin.token)
.patch(`/admin/reports/${reportId}`)
.body({status: 'resolved', notify_reporter: false, ...body})
.expect(HTTP_STATUS.OK)
.execute();
}
test('a staff dismissal publishes the outcome staff chose', async () => {
const {reporter, reported, reportId} = await fileReport();
await resolve(reportId, {resolution: 'no_violation'});
const [resolved] = publisher.of('report_resolved');
expect(resolved?.key).toBe(reported.userId);
expect(resolved?.data).toEqual({
report_id: reportId,
reporter_id: reporter.userId,
category: 'harassment',
target_type: 'user',
reported_user_id: reported.userId,
outcome: 'no_violation',
resolved_by: 'staff',
});
expect(resolved?.meta.ip).toBeNull();
});
test('without a chosen outcome the reported account state decides between actioned and unspecified', async () => {
const first = await fileReport();
await resolve(first.reportId, {});
const second = await fileReport();
await createBuilder(harness, admin.token)
.put(`/admin/users/${second.reported.userId}/ban`)
.body({duration_hours: 24, notify_user: false})
.expect(HTTP_STATUS.OK)
.execute();
await resolve(second.reportId, {});
expect(publisher.of('report_resolved').map((event) => event.data.outcome)).toEqual(['unspecified', 'actioned']);
});
test('an unknown resolution is rejected', async () => {
const {reportId} = await fileReport();
await createBuilder(harness, admin.token)
.patch(`/admin/reports/${reportId}`)
.body({status: 'resolved', resolution: 'maybe'})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
expect(publisher.of('report_resolved')).toEqual([]);
});
});
});