feat(captcha): add ALTCHA proof-of-work captcha experiment (#2986)

This commit is contained in:
Hampus
2026-09-27 21:02:55 +02:00
committed by GitHub
parent 33a118d12a
commit 7e1b934637
69 changed files with 1848 additions and 216 deletions
@@ -11,6 +11,10 @@ import {ADMIN_ACL_COUNT, AdminAclType} from '@fluxer/schema/src/domains/admin/Ad
import {AdminArchiveResponseSchema} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
import {GuildAdminResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
import {UserAdminResponseSchema} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {
AltchaCaptchaConfigResponse,
AltchaCaptchaConfigUpdateRequest,
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {
DomainMigrationConfigResponse,
DomainMigrationConfigUpdateRequest,
@@ -654,6 +658,7 @@ export const InstanceConfigResponse = z.object({
voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
push_service_delivery: PushServiceDeliveryConfigResponse,
domain_migration: DomainMigrationConfigResponse,
altcha_captcha: AltchaCaptchaConfigResponse,
experiment_delivery: ExperimentDeliveryConfigResponse,
registration: InstanceRegistrationResponse,
self_hosted: z.boolean(),
@@ -692,6 +697,7 @@ export const InstanceConfigUpdateRequest = z.object({
voice_noise_suppression: VoiceNoiseSuppressionConfigUpdateRequest.nullish(),
push_service_delivery: PushServiceDeliveryConfigUpdateRequest.nullish(),
domain_migration: DomainMigrationConfigUpdateRequest.nullish(),
altcha_captcha: AltchaCaptchaConfigUpdateRequest.nullish(),
experiment_delivery: ExperimentDeliveryConfigUpdateRequest.nullish(),
registration: z
.object({
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type AltchaCaptchaConfig,
AltchaCaptchaConfigSchema,
AltchaCaptchaConfigUpdateRequest,
altchaCaptchaAppliesTo,
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
resolveAltchaCaptchaAssignment,
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {describe, expect, test} from 'vitest';
const TARGETED_USER_ID = '1000000000000000001';
function createConfig(overrides: Partial<AltchaCaptchaConfig> = {}): AltchaCaptchaConfig {
return {...DEFAULT_ALTCHA_CAPTCHA_CONFIG, included_user_ids: [], excluded_user_ids: [], ...overrides};
}
function syntheticUserIds(count: number): Array<string> {
return Array.from({length: count}, (_, index) => (1400000000000000000n + BigInt(index)).toString());
}
describe('altcha captcha configuration', () => {
test('defaults to disabled with no anonymous traffic', () => {
expect(AltchaCaptchaConfigSchema.parse({})).toEqual({
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: 'altcha-captcha-v1',
included_user_ids: [],
excluded_user_ids: [],
anonymous_enabled: false,
cost: 5000,
max_counter: 10000,
});
});
test('rejects difficulty outside the supported range', () => {
expect(AltchaCaptchaConfigUpdateRequest.safeParse({cost: 999}).success).toBe(false);
expect(AltchaCaptchaConfigUpdateRequest.safeParse({cost: 100001}).success).toBe(false);
expect(AltchaCaptchaConfigUpdateRequest.safeParse({max_counter: 99}).success).toBe(false);
expect(AltchaCaptchaConfigUpdateRequest.safeParse({max_counter: 1000001}).success).toBe(false);
expect(AltchaCaptchaConfigUpdateRequest.safeParse({config_version: 3}).data).toEqual({});
});
});
describe('resolveAltchaCaptchaAssignment', () => {
test('serves nobody while disabled, even included users', () => {
const config = createConfig({rollout_basis_points: 10000, included_user_ids: [TARGETED_USER_ID]});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID)).toEqual({enabled: false});
});
test('applies exclusions before inclusions', () => {
const config = createConfig({
enabled: true,
included_user_ids: [TARGETED_USER_ID],
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID)).toEqual({enabled: false});
});
test('serves included users at zero rollout', () => {
const config = createConfig({enabled: true, included_user_ids: [TARGETED_USER_ID]});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID)).toEqual({enabled: true});
});
test('buckets the rollout by salt and user id', () => {
const config = createConfig({enabled: true, rollout_basis_points: 2500});
for (const userId of syntheticUserIds(200)) {
expect(resolveAltchaCaptchaAssignment(config, userId).enabled).toBe(
experimentBucket(userId, config.rollout_salt) < 2500,
);
}
});
});
describe('altchaCaptchaAppliesTo', () => {
test('serves anonymous requests only when anonymous_enabled is set', () => {
expect(altchaCaptchaAppliesTo(createConfig({enabled: true}), null)).toBe(false);
expect(altchaCaptchaAppliesTo(createConfig({enabled: true, anonymous_enabled: true}), null)).toBe(true);
expect(altchaCaptchaAppliesTo(createConfig({anonymous_enabled: true}), null)).toBe(false);
});
test('keeps signed-in users on their own bucket regardless of the anonymous switch', () => {
const config = createConfig({enabled: true, anonymous_enabled: true, excluded_user_ids: [TARGETED_USER_ID]});
expect(altchaCaptchaAppliesTo(config, TARGETED_USER_ID)).toBe(false);
});
});
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {z} from 'zod';
const ALTCHA_CAPTCHA_ROLLOUT_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION;
const ALTCHA_CAPTCHA_MAX_TARGETED_USERS = 1000;
const DEFAULT_ALTCHA_CAPTCHA_SALT = 'altcha-captcha-v1';
export const ALTCHA_CAPTCHA_MIN_COST = 1000;
export const ALTCHA_CAPTCHA_MAX_COST = 100000;
export const ALTCHA_CAPTCHA_MIN_MAX_COUNTER = 100;
export const ALTCHA_CAPTCHA_MAX_MAX_COUNTER = 1000000;
const ALTCHA_CAPTCHA_SALT_PATTERN = /^[\x20-\x7e]+$/u;
const AltchaCaptchaTargetIdSchema = z.string().regex(/^\d{1,20}$/u);
const AltchaCaptchaTargetedUserIdsSchema = z.array(AltchaCaptchaTargetIdSchema).max(ALTCHA_CAPTCHA_MAX_TARGETED_USERS);
const altchaCaptchaConfigFields = {
enabled: z.boolean(),
config_version: z.number().int().min(0),
rollout_basis_points: z.number().int().min(0).max(ALTCHA_CAPTCHA_ROLLOUT_BASIS_POINTS_MAX),
rollout_salt: z.string().trim().min(1).max(64).regex(ALTCHA_CAPTCHA_SALT_PATTERN),
included_user_ids: AltchaCaptchaTargetedUserIdsSchema,
excluded_user_ids: AltchaCaptchaTargetedUserIdsSchema,
anonymous_enabled: z.boolean(),
cost: z.number().int().min(ALTCHA_CAPTCHA_MIN_COST).max(ALTCHA_CAPTCHA_MAX_COST),
max_counter: z.number().int().min(ALTCHA_CAPTCHA_MIN_MAX_COUNTER).max(ALTCHA_CAPTCHA_MAX_MAX_COUNTER),
};
export const AltchaCaptchaConfigSchema = z.object({
enabled: altchaCaptchaConfigFields.enabled.default(false),
config_version: altchaCaptchaConfigFields.config_version.default(0),
rollout_basis_points: altchaCaptchaConfigFields.rollout_basis_points.default(0),
rollout_salt: altchaCaptchaConfigFields.rollout_salt.default(DEFAULT_ALTCHA_CAPTCHA_SALT),
included_user_ids: altchaCaptchaConfigFields.included_user_ids.default([]),
excluded_user_ids: altchaCaptchaConfigFields.excluded_user_ids.default([]),
anonymous_enabled: altchaCaptchaConfigFields.anonymous_enabled.default(false),
cost: altchaCaptchaConfigFields.cost.default(5000),
max_counter: altchaCaptchaConfigFields.max_counter.default(10000),
});
export type AltchaCaptchaConfig = z.infer<typeof AltchaCaptchaConfigSchema>;
export const DEFAULT_ALTCHA_CAPTCHA_CONFIG: AltchaCaptchaConfig = AltchaCaptchaConfigSchema.parse({});
export const AltchaCaptchaConfigUpdateRequest = z
.object(altchaCaptchaConfigFields)
.omit({config_version: true})
.partial();
export type AltchaCaptchaConfigUpdateRequest = z.infer<typeof AltchaCaptchaConfigUpdateRequest>;
export const AltchaCaptchaConfigResponse = AltchaCaptchaConfigSchema;
export type AltchaCaptchaConfigResponse = z.infer<typeof AltchaCaptchaConfigResponse>;
export const AltchaCaptchaAssignmentResponse = z.object({
enabled: altchaCaptchaConfigFields.enabled,
});
export type AltchaCaptchaAssignmentResponse = z.infer<typeof AltchaCaptchaAssignmentResponse>;
export const INERT_ALTCHA_CAPTCHA_ASSIGNMENT: AltchaCaptchaAssignmentResponse = {
enabled: false,
};
export function resolveAltchaCaptchaAssignment(
config: AltchaCaptchaConfig,
userId: string,
): AltchaCaptchaAssignmentResponse {
if (!config.enabled) return {...INERT_ALTCHA_CAPTCHA_ASSIGNMENT};
if (config.excluded_user_ids.includes(userId)) return {...INERT_ALTCHA_CAPTCHA_ASSIGNMENT};
if (config.included_user_ids.includes(userId)) return {enabled: true};
return {enabled: experimentBucket(userId, config.rollout_salt) < config.rollout_basis_points};
}
export function altchaCaptchaAppliesTo(config: AltchaCaptchaConfig, userId: string | null): boolean {
if (userId === null) return config.enabled && config.anonymous_enabled;
return resolveAltchaCaptchaAssignment(config, userId).enabled;
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AltchaCaptchaAssignmentResponse} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {
DomainMigrationAssignmentResponse,
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
@@ -47,6 +48,7 @@ export type ExperimentDeliveryConfigResponse = z.infer<typeof ExperimentDelivery
const ExperimentAssignmentsSchema = z.object({
voice_noise_suppression: VoiceNoiseSuppressionAssignmentResponse.optional(),
domain_migration: DomainMigrationAssignmentResponse.optional(),
altcha_captcha: AltchaCaptchaAssignmentResponse.optional(),
});
export const ExperimentAssignmentsResponse = z.object({