fix(admin): save premium limits and model changes (#3386)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-11 13:26:39 +02:00
1 parent 7d2c51a57f
commit 6acd7f3d22
18 files changed
+1035 -59

No files matched your search

@@ -0,0 +1,73 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {getInstanceConfigRepository, getLimitConfigService} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {MAX_BIO_LENGTH} from '@fluxer/constants/src/LimitConstants';
import type {LimitConfigGetResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import type {z} from 'zod';
type LimitConfigResponse = z.infer<typeof LimitConfigGetResponse>;
describe('limit config allowed ranges', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness.shutdown();
});
async function createAdmin(): Promise<TestAccount> {
return await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW,
AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE,
]);
}
function vipRule(maxBioLength: number) {
return {id: 'vip', filters: {traits: ['vip']}, limits: {max_bio_length: maxBioLength}};
}
it('refuses a bio length above what a profile can hold', async () => {
const admin = await createAdmin();
await createBuilder(harness, admin.token)
.put('/admin/limit-config')
.body({limit_config: {traitDefinitions: ['vip'], rules: [vipRule(MAX_BIO_LENGTH + 1)]}})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
.execute();
const saved = await createBuilder<LimitConfigResponse>(harness, admin.token)
.put('/admin/limit-config')
.body({limit_config: {traitDefinitions: ['vip'], rules: [vipRule(MAX_BIO_LENGTH)]}})
.execute();
expect(saved.limit_config.rules.find((rule) => rule.id === 'vip')?.limits).toEqual({
max_bio_length: MAX_BIO_LENGTH,
});
expect(saved.metadata.max_bio_length).toMatchObject({min: 0, max: MAX_BIO_LENGTH});
});
it('reads a stored bio length above the profile maximum as the maximum', async () => {
const admin = await createAdmin();
await getInstanceConfigRepository().setLimitConfig({traitDefinitions: ['vip'], rules: [vipRule(1000)]});
await getLimitConfigService().refreshCache();
const response = await createBuilder<LimitConfigResponse>(harness, admin.token)
.get('/admin/limit-config')
.execute();
expect(response.limit_config.rules.find((rule) => rule.id === 'vip')?.limits).toEqual({
max_bio_length: MAX_BIO_LENGTH,
});
});
});
@@ -0,0 +1,289 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {getConfig} from '@app/api/Config';
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
import {getInstanceConfigRepository, getLimitConfigService} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {fetchUserMe, grantPremium} from '@app/api/user/tests/UserTestUtils';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {LIMIT_KEYS} from '@fluxer/constants/src/LimitConfigMetadata';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {DEFAULT_RESTRICTED_LIMITS, DEFAULT_STOCK_LIMITS} from '@fluxer/limits/src/LimitDefaults';
import {expandWireFormat} from '@fluxer/limits/src/LimitDiffer';
import {resolveLimits} from '@fluxer/limits/src/LimitResolver';
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
import type {LimitConfigGetResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {WellKnownFluxerResponse} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import type {z} from 'zod';
type LimitConfigResponse = z.infer<typeof LimitConfigGetResponse>;
const MODE_KEYS = LIMIT_KEYS.filter((key) => DEFAULT_STOCK_LIMITS[key] !== DEFAULT_RESTRICTED_LIMITS[key]);
describe('limit config across premium model switches on a self-hosted instance', () => {
let harness: ApiTestHarness;
let originalSelfHosted: boolean;
let originalPremiumMode: ReturnType<typeof getCachedInstancePremiumMode>;
beforeAll(async () => {
originalSelfHosted = getConfig().instance.selfHosted;
originalPremiumMode = getCachedInstancePremiumMode();
getConfig().instance.selfHosted = true;
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness.shutdown();
getConfig().instance.selfHosted = originalSelfHosted;
setCachedInstancePremiumMode(originalPremiumMode);
});
async function createAdmin(): Promise<TestAccount> {
return await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_UPDATE,
AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW,
AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE,
]);
}
async function setPremiumMode(admin: TestAccount, premiumMode: 'mirror' | 'everyone'): Promise<void> {
await createBuilder(harness, admin.token)
.patch('/admin/instance/config')
.body({policy: {premium_mode: premiumMode}})
.execute();
}
async function getLimitConfig(admin: TestAccount): Promise<LimitConfigResponse> {
return await createBuilder<LimitConfigResponse>(harness, admin.token).get('/admin/limit-config').execute();
}
async function saveRule(admin: TestAccount, ruleId: string, overrides: Record<string, number>): Promise<void> {
const {limit_config: config} = await getLimitConfig(admin);
await createBuilder(harness, admin.token)
.put('/admin/limit-config')
.body({
limit_config: {
traitDefinitions: config.traitDefinitions,
rules: config.rules.map(({modifiedFields: _modifiedFields, ...rule}) =>
rule.id === ruleId ? {...rule, limits: {...rule.limits, ...overrides}} : rule,
),
},
})
.execute();
}
function saveDefaultRule(admin: TestAccount, overrides: Record<string, number>): Promise<void> {
return saveRule(admin, 'default', overrides);
}
function findRule(response: LimitConfigResponse, ruleId: string) {
const rule = response.limit_config.rules.find((candidate) => candidate.id === ruleId);
expect(rule).toBeDefined();
return rule!;
}
function defaultRule(response: LimitConfigResponse) {
return findRule(response, 'default');
}
async function storeLegacyLimitConfig(config: LimitConfigSnapshot): Promise<void> {
await getInstanceConfigRepository().setLimitConfig(config);
await getLimitConfigService().refreshCache();
}
async function changeTag(account: TestAccount, discriminator: string): Promise<number> {
const {response} = await createBuilder(harness, account.token)
.patch('/users/@me')
.body({discriminator, password: account.password})
.executeRaw();
return response.status;
}
it('gives everyone premium limits after a save in mirror mode and keeps edited values', async () => {
const admin = await createAdmin();
await setPremiumMode(admin, 'mirror');
await saveDefaultRule(admin, {max_bio_length: 300});
await setPremiumMode(admin, 'everyone');
const rule = defaultRule(await getLimitConfig(admin));
expect(rule.limits).toEqual({...DEFAULT_STOCK_LIMITS, max_bio_length: 300});
expect(rule.modifiedFields).toEqual(['max_bio_length']);
const discovery = await createBuilderWithoutAuth<WellKnownFluxerResponse>(harness)
.get('/.well-known/fluxer')
.execute();
expect(discovery.limits.rules.find((candidate) => candidate.id === 'default')?.overrides).toMatchObject({
feature_custom_discriminator: 1,
max_guilds: DEFAULT_STOCK_LIMITS.max_guilds,
});
});
it('restores free limits after a save in everyone mode', async () => {
const admin = await createAdmin();
await setPremiumMode(admin, 'everyone');
await saveDefaultRule(admin, {});
await setPremiumMode(admin, 'mirror');
const response = await getLimitConfig(admin);
expect(defaultRule(response).limits).toEqual(DEFAULT_RESTRICTED_LIMITS);
expect(response.limit_config.rules.find((rule) => rule.id === 'premium')?.limits).toEqual(DEFAULT_STOCK_LIMITS);
});
it('stores only the edited values', async () => {
const admin = await createAdmin();
await setPremiumMode(admin, 'mirror');
await saveDefaultRule(admin, {max_bio_length: 300});
const {config: stored} = await getInstanceConfigRepository().readLimitConfigInputs();
expect(stored?.overridesOnly).toBe(true);
expect(stored?.rules).toEqual([
{id: 'premium', filters: {traits: ['premium']}, limits: {}},
{id: 'default', limits: {max_bio_length: 300}},
{id: 'very_large_guild', filters: {guildFeatures: ['VERY_LARGE_GUILD']}, limits: {}},
]);
});
it('repairs free limits stored in full before an earlier switch to everyone', async () => {
const admin = await createAdmin();
await setPremiumMode(admin, 'everyone');
await storeLegacyLimitConfig({
traitDefinitions: ['premium'],
rules: [
{id: 'premium', filters: {traits: ['premium']}, limits: {...DEFAULT_STOCK_LIMITS}},
{
id: 'default',
limits: {...DEFAULT_RESTRICTED_LIMITS, max_bio_length: 300},
modifiedFields: ['max_bio_length'],
},
],
});
const rule = defaultRule(await getLimitConfig(admin));
expect(rule.limits).toEqual({...DEFAULT_STOCK_LIMITS, max_bio_length: 300});
expect(rule.modifiedFields).toEqual(['max_bio_length']);
});
it('repairs premium limits stored in full before an earlier switch to mirror', async () => {
const admin = await createAdmin();
await setPremiumMode(admin, 'mirror');
await storeLegacyLimitConfig({
traitDefinitions: [],
rules: [{id: 'default', limits: {...DEFAULT_STOCK_LIMITS}}],
});
const response = await getLimitConfig(admin);
expect(defaultRule(response).limits).toEqual(DEFAULT_RESTRICTED_LIMITS);
expect(defaultRule(response).modifiedFields).toBeUndefined();
});
it('repairs free limits saved again after an earlier switch to everyone', async () => {
const admin = await createAdmin();
await setPremiumMode(admin, 'everyone');
await storeLegacyLimitConfig({
traitDefinitions: [],
rules: [{id: 'default', limits: {...DEFAULT_RESTRICTED_LIMITS}, modifiedFields: MODE_KEYS}],
});
const rule = defaultRule(await getLimitConfig(admin));
expect(rule.limits).toEqual(DEFAULT_STOCK_LIMITS);
expect(rule.modifiedFields).toBeUndefined();
const member = await createTestAccount(harness);
expect(await changeTag(member, '4242')).toBe(200);
await saveDefaultRule(admin, {max_bio_length: 300});
const {config: stored} = await getInstanceConfigRepository().readLimitConfigInputs();
expect(stored?.rules.find((candidate) => candidate.id === 'default')).toEqual({
id: 'default',
limits: {max_bio_length: 300},
});
});
it('repairs premium limits saved again after an earlier switch to mirror', async () => {
const admin = await createAdmin();
await setPremiumMode(admin, 'mirror');
await storeLegacyLimitConfig({
traitDefinitions: [],
rules: [{id: 'default', limits: {...DEFAULT_STOCK_LIMITS}, modifiedFields: MODE_KEYS}],
});
const response = await getLimitConfig(admin);
expect(defaultRule(response).limits).toEqual(DEFAULT_RESTRICTED_LIMITS);
expect(findRule(response, 'premium').limits).toEqual(DEFAULT_STOCK_LIMITS);
expect(response.limit_config.traitDefinitions).toEqual(['premium']);
});
it('reads limits stored in full under the model that is being left', async () => {
const admin = await createAdmin();
await setPremiumMode(admin, 'mirror');
await storeLegacyLimitConfig({
traitDefinitions: ['premium'],
rules: [
{id: 'premium', filters: {traits: ['premium']}, limits: {...DEFAULT_STOCK_LIMITS}},
{id: 'default', limits: {...DEFAULT_RESTRICTED_LIMITS, max_guilds: 150}},
],
});
await setPremiumMode(admin, 'everyone');
expect(defaultRule(await getLimitConfig(admin)).limits).toEqual({...DEFAULT_STOCK_LIMITS, max_guilds: 150});
const {config: stored} = await getInstanceConfigRepository().readLimitConfigInputs();
expect(stored?.rules).toEqual([
{id: 'premium', filters: {traits: ['premium']}, limits: {}},
{id: 'default', limits: {max_guilds: 150}},
]);
});
it('keeps edits that match the other model across a save there', async () => {
const admin = await createAdmin();
await setPremiumMode(admin, 'mirror');
const edits = {feature_custom_discriminator: 1, max_guilds: DEFAULT_STOCK_LIMITS.max_guilds};
await saveDefaultRule(admin, edits);
await setPremiumMode(admin, 'everyone');
await saveDefaultRule(admin, {max_bio_length: 300});
await setPremiumMode(admin, 'mirror');
const rule = defaultRule(await getLimitConfig(admin));
expect(rule.limits).toEqual({...DEFAULT_RESTRICTED_LIMITS, ...edits, max_bio_length: 300});
expect(rule.modifiedFields).toEqual(['feature_custom_discriminator', 'max_bio_length', 'max_guilds']);
});
it('keeps premium rule edits across a save while everyone hides the rule', async () => {
const admin = await createAdmin();
await setPremiumMode(admin, 'mirror');
const edits = {max_guilds: 999, feature_animated_avatar: 0};
await saveRule(admin, 'premium', edits);
await setPremiumMode(admin, 'everyone');
const hidden = await getLimitConfig(admin);
expect(hidden.limit_config.rules.map((rule) => rule.id)).toEqual(['default', 'very_large_guild']);
expect(hidden.limit_config.traitDefinitions).toEqual([]);
await saveDefaultRule(admin, {max_bio_length: 300});
await setPremiumMode(admin, 'mirror');
const response = await getLimitConfig(admin);
expect(findRule(response, 'premium').limits).toEqual({...DEFAULT_STOCK_LIMITS, ...edits});
expect(response.limit_config.traitDefinitions).toEqual(['premium']);
expect(defaultRule(response).limits).toEqual({...DEFAULT_RESTRICTED_LIMITS, max_bio_length: 300});
});
it('lets only premium members change their tag in mirror mode', async () => {
const admin = await createAdmin();
await setPremiumMode(admin, 'everyone');
await saveDefaultRule(admin, {});
await setPremiumMode(admin, 'mirror');
const free = await createTestAccount(harness);
const subscriber = await createTestAccount(harness);
const gifted = await createTestAccount(harness);
await grantPremium(harness, subscriber.userId, UserPremiumTypes.SUBSCRIPTION);
await grantPremium(harness, gifted.userId, UserPremiumTypes.LIFETIME);
expect(await changeTag(free, '4242')).toBe(400);
expect(await changeTag(subscriber, '4242')).toBe(200);
expect(await changeTag(gifted, '4243')).toBe(200);
const {json: me} = await fetchUserMe(harness, subscriber.token);
expect(me.traits).toContain('premium');
const discovery = await createBuilderWithoutAuth<WellKnownFluxerResponse>(harness)
.get('/.well-known/fluxer')
.execute();
const {limits} = resolveLimits(expandWireFormat(discovery.limits), {
traits: new Set(me.traits),
guildFeatures: new Set(),
});
expect(limits.feature_custom_discriminator).toBe(1);
expect(limits.max_guilds).toBe(DEFAULT_STOCK_LIMITS.max_guilds);
});
});
+140 -15
View File
@@ -3,7 +3,7 @@
import assert from 'node:assert/strict';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
import {LIMIT_KEYS} from '@fluxer/constants/src/LimitConfigMetadata';
import {LIMIT_KEY_METADATA, LIMIT_KEYS} from '@fluxer/constants/src/LimitConfigMetadata';
import {MAX_GUILD_MEMBERS_VERY_LARGE_GUILD} from '@fluxer/constants/src/LimitConstants';
import {DEFAULT_RESTRICTED_LIMITS, DEFAULT_STOCK_LIMITS} from '@fluxer/limits/src/LimitDefaults';
import type {LimitConfigSnapshot, LimitRule} from '@fluxer/limits/src/LimitTypes';
@@ -20,16 +20,22 @@ export function getLegacyLimitConfigKvKey(selfHosted: boolean): string {
export const LIMIT_CONFIG_REFRESH_CHANNEL = 'limit-config-refresh';
export interface LimitBuildOptions {
selfHosted?: boolean;
premiumMode?: 'mirror' | 'everyone';
}
export interface StoredLimitConfig extends LimitConfigSnapshot {
overridesOnly?: boolean;
}
function cloneLimitConfigSnapshot(config: LimitConfigSnapshot): LimitConfigSnapshot {
return structuredClone(config);
}
export function sanitizeLimitConfigForInstance(
config: LimitConfigSnapshot,
options?: {
selfHosted?: boolean;
premiumMode?: 'mirror' | 'everyone';
},
options?: LimitBuildOptions,
): LimitConfigSnapshot {
const selfHosted = options?.selfHosted ?? false;
const premiumMode = options?.premiumMode ?? 'everyone';
@@ -51,10 +57,7 @@ export function sanitizeLimitConfigForInstance(
};
}
export function createDefaultLimitConfig(options?: {
selfHosted?: boolean;
premiumMode?: 'mirror' | 'everyone';
}): LimitConfigSnapshot {
export function createDefaultLimitConfig(options?: LimitBuildOptions): LimitConfigSnapshot {
const selfHosted = options?.selfHosted ?? false;
const premiumMode = options?.premiumMode ?? 'everyone';
const useTiers = !selfHosted || premiumMode === 'mirror';
@@ -94,10 +97,7 @@ export function createDefaultLimitConfig(options?: {
export function mergeWithCurrentDefaults(
stored: LimitConfigSnapshot,
options?: {
selfHosted?: boolean;
premiumMode?: 'mirror' | 'everyone';
},
options?: LimitBuildOptions,
): LimitConfigSnapshot {
const selfHosted = options?.selfHosted ?? false;
const premiumMode = options?.premiumMode ?? 'everyone';
@@ -107,7 +107,7 @@ export function mergeWithCurrentDefaults(
const mergedRules: Array<LimitRule> = [];
const existingRulesMap = new Map<string, LimitRule>();
for (const rule of stored.rules) {
existingRulesMap.set(rule.id, rule);
existingRulesMap.set(rule.id, {...rule, limits: clampToAllowedRange(rule.limits)});
}
for (const defaultRule of newDefaults.rules) {
const existingRule = existingRulesMap.get(defaultRule.id);
@@ -138,11 +138,136 @@ export function mergeWithCurrentDefaults(
});
}
return {
traitDefinitions: stored.traitDefinitions,
traitDefinitions: [...new Set([...newDefaults.traitDefinitions, ...stored.traitDefinitions])],
rules: mergedRules,
};
}
export function readStoredLimitOverrides(
stored: StoredLimitConfig,
options: Required<LimitBuildOptions>,
): StoredLimitConfig {
if (!options.selfHosted || stored.overridesOnly) {
return stored;
}
const currentDefaults = builtInRuleLimits(options);
const otherDefaults = builtInRuleLimits({
selfHosted: true,
premiumMode: options.premiumMode === 'mirror' ? 'everyone' : 'mirror',
});
return {
overridesOnly: true,
traitDefinitions: stored.traitDefinitions,
rules: stored.rules.map((rule) => {
const current = currentDefaults.get(rule.id);
const other = otherDefaults.get(rule.id);
const savedUnder = current && !(other && wasSavedUnderOtherDefaults(rule, current, other)) ? current : other;
return {
id: rule.id,
filters: rule.filters,
limits: savedUnder ? pickModifiedLimits(rule.limits, savedUnder) : rule.limits,
};
}),
};
}
export function buildStoredLimitConfig(
submitted: LimitConfigSnapshot,
previous: StoredLimitConfig | null,
options: Required<LimitBuildOptions>,
): StoredLimitConfig {
const visible = sanitizeLimitConfigForInstance(submitted, options);
if (!options.selfHosted) {
return mergeWithCurrentDefaults(visible, options);
}
const kept = previous === null ? null : readStoredLimitOverrides(previous, options);
const keptRules = new Map((kept?.rules ?? []).map((rule) => [rule.id, rule]));
const defaults = builtInRuleLimits(options);
const rules: Array<LimitRule> = visible.rules.map((rule) => {
const ruleDefaults = defaults.get(rule.id);
const keptLimits = keptRules.get(rule.id)?.limits ?? {};
return {
id: rule.id,
filters: rule.filters,
limits: ruleDefaults
? pickLimits(rule.limits, (key, value) => value !== ruleDefaults[key] || value === keptLimits[key])
: rule.limits,
};
});
const hidden = kept === null ? null : hiddenFromInstance(kept, options);
const visibleRuleIds = new Set(rules.map((rule) => rule.id));
return {
overridesOnly: true,
traitDefinitions: [
...(hidden?.traitDefinitions ?? []).filter((trait) => !visible.traitDefinitions.includes(trait)),
...visible.traitDefinitions,
],
rules: [...(hidden?.rules ?? []).filter((rule) => !visibleRuleIds.has(rule.id)), ...rules],
};
}
function hiddenFromInstance(config: LimitConfigSnapshot, options: Required<LimitBuildOptions>): LimitConfigSnapshot {
const visible = sanitizeLimitConfigForInstance(config, options);
return {
traitDefinitions: config.traitDefinitions.filter((trait) => !visible.traitDefinitions.includes(trait)),
rules: config.rules.filter((rule) => !visible.rules.includes(rule)),
};
}
function builtInRuleLimits(options?: LimitBuildOptions): Map<string, Partial<Record<LimitKey, number>>> {
return new Map(createDefaultLimitConfig(options).rules.map((rule) => [rule.id, rule.limits]));
}
function wasSavedUnderOtherDefaults(
rule: LimitRule,
currentDefaults: Partial<Record<LimitKey, number>>,
otherDefaults: Partial<Record<LimitKey, number>>,
): boolean {
const modeKeys = LIMIT_KEYS.filter((key) => currentDefaults[key] !== otherDefaults[key]);
const edited = new Set<string>(rule.modifiedFields ?? []);
const untouchedModeKeys = modeKeys.filter((key) => !edited.has(key));
if (untouchedModeKeys.length > 0) {
return untouchedModeKeys.every((key) => rule.limits[key] === otherDefaults[key]);
}
return (
modeKeys.some((key) => rule.limits[key] === otherDefaults[key]) &&
modeKeys.every((key) => rule.limits[key] !== undefined && rule.limits[key] !== currentDefaults[key])
);
}
function clampToAllowedRange(limits: Partial<Record<LimitKey, number>>): Partial<Record<LimitKey, number>> {
const clamped = {...limits};
for (const key of LIMIT_KEYS) {
const value = clamped[key];
const {min = 0, max} = LIMIT_KEY_METADATA[key];
if (value !== undefined && max !== undefined) {
clamped[key] = Math.min(Math.max(value, min), max);
}
}
return clamped;
}
function pickModifiedLimits(
limits: Partial<Record<LimitKey, number>>,
defaultLimits: Partial<Record<LimitKey, number>>,
): Partial<Record<LimitKey, number>> {
return pickLimits(limits, (key, value) => value !== defaultLimits[key]);
}
function pickLimits(
limits: Partial<Record<LimitKey, number>>,
keep: (key: LimitKey, value: number) => boolean,
): Partial<Record<LimitKey, number>> {
const picked: Partial<Record<LimitKey, number>> = {};
for (const key of LIMIT_KEYS) {
const value = limits[key];
if (value !== undefined && keep(key, value)) {
picked[key] = value;
}
}
return picked;
}
function findModifiedLimits(
currentLimits: Partial<Record<LimitKey, number>>,
defaultLimits: Partial<Record<LimitKey, number>>,
@@ -3,6 +3,7 @@
import crypto from 'node:crypto';
import {Config} from '@app/api/Config';
import type {APIConfig, BlueskyOAuthConfig, BlueskyOAuthKeyConfig} from '@app/api/config/APIConfig';
import type {StoredLimitConfig} from '@app/api/constants/LimitConfig';
import {executeConditional, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {Db, type PreparedQuery} from '@app/api/database/CassandraTypes';
import type {InstanceConfigurationRow} from '@app/api/database/types/InstanceConfigTypes';
@@ -33,7 +34,7 @@ import {setCachedConfiguredLegalUrls} from '@app/api/instance/LegalUrls';
import {getDefaultProductName, setCachedProductName} from '@app/api/instance/ProductName';
import {normalizeSsoAllowedEmailDomains} from '@app/api/instance/SsoConfigValidation';
import {Logger} from '@app/api/Logger';
import {isLimitConfigSnapshot} from '@app/api/limits/LimitConfigValidation';
import {isStoredLimitConfig} from '@app/api/limits/LimitConfigValidation';
import {
getEffectiveBillingConfig,
isBillingActive,
@@ -52,7 +53,6 @@ import {
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
import {
InstanceConfigResponse,
InstanceConfigUpdateRequest,
@@ -198,7 +198,7 @@ export interface InstanceBillingAdminConfig {
export type InstancePremiumMode = 'mirror' | 'everyone';
interface LimitConfigInputs {
config: LimitConfigSnapshot | null;
config: StoredLimitConfig | null;
premiumMode: InstancePremiumMode;
}
@@ -382,7 +382,7 @@ const FETCH_LIMIT_CONFIG_INPUTS_QUERY = InstanceConfiguration.selectCql({
where: InstanceConfiguration.where.in('key', 'keys'),
});
function parseStoredLimitConfig(raw: string | null): LimitConfigSnapshot | null {
function parseStoredLimitConfig(raw: string | null): StoredLimitConfig | null {
if (raw === null) return null;
let parsed: unknown;
try {
@@ -391,7 +391,7 @@ function parseStoredLimitConfig(raw: string | null): LimitConfigSnapshot | null
Logger.error({error}, 'Stored limit configuration is not valid JSON, falling back to default limits');
return null;
}
if (!isLimitConfigSnapshot(parsed)) {
if (!isStoredLimitConfig(parsed)) {
Logger.error('Stored limit configuration has an invalid shape, falling back to default limits');
return null;
}
@@ -1680,7 +1680,7 @@ export class InstanceConfigRepository {
};
}
async setLimitConfig(config: LimitConfigSnapshot): Promise<void> {
async setLimitConfig(config: StoredLimitConfig): Promise<void> {
await this.setConfig(LIMIT_CONFIG_KEY, JSON.stringify(config));
}
+32 -14
View File
@@ -2,10 +2,13 @@
import {Config} from '@app/api/Config';
import {
buildStoredLimitConfig,
createDefaultLimitConfig,
getLegacyLimitConfigKvKey,
LIMIT_CONFIG_REFRESH_CHANNEL,
type LimitBuildOptions,
mergeWithCurrentDefaults,
readStoredLimitOverrides,
sanitizeLimitConfigForInstance,
} from '@app/api/constants/LimitConfig';
import {
@@ -28,11 +31,6 @@ const MAX_PENDING_OPERATIONS = 32;
let globalLimitConfigService: LimitConfigService | null = null;
interface LimitBuildOptions {
selfHosted: boolean;
premiumMode: InstancePremiumMode;
}
export class LimitConfigService {
private premiumMode: InstancePremiumMode = 'everyone';
private config: LimitConfigSnapshot = createDefaultLimitConfig({
@@ -146,40 +144,60 @@ export class LimitConfigService {
this.assertRefreshActive(generation);
const {config: stored, premiumMode} = await this.repository.readLimitConfigInputs();
this.assertRefreshActive(generation);
const buildOptions: LimitBuildOptions = {
const buildOptions: Required<LimitBuildOptions> = {
selfHosted: Config.instance.selfHosted,
premiumMode,
};
this.config =
stored === null
? createDefaultLimitConfig(buildOptions)
: mergeWithCurrentDefaults(sanitizeLimitConfigForInstance(stored, buildOptions), buildOptions);
: mergeWithCurrentDefaults(
sanitizeLimitConfigForInstance(readStoredLimitOverrides(stored, buildOptions), buildOptions),
buildOptions,
);
this.premiumMode = premiumMode;
setCachedInstancePremiumMode(premiumMode);
}
updateConfig(config: LimitConfigSnapshot): Promise<void> {
return this.runOperation(async (generation) => {
const policy = await this.repository.readStoredInstancePolicyConfig();
const {config: previous, premiumMode} = await this.repository.readLimitConfigInputs();
this.assertRefreshActive(generation);
const buildOptions: LimitBuildOptions = {
const stored = buildStoredLimitConfig(config, previous, {
selfHosted: Config.instance.selfHosted,
premiumMode: policy.premium_mode,
};
const normalized = mergeWithCurrentDefaults(sanitizeLimitConfigForInstance(config, buildOptions), buildOptions);
await this.repository.setLimitConfig(normalized);
premiumMode,
});
await this.repository.setLimitConfig(stored);
await this.reloadConfig(generation);
Logger.info({ruleCount: normalized.rules.length}, 'Limit config updated');
Logger.info({ruleCount: stored.rules.length}, 'Limit config updated');
});
}
updatePolicyConfig(patch: Partial<InstancePolicyConfig>): Promise<void> {
return this.runOperation(async (generation) => {
if (patch.premium_mode !== undefined) {
await this.detachStoredLimitsFromPremiumMode(generation, patch.premium_mode);
}
await this.repository.setInstancePolicyConfig(patch);
await this.reloadConfig(generation);
});
}
private async detachStoredLimitsFromPremiumMode(
generation: number,
nextPremiumMode: InstancePremiumMode,
): Promise<void> {
const {config: stored, premiumMode} = await this.repository.readLimitConfigInputs();
this.assertRefreshActive(generation);
if (stored === null || premiumMode === nextPremiumMode) {
return;
}
const overrides = readStoredLimitOverrides(stored, {selfHosted: Config.instance.selfHosted, premiumMode});
if (overrides !== stored) {
await this.repository.setLimitConfig(overrides);
}
}
private async reloadConfig(generation: number): Promise<void> {
this.assertRefreshActive(generation);
await this.cacheService.delete(getLegacyLimitConfigKvKey(Config.instance.selfHosted));
@@ -1,5 +1,6 @@
import type {StoredLimitConfig} from '@app/api/constants/LimitConfig';
import {isJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
import type {LimitConfigSnapshot, LimitRule} from '@fluxer/limits/src/LimitTypes';
import type {LimitRule} from '@fluxer/limits/src/LimitTypes';
function isStringArray(value: unknown): value is Array<string> {
return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
@@ -17,10 +18,11 @@ function isLimitRuleSnapshot(value: unknown): value is LimitRule {
);
}
export function isLimitConfigSnapshot(value: unknown): value is LimitConfigSnapshot {
export function isStoredLimitConfig(value: unknown): value is StoredLimitConfig {
return (
isJsonRecord(value) &&
(value.version === undefined || typeof value.version === 'number') &&
(value.overridesOnly === undefined || typeof value.overridesOnly === 'boolean') &&
isStringArray(value.traitDefinitions) &&
Array.isArray(value.rules) &&
value.rules.every(isLimitRuleSnapshot)
@@ -0,0 +1,188 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
buildStoredLimitConfig,
createDefaultLimitConfig,
mergeWithCurrentDefaults,
readStoredLimitOverrides,
sanitizeLimitConfigForInstance,
} from '@app/api/constants/LimitConfig';
import {LIMIT_KEYS} from '@fluxer/constants/src/LimitConfigMetadata';
import {DEFAULT_RESTRICTED_LIMITS, DEFAULT_STOCK_LIMITS} from '@fluxer/limits/src/LimitDefaults';
import type {LimitConfigSnapshot, LimitRule} from '@fluxer/limits/src/LimitTypes';
import {describe, expect, test} from 'vitest';
describe('Limit config storage', () => {
const mirror = {selfHosted: true, premiumMode: 'mirror'} as const;
const everyone = {selfHosted: true, premiumMode: 'everyone'} as const;
const hosted = {selfHosted: false, premiumMode: 'everyone'} as const;
const modeKeys = LIMIT_KEYS.filter((key) => DEFAULT_STOCK_LIMITS[key] !== DEFAULT_RESTRICTED_LIMITS[key]);
function defaultRuleConfig(rule: Omit<LimitRule, 'id'>): LimitConfigSnapshot {
return {traitDefinitions: [], rules: [{id: 'default', ...rule}]};
}
function fullConfig(options: {selfHosted: boolean; premiumMode: 'mirror' | 'everyone'}): LimitConfigSnapshot {
const {traitDefinitions, rules} = createDefaultLimitConfig(options);
return {traitDefinitions, rules};
}
function withLimits(config: LimitConfigSnapshot, ruleId: string, limits: LimitRule['limits']): LimitConfigSnapshot {
return {
traitDefinitions: config.traitDefinitions,
rules: config.rules.map((rule) => (rule.id === ruleId ? {...rule, limits: {...rule.limits, ...limits}} : rule)),
};
}
function load(stored: LimitConfigSnapshot, options: typeof mirror | typeof everyone): LimitConfigSnapshot {
const overrides = readStoredLimitOverrides(structuredClone(stored), options);
return mergeWithCurrentDefaults(sanitizeLimitConfigForInstance(overrides, options), options);
}
function limitsOf(config: LimitConfigSnapshot, ruleId: string): LimitRule['limits'] | undefined {
return config.rules.find((rule) => rule.id === ruleId)?.limits;
}
test('built-in rules keep only values that differ from the defaults', () => {
const stored = buildStoredLimitConfig(
{
traitDefinitions: ['premium', 'vip'],
rules: [
{id: 'premium', filters: {traits: ['premium']}, limits: {...DEFAULT_STOCK_LIMITS, max_guilds: 250}},
{id: 'default', limits: {...DEFAULT_RESTRICTED_LIMITS}, modifiedFields: ['max_guilds']},
{id: 'vip', filters: {traits: ['vip']}, limits: {max_guilds: 100}},
],
},
null,
mirror,
);
expect(stored.rules).toEqual([
{id: 'premium', filters: {traits: ['premium']}, limits: {max_guilds: 250}},
{id: 'default', limits: {}},
{id: 'vip', filters: {traits: ['vip']}, limits: {max_guilds: 100}},
]);
});
test('hosted instances keep storing whole rules', () => {
const submitted = withLimits(fullConfig(hosted), 'default', {max_guilds: 150});
const stored = buildStoredLimitConfig(submitted, null, hosted);
expect(stored).toEqual(mergeWithCurrentDefaults(submitted, hosted));
expect(limitsOf(stored, 'default')).toEqual({...DEFAULT_RESTRICTED_LIMITS, max_guilds: 150});
expect(readStoredLimitOverrides(stored, hosted)).toBe(stored);
});
test('a whole default rule saved under the other premium model keeps only its edits', () => {
const stored = defaultRuleConfig({
limits: {...DEFAULT_RESTRICTED_LIMITS, max_guilds: 150, max_bio_length: 300},
modifiedFields: ['max_guilds', 'max_bio_length'],
});
expect(readStoredLimitOverrides(stored, everyone).rules).toEqual([
{id: 'default', limits: {max_guilds: 150, max_bio_length: 300}},
]);
});
test('a whole default rule saved under the current premium model keeps only its edits', () => {
const stored = defaultRuleConfig({
limits: {...DEFAULT_RESTRICTED_LIMITS, feature_custom_discriminator: 1},
modifiedFields: ['feature_custom_discriminator'],
});
expect(readStoredLimitOverrides(stored, mirror).rules).toEqual([
{id: 'default', limits: {feature_custom_discriminator: 1}},
]);
});
test('free limits saved again after a switch to everyone are dropped', () => {
const savedInMirror = mergeWithCurrentDefaults(fullConfig(mirror), mirror);
const shownInEveryone = mergeWithCurrentDefaults(savedInMirror, everyone);
expect(limitsOf(shownInEveryone, 'default')).toEqual(DEFAULT_RESTRICTED_LIMITS);
const savedAgain = mergeWithCurrentDefaults(shownInEveryone, everyone);
expect(savedAgain.rules.find((rule) => rule.id === 'default')?.modifiedFields).toEqual(modeKeys);
expect(limitsOf(load(savedAgain, everyone), 'default')).toEqual(DEFAULT_STOCK_LIMITS);
expect(limitsOf(load(savedAgain, mirror), 'default')).toEqual(DEFAULT_RESTRICTED_LIMITS);
});
test('free limits saved again with an edit after a switch to everyone keep only the edit', () => {
const savedInMirror = mergeWithCurrentDefaults(
withLimits(fullConfig(mirror), 'default', {max_guilds: 150, max_bio_length: 300}),
mirror,
);
const savedAgain = mergeWithCurrentDefaults(mergeWithCurrentDefaults(savedInMirror, everyone), everyone);
expect(savedAgain.rules.find((rule) => rule.id === 'default')?.modifiedFields).toEqual(
LIMIT_KEYS.filter((key) => modeKeys.includes(key) || key === 'max_bio_length'),
);
expect(readStoredLimitOverrides(savedAgain, everyone).rules.find((rule) => rule.id === 'default')).toEqual({
id: 'default',
limits: {max_bio_length: 300, max_guilds: 150},
});
});
test('a default rule fixed by hand after a switch is left as saved', () => {
const stored = defaultRuleConfig({
limits: {...DEFAULT_RESTRICTED_LIMITS, feature_custom_discriminator: 1},
modifiedFields: modeKeys.filter((key) => key !== 'feature_custom_discriminator'),
});
expect(limitsOf(load(stored, everyone), 'default')).toEqual({
...DEFAULT_RESTRICTED_LIMITS,
feature_custom_discriminator: 1,
});
});
test('premium limits saved again after a switch to mirror are dropped', () => {
const savedInEveryone = mergeWithCurrentDefaults(fullConfig(everyone), everyone);
const shownInMirror = mergeWithCurrentDefaults(savedInEveryone, mirror);
expect(limitsOf(shownInMirror, 'default')).toEqual(DEFAULT_STOCK_LIMITS);
const savedAgain = mergeWithCurrentDefaults(shownInMirror, mirror);
const loaded = load(savedAgain, mirror);
expect(limitsOf(loaded, 'default')).toEqual(DEFAULT_RESTRICTED_LIMITS);
expect(limitsOf(loaded, 'premium')).toEqual(DEFAULT_STOCK_LIMITS);
expect(loaded.traitDefinitions).toEqual(['premium']);
});
test('overrides saved in the current format are never reinterpreted', () => {
const raised = buildStoredLimitConfig(
withLimits(fullConfig(mirror), 'default', DEFAULT_STOCK_LIMITS),
null,
mirror,
);
expect(Object.keys(limitsOf(raised, 'default') ?? {})).toEqual(modeKeys);
expect(readStoredLimitOverrides(raised, mirror)).toBe(raised);
expect(readStoredLimitOverrides(raised, everyone)).toBe(raised);
expect(limitsOf(load(raised, mirror), 'default')).toEqual(DEFAULT_STOCK_LIMITS);
});
test('an edit that matches the other premium model survives a save there', () => {
const edits = {feature_custom_discriminator: 1, max_guilds: DEFAULT_STOCK_LIMITS.max_guilds};
const savedInMirror = buildStoredLimitConfig(withLimits(fullConfig(mirror), 'default', edits), null, mirror);
const savedInEveryone = buildStoredLimitConfig(
withLimits(load(savedInMirror, everyone), 'default', {max_bio_length: 300}),
savedInMirror,
everyone,
);
expect(limitsOf(savedInEveryone, 'default')).toEqual({...edits, max_bio_length: 300});
expect(limitsOf(load(savedInEveryone, mirror), 'default')).toEqual({
...DEFAULT_RESTRICTED_LIMITS,
...edits,
max_bio_length: 300,
});
});
test('a value moved back to the default stops being stored', () => {
const savedInMirror = buildStoredLimitConfig(
withLimits(fullConfig(mirror), 'default', {max_guilds: 150}),
null,
mirror,
);
const reverted = buildStoredLimitConfig(fullConfig(mirror), savedInMirror, mirror);
expect(limitsOf(reverted, 'default')).toEqual({});
});
test('premium rule edits survive a save while everyone hides the rule', () => {
const edits = {max_guilds: 999, feature_custom_discriminator: 0};
const savedInMirror = buildStoredLimitConfig(withLimits(fullConfig(mirror), 'premium', edits), null, mirror);
expect(limitsOf(load(savedInMirror, everyone), 'premium')).toBeUndefined();
const savedInEveryone = buildStoredLimitConfig(fullConfig(everyone), savedInMirror, everyone);
expect(savedInEveryone.traitDefinitions).toEqual(['premium']);
expect(limitsOf(savedInEveryone, 'premium')).toEqual(edits);
expect(limitsOf(load(savedInEveryone, mirror), 'premium')).toEqual({...DEFAULT_STOCK_LIMITS, ...edits});
});
});