feat(api): add managed guild invite pauses (#3419)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-12 15:14:03 +02:00
1 parent c890956e4f
commit 17746fcdc4
28 files changed
+1203 -60

No files matched your search

@@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {GuildID} from '@app/api/BrandedTypes';
import {ResourceLockedError} from '@fluxer/errors/src/domains/core/ResourceLockedError';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
const GUILD_UPDATE_LOCK_TTL_SECONDS = 10;
const GUILD_UPDATE_LOCK_RETRY_DELAY_MS = 50;
const GUILD_UPDATE_LOCK_MAX_WAIT_MS = 5000;
export type GuildUpdateLocks = Pick<ICacheService, 'acquireLock' | 'releaseLock'>;
async function acquireGuildUpdateLock(locks: GuildUpdateLocks, lockKey: string): Promise<string | null> {
const startTime = Date.now();
while (Date.now() - startTime < GUILD_UPDATE_LOCK_MAX_WAIT_MS) {
const token = await locks.acquireLock(lockKey, GUILD_UPDATE_LOCK_TTL_SECONDS);
if (token) {
return token;
}
await new Promise((resolve) => setTimeout(resolve, GUILD_UPDATE_LOCK_RETRY_DELAY_MS));
}
return null;
}
export async function withGuildUpdateLock<T>(
locks: GuildUpdateLocks,
guildId: GuildID,
fn: () => Promise<T>,
): Promise<T> {
const lockKey = `guild:${guildId}:update`;
const lockToken = await acquireGuildUpdateLock(locks, lockKey);
if (!lockToken) {
throw new ResourceLockedError();
}
try {
return await fn();
} finally {
await locks.releaseLock(lockKey, lockToken);
}
}
@@ -4,6 +4,7 @@ import {createChannelID, createGuildID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes';
import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate';
import {emitGuildJoinRefused} from '@app/api/infrastructure/activity/GuildEvents';
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
@@ -153,6 +154,9 @@ export function GuildDiscoveryController(app: HonoApp) {
}
const guild = await ctx.get('guildService').data.operations.getGuildSystem(guildId);
if (guild.features.has(GuildFeatures.INVITES_DISABLED)) {
if (!(await ctx.get('gatewayService').hasGuildMember({guildId, userId: user.id}))) {
await emitGuildJoinRefused({guildId, userId: user.id, joinSource: 'discovery'});
}
throw new InvitesDisabledError();
}
await ctx.get('guildService').members.addUserToGuild({
@@ -0,0 +1,160 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createGuildID} from '@app/api/BrandedTypes';
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel';
import {type GuildUpdateLocks, withGuildUpdateLock} from '@app/api/guild/GuildUpdateLock';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import {workerMeta} from '@app/api/infrastructure/activity/ActivityMeta';
import {
type ActionOutcome,
type FeatureSource,
INVITES_ALREADY_PAUSED,
} from '@app/api/infrastructure/activity/Contract.generated';
import {emitGuildFeaturesChanged} from '@app/api/infrastructure/activity/GuildEvents';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import {Logger} from '@app/api/Logger';
import type {Guild} from '@app/api/models/Guild';
import {type ActionOf, outcomeOf} from '@app/api/user/services/AccountStateApplier';
import {serializeGuildForAudit} from '@app/api/utils/AuditSerializationUtils';
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
export const GUILD_INVITE_PAUSE_SCHEDULE_KEY = 'guild:invite_pause:lifts';
const INVITE_PAUSE_FEATURES = [GuildFeatures.INVITES_DISABLED, GuildFeatures.RAID_DETECTED] as const;
export interface GuildInvitePauseDeps {
guilds: Pick<GuildRepository, 'findUnique' | 'upsertPartial'>;
locks: GuildUpdateLocks;
schedule: Pick<IKVProvider, 'zadd' | 'zrem' | 'zrangebyscore'>;
gateway: Pick<IGatewayService, 'dispatchGuild'>;
auditLog: Pick<GuildAuditLogService, 'record' | 'computeChanges'>;
now?: () => number;
}
export function memberEndsInvitePause(guild: Guild, nextFeatures: ReadonlySet<string>): boolean {
return (
guild.invitesPausedUntil !== null &&
guild.features.has(GuildFeatures.INVITES_DISABLED) &&
!nextFeatures.has(GuildFeatures.INVITES_DISABLED)
);
}
export function staffEditTouchesInvitePause(guild: Guild, changed: Iterable<string>): boolean {
if (guild.invitesPausedUntil === null) return false;
for (const feature of changed) {
if ((INVITE_PAUSE_FEATURES as ReadonlyArray<string>).includes(feature)) return true;
}
return false;
}
async function writePause(
deps: GuildInvitePauseDeps,
guild: Guild,
features: Set<string>,
until: Date | null,
source: FeatureSource,
): Promise<void> {
const updated = await deps.guilds.upsertPartial(guild.id, {features, invites_paused_until: until}, guild.toRow());
try {
await deps.gateway.dispatchGuild({
guildId: guild.id,
event: 'GUILD_UPDATE',
data: mapGuildToGuildResponse(updated),
});
} catch (error) {
Logger.error({error, guildId: guild.id}, 'Failed to dispatch guild update after invite pause change');
}
const changes = deps.auditLog
.computeChanges(serializeGuildForAudit(guild), serializeGuildForAudit(updated))
.filter((change) => change.key === 'features');
if (changes.length > 0) {
try {
await deps.auditLog.record({
guildId: guild.id,
userId: SYSTEM_USER_ID,
action: AuditLogActionType.GUILD_UPDATE,
targetId: guild.id,
changes,
});
} catch (error) {
Logger.error({error, guildId: guild.id}, 'Failed to record invite pause change in the guild audit log');
}
}
await emitGuildFeaturesChanged({
previous: guild,
updated,
actorId: null,
source,
meta: workerMeta(),
});
}
async function liftPause(deps: GuildInvitePauseDeps, guild: Guild, source: FeatureSource): Promise<void> {
const features = new Set(guild.features);
for (const feature of INVITE_PAUSE_FEATURES) features.delete(feature);
await writePause(deps, guild, features, null, source);
await deps.schedule.zrem(GUILD_INVITE_PAUSE_SCHEDULE_KEY, guild.id.toString());
}
export async function applyPauseGuildInvites(
deps: GuildInvitePauseDeps,
env: ActionOf<'pause_guild_invites'>,
): Promise<ActionOutcome> {
const guildId = createGuildID(BigInt(env.guild_id));
return withGuildUpdateLock(deps.locks, guildId, async () => {
const guild = await deps.guilds.findUnique(guildId);
if (!guild) return outcomeOf(env, 'ineligible');
const current = guild.invitesPausedUntil?.getTime() ?? null;
if (!env.on) {
if (current === null) return outcomeOf(env, 'noop');
if (env.until_ms > 0 && current > env.until_ms) return outcomeOf(env, 'noop', null, `until_ms=${current}`);
await liftPause(deps, guild, 'action');
return outcomeOf(env, 'applied');
}
if (current === null && guild.features.has(GuildFeatures.INVITES_DISABLED)) {
return outcomeOf(env, 'noop', null, INVITES_ALREADY_PAUSED);
}
const now = deps.now?.() ?? Date.now();
const until = Math.max(current ?? 0, env.until_ms);
if (until <= now) return outcomeOf(env, 'noop');
const features = new Set(guild.features);
for (const feature of INVITE_PAUSE_FEATURES) features.add(feature);
const detail = `until_ms=${until}`;
await deps.schedule.zadd(GUILD_INVITE_PAUSE_SCHEDULE_KEY, until, guildId.toString());
if (until === current && features.size === guild.features.size) return outcomeOf(env, 'noop', null, detail);
await writePause(deps, guild, features, new Date(until), 'action');
return outcomeOf(env, 'applied', null, detail);
});
}
export async function liftExpiredGuildInvitePauses(deps: GuildInvitePauseDeps, limit: number): Promise<number> {
const now = deps.now?.() ?? Date.now();
const due = await deps.schedule.zrangebyscore(GUILD_INVITE_PAUSE_SCHEDULE_KEY, '-inf', now, 'LIMIT', 0, limit);
let lifted = 0;
for (const member of due) {
const guildId = createGuildID(BigInt(member));
try {
const done = await withGuildUpdateLock(deps.locks, guildId, async () => {
const guild = await deps.guilds.findUnique(guildId);
const until = guild?.invitesPausedUntil?.getTime() ?? null;
if (!guild || until === null) {
await deps.schedule.zrem(GUILD_INVITE_PAUSE_SCHEDULE_KEY, member);
return false;
}
if (until > now) {
await deps.schedule.zadd(GUILD_INVITE_PAUSE_SCHEDULE_KEY, until, member);
return false;
}
await liftPause(deps, guild, 'system');
return true;
});
if (done) lifted++;
} catch (error) {
Logger.error({error, guildId: member}, 'Failed to lift expired guild invite pause');
}
}
return lifted;
}
@@ -25,6 +25,7 @@ import {
THREAD_AUDIT_LOG_ACTION_TYPES,
} from '@app/api/guild/GuildAuditLogEntryMapper';
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import {withGuildUpdateLock} from '@app/api/guild/GuildUpdateLock';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import {GuildChannelService} from '@app/api/guild/services/GuildChannelService';
import {GuildContentService} from '@app/api/guild/services/GuildContentService';
@@ -53,7 +54,6 @@ import {THREAD_FEATURE_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstant
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {ResourceLockedError} from '@fluxer/errors/src/domains/core/ResourceLockedError';
import {UnknownGuildEmojiError} from '@fluxer/errors/src/domains/guild/UnknownGuildEmojiError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {UnknownGuildStickerError} from '@fluxer/errors/src/domains/guild/UnknownGuildStickerError';
@@ -80,10 +80,6 @@ interface StoredGuildAuditLogListResponse extends Omit<GuildAuditLogListResponse
webhooks: Array<StoredAuditLogWebhookResponse>;
}
const GUILD_UPDATE_LOCK_TTL_SECONDS = 10;
const GUILD_UPDATE_LOCK_RETRY_DELAY_MS = 50;
const GUILD_UPDATE_LOCK_MAX_WAIT_MS = 5000;
export class GuildService {
public readonly data: GuildDataService;
public readonly members: GuildMemberService;
@@ -211,9 +207,10 @@ export class GuildService {
auditLogReason?: string | null,
): Promise<GuildResponse> {
const {guildId, requestCache} = params;
const {guild, previousFeatures, updatedFeatures} = await this.withGuildUpdateLock(guildId, requestCache, () =>
this.data.operations.updateGuild(params, auditLogReason),
);
const {guild, previousFeatures, updatedFeatures} = await this.withUpdateLock(guildId, () => {
requestCache.guilds.delete(guildId);
return this.data.operations.updateGuild(params, auditLogReason);
});
if (
previousFeatures.has(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES) &&
!updatedFeatures.has(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES)
@@ -223,30 +220,8 @@ export class GuildService {
return guild;
}
private async withGuildUpdateLock<T>(guildId: GuildID, requestCache: RequestCache, fn: () => Promise<T>): Promise<T> {
const lockKey = `guild:${guildId}:update`;
const lockToken = await this.acquireGuildUpdateLock(lockKey);
if (!lockToken) {
throw new ResourceLockedError();
}
try {
requestCache.guilds.delete(guildId);
return await fn();
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
}
}
private async acquireGuildUpdateLock(lockKey: string): Promise<string | null> {
const startTime = Date.now();
while (Date.now() - startTime < GUILD_UPDATE_LOCK_MAX_WAIT_MS) {
const token = await this.cacheService.acquireLock(lockKey, GUILD_UPDATE_LOCK_TTL_SECONDS);
if (token) {
return token;
}
await new Promise((resolve) => setTimeout(resolve, GUILD_UPDATE_LOCK_RETRY_DELAY_MS));
}
return null;
withUpdateLock<T>(guildId: GuildID, fn: () => Promise<T>): Promise<T> {
return withGuildUpdateLock(this.cacheService, guildId, fn);
}
async getEmojiMetadata(emojiId: EmojiID): Promise<GuildEmojiMetadataResponse> {
@@ -25,6 +25,8 @@ import {mapGuildToGuildResponse, mapGuildToPartialResponse} from '@app/api/guild
import type {GuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {GuildDataHelpers} from '@app/api/guild/services/data/GuildDataHelpers';
import {memberEndsInvitePause} from '@app/api/guild/services/GuildInvitePauseApplier';
import {emitGuildCreated, emitGuildFeaturesChanged} from '@app/api/infrastructure/activity/GuildEvents';
import {contentModerationService} from '@app/api/infrastructure/ContentModerationService';
import type {EntityAssetService, PreparedAssetUpload} from '@app/api/infrastructure/EntityAssetService';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
@@ -393,6 +395,7 @@ export class GuildOperationsService {
audit_logs_indexed_at: null,
members_indexed_at: null,
message_history_cutoff: null,
invites_paused_until: null,
version: 1,
};
batch.addPrepared(Guilds.insert(guildData));
@@ -425,6 +428,7 @@ export class GuildOperationsService {
await batch.execute();
await this.channelRepository.threads.ensureGuildMarker(guildId, {permsSeededAt: new Date()});
const guild = new Guild(guildData);
await emitGuildCreated(guild);
await this.gatewayService.startGuild(guildId);
await this.gatewayService.joinGuild({userId: user.id, guildId});
if (!user.isBot) {
@@ -507,6 +511,10 @@ export class GuildOperationsService {
}
}
const updatedFeatures = this.computeUpdatedFeatures(previousFeatures, data.features);
const endsInvitePause = memberEndsInvitePause(currentGuild, updatedFeatures);
if (endsInvitePause) {
updatedFeatures.delete(GuildFeatures.RAID_DETECTED);
}
const featuresChanged = !setsEqual(previousFeatures, updatedFeatures);
const patch: Partial<GuildRow> = {};
if (data.name !== undefined) {
@@ -617,6 +625,9 @@ export class GuildOperationsService {
if (featuresChanged) {
patch.features = updatedFeatures;
}
if (endsInvitePause) {
patch.invites_paused_until = null;
}
const preparedAssets = await this.prepareGuildAssetUpdates(currentGuild, data, patch);
let updatedGuild: Guild;
if (Object.keys(patch).length === 0) {
@@ -635,6 +646,14 @@ export class GuildOperationsService {
Logger.error({error, guildId}, 'Failed to commit asset changes after successful guild update');
}
await this.helpers.dispatchGuildUpdate(updatedGuild);
if (featuresChanged) {
await emitGuildFeaturesChanged({
previous: currentGuild,
updated: updatedGuild,
actorId: userId,
source: 'member',
});
}
const guildSearchService = getGuildSearchService();
if (guildSearchService) {
let discoveryContext: GuildDiscoveryContext | undefined;
@@ -23,6 +23,7 @@ import type {GuildMemberSearchIndexService} from '@app/api/guild/services/member
import type {GuildMemberValidationService} from '@app/api/guild/services/member/GuildMemberValidationService';
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
import type {JoinSource} from '@app/api/infrastructure/activity/Contract.generated';
import {emitGuildMemberRemoved} from '@app/api/infrastructure/activity/GuildEvents';
import {contentModerationService} from '@app/api/infrastructure/ContentModerationService';
import type {EntityAssetService, PreparedAssetUpload} from '@app/api/infrastructure/EntityAssetService';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
@@ -388,6 +389,7 @@ export class GuildMemberOperationsService {
);
}
await this.gatewayService.leaveGuild({userId: targetId, guildId});
await emitGuildMemberRemoved({guildId, userId: targetId, actorId: userId, kind: 'kicked'});
}
async addUserToGuild(
@@ -541,6 +543,7 @@ export class GuildMemberOperationsService {
});
}
await this.gatewayService.leaveGuild({userId, guildId});
await emitGuildMemberRemoved({guildId, userId, actorId: null, kind: 'left'});
}
private async validateTimeout(params: {
@@ -6,7 +6,14 @@ import {createTestBotAccount} from '@app/api/bot/tests/BotTestUtils';
import {createPermissionOverwrite} from '@app/api/channel/tests/ChannelTestUtils';
import {threadsRequest} from '@app/api/channel/tests/ThreadTestUtils';
import {clearChannelThreadsTaintCacheForTesting} from '@app/api/experiment/ChannelThreadsGate';
import {createChannel, createGuild} from '@app/api/guild/tests/GuildTestUtils';
import {createChannel, createGuild, updateGuild} from '@app/api/guild/tests/GuildTestUtils';
import {
idleActivityEvents,
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 {setInjectedGatewayService} from '@app/api/middleware/ServiceRegistry';
import {getGuildRepository} from '@app/api/middleware/ServiceSingletons';
import {banUser} from '@app/api/moderation/tests/ModerationTestUtils';
@@ -20,11 +27,13 @@ import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/wo
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {DiscoveryCategories} from '@fluxer/constants/src/DiscoveryConstants';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import type {
DiscoveryApplicationResponse,
DiscoveryChannelPreviewResponse,
DiscoveryGuildListResponse,
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
import {MockKVProvider} from '@pkgs/kv_client/src/testing/MockKVProvider';
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
@@ -42,6 +51,14 @@ interface LiveGuildCounts {
const WORKER_HELPERS = {logger: new NoopLogger()} as unknown as WorkerTaskHelpers;
class CapturingPublisher implements ActivityPublisher {
readonly events: Array<Event> = [];
async publish(_subject: string, payload: string): Promise<void> {
this.events.push(JSON.parse(payload) as Event);
}
}
class LiveCountsGatewayService extends NoopGatewayService {
constructor(private readonly liveCounts: Map<string, LiveGuildCounts>) {
super();
@@ -248,6 +265,46 @@ describe('Discovery Search and Join', () => {
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE)
.execute();
});
test('should publish a refusal for a non-member discovery join while invites are disabled', async () => {
const publisher = new CapturingPublisher();
await startActivityEvents({publisher, kv: new MockKVProvider()});
try {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Closed Discovery Guild');
await setGuildMemberCount(harness, guild.id, 10);
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
await applyAndApprove(
harness,
owner.token,
admin.token,
guild.id,
'Join this closed community',
DiscoveryCategories.GAMING,
);
await updateGuild(harness, owner.token, guild.id, {
features: [...guild.features, GuildFeatures.INVITES_DISABLED],
});
const joiner = await createTestAccount(harness);
for (const account of [owner, joiner]) {
await createBuilder(harness, account.token)
.post(`/discovery/guilds/${guild.id}/join`)
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVITES_DISABLED)
.execute();
}
await idleActivityEvents();
const refusals = publisher.events.filter((event) => event.kind === 'guild_join_refused');
expect(refusals.map((event) => [event.key, event.data])).toEqual([
[
joiner.userId,
{guild_id: guild.id, user_id: joiner.userId, cause: 'invites_paused', join_source: 'discovery'},
],
]);
} finally {
resetActivityEventsForTests();
}
});
test('should not allow bot accounts to join discoverable guilds', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'No Bots Guild');
@@ -0,0 +1,403 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createGuildID, createUserID, type GuildID} from '@app/api/BrandedTypes';
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
import type {GuildRow} from '@app/api/database/types/GuildTypes';
import {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import {
applyPauseGuildInvites,
GUILD_INVITE_PAUSE_SCHEDULE_KEY,
type GuildInvitePauseDeps,
liftExpiredGuildInvitePauses,
} from '@app/api/guild/services/GuildInvitePauseApplier';
import {resetActivityEventsForTests, startActivityEvents} from '@app/api/infrastructure/activity/ActivityEvents';
import type {ActivityPublisher} from '@app/api/infrastructure/activity/ActivitySpool';
import type {ActionEnvelope, Event} from '@app/api/infrastructure/activity/Contract.generated';
import {Guild} from '@app/api/models/Guild';
import {applyAction} from '@app/api/worker/AccountActionConsumer';
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {MockKVProvider} from '@pkgs/kv_client/src/testing/MockKVProvider';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
const GUILD_ID = '1472623911696138261';
const NOW = 1_760_000_000_000;
const HOUR_MS = 3_600_000;
const BASE_FEATURES = [GuildFeatures.BANNER];
type PauseEnvelope = Extract<ActionEnvelope, {type: 'pause_guild_invites'}>;
function pauseEnvelope(overrides: Partial<PauseEnvelope> = {}): PauseEnvelope {
return {
v: 1,
id: 'a:11:guild:0',
key: `guild:${GUILD_ID}`,
issued_at_ms: NOW,
expires_at_ms: NOW + 60_000,
type: 'pause_guild_invites',
guild_id: GUILD_ID,
on: true,
until_ms: NOW + 2 * HOUR_MS,
...overrides,
};
}
function guildRow(features: Iterable<string>, pausedUntil: Date | null = null): GuildRow {
return {
guild_id: createGuildID(BigInt(GUILD_ID)),
owner_id: createUserID(1472623911696138262n),
name: 'Paused',
vanity_url_code: null,
icon_hash: null,
banner_hash: null,
banner_width: null,
banner_height: null,
splash_hash: null,
splash_width: null,
splash_height: null,
splash_card_alignment: null,
embed_splash_hash: null,
embed_splash_width: null,
embed_splash_height: null,
features: new Set(features),
verification_level: 0,
mfa_level: 0,
nsfw_level: 0,
explicit_content_filter: 0,
default_message_notifications: 0,
system_channel_id: null,
system_channel_flags: 0,
rules_channel_id: null,
afk_channel_id: null,
afk_timeout: 0,
disabled_operations: 0,
member_count: 12,
audit_logs_indexed_at: null,
members_indexed_at: null,
message_history_cutoff: null,
invites_paused_until: pausedUntil,
version: 1,
};
}
class FakeGuilds {
row: GuildRow | null = null;
writes = 0;
current(): Guild {
return new Guild(this.row!);
}
async findUnique(_guildId: GuildID): Promise<Guild | null> {
return this.row ? new Guild(this.row) : null;
}
async upsertPartial(_guildId: GuildID, patch: Partial<GuildRow>): Promise<Guild> {
this.writes++;
this.row = {...this.row!, ...patch, version: this.row!.version + 1};
return new Guild(this.row);
}
}
class CapturingPublisher implements ActivityPublisher {
readonly events: Array<Event> = [];
async publish(_subject: string, payload: string): Promise<void> {
this.events.push(JSON.parse(payload) as Event);
}
featureChanges() {
return this.events.flatMap((event) => (event.kind === 'guild_features_changed' ? [event] : []));
}
}
interface Harness {
guilds: FakeGuilds;
schedule: MockKVProvider;
dispatched: Array<unknown>;
audits: Array<{userId: bigint; action: number; changes: unknown}>;
clock: {now: number};
deps: GuildInvitePauseDeps;
}
function harness(): Harness {
const guilds = new FakeGuilds();
const schedule = new MockKVProvider();
const h: Harness = {guilds, schedule, dispatched: [], audits: [], clock: {now: NOW}, deps: null as never};
const auditService = new GuildAuditLogService(null as never, null as never, null as never, null as never);
h.deps = {
guilds,
locks: {acquireLock: async () => 'token', releaseLock: async () => true},
schedule,
gateway: {
dispatchGuild: async (params) => {
h.dispatched.push(params.data);
},
},
auditLog: {
computeChanges: (previous, next) => auditService.computeChanges(previous, next),
record: async (params) => {
h.audits.push({userId: params.userId, action: params.action, changes: params.changes});
},
},
now: () => h.clock.now,
};
return h;
}
async function scheduled(h: Harness): Promise<Array<string>> {
return h.schedule.zrangebyscore(GUILD_INVITE_PAUSE_SCHEDULE_KEY, '-inf', '+inf');
}
describe('guild invite pause', () => {
let h: Harness;
let publisher: CapturingPublisher;
beforeEach(async () => {
h = harness();
publisher = new CapturingPublisher();
await startActivityEvents({publisher, kv: new MockKVProvider()});
});
afterEach(() => {
resetActivityEventsForTests();
});
it('leaves a pause the guild set itself alone and reports it as already paused', async () => {
h.guilds.row = guildRow([...BASE_FEATURES, GuildFeatures.INVITES_DISABLED]);
const outcome = await applyPauseGuildInvites(h.deps, pauseEnvelope());
expect(outcome).toEqual({
action_id: 'a:11:guild:0',
action_type: 'pause_guild_invites',
status: 'noop',
detail: 'already_paused',
observed: null,
guild_id: GUILD_ID,
});
expect(h.guilds.writes).toBe(0);
expect(await scheduled(h)).toEqual([]);
expect(publisher.events).toEqual([]);
const lifted = await applyPauseGuildInvites(h.deps, pauseEnvelope({on: false}));
expect(lifted.status).toBe('noop');
expect(h.guilds.current().features.has(GuildFeatures.INVITES_DISABLED)).toBe(true);
});
it('pauses invites, records it as the system user and schedules the lift', async () => {
h.guilds.row = guildRow(BASE_FEATURES);
const outcome = await applyPauseGuildInvites(h.deps, pauseEnvelope());
expect(outcome).toMatchObject({status: 'applied', detail: `until_ms=${NOW + 2 * HOUR_MS}`, guild_id: GUILD_ID});
const guild = h.guilds.current();
expect([...guild.features].sort()).toEqual(
[...BASE_FEATURES, GuildFeatures.INVITES_DISABLED, GuildFeatures.RAID_DETECTED].sort(),
);
expect(guild.invitesPausedUntil?.getTime()).toBe(NOW + 2 * HOUR_MS);
expect(h.dispatched).toHaveLength(1);
expect(h.audits).toEqual([
{
userId: SYSTEM_USER_ID,
action: AuditLogActionType.GUILD_UPDATE,
changes: [
{
key: 'features',
old_value: [...BASE_FEATURES],
new_value: [...BASE_FEATURES, GuildFeatures.INVITES_DISABLED, GuildFeatures.RAID_DETECTED].sort(),
},
],
},
]);
expect(await scheduled(h)).toEqual([GUILD_ID]);
const [event] = publisher.featureChanges();
expect(event?.key).toBe(`guild:${GUILD_ID}`);
expect(event?.meta.channel).toBe('worker');
expect(event?.data).toEqual({
guild_id: GUILD_ID,
actor_id: null,
source: 'action',
added: [GuildFeatures.INVITES_DISABLED, GuildFeatures.RAID_DETECTED],
removed: [],
invites_paused_until_ms: NOW + 2 * HOUR_MS,
});
});
it('extends its own pause but never shortens it', async () => {
h.guilds.row = guildRow(BASE_FEATURES);
await applyPauseGuildInvites(h.deps, pauseEnvelope());
const extended = await applyPauseGuildInvites(
h.deps,
pauseEnvelope({id: 'a:11:guild:1', until_ms: NOW + 3 * HOUR_MS}),
);
expect(extended.status).toBe('applied');
expect(h.guilds.current().invitesPausedUntil?.getTime()).toBe(NOW + 3 * HOUR_MS);
expect(
await h.schedule.zrangebyscore(GUILD_INVITE_PAUSE_SCHEDULE_KEY, NOW + 3 * HOUR_MS, NOW + 3 * HOUR_MS),
).toEqual([GUILD_ID]);
const shorter = await applyPauseGuildInvites(h.deps, pauseEnvelope({id: 'a:11:guild:2', until_ms: NOW + HOUR_MS}));
expect(shorter).toMatchObject({status: 'noop', detail: `until_ms=${NOW + 3 * HOUR_MS}`});
expect(h.guilds.current().invitesPausedUntil?.getTime()).toBe(NOW + 3 * HOUR_MS);
expect(publisher.featureChanges().map((event) => event.data.invites_paused_until_ms)).toEqual([
NOW + 2 * HOUR_MS,
NOW + 3 * HOUR_MS,
]);
});
it('lifts only its own pause and removes both features', async () => {
h.guilds.row = guildRow(BASE_FEATURES);
await applyPauseGuildInvites(h.deps, pauseEnvelope());
const outcome = await applyAction(
{js: {} as never, state: {} as never, guildInvites: h.deps, now: () => NOW},
pauseEnvelope({id: 'a:11:guild:3', on: false}),
);
expect(outcome).toMatchObject({status: 'applied', guild_id: GUILD_ID});
const guild = h.guilds.current();
expect([...guild.features]).toEqual(BASE_FEATURES);
expect(guild.invitesPausedUntil).toBeNull();
expect(await scheduled(h)).toEqual([]);
expect(publisher.featureChanges().at(-1)?.data).toMatchObject({
source: 'action',
removed: [GuildFeatures.INVITES_DISABLED, GuildFeatures.RAID_DETECTED],
invites_paused_until_ms: null,
});
const again = await applyPauseGuildInvites(h.deps, pauseEnvelope({id: 'a:11:guild:4', on: false}));
expect(again.status).toBe('noop');
});
it('ignores a late lift for an earlier window once the pause was extended', async () => {
h.guilds.row = guildRow(BASE_FEATURES);
await applyPauseGuildInvites(h.deps, pauseEnvelope());
await applyPauseGuildInvites(h.deps, pauseEnvelope({id: 'a:11:guild:5', until_ms: NOW + 3 * HOUR_MS}));
const late = await applyPauseGuildInvites(
h.deps,
pauseEnvelope({id: 'a:11:guild:6', on: false, until_ms: NOW + 2 * HOUR_MS}),
);
expect(late).toMatchObject({status: 'noop', detail: `until_ms=${NOW + 3 * HOUR_MS}`});
expect(h.guilds.current().features.has(GuildFeatures.INVITES_DISABLED)).toBe(true);
expect(h.guilds.current().invitesPausedUntil?.getTime()).toBe(NOW + 3 * HOUR_MS);
const current = await applyPauseGuildInvites(
h.deps,
pauseEnvelope({id: 'a:11:guild:7', on: false, until_ms: NOW + 3 * HOUR_MS}),
);
expect(current.status).toBe('applied');
expect(h.guilds.current().invitesPausedUntil).toBeNull();
});
it('keeps the lift scheduled when the gateway fails after the write and a redelivery changes nothing', async () => {
h.guilds.row = guildRow(BASE_FEATURES);
const dispatch = h.deps.gateway.dispatchGuild;
let failures = 1;
h.deps.gateway = {
dispatchGuild: async (params) => {
if (failures-- > 0) throw new Error('gateway timeout');
await dispatch(params);
},
};
const first = await applyPauseGuildInvites(h.deps, pauseEnvelope());
expect(first).toMatchObject({status: 'applied', guild_id: GUILD_ID});
expect(h.audits).toHaveLength(1);
expect(publisher.featureChanges()).toHaveLength(1);
const redelivered = await applyPauseGuildInvites(h.deps, pauseEnvelope());
expect(redelivered).toMatchObject({status: 'noop', detail: `until_ms=${NOW + 2 * HOUR_MS}`});
expect(h.guilds.writes).toBe(1);
expect(await scheduled(h)).toEqual([GUILD_ID]);
h.clock.now = NOW + 2 * HOUR_MS;
expect(await liftExpiredGuildInvitePauses(h.deps, 100)).toBe(1);
expect([...h.guilds.current().features]).toEqual(BASE_FEATURES);
});
it('restores a lost schedule entry when the same pause is redelivered', async () => {
h.guilds.row = guildRow(
[...BASE_FEATURES, GuildFeatures.INVITES_DISABLED, GuildFeatures.RAID_DETECTED],
new Date(NOW + 2 * HOUR_MS),
);
const outcome = await applyPauseGuildInvites(h.deps, pauseEnvelope());
expect(outcome.status).toBe('noop');
expect(h.guilds.writes).toBe(0);
expect(await scheduled(h)).toEqual([GUILD_ID]);
});
it('writes nothing when scheduling fails so the retry applies the pause', async () => {
h.guilds.row = guildRow(BASE_FEATURES);
const zadd = h.schedule.zadd.bind(h.schedule);
let failures = 1;
h.deps.schedule = {
zadd: async (...args: Parameters<typeof zadd>) => {
if (failures-- > 0) throw new Error('kv unavailable');
return zadd(...args);
},
zrem: h.schedule.zrem.bind(h.schedule),
zrangebyscore: h.schedule.zrangebyscore.bind(h.schedule),
};
await expect(applyPauseGuildInvites(h.deps, pauseEnvelope())).rejects.toThrow('kv unavailable');
expect(h.guilds.writes).toBe(0);
expect((await applyPauseGuildInvites(h.deps, pauseEnvelope())).status).toBe('applied');
expect(await scheduled(h)).toEqual([GUILD_ID]);
});
it('routes the action through the consumer and answers every outcome with the guild', async () => {
h.guilds.row = guildRow(BASE_FEATURES);
const consumer = {js: {} as never, state: {} as never, guildInvites: h.deps, now: () => NOW};
expect(await applyAction(consumer, pauseEnvelope())).toMatchObject({status: 'applied', guild_id: GUILD_ID});
expect(await applyAction(consumer, pauseEnvelope({expires_at_ms: NOW}))).toMatchObject({
status: 'expired',
guild_id: GUILD_ID,
});
expect(await applyAction(consumer, {...pauseEnvelope(), v: 2})).toMatchObject({
status: 'unsupported',
guild_id: GUILD_ID,
});
h.guilds.row = guildRow([...BASE_FEATURES, GuildFeatures.INVITES_DISABLED]);
expect(await applyAction(consumer, pauseEnvelope({id: 'a:11:guild:5'}))).toMatchObject({
status: 'noop',
detail: 'already_paused',
guild_id: GUILD_ID,
});
expect(h.guilds.writes).toBe(1);
});
it('removes a notice staff set before the pause when the pause ends', async () => {
h.guilds.row = guildRow([...BASE_FEATURES, GuildFeatures.RAID_DETECTED]);
expect((await applyPauseGuildInvites(h.deps, pauseEnvelope())).status).toBe('applied');
h.clock.now = NOW + 2 * HOUR_MS;
expect(await liftExpiredGuildInvitePauses(h.deps, 100)).toBe(1);
expect([...h.guilds.current().features]).toEqual(BASE_FEATURES);
});
it('reports a missing guild as ineligible', async () => {
expect((await applyPauseGuildInvites(h.deps, pauseEnvelope())).status).toBe('ineligible');
});
it('lifts an expired pause on the sweep without any action', async () => {
h.guilds.row = guildRow(BASE_FEATURES);
await applyPauseGuildInvites(h.deps, pauseEnvelope());
expect(await liftExpiredGuildInvitePauses(h.deps, 100)).toBe(0);
expect(h.guilds.current().features.has(GuildFeatures.INVITES_DISABLED)).toBe(true);
h.clock.now = NOW + 2 * HOUR_MS;
expect(await liftExpiredGuildInvitePauses(h.deps, 100)).toBe(1);
const guild = h.guilds.current();
expect([...guild.features]).toEqual(BASE_FEATURES);
expect(guild.invitesPausedUntil).toBeNull();
expect(await scheduled(h)).toEqual([]);
expect(publisher.featureChanges().at(-1)?.data).toMatchObject({
source: 'system',
actor_id: null,
removed: [GuildFeatures.INVITES_DISABLED, GuildFeatures.RAID_DETECTED],
});
});
it('drops a sweep entry once the pause is no longer ours and reschedules an extended one', async () => {
h.guilds.row = guildRow([...BASE_FEATURES, GuildFeatures.INVITES_DISABLED]);
await h.schedule.zadd(GUILD_INVITE_PAUSE_SCHEDULE_KEY, NOW - 1, GUILD_ID);
expect(await liftExpiredGuildInvitePauses(h.deps, 100)).toBe(0);
expect(await scheduled(h)).toEqual([]);
expect(h.guilds.current().features.has(GuildFeatures.INVITES_DISABLED)).toBe(true);
h.guilds.row = guildRow(
[...BASE_FEATURES, GuildFeatures.INVITES_DISABLED, GuildFeatures.RAID_DETECTED],
new Date(NOW + HOUR_MS),
);
await h.schedule.zadd(GUILD_INVITE_PAUSE_SCHEDULE_KEY, NOW - 1, GUILD_ID);
expect(await liftExpiredGuildInvitePauses(h.deps, 100)).toBe(0);
expect(await h.schedule.zrangebyscore(GUILD_INVITE_PAUSE_SCHEDULE_KEY, NOW + HOUR_MS, NOW + HOUR_MS)).toEqual([
GUILD_ID,
]);
expect(h.guilds.writes).toBe(0);
});
});
@@ -43,6 +43,7 @@ function createGuild(overrides: Partial<GuildRow> = {}): Guild {
audit_logs_indexed_at: null,
members_indexed_at: null,
message_history_cutoff: null,
invites_paused_until: null,
version: 1,
...overrides,
});