Files
fluxer/fluxer_api/src/api/instance/InstanceConfigRepository.ts
T

1703 lines
58 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import crypto from 'node:crypto';
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
import {
type GatewayRolloutConfig,
GatewayRolloutConfigSchema,
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {
type VoiceNoiseSuppressionConfig,
VoiceNoiseSuppressionConfigSchema,
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {
type ExperimentDeliveryConfig,
ExperimentDeliveryConfigSchema,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {
type InstanceAppPublic,
type InstanceBranding,
type InstanceCaptchaProvider,
InstanceCaptchaProviderSchema,
type InstanceCommunity,
type InstanceRegistration,
type InstanceRegistrationMode,
InstanceRegistrationModeSchema,
type InstanceServices,
type InstanceSetup,
} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import type {IKVProvider, IKVSubscription} from '@pkgs/kv_client/src/IKVProvider';
import {Config} from '../Config';
import type {APIConfig, BlueskyOAuthConfig, BlueskyOAuthKeyConfig} from '../config/APIConfig';
import {sanitizeLimitConfigForInstance} from '../constants/LimitConfig';
import {fetchMany, fetchOne, upsertOne} from '../database/CassandraQueryExecution';
import type {InstanceConfigurationRow} from '../database/types/InstanceConfigTypes';
import {Logger} from '../Logger';
import {resolveDeferredPhoneGateEnabled, setCachedDeferredPhoneGateEnabled} from '../risk/DeferredPhoneGateCache';
import {InstanceConfiguration} from '../Tables';
import {DEFAULT_DECAY_CONSTANTS, DEFAULT_RENEWAL_CONSTANTS} from '../utils/AttachmentDecay';
import {isJsonRecord, parseJsonArray, parseJsonRecord} from '../utils/JsonBoundaryUtils';
import {getDefaultDateOfBirthCollection, setCachedDateOfBirthCollection} from './DateOfBirthCollectionCache';
import {normalizeSsoAllowedEmailDomains} from './SsoConfigValidation';
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
const REGISTRATION_CONFIG_KEY = 'registration_config';
const REGISTRATION_URLS_KEY = 'registration_urls';
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
const ADMIN_BOOTSTRAP_KEY = 'admin_bootstrapped';
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
const INSTANCE_INTEGRATIONS_CONFIG_KEY = 'instance_integrations_config';
const INSTANCE_MEDIA_CONFIG_KEY = 'instance_media_config';
export const INSTANCE_CONFIG_REFRESH_CHANNEL = 'instance-config-refresh';
export const REGISTRATION_PENDING_APPROVAL_TRAIT = 'registration_pending_approval';
export const REGISTRATION_REJECTED_TRAIT = 'registration_rejected';
const DEFAULT_GATEWAY_ROLLOUT_CONFIG: GatewayRolloutConfig = {
session_rollout_percentage: 100,
session_rollout_mode: 'modulo',
guild_rollout_percentage: 100,
rpc_request_timeout_ms: 10000,
max_concurrent_session_starts: 512,
max_concurrent_guild_starts: 256,
gateway_dispatch_relay_shards: 32,
gateway_dispatch_relay_max_queue: 50000,
voice_e2ee_scope: 'guild_feature_only',
};
export type InstanceRegistrationConfig = InstanceRegistration;
interface InstanceAppPublicConfig extends Omit<InstanceAppPublic, 'setup'> {
setup: Pick<InstanceSetup, 'configured'>;
}
export type InstancePremiumMode = 'mirror' | 'everyone';
export interface InstancePolicyConfig {
single_community_enabled: boolean;
single_community_guild_id: string | null;
direct_messages_disabled: boolean;
direct_messages_locked: boolean;
premium_mode: InstancePremiumMode;
gif_enabled: boolean | null;
youtube_enabled: boolean | null;
bluesky_enabled: boolean | null;
deferred_phone_gate_enabled: boolean;
deferred_phone_gate_window_hours: number;
deferred_phone_gate_member_threshold: number;
}
type InstanceEmailProvider = 'smtp' | 'none';
interface InstanceGifIntegrationConfig {
klipy_api_key: string | null;
}
interface InstanceYoutubeIntegrationConfig {
api_key: string | null;
}
interface InstanceCaptchaIntegrationConfig {
provider: InstanceCaptchaProvider | null;
hcaptcha_site_key: string | null;
hcaptcha_secret_key: string | null;
turnstile_site_key: string | null;
turnstile_secret_key: string | null;
}
interface InstanceEmailSmtpIntegrationConfig {
host: string | null;
port: number | null;
username: string | null;
password: string | null;
secure: boolean | null;
}
interface InstanceEmailIntegrationConfig {
enabled: boolean | null;
provider: InstanceEmailProvider | null;
from_email: string | null;
from_name: string | null;
smtp: InstanceEmailSmtpIntegrationConfig;
disable_new_ip_authorization: boolean | null;
}
interface InstanceBlueskyKeyIntegrationConfig {
kid: string;
private_key: string | null;
}
interface InstanceBlueskyIntegrationConfig {
enabled: boolean | null;
client_name: string | null;
client_uri: string | null;
logo_uri: string | null;
tos_uri: string | null;
policy_uri: string | null;
keys: Array<InstanceBlueskyKeyIntegrationConfig>;
}
interface InstanceIntegrationsConfig {
gif: InstanceGifIntegrationConfig;
youtube: InstanceYoutubeIntegrationConfig;
captcha: InstanceCaptchaIntegrationConfig;
email: InstanceEmailIntegrationConfig;
bluesky: InstanceBlueskyIntegrationConfig;
}
interface InstanceGifEffectiveConfig {
klipy_api_key: string | null;
active_api_key: string | null;
available: boolean;
}
export interface InstanceCaptchaEffectiveConfig {
enabled: boolean;
provider: InstanceCaptchaProvider;
hcaptcha_site_key: string | null;
hcaptcha_secret_key: string | null;
turnstile_site_key: string | null;
turnstile_secret_key: string | null;
}
interface InstanceIntegrationsAdminConfig {
gif: {
klipy_api_key_set: boolean;
effective_available: boolean;
};
youtube: {
api_key_set: boolean;
effective_available: boolean;
};
captcha: {
provider: InstanceCaptchaProvider | null;
effective_provider: InstanceCaptchaProvider;
hcaptcha_site_key: string | null;
hcaptcha_secret_key_set: boolean;
turnstile_site_key: string | null;
turnstile_secret_key_set: boolean;
effective_enabled: boolean;
};
email: {
enabled: boolean | null;
effective_enabled: boolean;
provider: InstanceEmailProvider | null;
effective_provider: InstanceEmailProvider;
from_email: string | null;
from_name: string | null;
smtp: {
host: string | null;
port: number | null;
username: string | null;
password_set: boolean;
secure: boolean | null;
};
disable_new_ip_authorization: boolean;
effective_disable_new_ip_authorization: boolean;
};
bluesky: {
enabled: boolean | null;
effective_enabled: boolean;
client_name: string | null;
client_uri: string | null;
logo_uri: string | null;
tos_uri: string | null;
policy_uri: string | null;
key_count: number;
};
}
interface InstanceAttachmentDecayConfig {
enabled: boolean | null;
min_size_mb: number | null;
max_size_mb: number | null;
max_eligible_size_mb: number | null;
min_lifetime_days: number | null;
max_lifetime_days: number | null;
curve: number | null;
renew_threshold_days: number | null;
renew_window_days: number | null;
}
interface InstanceMediaConfig {
attachment_decay: InstanceAttachmentDecayConfig;
}
export interface InstanceAttachmentDecayEffectiveConfig {
enabled: boolean;
min_size_mb: number;
max_size_mb: number;
max_eligible_size_mb: number;
min_lifetime_days: number;
max_lifetime_days: number;
curve: number;
renew_threshold_days: number;
renew_window_days: number;
}
interface InstanceMediaAdminConfig {
attachment_decay: InstanceAttachmentDecayConfig & {
effective: InstanceAttachmentDecayEffectiveConfig;
};
}
interface InstanceIntegrationsConfigPatch {
gif?: Partial<InstanceGifIntegrationConfig>;
youtube?: Partial<InstanceYoutubeIntegrationConfig>;
captcha?: Partial<InstanceCaptchaIntegrationConfig>;
email?: Partial<Omit<InstanceEmailIntegrationConfig, 'smtp'>> & {
smtp?: Partial<InstanceEmailSmtpIntegrationConfig>;
};
bluesky?: Partial<Omit<InstanceBlueskyIntegrationConfig, 'keys'>> & {
keys?: Array<Partial<InstanceBlueskyKeyIntegrationConfig>>;
};
}
interface InstanceMediaConfigPatch {
attachment_decay?: Partial<InstanceAttachmentDecayConfig>;
}
export interface InstanceRegistrationUrl {
id: string;
label: string | null;
code_hash: string;
created_by_user_id: string;
created_at: string;
expires_at: string | null;
max_uses: number | null;
use_count: number;
revoked_at: string | null;
approval_required: boolean;
last_used_at: string | null;
last_used_by_user_id: string | null;
}
type InstanceRegistrationUrlPublic = Omit<InstanceRegistrationUrl, 'code_hash'>;
interface InstancePendingRegistration {
user_id: string;
username: string;
discriminator: number;
global_name: string | null;
email: string | null;
requested_at: string;
registration_url_id: string | null;
client_ip: string | null;
}
const DEFAULT_REGISTRATION_CONFIG: InstanceRegistrationConfig = {
mode: 'open',
admin_registration_urls_enabled: true,
};
const FETCH_CONFIG_QUERY = InstanceConfiguration.selectCql({
where: InstanceConfiguration.where.eq('key'),
limit: 1,
});
const FETCH_ALL_CONFIG_QUERY = InstanceConfiguration.selectCql();
function isStringArray(value: unknown): value is Array<string> {
return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
}
function isRegistrationMode(value: unknown): value is InstanceRegistrationMode {
return InstanceRegistrationModeSchema.safeParse(value).success;
}
function normalizeNullableString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
function normalizePublicString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function normalizeOptionalPublicString(
value: Record<string, unknown>,
key: string,
fallback: string | null,
): string | null {
if (!Object.hasOwn(value, key)) {
return fallback;
}
return normalizePublicString(value[key]);
}
function getDefaultAppPublicConfig(): InstanceAppPublicConfig {
return {
branding: {
product_name: Config.instance.branding.productName || 'Fluxer',
icon_url: normalizePublicString(Config.instance.branding.iconUrl),
symbol_url: normalizePublicString(Config.instance.branding.symbolUrl),
logo_url: normalizePublicString(Config.instance.branding.logoUrl),
wordmark_url: normalizePublicString(Config.instance.branding.wordmarkUrl),
favicon_url: normalizePublicString(Config.instance.branding.faviconUrl),
theme_color: normalizePublicString(Config.instance.branding.themeColor),
},
setup: {
configured: !Config.instance.selfHosted || Config.instance.setup.configured,
},
legal: {
terms_url: null,
privacy_url: null,
},
registration: {
collect_date_of_birth: getDefaultDateOfBirthCollection(),
},
};
}
function parseAppPublicConfig(raw: string): InstanceAppPublicConfig {
try {
const parsed: unknown = JSON.parse(raw);
return normalizeAppPublicConfig(parsed);
} catch (error) {
Logger.warn({error}, 'Invalid app public config JSON, returning defaults');
return getDefaultAppPublicConfig();
}
}
function normalizeAppPublicConfig(value: unknown): InstanceAppPublicConfig {
const defaults = getDefaultAppPublicConfig();
if (!isJsonRecord(value)) {
return defaults;
}
const branding = isJsonRecord(value.branding) ? value.branding : {};
const setup = isJsonRecord(value.setup) ? value.setup : {};
const legal = isJsonRecord(value.legal) ? value.legal : {};
const registration = isJsonRecord(value.registration) ? value.registration : {};
return {
branding: {
product_name: normalizePublicString(branding.product_name) ?? defaults.branding.product_name,
icon_url: normalizeOptionalPublicString(branding, 'icon_url', defaults.branding.icon_url),
symbol_url: normalizeOptionalPublicString(branding, 'symbol_url', defaults.branding.symbol_url),
logo_url: normalizeOptionalPublicString(branding, 'logo_url', defaults.branding.logo_url),
wordmark_url: normalizeOptionalPublicString(branding, 'wordmark_url', defaults.branding.wordmark_url),
favicon_url: normalizeOptionalPublicString(branding, 'favicon_url', defaults.branding.favicon_url),
theme_color: normalizeOptionalPublicString(branding, 'theme_color', defaults.branding.theme_color),
},
setup: {
configured: typeof setup.configured === 'boolean' ? setup.configured : defaults.setup.configured,
},
legal: {
terms_url: normalizeOptionalPublicString(legal, 'terms_url', defaults.legal.terms_url),
privacy_url: normalizeOptionalPublicString(legal, 'privacy_url', defaults.legal.privacy_url),
},
registration: {
collect_date_of_birth:
typeof registration.collect_date_of_birth === 'boolean'
? registration.collect_date_of_birth
: defaults.registration.collect_date_of_birth,
},
};
}
const DEFAULT_INSTANCE_POLICY_CONFIG: InstancePolicyConfig = {
single_community_enabled: false,
single_community_guild_id: null,
direct_messages_disabled: false,
direct_messages_locked: false,
premium_mode: 'everyone',
gif_enabled: null,
youtube_enabled: null,
bluesky_enabled: null,
deferred_phone_gate_enabled: false,
deferred_phone_gate_window_hours: 6,
deferred_phone_gate_member_threshold: 50,
};
function isPremiumMode(value: unknown): value is InstancePremiumMode {
return value === 'mirror' || value === 'everyone';
}
function normalizeNullableBoolean(value: unknown): boolean | null {
return typeof value === 'boolean' ? value : null;
}
function normalizePositiveNumber(value: unknown, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return fallback;
}
return value;
}
function normalizeInstancePolicyConfig(value: unknown): InstancePolicyConfig {
if (!isJsonRecord(value)) {
return {...DEFAULT_INSTANCE_POLICY_CONFIG};
}
return {
single_community_enabled: value.single_community_enabled === true,
single_community_guild_id: normalizeNullableString(value.single_community_guild_id),
direct_messages_disabled: value.direct_messages_disabled === true,
direct_messages_locked: value.direct_messages_locked === true,
premium_mode: isPremiumMode(value.premium_mode) ? value.premium_mode : DEFAULT_INSTANCE_POLICY_CONFIG.premium_mode,
gif_enabled: normalizeNullableBoolean(value.gif_enabled),
youtube_enabled: normalizeNullableBoolean(value.youtube_enabled),
bluesky_enabled: normalizeNullableBoolean(value.bluesky_enabled),
deferred_phone_gate_enabled: value.deferred_phone_gate_enabled === true,
deferred_phone_gate_window_hours: normalizePositiveNumber(
value.deferred_phone_gate_window_hours,
DEFAULT_INSTANCE_POLICY_CONFIG.deferred_phone_gate_window_hours,
),
deferred_phone_gate_member_threshold: normalizePositiveNumber(
value.deferred_phone_gate_member_threshold,
DEFAULT_INSTANCE_POLICY_CONFIG.deferred_phone_gate_member_threshold,
),
};
}
const DEFAULT_INSTANCE_INTEGRATIONS_CONFIG: InstanceIntegrationsConfig = {
gif: {
klipy_api_key: null,
},
youtube: {
api_key: null,
},
captcha: {
provider: null,
hcaptcha_site_key: null,
hcaptcha_secret_key: null,
turnstile_site_key: null,
turnstile_secret_key: null,
},
email: {
enabled: null,
provider: null,
from_email: null,
from_name: null,
smtp: {
host: null,
port: null,
username: null,
password: null,
secure: null,
},
disable_new_ip_authorization: null,
},
bluesky: {
enabled: null,
client_name: null,
client_uri: null,
logo_uri: null,
tos_uri: null,
policy_uri: null,
keys: [],
},
};
const DEFAULT_INSTANCE_ATTACHMENT_DECAY_CONFIG: InstanceAttachmentDecayConfig = {
enabled: null,
min_size_mb: null,
max_size_mb: null,
max_eligible_size_mb: null,
min_lifetime_days: null,
max_lifetime_days: null,
curve: null,
renew_threshold_days: null,
renew_window_days: null,
};
const DEFAULT_INSTANCE_MEDIA_CONFIG: InstanceMediaConfig = {
attachment_decay: DEFAULT_INSTANCE_ATTACHMENT_DECAY_CONFIG,
};
function isCaptchaProvider(value: unknown): value is InstanceCaptchaProvider {
return InstanceCaptchaProviderSchema.safeParse(value).success;
}
function isEmailProvider(value: unknown): value is InstanceEmailProvider {
return value === 'smtp' || value === 'none';
}
function normalizeSecretString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function secretIsSet(value: unknown): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
function normalizeNullablePort(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) return null;
return value;
}
function normalizeNullablePositiveNumber(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;
return value;
}
function normalizeNullablePositiveInteger(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) return null;
return value;
}
function normalizeNullableCurve(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) return null;
return value;
}
function normalizeBlueskyKey(value: unknown): InstanceBlueskyKeyIntegrationConfig | null {
if (!isJsonRecord(value)) return null;
const kid = normalizePublicString(value.kid);
if (!kid) return null;
return {
kid,
private_key: normalizeSecretString(value.private_key),
};
}
function normalizeInstanceIntegrationsConfig(value: unknown): InstanceIntegrationsConfig {
const defaults = DEFAULT_INSTANCE_INTEGRATIONS_CONFIG;
if (!isJsonRecord(value)) {
return structuredClone(defaults);
}
const gif = isJsonRecord(value.gif) ? value.gif : {};
const youtube = isJsonRecord(value.youtube) ? value.youtube : {};
const captcha = isJsonRecord(value.captcha) ? value.captcha : {};
const email = isJsonRecord(value.email) ? value.email : {};
const smtp = isJsonRecord(email.smtp) ? email.smtp : {};
const bluesky = isJsonRecord(value.bluesky) ? value.bluesky : {};
const blueskyKeys = Array.isArray(bluesky.keys)
? bluesky.keys.flatMap((entry) => {
const normalized = normalizeBlueskyKey(entry);
return normalized ? [normalized] : [];
})
: defaults.bluesky.keys;
return {
gif: {
klipy_api_key: normalizeSecretString(gif.klipy_api_key),
},
youtube: {
api_key: normalizeSecretString(youtube.api_key),
},
captcha: {
provider: isCaptchaProvider(captcha.provider) ? captcha.provider : defaults.captcha.provider,
hcaptcha_site_key: normalizeSecretString(captcha.hcaptcha_site_key),
hcaptcha_secret_key: normalizeSecretString(captcha.hcaptcha_secret_key),
turnstile_site_key: normalizeSecretString(captcha.turnstile_site_key),
turnstile_secret_key: normalizeSecretString(captcha.turnstile_secret_key),
},
email: {
enabled: normalizeNullableBoolean(email.enabled),
provider: isEmailProvider(email.provider) ? email.provider : defaults.email.provider,
from_email: normalizePublicString(email.from_email),
from_name: normalizePublicString(email.from_name),
smtp: {
host: normalizePublicString(smtp.host),
port: normalizeNullablePort(smtp.port),
username: normalizePublicString(smtp.username),
password: normalizeSecretString(smtp.password),
secure: normalizeNullableBoolean(smtp.secure),
},
disable_new_ip_authorization: normalizeNullableBoolean(email.disable_new_ip_authorization),
},
bluesky: {
enabled: normalizeNullableBoolean(bluesky.enabled),
client_name: normalizePublicString(bluesky.client_name),
client_uri: normalizePublicString(bluesky.client_uri),
logo_uri: normalizePublicString(bluesky.logo_uri),
tos_uri: normalizePublicString(bluesky.tos_uri),
policy_uri: normalizePublicString(bluesky.policy_uri),
keys: blueskyKeys,
},
};
}
function normalizeInstanceAttachmentDecayConfig(value: unknown): InstanceAttachmentDecayConfig {
if (!isJsonRecord(value)) {
return {...DEFAULT_INSTANCE_ATTACHMENT_DECAY_CONFIG};
}
const minSizeMb = normalizeNullablePositiveNumber(value.min_size_mb);
let maxSizeMb = normalizeNullablePositiveNumber(value.max_size_mb);
let maxEligibleSizeMb = normalizeNullablePositiveNumber(value.max_eligible_size_mb);
const minLifetimeDays = normalizeNullablePositiveInteger(value.min_lifetime_days);
let maxLifetimeDays = normalizeNullablePositiveInteger(value.max_lifetime_days);
if (minSizeMb !== null && maxSizeMb !== null && maxSizeMb <= minSizeMb) {
maxSizeMb = null;
}
if (maxSizeMb !== null && maxEligibleSizeMb !== null && maxEligibleSizeMb < maxSizeMb) {
maxEligibleSizeMb = null;
}
if (minLifetimeDays !== null && maxLifetimeDays !== null && maxLifetimeDays < minLifetimeDays) {
maxLifetimeDays = null;
}
return {
enabled: normalizeNullableBoolean(value.enabled),
min_size_mb: minSizeMb,
max_size_mb: maxSizeMb,
max_eligible_size_mb: maxEligibleSizeMb,
min_lifetime_days: minLifetimeDays,
max_lifetime_days: maxLifetimeDays,
curve: normalizeNullableCurve(value.curve),
renew_threshold_days: normalizeNullablePositiveInteger(value.renew_threshold_days),
renew_window_days: normalizeNullablePositiveInteger(value.renew_window_days),
};
}
function normalizeInstanceMediaConfig(value: unknown): InstanceMediaConfig {
if (!isJsonRecord(value)) {
return structuredClone(DEFAULT_INSTANCE_MEDIA_CONFIG);
}
return {
attachment_decay: normalizeInstanceAttachmentDecayConfig(value.attachment_decay),
};
}
function hasCompleteSmtpConfig(config: APIConfig['email']): boolean {
if (config.provider !== 'smtp' || !config.smtp) return false;
return Boolean(
config.fromEmail.trim() &&
config.smtp.host.trim() &&
config.smtp.port &&
config.smtp.username.trim() &&
config.smtp.password.trim(),
);
}
function normalizeIsoDateString(value: unknown): string | null {
if (typeof value !== 'string') return null;
const time = Date.parse(value);
return Number.isFinite(time) ? new Date(time).toISOString() : null;
}
function normalizePositiveInteger(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) return null;
return value;
}
function normalizeNonNegativeInteger(value: unknown): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) return 0;
return value;
}
function normalizeRegistrationConfig(value: unknown): InstanceRegistrationConfig {
if (!isJsonRecord(value)) {
return {...DEFAULT_REGISTRATION_CONFIG};
}
const mode = isRegistrationMode(value.mode) ? value.mode : DEFAULT_REGISTRATION_CONFIG.mode;
const adminRegistrationUrlsEnabled =
typeof value.admin_registration_urls_enabled === 'boolean'
? value.admin_registration_urls_enabled
: typeof value.adminRegistrationUrlsEnabled === 'boolean'
? value.adminRegistrationUrlsEnabled
: DEFAULT_REGISTRATION_CONFIG.admin_registration_urls_enabled;
return {
mode,
admin_registration_urls_enabled: adminRegistrationUrlsEnabled,
};
}
function normalizeRegistrationUrl(value: unknown): InstanceRegistrationUrl | null {
if (!isJsonRecord(value)) return null;
if (
typeof value.id !== 'string' ||
typeof value.code_hash !== 'string' ||
typeof value.created_by_user_id !== 'string'
) {
return null;
}
const createdAt = normalizeIsoDateString(value.created_at);
if (!createdAt) return null;
return {
id: value.id,
label: normalizeNullableString(value.label),
code_hash: value.code_hash,
created_by_user_id: value.created_by_user_id,
created_at: createdAt,
expires_at: normalizeIsoDateString(value.expires_at),
max_uses: normalizePositiveInteger(value.max_uses),
use_count: normalizeNonNegativeInteger(value.use_count),
revoked_at: normalizeIsoDateString(value.revoked_at),
approval_required: value.approval_required === true,
last_used_at: normalizeIsoDateString(value.last_used_at),
last_used_by_user_id: normalizeNullableString(value.last_used_by_user_id),
};
}
function normalizePendingRegistration(value: unknown): InstancePendingRegistration | null {
if (!isJsonRecord(value)) return null;
if (typeof value.user_id !== 'string' || typeof value.username !== 'string') return null;
const requestedAt = normalizeIsoDateString(value.requested_at);
if (!requestedAt) return null;
return {
user_id: value.user_id,
username: value.username,
discriminator: typeof value.discriminator === 'number' ? value.discriminator : 0,
global_name: normalizeNullableString(value.global_name),
email: normalizeNullableString(value.email),
requested_at: requestedAt,
registration_url_id: normalizeNullableString(value.registration_url_id),
client_ip: normalizeNullableString(value.client_ip),
};
}
function isRegistrationUrlUsable(registrationUrl: InstanceRegistrationUrl, now: Date): boolean {
if (registrationUrl.revoked_at) return false;
if (registrationUrl.expires_at && Date.parse(registrationUrl.expires_at) <= now.getTime()) return false;
if (registrationUrl.max_uses !== null && registrationUrl.use_count >= registrationUrl.max_uses) return false;
return true;
}
function redactRegistrationUrl(registrationUrl: InstanceRegistrationUrl): InstanceRegistrationUrlPublic {
const {code_hash: _codeHash, ...redacted} = registrationUrl;
return redacted;
}
function isLimitRuleSnapshot(value: unknown): value is LimitConfigSnapshot['rules'][number] {
if (!isJsonRecord(value) || typeof value.id !== 'string' || !isJsonRecord(value.limits)) return false;
const filters = value.filters;
return (
(filters === undefined ||
(isJsonRecord(filters) &&
(filters.traits === undefined || isStringArray(filters.traits)) &&
(filters.guildFeatures === undefined || isStringArray(filters.guildFeatures)))) &&
(value.modifiedFields === undefined || isStringArray(value.modifiedFields))
);
}
function isLimitConfigSnapshot(value: unknown): value is LimitConfigSnapshot {
return (
isJsonRecord(value) &&
(value.version === undefined || typeof value.version === 'number') &&
isStringArray(value.traitDefinitions) &&
Array.isArray(value.rules) &&
value.rules.every(isLimitRuleSnapshot)
);
}
export interface InstanceSsoConfig {
enabled: boolean;
enforced: boolean;
displayName: string | null;
issuer: string | null;
authorizationUrl: string | null;
tokenUrl: string | null;
userInfoUrl: string | null;
jwksUrl: string | null;
clientId: string | null;
clientSecret?: string | null;
clientSecretSet?: boolean;
scope: string | null;
allowedEmailDomains: Array<string>;
autoProvision: boolean;
redirectUri: string | null;
}
export class InstanceConfigRepository {
private readonly pubSubSourceId = crypto.randomUUID();
private readonly kvClient: IKVProvider | null;
private configCache: Map<string, string> | null = null;
private cacheInitializationPromise: Promise<void> | null = null;
private refreshPromise: Promise<void> | null = null;
private refreshRequested = false;
private kvSubscription: IKVSubscription | null = null;
private subscriberInitialized = false;
private subscriberInitializationPromise: Promise<boolean> | null = null;
private messageHandler: ((channel: string, message: string) => void) | null = null;
private effectiveBlueskyConfig: BlueskyOAuthConfig | null = null;
private effectiveBlueskyConfigSource: string | null = null;
constructor(kvClient: IKVProvider | null = null) {
this.kvClient = kvClient;
}
async getConfig(key: string): Promise<string | null> {
if (!(await this.ensureCacheReadable())) {
return this.fetchConfigFromDatabase(key);
}
return this.configCache?.get(key) ?? null;
}
async getAllConfigs(): Promise<Map<string, string>> {
if (!(await this.ensureCacheReadable())) {
return this.fetchAllConfigsFromDatabase();
}
return new Map(this.configCache ?? []);
}
async setConfig(key: string, value: string): Promise<void> {
await this.writeConfig(key, value);
this.updateCachedConfigs([[key, value]]);
await this.publishRefresh();
}
private async setConfigs(entries: Array<[string, string]>): Promise<void> {
if (entries.length === 0) return;
await Promise.all(entries.map(([key, value]) => this.writeConfig(key, value)));
this.updateCachedConfigs(entries);
await this.publishRefresh();
}
private async writeConfig(key: string, value: string): Promise<void> {
await upsertOne(
InstanceConfiguration.upsertAll({
key,
value,
updated_at: new Date(),
}),
);
}
private async fetchConfigFromDatabase(key: string): Promise<string | null> {
const row = await fetchOne<InstanceConfigurationRow>(FETCH_CONFIG_QUERY, {key});
return row?.value ?? null;
}
private async fetchAllConfigsFromDatabase(): Promise<Map<string, string>> {
const rows = await fetchMany<InstanceConfigurationRow>(FETCH_ALL_CONFIG_QUERY, {});
const configs = new Map<string, string>();
for (const row of rows) {
if (row.value != null) {
configs.set(row.key, row.value);
}
}
return configs;
}
private async ensureCacheReadable(): Promise<boolean> {
if (!this.kvClient) {
return false;
}
if (this.configCache !== null) {
return true;
}
if (!this.cacheInitializationPromise) {
this.cacheInitializationPromise = (async () => {
const subscribed = await this.ensureSubscriberInitialized();
if (!subscribed) {
return;
}
await this.refreshCacheFromDatabase();
})().finally(() => {
this.cacheInitializationPromise = null;
});
}
await this.cacheInitializationPromise;
return this.configCache !== null;
}
private async ensureSubscriberInitialized(): Promise<boolean> {
if (this.subscriberInitialized) {
return true;
}
if (!this.kvClient) {
return false;
}
if (!this.subscriberInitializationPromise) {
this.subscriberInitializationPromise = this.startSubscriber()
.then(() => true)
.catch((error) => {
Logger.error({error}, 'Failed to subscribe to instance config refresh channel');
this.closeSubscription();
return false;
})
.finally(() => {
this.subscriberInitializationPromise = null;
});
}
return this.subscriberInitializationPromise;
}
private async startSubscriber(): Promise<void> {
if (!this.kvClient) {
return;
}
const subscription = this.kvClient.duplicate();
this.kvSubscription = subscription;
this.messageHandler = (channel: string, message: string) => {
if (channel !== INSTANCE_CONFIG_REFRESH_CHANNEL) {
return;
}
if (this.isOwnRefreshMessage(message)) {
return;
}
this.refreshCacheFromDatabase().catch((error) => {
Logger.error({error}, 'Failed to refresh instance config cache from pubsub');
});
};
subscription.on('message', this.messageHandler);
await subscription.connect();
await subscription.subscribe(INSTANCE_CONFIG_REFRESH_CHANNEL);
this.subscriberInitialized = true;
}
private async refreshCacheFromDatabase(): Promise<void> {
if (this.refreshPromise) {
this.refreshRequested = true;
await this.refreshPromise;
return;
}
this.refreshPromise = (async () => {
do {
this.refreshRequested = false;
this.configCache = await this.fetchAllConfigsFromDatabase();
} while (this.refreshRequested);
this.syncDeferredPhoneGateCache(this.configCache.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
this.syncDateOfBirthCollectionCache(this.configCache.get(APP_PUBLIC_CONFIG_KEY) ?? null);
})().finally(() => {
this.refreshPromise = null;
});
await this.refreshPromise;
}
private syncDeferredPhoneGateCache(raw: string | null): void {
const policy = raw ? normalizeInstancePolicyConfig(parseJsonRecord(raw)) : {...DEFAULT_INSTANCE_POLICY_CONFIG};
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(policy));
}
private syncDateOfBirthCollectionCache(raw: string | null): void {
const appPublic = raw ? normalizeAppPublicConfig(parseJsonRecord(raw)) : getDefaultAppPublicConfig();
setCachedDateOfBirthCollection(appPublic.registration.collect_date_of_birth);
}
private updateCachedConfigs(entries: Array<[string, string]>): void {
if (!this.configCache) {
return;
}
for (const [key, value] of entries) {
this.configCache.set(key, value);
}
}
private async publishRefresh(): Promise<void> {
if (!this.kvClient) {
return;
}
await this.kvClient.publish(
INSTANCE_CONFIG_REFRESH_CHANNEL,
JSON.stringify({source_id: this.pubSubSourceId, type: 'refresh'}),
);
}
private isOwnRefreshMessage(message: string): boolean {
const parsed = parseJsonRecord(message);
return parsed?.source_id === this.pubSubSourceId;
}
private closeSubscription(): void {
if (this.kvSubscription && this.messageHandler) {
this.kvSubscription.off('message', this.messageHandler);
}
if (this.kvSubscription) {
this.kvSubscription.disconnect();
this.kvSubscription = null;
}
this.messageHandler = null;
this.subscriberInitialized = false;
}
shutdown(): void {
this.closeSubscription();
}
clearCacheForTesting(): void {
this.closeSubscription();
this.configCache = null;
this.cacheInitializationPromise = null;
this.refreshPromise = null;
this.refreshRequested = false;
this.subscriberInitializationPromise = null;
}
async isAdminBootstrapped(): Promise<boolean> {
return (await this.getConfig(ADMIN_BOOTSTRAP_KEY)) === 'true';
}
async markAdminBootstrapped(): Promise<void> {
await this.setConfig(ADMIN_BOOTSTRAP_KEY, 'true');
}
async getGatewayRolloutConfig(): Promise<GatewayRolloutConfig> {
const raw = await this.getConfig(GATEWAY_ROLLOUT_CONFIG_KEY);
if (!raw) {
return {...DEFAULT_GATEWAY_ROLLOUT_CONFIG};
}
try {
const parsed = parseJsonRecord(raw);
if (!parsed) {
return {...DEFAULT_GATEWAY_ROLLOUT_CONFIG};
}
const normalizedParsed: Record<string, unknown> = {...parsed};
if (
normalizedParsed.rpc_request_timeout_ms === undefined &&
typeof normalizedParsed.nats_request_timeout_ms === 'number'
) {
normalizedParsed.rpc_request_timeout_ms = normalizedParsed.nats_request_timeout_ms;
}
return GatewayRolloutConfigSchema.parse({...DEFAULT_GATEWAY_ROLLOUT_CONFIG, ...normalizedParsed});
} catch (error) {
Logger.error({error}, 'Invalid gateway rollout config');
throw error;
}
}
async setGatewayRolloutConfig(config: GatewayRolloutConfig): Promise<void> {
await this.setConfig(GATEWAY_ROLLOUT_CONFIG_KEY, JSON.stringify(config));
}
async getVoiceNoiseSuppressionConfig(): Promise<VoiceNoiseSuppressionConfig> {
const raw = await this.getConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY);
if (!raw) {
return VoiceNoiseSuppressionConfigSchema.parse({});
}
const parsed = parseJsonRecord(raw);
if (!parsed) {
return VoiceNoiseSuppressionConfigSchema.parse({});
}
const result = VoiceNoiseSuppressionConfigSchema.safeParse(parsed);
if (!result.success) {
Logger.error({error: result.error}, 'Invalid voice noise suppression config');
return VoiceNoiseSuppressionConfigSchema.parse({});
}
return result.data;
}
async setVoiceNoiseSuppressionConfig(config: VoiceNoiseSuppressionConfig): Promise<void> {
await this.setConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY, JSON.stringify(config));
}
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
if (!raw) {
return ExperimentDeliveryConfigSchema.parse({});
}
const parsed = parseJsonRecord(raw);
if (!parsed) {
return ExperimentDeliveryConfigSchema.parse({});
}
const result = ExperimentDeliveryConfigSchema.safeParse(parsed);
if (!result.success) {
Logger.error({error: result.error}, 'Invalid experiment delivery config');
return ExperimentDeliveryConfigSchema.parse({});
}
return result.data;
}
async setExperimentDeliveryConfig(config: ExperimentDeliveryConfig): Promise<void> {
await this.setConfig(EXPERIMENT_DELIVERY_CONFIG_KEY, JSON.stringify(config));
}
async hasLimitConfig(): Promise<boolean> {
const raw = await this.getConfig('limit_config');
return raw !== null;
}
async getLimitConfig(): Promise<LimitConfigSnapshot | null> {
const raw = await this.getConfig('limit_config');
if (!raw) {
return null;
}
try {
const parsed: unknown = JSON.parse(raw);
if (!isLimitConfigSnapshot(parsed)) {
return null;
}
const policy = await this.getInstancePolicyConfig();
return sanitizeLimitConfigForInstance(parsed, {
selfHosted: Config.instance.selfHosted,
premiumMode: policy.premium_mode,
});
} catch (error) {
Logger.warn({error}, 'Invalid limit config JSON, returning null');
return null;
}
}
async setLimitConfig(config: LimitConfigSnapshot): Promise<void> {
await this.setConfig('limit_config', JSON.stringify(config));
}
async getAppPublicConfig(): Promise<InstanceAppPublicConfig> {
const raw = await this.getConfig(APP_PUBLIC_CONFIG_KEY);
const config = raw ? parseAppPublicConfig(raw) : getDefaultAppPublicConfig();
setCachedDateOfBirthCollection(config.registration.collect_date_of_birth);
return config;
}
async setAppPublicConfig(config: {
branding?: Partial<InstanceBranding>;
setup?: Partial<InstanceAppPublicConfig['setup']>;
legal?: Partial<InstanceAppPublicConfig['legal']>;
registration?: Partial<InstanceAppPublicConfig['registration']>;
}): Promise<InstanceAppPublicConfig> {
const current = await this.getAppPublicConfig();
const next = normalizeAppPublicConfig({
branding: {
...current.branding,
...(config.branding ?? {}),
},
setup: {
...current.setup,
...(config.setup ?? {}),
},
legal: {
...current.legal,
...(config.legal ?? {}),
},
registration: {
...current.registration,
...(config.registration ?? {}),
},
});
await this.setConfig(APP_PUBLIC_CONFIG_KEY, JSON.stringify(next));
setCachedDateOfBirthCollection(next.registration.collect_date_of_birth);
return next;
}
async getInstancePolicyConfig(): Promise<InstancePolicyConfig> {
const raw = await this.getConfig(INSTANCE_POLICY_CONFIG_KEY);
const policy = raw ? normalizeInstancePolicyConfig(parseJsonRecord(raw)) : {...DEFAULT_INSTANCE_POLICY_CONFIG};
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(policy));
return policy;
}
async setInstancePolicyConfig(config: Partial<InstancePolicyConfig>): Promise<InstancePolicyConfig> {
const current = await this.getInstancePolicyConfig();
const next = normalizeInstancePolicyConfig({...current, ...config});
await this.setConfig(INSTANCE_POLICY_CONFIG_KEY, JSON.stringify(next));
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(next));
return next;
}
async getInstanceIntegrationsConfig(): Promise<InstanceIntegrationsConfig> {
const raw = await this.getConfig(INSTANCE_INTEGRATIONS_CONFIG_KEY);
if (!raw) {
return normalizeInstanceIntegrationsConfig(null);
}
return normalizeInstanceIntegrationsConfig(parseJsonRecord(raw));
}
async setInstanceIntegrationsConfig(config: InstanceIntegrationsConfigPatch): Promise<InstanceIntegrationsConfig> {
const current = await this.getInstanceIntegrationsConfig();
const next = normalizeInstanceIntegrationsConfig({
gif: {
...current.gif,
...(config.gif ?? {}),
},
youtube: {
...current.youtube,
...(config.youtube ?? {}),
},
captcha: {
...current.captcha,
...(config.captcha ?? {}),
},
email: {
...current.email,
...(config.email ?? {}),
smtp: {
...current.email.smtp,
...(config.email?.smtp ?? {}),
},
},
bluesky: {
...current.bluesky,
...(config.bluesky ?? {}),
keys: config.bluesky?.keys ?? current.bluesky.keys,
},
});
await this.setConfig(INSTANCE_INTEGRATIONS_CONFIG_KEY, JSON.stringify(next));
return next;
}
async getInstanceMediaConfig(): Promise<InstanceMediaConfig> {
const raw = await this.getConfig(INSTANCE_MEDIA_CONFIG_KEY);
if (!raw) {
return normalizeInstanceMediaConfig(null);
}
return normalizeInstanceMediaConfig(parseJsonRecord(raw));
}
async setInstanceMediaConfig(config: InstanceMediaConfigPatch): Promise<InstanceMediaConfig> {
const current = await this.getInstanceMediaConfig();
const next = normalizeInstanceMediaConfig({
attachment_decay: {
...current.attachment_decay,
...(config.attachment_decay ?? {}),
},
});
await this.setConfig(INSTANCE_MEDIA_CONFIG_KEY, JSON.stringify(next));
return next;
}
async getEffectiveAttachmentDecayConfig(): Promise<InstanceAttachmentDecayEffectiveConfig> {
const media = await this.getInstanceMediaConfig();
const attachmentDecay = media.attachment_decay;
const minSizeMb = attachmentDecay.min_size_mb ?? DEFAULT_DECAY_CONSTANTS.MIN_MB;
const configuredMaxSizeMb = attachmentDecay.max_size_mb ?? DEFAULT_DECAY_CONSTANTS.MAX_MB;
const maxSizeMb =
configuredMaxSizeMb > minSizeMb ? configuredMaxSizeMb : Math.max(DEFAULT_DECAY_CONSTANTS.MAX_MB, minSizeMb + 1);
const configuredMaxEligibleSizeMb = attachmentDecay.max_eligible_size_mb ?? DEFAULT_DECAY_CONSTANTS.PLAN_MB;
const maxEligibleSizeMb = Math.max(maxSizeMb, configuredMaxEligibleSizeMb);
const minLifetimeDays = attachmentDecay.min_lifetime_days ?? DEFAULT_DECAY_CONSTANTS.MIN_DAYS;
const configuredMaxLifetimeDays = attachmentDecay.max_lifetime_days ?? DEFAULT_DECAY_CONSTANTS.MAX_DAYS;
const maxLifetimeDays =
configuredMaxLifetimeDays >= minLifetimeDays
? configuredMaxLifetimeDays
: Math.max(DEFAULT_DECAY_CONSTANTS.MAX_DAYS, minLifetimeDays);
return {
enabled: attachmentDecay.enabled ?? Config.attachmentDecayEnabled,
min_size_mb: minSizeMb,
max_size_mb: maxSizeMb,
max_eligible_size_mb: maxEligibleSizeMb,
min_lifetime_days: minLifetimeDays,
max_lifetime_days: maxLifetimeDays,
curve: attachmentDecay.curve ?? DEFAULT_DECAY_CONSTANTS.CURVE,
renew_threshold_days: attachmentDecay.renew_threshold_days ?? DEFAULT_RENEWAL_CONSTANTS.RENEW_THRESHOLD_DAYS,
renew_window_days: attachmentDecay.renew_window_days ?? DEFAULT_RENEWAL_CONSTANTS.RENEW_WINDOW_DAYS,
};
}
async isAttachmentDecayEnabled(): Promise<boolean> {
return (await this.getEffectiveAttachmentDecayConfig()).enabled;
}
async getInstanceMediaAdminConfig(): Promise<InstanceMediaAdminConfig> {
const [media, attachmentDecay] = await Promise.all([
this.getInstanceMediaConfig(),
this.getEffectiveAttachmentDecayConfig(),
]);
return {
attachment_decay: {
...media.attachment_decay,
effective: attachmentDecay,
},
};
}
async getEffectiveGifConfig(): Promise<InstanceGifEffectiveConfig> {
const integrations = await this.getInstanceIntegrationsConfig();
const klipyApiKey = integrations.gif.klipy_api_key ?? normalizeSecretString(Config.klipy.apiKey);
return {
klipy_api_key: klipyApiKey,
active_api_key: klipyApiKey,
available: Boolean(klipyApiKey),
};
}
async getEffectiveYoutubeApiKey(): Promise<string | null> {
const integrations = await this.getInstanceIntegrationsConfig();
return integrations.youtube.api_key ?? normalizeSecretString(Config.youtube.apiKey);
}
async getEffectiveCaptchaConfig(): Promise<InstanceCaptchaEffectiveConfig> {
const integrations = await this.getInstanceIntegrationsConfig();
const provider = integrations.captcha.provider ?? (Config.captcha.enabled ? Config.captcha.provider : 'none');
const hcaptchaSiteKey =
integrations.captcha.hcaptcha_site_key ?? normalizeSecretString(Config.captcha.hcaptcha?.siteKey);
const hcaptchaSecretKey =
integrations.captcha.hcaptcha_secret_key ?? normalizeSecretString(Config.captcha.hcaptcha?.secretKey);
const turnstileSiteKey =
integrations.captcha.turnstile_site_key ?? normalizeSecretString(Config.captcha.turnstile?.siteKey);
const turnstileSecretKey =
integrations.captcha.turnstile_secret_key ?? normalizeSecretString(Config.captcha.turnstile?.secretKey);
const providerReady =
provider === 'hcaptcha'
? Boolean(hcaptchaSiteKey && hcaptchaSecretKey)
: provider === 'turnstile'
? Boolean(turnstileSiteKey && turnstileSecretKey)
: false;
return {
enabled: providerReady,
provider: providerReady ? provider : 'none',
hcaptcha_site_key: hcaptchaSiteKey,
hcaptcha_secret_key: hcaptchaSecretKey,
turnstile_site_key: turnstileSiteKey,
turnstile_secret_key: turnstileSecretKey,
};
}
async getEffectiveEmailConfig(): Promise<APIConfig['email']> {
const integrations = await this.getInstanceIntegrationsConfig();
const provider = integrations.email.provider ?? Config.email.provider;
const fromEmail = integrations.email.from_email ?? Config.email.fromEmail;
const fromName = integrations.email.from_name ?? Config.email.fromName;
const smtp =
provider === 'smtp'
? {
host: integrations.email.smtp.host ?? Config.email.smtp?.host ?? '',
port: integrations.email.smtp.port ?? Config.email.smtp?.port ?? 587,
username: integrations.email.smtp.username ?? Config.email.smtp?.username ?? '',
password: integrations.email.smtp.password ?? Config.email.smtp?.password ?? '',
secure: integrations.email.smtp.secure ?? Config.email.smtp?.secure ?? true,
}
: undefined;
const next: APIConfig['email'] = {
...Config.email,
enabled: integrations.email.enabled ?? Config.email.enabled,
provider,
fromEmail,
fromName,
smtp,
};
return {
...next,
enabled: next.enabled && hasCompleteSmtpConfig(next),
};
}
async isEmailEnabled(): Promise<boolean> {
return (await this.getEffectiveEmailConfig()).enabled;
}
async getEffectiveBlueskyConfig(): Promise<BlueskyOAuthConfig> {
const raw = await this.getConfig(INSTANCE_INTEGRATIONS_CONFIG_KEY);
const memoized = this.effectiveBlueskyConfig;
if (memoized && this.effectiveBlueskyConfigSource === raw) {
return memoized;
}
const integrations = normalizeInstanceIntegrationsConfig(raw ? parseJsonRecord(raw) : null);
const runtimeKeys = integrations.bluesky.keys.flatMap((key): Array<BlueskyOAuthKeyConfig> => {
if (!key.private_key) return [];
return [{kid: key.kid, private_key: key.private_key}];
});
const keys = runtimeKeys.length > 0 ? runtimeKeys : Config.auth.bluesky.keys;
const enabled = (integrations.bluesky.enabled ?? Config.auth.bluesky.enabled) && keys.length > 0;
const effective: BlueskyOAuthConfig = {
...Config.auth.bluesky,
enabled,
client_name: integrations.bluesky.client_name ?? Config.auth.bluesky.client_name,
client_uri: integrations.bluesky.client_uri ?? Config.auth.bluesky.client_uri,
logo_uri: integrations.bluesky.logo_uri ?? Config.auth.bluesky.logo_uri,
tos_uri: integrations.bluesky.tos_uri ?? Config.auth.bluesky.tos_uri,
policy_uri: integrations.bluesky.policy_uri ?? Config.auth.bluesky.policy_uri,
keys,
};
this.effectiveBlueskyConfigSource = raw;
this.effectiveBlueskyConfig = effective;
return effective;
}
async getInstanceIntegrationsAdminConfig(): Promise<InstanceIntegrationsAdminConfig> {
const [integrations, gif, youtubeApiKey, captcha, email, bluesky] = await Promise.all([
this.getInstanceIntegrationsConfig(),
this.getEffectiveGifConfig(),
this.getEffectiveYoutubeApiKey(),
this.getEffectiveCaptchaConfig(),
this.getEffectiveEmailConfig(),
this.getEffectiveBlueskyConfig(),
]);
return {
gif: {
klipy_api_key_set: secretIsSet(integrations.gif.klipy_api_key) || secretIsSet(Config.klipy.apiKey),
effective_available: gif.available,
},
youtube: {
api_key_set: secretIsSet(integrations.youtube.api_key) || secretIsSet(Config.youtube.apiKey),
effective_available: Boolean(youtubeApiKey),
},
captcha: {
provider: integrations.captcha.provider,
effective_provider: captcha.provider,
hcaptcha_site_key: captcha.hcaptcha_site_key,
hcaptcha_secret_key_set:
secretIsSet(integrations.captcha.hcaptcha_secret_key) || secretIsSet(Config.captcha.hcaptcha?.secretKey),
turnstile_site_key: captcha.turnstile_site_key,
turnstile_secret_key_set:
secretIsSet(integrations.captcha.turnstile_secret_key) || secretIsSet(Config.captcha.turnstile?.secretKey),
effective_enabled: captcha.enabled,
},
email: {
enabled: integrations.email.enabled,
effective_enabled: email.enabled,
provider: integrations.email.provider,
effective_provider: email.provider,
from_email: email.fromEmail || null,
from_name: email.fromName || null,
smtp: {
host: email.smtp?.host || null,
port: email.smtp?.port ?? null,
username: email.smtp?.username || null,
password_set: secretIsSet(integrations.email.smtp.password) || secretIsSet(Config.email.smtp?.password),
secure: email.smtp?.secure ?? null,
},
disable_new_ip_authorization: integrations.email.disable_new_ip_authorization ?? false,
effective_disable_new_ip_authorization: integrations.email.disable_new_ip_authorization || !email.enabled,
},
bluesky: {
enabled: integrations.bluesky.enabled,
effective_enabled: bluesky.enabled,
client_name: bluesky.client_name || null,
client_uri: bluesky.client_uri || null,
logo_uri: bluesky.logo_uri || null,
tos_uri: bluesky.tos_uri || null,
policy_uri: bluesky.policy_uri || null,
key_count: bluesky.keys.length,
},
};
}
async getInstanceCommunityPublicConfig(): Promise<InstanceCommunity> {
const policy = await this.getInstancePolicyConfig();
return {
single_community: policy.single_community_enabled,
single_community_guild_id: policy.single_community_enabled ? policy.single_community_guild_id : null,
direct_messages_disabled: policy.direct_messages_disabled,
};
}
async getResolvedServicesConfig(): Promise<InstanceServices> {
const [policy, gif, youtubeApiKey, bluesky] = await Promise.all([
this.getInstancePolicyConfig(),
this.getEffectiveGifConfig(),
this.getEffectiveYoutubeApiKey(),
this.getEffectiveBlueskyConfig(),
]);
return {
gif_enabled: policy.gif_enabled ?? gif.available,
youtube_enabled: policy.youtube_enabled ?? Boolean(youtubeApiKey),
bluesky_enabled: policy.bluesky_enabled ?? bluesky.enabled,
};
}
async getRegistrationConfig(): Promise<InstanceRegistrationConfig> {
const raw = await this.getConfig(REGISTRATION_CONFIG_KEY);
if (!raw) {
return {...DEFAULT_REGISTRATION_CONFIG};
}
const parsed = parseJsonRecord(raw);
return normalizeRegistrationConfig(parsed);
}
async setRegistrationConfig(config: Partial<InstanceRegistrationConfig>): Promise<InstanceRegistrationConfig> {
const current = await this.getRegistrationConfig();
const next = normalizeRegistrationConfig({
mode: config.mode ?? current.mode,
admin_registration_urls_enabled:
config.admin_registration_urls_enabled ?? current.admin_registration_urls_enabled,
});
await this.setConfig(REGISTRATION_CONFIG_KEY, JSON.stringify(next));
return next;
}
async getRegistrationPublicConfig(): Promise<InstanceRegistrationConfig> {
return this.getRegistrationConfig();
}
async getRegistrationUrls(): Promise<Array<InstanceRegistrationUrl>> {
const raw = await this.getConfig(REGISTRATION_URLS_KEY);
if (!raw) {
return [];
}
const parsed = parseJsonArray(raw);
if (!parsed) {
Logger.warn('Invalid registration URL config JSON, returning empty list');
return [];
}
return parsed.flatMap((entry) => {
const normalized = normalizeRegistrationUrl(entry);
return normalized ? [normalized] : [];
});
}
async getRegistrationUrlsForAdmin(): Promise<Array<InstanceRegistrationUrlPublic>> {
return (await this.getRegistrationUrls())
.toSorted((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
.map(redactRegistrationUrl);
}
async createRegistrationUrl(params: {
label: string | null;
createdByUserId: string;
expiresAt: Date | null;
maxUses: number | null;
approvalRequired: boolean;
}): Promise<{registrationUrl: InstanceRegistrationUrlPublic; code: string}> {
const id = crypto.randomUUID();
const code = id;
const registrationUrl: InstanceRegistrationUrl = {
id,
label: params.label,
code_hash: this.hashRegistrationUrlCode(code),
created_by_user_id: params.createdByUserId,
created_at: new Date().toISOString(),
expires_at: params.expiresAt?.toISOString() ?? null,
max_uses: params.maxUses,
use_count: 0,
revoked_at: null,
approval_required: params.approvalRequired,
last_used_at: null,
last_used_by_user_id: null,
};
const registrationUrls = await this.getRegistrationUrls();
await this.setRegistrationUrls([registrationUrl, ...registrationUrls]);
return {registrationUrl: redactRegistrationUrl(registrationUrl), code};
}
async revokeRegistrationUrl(id: string): Promise<void> {
const now = new Date().toISOString();
const registrationUrls = await this.getRegistrationUrls();
await this.setRegistrationUrls(
registrationUrls.map((registrationUrl) =>
registrationUrl.id === id && !registrationUrl.revoked_at
? {...registrationUrl, revoked_at: now}
: registrationUrl,
),
);
}
async resolveRegistrationUrlCode(code: string): Promise<InstanceRegistrationUrl | null> {
const normalizedCode = code.trim();
if (!normalizedCode) return null;
const hash = this.hashRegistrationUrlCode(normalizedCode);
const now = new Date();
const registrationUrls = await this.getRegistrationUrls();
return (
registrationUrls.find(
(registrationUrl) =>
(registrationUrl.id === normalizedCode || registrationUrl.code_hash === hash) &&
isRegistrationUrlUsable(registrationUrl, now),
) ?? null
);
}
async recordRegistrationUrlUse(id: string, userId: string): Promise<void> {
const now = new Date().toISOString();
const registrationUrls = await this.getRegistrationUrls();
await this.setRegistrationUrls(
registrationUrls.map((registrationUrl) =>
registrationUrl.id === id
? {
...registrationUrl,
use_count: registrationUrl.use_count + 1,
last_used_at: now,
last_used_by_user_id: userId,
}
: registrationUrl,
),
);
}
async getPendingRegistrations(): Promise<Array<InstancePendingRegistration>> {
const raw = await this.getConfig(REGISTRATION_PENDING_APPROVALS_KEY);
if (!raw) {
return [];
}
const parsed = parseJsonArray(raw);
if (!parsed) {
Logger.warn('Invalid pending registration config JSON, returning empty list');
return [];
}
return parsed
.flatMap((entry) => {
const normalized = normalizePendingRegistration(entry);
return normalized ? [normalized] : [];
})
.toSorted((a, b) => Date.parse(a.requested_at) - Date.parse(b.requested_at));
}
async addPendingRegistration(pendingRegistration: InstancePendingRegistration): Promise<void> {
const pendingRegistrations = await this.getPendingRegistrations();
const next = [
pendingRegistration,
...pendingRegistrations.filter((entry) => entry.user_id !== pendingRegistration.user_id),
];
await this.setConfig(REGISTRATION_PENDING_APPROVALS_KEY, JSON.stringify(next));
}
async removePendingRegistration(userId: string): Promise<void> {
const pendingRegistrations = await this.getPendingRegistrations();
await this.setConfig(
REGISTRATION_PENDING_APPROVALS_KEY,
JSON.stringify(pendingRegistrations.filter((entry) => entry.user_id !== userId)),
);
}
async getSsoConfig(options?: {includeSecret?: boolean}): Promise<InstanceSsoConfig> {
const configs = await this.getAllConfigs();
const read = (key: string): string | null => {
const v = configs.get(key);
if (!v) return null;
const trimmed = v.trim();
return trimmed.length === 0 ? null : trimmed;
};
const allowedDomainsRaw = configs.get('sso_allowed_domains');
let allowedDomains: Array<string> = [];
if (allowedDomainsRaw) {
const parsed = parseJsonArray(allowedDomainsRaw);
if (parsed) {
allowedDomains = parsed.map((item) => String(item)).filter((item) => item.length > 0);
} else {
allowedDomains = allowedDomainsRaw
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
}
const clientSecret = read('sso_client_secret');
const enabled = configs.get('sso_enabled') === 'true';
const enforcedRaw = configs.get('sso_enforced');
return {
enabled,
enforced: enforcedRaw == null ? enabled : enforcedRaw === 'true',
displayName: read('sso_display_name'),
issuer: read('sso_issuer'),
authorizationUrl: read('sso_authorization_url'),
tokenUrl: read('sso_token_url'),
userInfoUrl: read('sso_userinfo_url'),
jwksUrl: read('sso_jwks_url'),
clientId: read('sso_client_id'),
clientSecret: options?.includeSecret ? clientSecret : undefined,
clientSecretSet: Boolean(clientSecret),
scope: read('sso_scope'),
allowedEmailDomains: allowedDomains,
autoProvision: configs.get('sso_auto_provision') !== 'false',
redirectUri: null,
};
}
async setSsoConfig(config: Partial<InstanceSsoConfig>): Promise<InstanceSsoConfig> {
const current = await this.getSsoConfig({includeSecret: true});
const definedConfig = Object.fromEntries(
Object.entries(config).filter(([, value]) => value !== undefined),
) as Partial<InstanceSsoConfig>;
const next: InstanceSsoConfig = {
...current,
...definedConfig,
clientSecret: config.clientSecret !== undefined ? config.clientSecret : current.clientSecret,
};
if (config.enabled === true && config.enforced === undefined && !current.enabled) {
next.enforced = true;
}
let allowedEmailDomains: Array<string>;
try {
allowedEmailDomains = normalizeSsoAllowedEmailDomains(next.allowedEmailDomains ?? []);
} catch (error) {
if (next.enabled) {
throw error;
}
Logger.warn({error}, 'Clearing invalid SSO allowed domain config while SSO is disabled');
allowedEmailDomains = [];
}
const entries: Array<[string, string]> = [
['sso_enabled', next.enabled ? 'true' : 'false'],
['sso_enforced', next.enforced ? 'true' : 'false'],
['sso_display_name', next.displayName ?? ''],
['sso_issuer', next.issuer ?? ''],
['sso_authorization_url', next.authorizationUrl ?? ''],
['sso_token_url', next.tokenUrl ?? ''],
['sso_userinfo_url', next.userInfoUrl ?? ''],
['sso_jwks_url', next.jwksUrl ?? ''],
['sso_client_id', next.clientId ?? ''],
['sso_scope', next.scope ?? ''],
['sso_allowed_domains', JSON.stringify(allowedEmailDomains)],
['sso_auto_provision', next.autoProvision ? 'true' : 'false'],
['sso_redirect_uri', ''],
];
if (config.clientSecret !== undefined) {
entries.push(['sso_client_secret', config.clientSecret ?? '']);
}
await this.setConfigs(entries);
return this.getSsoConfig({includeSecret: true});
}
private async setRegistrationUrls(registrationUrls: Array<InstanceRegistrationUrl>): Promise<void> {
await this.setConfig(REGISTRATION_URLS_KEY, JSON.stringify(registrationUrls));
}
private hashRegistrationUrlCode(code: string): string {
return crypto.createHash('sha256').update(code).digest('hex');
}
}