mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
refactor(api): tidy the api and shared packages (#2733)
This commit is contained in:
1 parent
580401d2dc
commit
5afbf67a70
333 files changed
+10180
-7910
No files matched your search
@@ -0,0 +1,114 @@
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {Logger} from '../Logger';
|
||||
import {parseJsonRecord} from '../utils/JsonBoundaryUtils';
|
||||
import {RefreshSubscription, RefreshSubscriptionUnavailableError} from '../utils/RefreshSubscription';
|
||||
|
||||
interface InstanceConfigCacheOptions {
|
||||
provider: IKVProvider | null;
|
||||
channel: string;
|
||||
load: () => Promise<Map<string, string>>;
|
||||
onRefresh: (snapshot: ReadonlyMap<string, string>) => void;
|
||||
}
|
||||
|
||||
export class InstanceConfigCache {
|
||||
readonly sourceId = randomUUID();
|
||||
private active = true;
|
||||
private snapshot: Map<string, string> | null = null;
|
||||
private writesDuringRefresh: Map<string, string> | null = null;
|
||||
private initialization: Promise<boolean> | null = null;
|
||||
private shutdownCompletion: Promise<void> | null = null;
|
||||
private readonly subscription: RefreshSubscription;
|
||||
|
||||
constructor(
|
||||
private readonly options: InstanceConfigCacheOptions,
|
||||
private readonly previousShutdown: Promise<void> = Promise.resolve(),
|
||||
) {
|
||||
this.subscription = new RefreshSubscription({
|
||||
name: 'InstanceConfigCache',
|
||||
channels: [options.channel],
|
||||
shouldRefresh: (_channel, message) => parseJsonRecord(message)?.source_id !== this.sourceId,
|
||||
drainInitialNotifications: true,
|
||||
refresh: () => this.refresh(),
|
||||
onRefreshError: (error) => {
|
||||
Logger.error({error}, 'Failed to refresh instance config cache from pubsub');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
assertActive(): void {
|
||||
if (!this.active) throw new Error('Instance config cache is shutting down or shut down');
|
||||
}
|
||||
|
||||
async getSnapshot(): Promise<ReadonlyMap<string, string> | null> {
|
||||
this.assertActive();
|
||||
if (!this.subscription.isReady) {
|
||||
this.initialization ??= this.initialize().finally(() => {
|
||||
this.initialization = null;
|
||||
});
|
||||
const cached = await this.initialization;
|
||||
this.assertActive();
|
||||
if (!cached) return null;
|
||||
}
|
||||
if (this.snapshot === null) throw new Error('Instance config cache is ready without a snapshot');
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
private async initialize(): Promise<boolean> {
|
||||
await this.previousShutdown;
|
||||
this.assertActive();
|
||||
if (this.options.provider === null) return false;
|
||||
try {
|
||||
await this.subscription.start(this.options.provider);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.assertActive();
|
||||
if (!(error instanceof RefreshSubscriptionUnavailableError)) throw error;
|
||||
Logger.error({error}, 'Instance config subscription unavailable, reading directly from the database');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async refresh(): Promise<void> {
|
||||
this.assertActive();
|
||||
if (this.writesDuringRefresh !== null) throw new Error('Instance config refresh already in progress');
|
||||
const writes = new Map<string, string>();
|
||||
this.writesDuringRefresh = writes;
|
||||
try {
|
||||
const snapshot = await this.options.load();
|
||||
if (!this.active) return;
|
||||
for (const [key, value] of writes) snapshot.set(key, value);
|
||||
this.options.onRefresh(snapshot);
|
||||
this.snapshot = snapshot;
|
||||
} finally {
|
||||
this.writesDuringRefresh = null;
|
||||
}
|
||||
}
|
||||
|
||||
update(key: string, value: string): void {
|
||||
if (!this.active) return;
|
||||
this.snapshot?.set(key, value);
|
||||
this.writesDuringRefresh?.set(key, value);
|
||||
}
|
||||
|
||||
shutdown(): Promise<void> {
|
||||
if (this.shutdownCompletion) return this.shutdownCompletion;
|
||||
this.active = false;
|
||||
this.snapshot = null;
|
||||
this.shutdownCompletion = this.close();
|
||||
return this.shutdownCompletion;
|
||||
}
|
||||
|
||||
private async close(): Promise<void> {
|
||||
const [subscription, previous] = await Promise.allSettled([
|
||||
this.subscription.stop(),
|
||||
this.previousShutdown,
|
||||
this.initialization,
|
||||
]);
|
||||
const errors: Array<unknown> = [subscription, previous].flatMap((result) =>
|
||||
result.status === 'rejected' ? [result.reason] : [],
|
||||
);
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, 'Failed to shut down instance config cache');
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
|
||||
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
|
||||
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
|
||||
const INSTANCE_INTEGRATIONS_CONFIG_KEY = 'instance_integrations_config';
|
||||
|
||||
class CountingInMemoryCassandraQueryExecutor extends InMemoryCassandraQueryExecutor {
|
||||
instanceConfigSelects = 0;
|
||||
@@ -158,6 +161,106 @@ describe('InstanceConfigRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the stored setup state when a branding field is invalid', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(
|
||||
APP_PUBLIC_CONFIG_KEY,
|
||||
JSON.stringify({branding: {product_name: 'Kept', icon_url: 42}, setup: {configured: false}}),
|
||||
);
|
||||
|
||||
const config = await repository.getAppPublicConfig();
|
||||
expect(config.setup.configured).toBe(false);
|
||||
expect(config.branding.product_name).toBe('Kept');
|
||||
});
|
||||
|
||||
it('keeps valid stored instance policy flags when one field is invalid', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(
|
||||
INSTANCE_POLICY_CONFIG_KEY,
|
||||
JSON.stringify({direct_messages_disabled: true, single_community_enabled: true, premium_mode: 'nonsense'}),
|
||||
);
|
||||
|
||||
const policy = await repository.getInstancePolicyConfig();
|
||||
expect(policy.direct_messages_disabled).toBe(true);
|
||||
expect(policy.single_community_enabled).toBe(true);
|
||||
expect(policy.premium_mode).toBe('everyone');
|
||||
});
|
||||
|
||||
it('keeps valid stored integration settings when one field is invalid', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(
|
||||
INSTANCE_INTEGRATIONS_CONFIG_KEY,
|
||||
JSON.stringify({bluesky: {client_name: 'Kept Instance', enabled: 'yes'}}),
|
||||
);
|
||||
|
||||
expect((await repository.getEffectiveBlueskyConfig()).client_name).toBe('Kept Instance');
|
||||
expect((await repository.getInstanceIntegrationsConfig()).bluesky.enabled).toBeNull();
|
||||
});
|
||||
|
||||
it('drops invalid stored SSO allowed domains and keeps the valid ones', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig('sso_allowed_domains', JSON.stringify(['example.com', '[email protected]', 'Kept.ORG']));
|
||||
|
||||
expect((await repository.getSsoConfig()).allowedEmailDomains).toEqual(['example.com', 'kept.org']);
|
||||
});
|
||||
|
||||
it('falls back to the default SSO flags when a stored flag is not a boolean', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig('sso_enabled', 'yes');
|
||||
await repository.setConfig('sso_enforced', 'sometimes');
|
||||
await repository.setConfig('sso_auto_provision', 'maybe');
|
||||
|
||||
const config = await repository.getSsoConfig();
|
||||
expect(config.enabled).toBe(false);
|
||||
expect(config.enforced).toBe(false);
|
||||
expect(config.autoProvision).toBe(true);
|
||||
});
|
||||
|
||||
it('clears an invalid allowed domain list only while SSO is disabled', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
const cleared = await repository.setSsoConfig({enabled: false, allowedEmailDomains: ['[email protected]']});
|
||||
expect(cleared.allowedEmailDomains).toEqual([]);
|
||||
|
||||
await expect(repository.setSsoConfig({enabled: true, allowedEmailDomains: ['[email protected]']})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('keeps an all-invalid stored allowed domain list non-empty so SSO still rejects every domain', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig('sso_allowed_domains', JSON.stringify(['[email protected]', 'bad/domain']));
|
||||
|
||||
const domains = (await repository.getSsoConfig()).allowedEmailDomains;
|
||||
expect(domains.length).toBeGreaterThan(0);
|
||||
expect(domains).not.toContain('example.com');
|
||||
});
|
||||
|
||||
it('returns the default voice noise suppression config when the key is absent', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {JoinSourceTypes} from '@fluxer/constants/src/GuildConstants';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {createGuildID, type GuildID, type UserID} from '../BrandedTypes';
|
||||
import type {GuildDataService} from '../guild/services/GuildDataService';
|
||||
import type {GuildMemberService} from '../guild/services/GuildMemberService';
|
||||
@@ -18,19 +19,15 @@ export class SingleCommunityService {
|
||||
|
||||
async getStockCommunityId(): Promise<GuildID | null> {
|
||||
const policy = await this.instanceConfigRepository.getInstancePolicyConfig();
|
||||
if (!policy.single_community_enabled || !policy.single_community_guild_id) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return createGuildID(BigInt(policy.single_community_guild_id));
|
||||
} catch {
|
||||
if (!policy.single_community_enabled || policy.single_community_guild_id === null) {
|
||||
return null;
|
||||
}
|
||||
return createGuildID(BigInt(policy.single_community_guild_id));
|
||||
}
|
||||
|
||||
async joinStockCommunity(userId: UserID, requestCache: RequestCache): Promise<void> {
|
||||
const guildId = await this.getStockCommunityId();
|
||||
if (!guildId) {
|
||||
if (guildId === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -64,20 +61,16 @@ export class SingleCommunityService {
|
||||
}
|
||||
|
||||
private async findDesignatedGuild(rawGuildId: string | null): Promise<GuildID | null> {
|
||||
if (!rawGuildId) {
|
||||
return null;
|
||||
}
|
||||
let guildId: GuildID;
|
||||
try {
|
||||
guildId = createGuildID(BigInt(rawGuildId));
|
||||
} catch {
|
||||
if (rawGuildId === null) {
|
||||
return null;
|
||||
}
|
||||
const guildId = createGuildID(BigInt(rawGuildId));
|
||||
try {
|
||||
await this.guildDataService.getGuildSystem(guildId);
|
||||
return guildId;
|
||||
} catch {
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof UnknownGuildError) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {domainToASCII} from 'node:url';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {RequestUrlPolicy} from '@pkgs/http_client/src/HttpClientTypes';
|
||||
import {createPublicInternetRequestUrlPolicy} from '@pkgs/http_client/src/PublicInternetRequestUrlPolicy';
|
||||
import {Config} from '../Config';
|
||||
@@ -38,6 +39,7 @@ export function getSsoRequestUrlPolicy(): RequestUrlPolicy {
|
||||
return ssoRequestUrlPolicy;
|
||||
}
|
||||
const DOMAIN_LABEL_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const SsoAllowedDomainsSchema = InstanceConfigResponse.shape.sso.shape.allowed_domains;
|
||||
|
||||
function normalizeOptionalSsoString(value: string | null): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
@@ -69,9 +71,11 @@ export function isTestSsoProvider(
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeSsoAllowedEmailDomains(domains: Array<string>): Array<string> {
|
||||
export function normalizeSsoAllowedEmailDomains(domains: unknown): Array<string> {
|
||||
const result = SsoAllowedDomainsSchema.safeParse(domains);
|
||||
if (!result.success) throwInvalidDomain();
|
||||
const normalized = new Set<string>();
|
||||
for (const rawDomain of domains) {
|
||||
for (const rawDomain of result.data) {
|
||||
const trimmed = rawDomain.trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {User} from '../../models/User';
|
||||
import type {InstancePolicyConfig} from '../InstanceConfigRepository';
|
||||
@@ -30,7 +31,7 @@ function createHarness(params: {designatedGuildId: string | null; designatedGuil
|
||||
const guildDataService = {
|
||||
getGuildSystem: async () => {
|
||||
if (!params.designatedGuildExists) {
|
||||
throw new Error('unknown guild');
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
return {} as never;
|
||||
},
|
||||
@@ -70,9 +71,9 @@ describe('SingleCommunityService.ensureStockCommunity', () => {
|
||||
expect(harness.createdNames).toEqual(['Fluxer']);
|
||||
});
|
||||
|
||||
it('creates a fresh community when the stored guild id is not a snowflake', async () => {
|
||||
it('rejects an invalid stored guild id without creating a community', async () => {
|
||||
const harness = createHarness({designatedGuildId: 'not-a-snowflake', designatedGuildExists: true});
|
||||
await harness.service.ensureStockCommunity({owner: OWNER, name: 'Fluxer'});
|
||||
expect(harness.createdNames).toEqual(['Fluxer']);
|
||||
await expect(harness.service.ensureStockCommunity({owner: OWNER, name: 'Fluxer'})).rejects.toThrow(SyntaxError);
|
||||
expect(harness.createdNames).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user