mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
504 lines
19 KiB
TypeScript
504 lines
19 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import type {GifProvider} from '@app/features/app/state/GifProviderConfig';
|
|
import {AccountIdentityModes, TagStyles} from '@fluxer/constants/src/AccountIdentityConstants';
|
|
import {LIMIT_KEYS, type LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
|
import {
|
|
ACCOUNT_DELETION_GRACE_PERIOD_HOURS_MAX,
|
|
ACCOUNT_DELETION_GRACE_PERIOD_HOURS_MIN,
|
|
AGE_POLICY_GEOS_MAX,
|
|
LIMIT_RULES_MAX,
|
|
MAX_BACKGROUND_GATEWAY_CONNECTIONS_MAX,
|
|
MAX_BACKGROUND_GATEWAY_CONNECTIONS_MIN,
|
|
parseInstanceDomainMigration,
|
|
} from '@fluxer/instance_bootstrap/src/Discovery';
|
|
import {InstanceEndpointKind, normalizeInstanceEndpoint} from '@fluxer/instance_bootstrap/src/EndpointNormalization';
|
|
import {CanonicalNetworkProtocol} from '@fluxer/instance_bootstrap/src/NetworkOrigin';
|
|
import {
|
|
AGE_POLICY_ACTIONS,
|
|
type InstanceAgePolicy,
|
|
type InstanceAgePolicyGeo,
|
|
type InstanceAppPublic,
|
|
type InstanceCommunity,
|
|
type InstanceDomainMigration,
|
|
type InstanceFeatures,
|
|
type InstanceRegistration,
|
|
type InstanceServices,
|
|
type InstanceSso,
|
|
REGISTRATION_MODES,
|
|
} from '@fluxer/instance_bootstrap/src/Types';
|
|
import type {LimitConfigSnapshot, LimitFilter, LimitRule} from '@fluxer/limits/src/LimitTypes';
|
|
|
|
const LIMIT_KEYS_SET = new Set<string>(LIMIT_KEYS);
|
|
const DEFAULT_PREMIUM_PRODUCT_NAME = 'Plutonium';
|
|
|
|
export interface RuntimeConfigIdentity {
|
|
readonly apiEndpoint: string;
|
|
}
|
|
|
|
export interface RuntimeConfigSnapshot extends RuntimeConfigIdentity {
|
|
readonly apiPublicEndpoint: string;
|
|
readonly gatewayEndpoint: string;
|
|
readonly mediaEndpoint: string;
|
|
readonly staticCdnEndpoint: string;
|
|
readonly marketingEndpoint: string;
|
|
readonly adminEndpoint: string;
|
|
readonly inviteEndpoint: string;
|
|
readonly giftEndpoint: string;
|
|
readonly webAppEndpoint: string;
|
|
readonly uploadRelayEndpoint: string | null;
|
|
readonly gifProvider: GifProvider;
|
|
readonly gifProviderDisplayName: string;
|
|
readonly gifAttributionRequired: boolean;
|
|
readonly apiCodeVersion: number;
|
|
readonly features: InstanceFeatures;
|
|
readonly sso: InstanceSso | null;
|
|
readonly registration: InstanceRegistration;
|
|
readonly community: InstanceCommunity;
|
|
readonly services: InstanceServices;
|
|
readonly publicPushVapidKey: string | null;
|
|
readonly limits: LimitConfigSnapshot;
|
|
readonly appPublic: InstanceAppPublic;
|
|
readonly agePolicy: InstanceAgePolicy | null;
|
|
readonly domainMigration: InstanceDomainMigration | null;
|
|
}
|
|
|
|
export class InvalidRuntimeConfigSnapshotError extends Error {
|
|
readonly path: string;
|
|
|
|
constructor(path: string, requirement: string) {
|
|
super(`Runtime snapshot ${path} ${requirement}`);
|
|
this.name = 'InvalidRuntimeConfigSnapshotError';
|
|
this.path = path;
|
|
}
|
|
}
|
|
|
|
function invalid(path: string, requirement: string): never {
|
|
throw new InvalidRuntimeConfigSnapshotError(path, requirement);
|
|
}
|
|
|
|
function readRecord(value: unknown, path: string): Record<string, unknown> {
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
invalid(path, 'must be an object');
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function readBoolean(source: Record<string, unknown>, key: string, path: string): boolean {
|
|
const value = source[key];
|
|
if (typeof value !== 'boolean') {
|
|
invalid(path, 'must be a boolean');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function readOptionalBoolean(source: Record<string, unknown>, key: string, path: string): boolean | undefined {
|
|
if (!Object.hasOwn(source, key)) {
|
|
return undefined;
|
|
}
|
|
return readBoolean(source, key, path);
|
|
}
|
|
|
|
function readNonEmptyString(source: Record<string, unknown>, key: string, path: string): string {
|
|
const value = source[key];
|
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
invalid(path, 'must be a non-empty string');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function readNullableNonEmptyString(source: Record<string, unknown>, key: string, path: string): string | null {
|
|
const value = source[key];
|
|
if (value === null) {
|
|
return null;
|
|
}
|
|
return readNonEmptyString(source, key, path);
|
|
}
|
|
|
|
function readOptionalText(source: Record<string, unknown>, key: string): string | null {
|
|
const value = source[key];
|
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
return null;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function readPositiveInteger(source: Record<string, unknown>, key: string, path: string): number {
|
|
const value = source[key];
|
|
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
|
|
invalid(path, 'must be a positive safe integer');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function readBoundedInteger(
|
|
source: Record<string, unknown>,
|
|
key: string,
|
|
path: string,
|
|
minimum: number,
|
|
maximum: number,
|
|
): number {
|
|
const value = source[key];
|
|
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
invalid(path, `must be a safe integer from ${minimum.toString()} through ${maximum.toString()}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function readOptionalBoundedInteger(
|
|
source: Record<string, unknown>,
|
|
key: string,
|
|
path: string,
|
|
minimum: number,
|
|
maximum: number,
|
|
): number | undefined {
|
|
if (!Object.hasOwn(source, key)) {
|
|
return undefined;
|
|
}
|
|
return readBoundedInteger(source, key, path, minimum, maximum);
|
|
}
|
|
|
|
function readLiteral<TValue extends string>(
|
|
source: Record<string, unknown>,
|
|
key: string,
|
|
path: string,
|
|
values: ReadonlyArray<TValue>,
|
|
): TValue {
|
|
const value = source[key];
|
|
const matched = values.find((candidate) => candidate === value);
|
|
if (matched === undefined) {
|
|
invalid(path, `must be one of ${values.join(', ')}`);
|
|
}
|
|
return matched;
|
|
}
|
|
|
|
function readEndpoint(source: Record<string, unknown>, key: string, path: string, kind: InstanceEndpointKind): string {
|
|
const normalized = normalizeInstanceEndpoint(source[key], kind);
|
|
if (normalized === null || normalized.startsWith('/')) {
|
|
invalid(path, 'must be an absolute network endpoint');
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function readNullableEndpoint(source: Record<string, unknown>, key: string, path: string): string | null {
|
|
const value = source[key];
|
|
if (value === null) {
|
|
return null;
|
|
}
|
|
const normalized = normalizeInstanceEndpoint(value, InstanceEndpointKind.SERVICE);
|
|
if (normalized === null) {
|
|
invalid(path, 'must be null or an absolute network endpoint');
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function readNullableAbsoluteHttpUrl(source: Record<string, unknown>, key: string, path: string): string | null {
|
|
const value = source[key];
|
|
if (value === null) {
|
|
return null;
|
|
}
|
|
if (typeof value !== 'string') {
|
|
invalid(path, 'must be null or an absolute HTTP(S) URL');
|
|
}
|
|
const trimmed = value.trim();
|
|
let url: URL;
|
|
try {
|
|
url = new URL(trimmed);
|
|
} catch {
|
|
invalid(path, 'must be null or an absolute HTTP(S) URL');
|
|
}
|
|
if (url.protocol !== CanonicalNetworkProtocol.HTTP && url.protocol !== CanonicalNetworkProtocol.HTTPS) {
|
|
invalid(path, 'must be null or an absolute HTTP(S) URL');
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function readStringArray(value: unknown, path: string): Array<string> {
|
|
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
|
|
invalid(path, 'must be an array of strings');
|
|
}
|
|
return [...value];
|
|
}
|
|
|
|
function readFeatures(value: unknown): InstanceFeatures {
|
|
const source = readRecord(value, 'features');
|
|
const features: InstanceFeatures = {
|
|
voice_enabled: readBoolean(source, 'voice_enabled', 'features.voice_enabled'),
|
|
stripe_enabled: readBoolean(source, 'stripe_enabled', 'features.stripe_enabled'),
|
|
self_hosted: readBoolean(source, 'self_hosted', 'features.self_hosted'),
|
|
presigned_attachment_uploads: readBoolean(
|
|
source,
|
|
'presigned_attachment_uploads',
|
|
'features.presigned_attachment_uploads',
|
|
),
|
|
emails_enabled: readBoolean(source, 'emails_enabled', 'features.emails_enabled'),
|
|
premium_enabled: false,
|
|
stripe_serviceable: readOptionalBoolean(source, 'stripe_serviceable', 'features.stripe_serviceable') ?? false,
|
|
phone_verification_enabled: false,
|
|
};
|
|
features.premium_enabled =
|
|
readOptionalBoolean(source, 'premium_enabled', 'features.premium_enabled') ?? !features.self_hosted;
|
|
const desktopModulesEnabled = readOptionalBoolean(
|
|
source,
|
|
'desktop_modules_enabled',
|
|
'features.desktop_modules_enabled',
|
|
);
|
|
if (desktopModulesEnabled !== undefined) {
|
|
features.desktop_modules_enabled = desktopModulesEnabled;
|
|
}
|
|
const deletionGracePeriod = readOptionalBoundedInteger(
|
|
source,
|
|
'account_deletion_grace_period_hours',
|
|
'features.account_deletion_grace_period_hours',
|
|
ACCOUNT_DELETION_GRACE_PERIOD_HOURS_MIN,
|
|
ACCOUNT_DELETION_GRACE_PERIOD_HOURS_MAX,
|
|
);
|
|
if (deletionGracePeriod !== undefined) {
|
|
features.account_deletion_grace_period_hours = deletionGracePeriod;
|
|
}
|
|
if (source.account_identity !== undefined) {
|
|
features.account_identity = readLiteral(
|
|
source,
|
|
'account_identity',
|
|
'features.account_identity',
|
|
Object.values(AccountIdentityModes),
|
|
);
|
|
}
|
|
if (source.tag_style !== undefined) {
|
|
features.tag_style = readLiteral(source, 'tag_style', 'features.tag_style', Object.values(TagStyles));
|
|
}
|
|
const maxBackgroundConnections = readOptionalBoundedInteger(
|
|
source,
|
|
'max_background_gateway_connections',
|
|
'features.max_background_gateway_connections',
|
|
MAX_BACKGROUND_GATEWAY_CONNECTIONS_MIN,
|
|
MAX_BACKGROUND_GATEWAY_CONNECTIONS_MAX,
|
|
);
|
|
if (maxBackgroundConnections !== undefined) {
|
|
features.max_background_gateway_connections = maxBackgroundConnections;
|
|
}
|
|
return features;
|
|
}
|
|
|
|
function readSso(value: unknown): InstanceSso | null {
|
|
if (value === null) {
|
|
return null;
|
|
}
|
|
const source = readRecord(value, 'sso');
|
|
const sso: InstanceSso = {
|
|
enabled: readBoolean(source, 'enabled', 'sso.enabled'),
|
|
enforced: readBoolean(source, 'enforced', 'sso.enforced'),
|
|
display_name: readNullableNonEmptyString(source, 'display_name', 'sso.display_name'),
|
|
redirect_uri: readEndpoint(source, 'redirect_uri', 'sso.redirect_uri', InstanceEndpointKind.SERVICE),
|
|
};
|
|
const available = readOptionalBoolean(source, 'available', 'sso.available');
|
|
if (available !== undefined) {
|
|
sso.available = available;
|
|
}
|
|
return sso;
|
|
}
|
|
|
|
function readRegistration(value: unknown): InstanceRegistration {
|
|
const source = readRecord(value, 'registration');
|
|
return {
|
|
mode: readLiteral(source, 'mode', 'registration.mode', REGISTRATION_MODES),
|
|
admin_registration_urls_enabled: readBoolean(
|
|
source,
|
|
'admin_registration_urls_enabled',
|
|
'registration.admin_registration_urls_enabled',
|
|
),
|
|
};
|
|
}
|
|
|
|
function readCommunity(value: unknown): InstanceCommunity {
|
|
const source = readRecord(value, 'community');
|
|
return {
|
|
single_community: readBoolean(source, 'single_community', 'community.single_community'),
|
|
single_community_guild_id: readNullableNonEmptyString(
|
|
source,
|
|
'single_community_guild_id',
|
|
'community.single_community_guild_id',
|
|
),
|
|
direct_messages_disabled: readBoolean(source, 'direct_messages_disabled', 'community.direct_messages_disabled'),
|
|
guild_create_access: readOptionalBoolean(source, 'guild_create_access', 'community.guild_create_access') ?? true,
|
|
};
|
|
}
|
|
|
|
function readServices(value: unknown): InstanceServices {
|
|
const source = readRecord(value, 'services');
|
|
return {
|
|
gif_enabled: readBoolean(source, 'gif_enabled', 'services.gif_enabled'),
|
|
youtube_enabled: readBoolean(source, 'youtube_enabled', 'services.youtube_enabled'),
|
|
bluesky_enabled: readBoolean(source, 'bluesky_enabled', 'services.bluesky_enabled'),
|
|
};
|
|
}
|
|
|
|
function readLimitFilter(value: unknown, path: string): LimitFilter {
|
|
const source = readRecord(value, path);
|
|
const filter: LimitFilter = {};
|
|
if (Object.hasOwn(source, 'traits')) {
|
|
filter.traits = readStringArray(source.traits, `${path}.traits`);
|
|
}
|
|
if (Object.hasOwn(source, 'guildFeatures')) {
|
|
filter.guildFeatures = readStringArray(source.guildFeatures, `${path}.guildFeatures`);
|
|
}
|
|
return filter;
|
|
}
|
|
|
|
function readLimitValues(value: unknown, path: string): Partial<Record<LimitKey, number>> {
|
|
const source = readRecord(value, path);
|
|
const result: Partial<Record<LimitKey, number>> = {};
|
|
for (const [key, entry] of Object.entries(source)) {
|
|
if (!LIMIT_KEYS_SET.has(key)) {
|
|
continue;
|
|
}
|
|
if (typeof entry !== 'number' || !Number.isFinite(entry)) {
|
|
invalid(`${path}.${key}`, 'must be a finite number');
|
|
}
|
|
result[key as LimitKey] = entry;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function readLimitRule(value: unknown, index: number): LimitRule {
|
|
const path = `limits.rules[${index.toString()}]`;
|
|
const source = readRecord(value, path);
|
|
const rule: LimitRule = {
|
|
id: readNonEmptyString(source, 'id', `${path}.id`),
|
|
limits: readLimitValues(source.limits, `${path}.limits`),
|
|
};
|
|
if (Object.hasOwn(source, 'filters')) {
|
|
rule.filters = readLimitFilter(source.filters, `${path}.filters`);
|
|
}
|
|
if (Object.hasOwn(source, 'modifiedFields')) {
|
|
const modifiedFields = readStringArray(source.modifiedFields, `${path}.modifiedFields`);
|
|
rule.modifiedFields = modifiedFields.filter((key): key is LimitKey => LIMIT_KEYS_SET.has(key));
|
|
}
|
|
return rule;
|
|
}
|
|
|
|
function readLimits(value: unknown): LimitConfigSnapshot {
|
|
const source = readRecord(value, 'limits');
|
|
const traitDefinitions = readStringArray(source.traitDefinitions, 'limits.traitDefinitions');
|
|
if (!Array.isArray(source.rules) || source.rules.length > LIMIT_RULES_MAX) {
|
|
invalid('limits.rules', `must be an array with at most ${LIMIT_RULES_MAX.toString()} entries`);
|
|
}
|
|
const snapshot: LimitConfigSnapshot = {
|
|
traitDefinitions,
|
|
rules: source.rules.map(readLimitRule),
|
|
};
|
|
if (Object.hasOwn(source, 'version')) {
|
|
snapshot.version = readPositiveInteger(source, 'version', 'limits.version');
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
function readAppPublic(value: unknown): InstanceAppPublic {
|
|
const source = readRecord(value, 'appPublic');
|
|
const branding = readRecord(source.branding, 'appPublic.branding');
|
|
const setup = readRecord(source.setup, 'appPublic.setup');
|
|
const legal = readRecord(source.legal, 'appPublic.legal');
|
|
const registration = readRecord(source.registration, 'appPublic.registration');
|
|
const accountIdentityLocked = readOptionalBoolean(
|
|
setup,
|
|
'account_identity_locked',
|
|
'appPublic.setup.account_identity_locked',
|
|
);
|
|
const setupTokenRequired = readOptionalBoolean(setup, 'setup_token_required', 'appPublic.setup.setup_token_required');
|
|
return {
|
|
branding: {
|
|
product_name: readNonEmptyString(branding, 'product_name', 'appPublic.branding.product_name'),
|
|
icon_url: readNullableAbsoluteHttpUrl(branding, 'icon_url', 'appPublic.branding.icon_url'),
|
|
symbol_url: readNullableAbsoluteHttpUrl(branding, 'symbol_url', 'appPublic.branding.symbol_url'),
|
|
logo_url: readNullableAbsoluteHttpUrl(branding, 'logo_url', 'appPublic.branding.logo_url'),
|
|
wordmark_url: readNullableAbsoluteHttpUrl(branding, 'wordmark_url', 'appPublic.branding.wordmark_url'),
|
|
favicon_url: readNullableAbsoluteHttpUrl(branding, 'favicon_url', 'appPublic.branding.favicon_url'),
|
|
theme_color: readNullableNonEmptyString(branding, 'theme_color', 'appPublic.branding.theme_color'),
|
|
status_page_url: readOptionalText(branding, 'status_page_url'),
|
|
status_page_incident_history_url: readOptionalText(branding, 'status_page_incident_history_url'),
|
|
premium_product_name: readOptionalText(branding, 'premium_product_name') ?? DEFAULT_PREMIUM_PRODUCT_NAME,
|
|
premium_info_url: Object.hasOwn(branding, 'premium_info_url')
|
|
? readNullableAbsoluteHttpUrl(branding, 'premium_info_url', 'appPublic.branding.premium_info_url')
|
|
: null,
|
|
},
|
|
setup: {
|
|
configured: readBoolean(setup, 'configured', 'appPublic.setup.configured'),
|
|
admin_url: readNullableAbsoluteHttpUrl(setup, 'admin_url', 'appPublic.setup.admin_url'),
|
|
...(accountIdentityLocked === undefined ? {} : {account_identity_locked: accountIdentityLocked}),
|
|
...(setupTokenRequired === undefined ? {} : {setup_token_required: setupTokenRequired}),
|
|
},
|
|
legal: {
|
|
terms_url: readNullableAbsoluteHttpUrl(legal, 'terms_url', 'appPublic.legal.terms_url'),
|
|
privacy_url: readNullableAbsoluteHttpUrl(legal, 'privacy_url', 'appPublic.legal.privacy_url'),
|
|
guidelines_url:
|
|
legal['guidelines_url'] === undefined
|
|
? null
|
|
: readNullableAbsoluteHttpUrl(legal, 'guidelines_url', 'appPublic.legal.guidelines_url'),
|
|
},
|
|
registration: {
|
|
collect_date_of_birth: readBoolean(
|
|
registration,
|
|
'collect_date_of_birth',
|
|
'appPublic.registration.collect_date_of_birth',
|
|
),
|
|
},
|
|
};
|
|
}
|
|
|
|
function readAgePolicyGeo(value: unknown, index: number): InstanceAgePolicyGeo {
|
|
const path = `agePolicy.geos[${index.toString()}]`;
|
|
const source = readRecord(value, path);
|
|
return {
|
|
country_code: readNonEmptyString(source, 'country_code', `${path}.country_code`),
|
|
region_code: readNullableNonEmptyString(source, 'region_code', `${path}.region_code`),
|
|
action: readLiteral(source, 'action', `${path}.action`, AGE_POLICY_ACTIONS),
|
|
card_verification_available: readBoolean(
|
|
source,
|
|
'card_verification_available',
|
|
`${path}.card_verification_available`,
|
|
),
|
|
};
|
|
}
|
|
|
|
function readAgePolicy(value: unknown): InstanceAgePolicy | null {
|
|
if (value === null) {
|
|
return null;
|
|
}
|
|
const source = readRecord(value, 'agePolicy');
|
|
if (!Array.isArray(source.geos) || source.geos.length > AGE_POLICY_GEOS_MAX) {
|
|
invalid('agePolicy.geos', `must be an array with at most ${AGE_POLICY_GEOS_MAX.toString()} entries`);
|
|
}
|
|
return {geos: source.geos.map(readAgePolicyGeo)};
|
|
}
|
|
|
|
export function requireRuntimeConfigSnapshot(value: unknown): RuntimeConfigSnapshot {
|
|
const source = readRecord(value, 'root');
|
|
return {
|
|
apiEndpoint: readEndpoint(source, 'apiEndpoint', 'apiEndpoint', InstanceEndpointKind.SERVICE),
|
|
apiPublicEndpoint: readEndpoint(source, 'apiPublicEndpoint', 'apiPublicEndpoint', InstanceEndpointKind.SERVICE),
|
|
gatewayEndpoint: readEndpoint(source, 'gatewayEndpoint', 'gatewayEndpoint', InstanceEndpointKind.GATEWAY),
|
|
mediaEndpoint: readEndpoint(source, 'mediaEndpoint', 'mediaEndpoint', InstanceEndpointKind.SERVICE),
|
|
staticCdnEndpoint: readEndpoint(source, 'staticCdnEndpoint', 'staticCdnEndpoint', InstanceEndpointKind.SERVICE),
|
|
marketingEndpoint: readEndpoint(source, 'marketingEndpoint', 'marketingEndpoint', InstanceEndpointKind.SERVICE),
|
|
adminEndpoint: readEndpoint(source, 'adminEndpoint', 'adminEndpoint', InstanceEndpointKind.SERVICE),
|
|
inviteEndpoint: readEndpoint(source, 'inviteEndpoint', 'inviteEndpoint', InstanceEndpointKind.SERVICE),
|
|
giftEndpoint: readEndpoint(source, 'giftEndpoint', 'giftEndpoint', InstanceEndpointKind.SERVICE),
|
|
webAppEndpoint: readEndpoint(source, 'webAppEndpoint', 'webAppEndpoint', InstanceEndpointKind.WEBAPP),
|
|
uploadRelayEndpoint: readNullableEndpoint(source, 'uploadRelayEndpoint', 'uploadRelayEndpoint'),
|
|
gifProvider: readNonEmptyString(source, 'gifProvider', 'gifProvider'),
|
|
gifProviderDisplayName: readNonEmptyString(source, 'gifProviderDisplayName', 'gifProviderDisplayName'),
|
|
gifAttributionRequired: readBoolean(source, 'gifAttributionRequired', 'gifAttributionRequired'),
|
|
apiCodeVersion: readPositiveInteger(source, 'apiCodeVersion', 'apiCodeVersion'),
|
|
features: readFeatures(source.features),
|
|
sso: readSso(source.sso),
|
|
registration: readRegistration(source.registration),
|
|
community: readCommunity(source.community),
|
|
services: readServices(source.services),
|
|
publicPushVapidKey: readNullableNonEmptyString(source, 'publicPushVapidKey', 'publicPushVapidKey'),
|
|
limits: readLimits(source.limits),
|
|
appPublic: readAppPublic(source.appPublic),
|
|
agePolicy: readAgePolicy(source.agePolicy),
|
|
domainMigration: parseInstanceDomainMigration(source.domainMigration),
|
|
};
|
|
}
|