feat(push): relay notifications as encrypted web push (#2906)

This commit is contained in:
Hampus
2026-09-23 14:04:55 +02:00
committed by GitHub
parent 7021a58090
commit 18c303abf6
111 changed files with 12473 additions and 1177 deletions
@@ -13,7 +13,11 @@ import {deriveSsoRedirectUri, normalizeAndValidateSsoConfig} from '@app/api/inst
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {getGatewayRolloutConfigPublisher, getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {
getGatewayRolloutConfigPublisher,
getInstanceConfigRepository,
getPushServiceDeliveryConfigPublisher,
} from '@app/api/middleware/ServiceSingletons';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
@@ -31,6 +35,7 @@ import {
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {PushServiceDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import {ScreenShareDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
@@ -61,6 +66,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gatewayRollout,
voiceNoiseSuppression,
screenShareDelivery,
pushServiceDelivery,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -70,6 +76,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getScreenShareDeliveryConfig(),
instanceConfigRepository.getPushServiceDeliveryConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
@@ -102,6 +109,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gateway_rollout: gatewayRollout,
voice_noise_suppression: voiceNoiseSuppression,
screen_share_delivery: screenShareDelivery,
push_service_delivery: pushServiceDelivery,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
@@ -247,43 +255,54 @@ export function InstanceConfigAdminController(app: HonoApp) {
const shouldGrantSetupCompleterAdmin =
appPublicBeforeUpdate !== null && completesInitialSetup(data, appPublicBeforeUpdate.setup.configured);
if (data.gateway_rollout) {
const currentRollout = await instanceConfigRepository.getGatewayRolloutConfig();
const merged = {...currentRollout, ...data.gateway_rollout};
const validated = GatewayRolloutConfigSchema.parse(merged);
await instanceConfigRepository.setGatewayRolloutConfig(validated);
await getGatewayRolloutConfigPublisher().publish(validated);
const patch = data.gateway_rollout;
const landed = await instanceConfigRepository.updateGatewayRolloutConfig((current) =>
GatewayRolloutConfigSchema.parse({...current, ...patch}),
);
await getGatewayRolloutConfigPublisher().publish(landed);
}
if (data.voice_noise_suppression) {
const patch = omitUndefinedFields(data.voice_noise_suppression);
if (Object.keys(patch).length > 0) {
const currentNoiseSuppression = await instanceConfigRepository.getVoiceNoiseSuppressionConfig();
const validated = VoiceNoiseSuppressionConfigSchema.parse({
...currentNoiseSuppression,
...patch,
config_version: currentNoiseSuppression.config_version + 1,
});
await instanceConfigRepository.setVoiceNoiseSuppressionConfig(validated);
await instanceConfigRepository.updateVoiceNoiseSuppressionConfig((current) =>
VoiceNoiseSuppressionConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.screen_share_delivery) {
const patch = omitUndefinedFields(data.screen_share_delivery);
if (Object.keys(patch).length > 0) {
const currentScreenShareDelivery = await instanceConfigRepository.getScreenShareDeliveryConfig();
const validated = ScreenShareDeliveryConfigSchema.parse({
...currentScreenShareDelivery,
...patch,
config_version: currentScreenShareDelivery.config_version + 1,
});
await instanceConfigRepository.setScreenShareDeliveryConfig(validated);
await instanceConfigRepository.updateScreenShareDeliveryConfig((current) =>
ScreenShareDeliveryConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.push_service_delivery) {
const patch = omitUndefinedFields(data.push_service_delivery);
if (Object.keys(patch).length > 0) {
const landed = await instanceConfigRepository.updatePushServiceDeliveryConfig((current) =>
PushServiceDeliveryConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
await getPushServiceDeliveryConfigPublisher().publish(landed);
}
}
if (data.experiment_delivery) {
const currentExperimentDelivery = await instanceConfigRepository.getExperimentDeliveryConfig();
const validated = ExperimentDeliveryConfigSchema.parse({
...currentExperimentDelivery,
...data.experiment_delivery,
});
await instanceConfigRepository.setExperimentDeliveryConfig(validated);
const patch = data.experiment_delivery;
await instanceConfigRepository.updateExperimentDeliveryConfig((current) =>
ExperimentDeliveryConfigSchema.parse({...current, ...patch}),
);
}
if (data.sso) {
const sso = data.sso;
@@ -308,21 +327,22 @@ export function InstanceConfigAdminController(app: HonoApp) {
const validated = await normalizeAndValidateSsoConfig(next, {
testModeEnabled: Config.dev.testModeEnabled,
});
const supplied = <T>(field: keyof typeof sso, value: T): T | undefined =>
readOptionalField(sso, field) === undefined ? undefined : value;
await instanceConfigRepository.setSsoConfig({
enabled: validated.enabled,
enforced: validated.enforced,
displayName: next.displayName,
issuer: validated.issuer,
authorizationUrl: validated.authorizationUrl,
tokenUrl: validated.tokenUrl,
userInfoUrl: validated.userInfoUrl,
jwksUrl: validated.jwksUrl,
clientId: validated.clientId,
enabled: supplied('enabled', validated.enabled),
enforced: supplied('enforced', validated.enforced),
displayName: supplied('display_name', next.displayName),
issuer: supplied('issuer', validated.issuer),
authorizationUrl: supplied('authorization_url', validated.authorizationUrl),
tokenUrl: supplied('token_url', validated.tokenUrl),
userInfoUrl: supplied('userinfo_url', validated.userInfoUrl),
jwksUrl: supplied('jwks_url', validated.jwksUrl),
clientId: supplied('client_id', validated.clientId),
clientSecret: readOptionalField(sso, 'client_secret'),
scope: next.scope,
allowedEmailDomains: validated.allowedEmailDomains,
autoProvision: next.autoProvision,
redirectUri: null,
scope: supplied('scope', next.scope),
allowedEmailDomains: supplied('allowed_domains', validated.allowedEmailDomains),
autoProvision: supplied('auto_provision', next.autoProvision),
});
}
if (data.registration) {
@@ -625,7 +645,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
async (ctx) => {
const userId = ctx.req.valid('param').user_id.toString();
const decision = ctx.req.valid('json').status === 'approved' ? 'approve' : 'reject';
await instanceConfigRepository.getPendingRegistrations();
await updatePendingRegistrationUser(ctx, userId, decision);
await instanceConfigRepository.removePendingRegistration(userId);
return ctx.json(await buildInstanceConfigResponse());
@@ -638,27 +657,47 @@ async function applyInstancePolicyUpdate(
policy: NonNullable<InstanceConfigUpdateRequest['policy']>,
): Promise<void> {
const instanceConfigRepository = getInstanceConfigRepository();
const [current, appPublic] = await Promise.all([
instanceConfigRepository.getInstancePolicyConfig(),
instanceConfigRepository.getAppPublicConfig(),
]);
const appPublic = await instanceConfigRepository.getAppPublicConfig();
const adminUser =
policy.single_community_enabled === true
? await ctx.get('userRepository').findUnique(ctx.get('adminUserId'))
: null;
let enablesSingleCommunity = false;
await instanceConfigRepository.updateInstancePolicyConfig((current) => {
const planned = planInstancePolicyPatch(policy, current, {
setupConfigured: appPublic.setup.configured,
adminUserFound: adminUser !== null,
});
enablesSingleCommunity = planned.enablesSingleCommunity;
return planned.patch;
});
if (enablesSingleCommunity && adminUser) {
await ctx.get('singleCommunityService').ensureStockCommunity({
owner: adminUser,
name: policy.single_community_name?.trim() || appPublic.branding.product_name,
});
}
if (policy.premium_mode !== undefined) {
await ctx.get('limitConfigService').updatePolicyConfig({premium_mode: policy.premium_mode});
}
}
function planInstancePolicyPatch(
policy: NonNullable<InstanceConfigUpdateRequest['policy']>,
current: InstancePolicyConfig,
context: {setupConfigured: boolean; adminUserFound: boolean},
): {patch: Partial<InstancePolicyConfig>; enablesSingleCommunity: boolean} {
const patch: Partial<InstancePolicyConfig> = {};
let enablesSingleCommunity = false;
if (
policy.single_community_enabled !== undefined &&
policy.single_community_enabled !== current.single_community_enabled
) {
if (policy.single_community_enabled) {
if (appPublic.setup.configured && current.single_community_guild_id == null) {
if ((context.setupConfigured && current.single_community_guild_id == null) || !context.adminUserFound) {
throw new InstancePolicyTransitionNotAllowedError();
}
const adminUser = await ctx.get('userRepository').findUnique(ctx.get('adminUserId'));
if (!adminUser) {
throw new InstancePolicyTransitionNotAllowedError();
}
await ctx.get('singleCommunityService').ensureStockCommunity({
owner: adminUser,
name: policy.single_community_name?.trim() || appPublic.branding.product_name,
});
enablesSingleCommunity = true;
} else {
patch.single_community_enabled = false;
}
@@ -679,9 +718,6 @@ async function applyInstancePolicyUpdate(
patch.direct_messages_locked = true;
}
}
if (policy.premium_mode !== undefined) {
patch.premium_mode = policy.premium_mode;
}
if (policy.services) {
if (policy.services.gif_enabled !== undefined) {
patch.gif_enabled = policy.services.gif_enabled ?? null;
@@ -704,11 +740,7 @@ async function applyInstancePolicyUpdate(
patch.deferred_phone_gate_member_threshold = policy.deferred_phone_gate.member_threshold;
}
}
if (patch.premium_mode !== undefined) {
await ctx.get('limitConfigService').updatePolicyConfig(patch);
} else if (Object.keys(patch).length > 0) {
await instanceConfigRepository.setInstancePolicyConfig(patch);
}
return {patch, enablesSingleCommunity};
}
async function updatePendingRegistrationUser(
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
type PushServiceDeliveryConfig,
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const PUSH_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_config';
describe('instance config admin PATCH under concurrent writes', () => {
let harness: ApiTestHarness;
let executor: InstanceConfigWriteRaceExecutor;
beforeAll(async () => {
harness = await createApiTestHarness();
executor = new InstanceConfigWriteRaceExecutor(new InMemoryCassandraQueryExecutor());
setCassandraQueryExecutorForTesting(executor);
});
beforeEach(async () => {
await harness.reset();
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness.shutdown();
});
const createAdmin = async (): Promise<TestAccount> =>
await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const patchConfig = (admin: TestAccount, body: Record<string, unknown>) =>
createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
const spyOnPushDeliveryPublishes = () =>
vi.spyOn(PushServiceDeliveryConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
async function readStoredPushServiceDelivery(): Promise<PushServiceDeliveryConfig> {
const raw = await executor.readDirectly(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
if (raw === null) throw new Error('push service delivery config was never stored');
return JSON.parse(raw) as PushServiceDeliveryConfig;
}
async function listConfigUpdateAudits(): Promise<Array<AdminAuditLog>> {
const logs = await getAdminRepository().listAllAuditLogsPaginated(100000);
return logs.filter((log) => log.action === 'update_instance_config');
}
it('answers with a conflict and neither writes, publishes nor audits once every attempt has lost the race', async () => {
const publish = spyOnPushDeliveryPublishes();
const admin = await createAdmin();
await patchConfig(admin, {push_service_delivery: {enabled: true, rollout_basis_points: 1000}}).execute();
publish.mockClear();
const auditsBefore = await listConfigUpdateAudits();
executor.watch(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
let competingWrites = 0;
executor.competeBeforeEachWrite(async () => {
competingWrites++;
await executor.writeDirectly(
PUSH_SERVICE_DELIVERY_CONFIG_KEY,
JSON.stringify({
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
enabled: false,
rollout_basis_points: 1000,
config_version: 100 + competingWrites,
}),
);
});
await patchConfig(admin, {push_service_delivery: {rollout_basis_points: 5000}})
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.CONFLICT)
.execute();
expect(executor.events).not.toContain('write');
expect(await readStoredPushServiceDelivery()).toEqual({
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
enabled: false,
rollout_basis_points: 1000,
config_version: 100 + competingWrites,
});
expect(publish).not.toHaveBeenCalled();
expect(await listConfigUpdateAudits()).toHaveLength(auditsBefore.length);
});
});
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
describe('instance config admin PATCH against state another node changed', () => {
let harness: ApiTestHarness;
let executor: InstanceConfigWriteRaceExecutor;
beforeAll(async () => {
harness = await createApiTestHarness();
executor = new InstanceConfigWriteRaceExecutor(new InMemoryCassandraQueryExecutor());
setCassandraQueryExecutorForTesting(executor);
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness.shutdown();
});
const createAdmin = async (): Promise<TestAccount> =>
await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const patchConfig = (admin: TestAccount, body: Record<string, unknown>) =>
createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
it('keeps an SSO field another node changed when a patch changes a different one', async () => {
const admin = await createAdmin();
await patchConfig(admin, {sso: {display_name: 'Before', client_id: 'client-before'}}).execute();
await executor.writeDirectly('sso_display_name', 'Changed on another node');
await patchConfig(admin, {sso: {client_id: 'client-after'}}).execute();
expect(await executor.readDirectly('sso_display_name')).toBe('Changed on another node');
expect(await executor.readDirectly('sso_client_id')).toBe('client-after');
});
it('refuses to disable direct messages when their lock lands between the read and the write', async () => {
const admin = await createAdmin();
await patchConfig(admin, {policy: {services: {gif_enabled: true}}}).execute();
executor.watch(INSTANCE_POLICY_CONFIG_KEY);
let competed = false;
executor.competeBeforeEachWrite(async () => {
if (competed) return;
competed = true;
await executor.writeDirectly(
INSTANCE_POLICY_CONFIG_KEY,
JSON.stringify({direct_messages_disabled: false, direct_messages_locked: true, gif_enabled: true}),
);
});
await patchConfig(admin, {policy: {direct_messages_disabled: true}})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INSTANCE_POLICY_TRANSITION_NOT_ALLOWED)
.execute();
const stored = JSON.parse((await executor.readDirectly(INSTANCE_POLICY_CONFIG_KEY)) ?? 'null');
expect(stored).toMatchObject({direct_messages_disabled: false, direct_messages_locked: true, gif_enabled: true});
});
it('applies the DM rule and a premium mode change from one request', async () => {
const admin = await createAdmin();
await patchConfig(admin, {policy: {direct_messages_disabled: true}}).execute();
const updated = await patchConfig(admin, {
policy: {direct_messages_disabled: false, premium_mode: 'mirror'},
}).execute();
expect(updated.policy).toMatchObject({
direct_messages_disabled: false,
direct_messages_locked: true,
premium_mode: 'mirror',
});
});
});
+68 -18
View File
@@ -7,12 +7,14 @@ import * as AuthUtility from '@app/api/auth/AuthUtility';
import type {IRegistrationRiskEvaluator} from '@app/api/auth/services/IRegistrationRiskEvaluator';
import {createEmailVerificationToken, createInviteCode, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {APIConfig} from '@app/api/config/APIConfig';
import type {UserRow} from '@app/api/database/types/UserTypes';
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {KVActivityTracker} from '@app/api/infrastructure/KVActivityTracker';
import {
type InstanceConfigRepository,
type InstanceRegistrationUrl,
REGISTRATION_PENDING_APPROVAL_TRAIT,
type RegistrationUrlClaim,
} from '@app/api/instance/InstanceConfigRepository';
import type {SingleCommunityService} from '@app/api/instance/SingleCommunityService';
import type {InviteService} from '@app/api/invite/InviteService';
@@ -135,9 +137,6 @@ export async function register(
}
const now = new Date();
const registrationAccess = await resolveRegistrationAccess(instanceConfigRepository, data.registration_url_code);
if (registrationAccess.pendingApproval) {
await instanceConfigRepository.getPendingRegistrations();
}
const clientIp = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
@@ -228,7 +227,7 @@ export async function register(
const userLocale = parseAcceptLanguage(acceptLanguage);
const passwordHash = data.password ? await AuthPassword.hashPassword(ctx, data.password) : null;
const flags = config.nodeEnv === 'development' ? UserFlags.STAFF : 0n;
let user = await users.create({
const userRow: UserRow = {
user_id: userId,
username,
discriminator,
@@ -287,7 +286,39 @@ export async function register(
mention_flags: null,
last_voice_activity_sharing_change_at: null,
version: 1,
});
};
const registrationUrlUse = await claimRegistrationUrlUse(
instanceConfigRepository,
registrationAccess.registrationUrl,
userId,
);
let user: User;
let createAttempted = false;
try {
if (registrationAccess.pendingApproval) {
await instanceConfigRepository.addPendingRegistration({
user_id: userId.toString(),
username: userRow.username,
discriminator: userRow.discriminator,
global_name: userRow.global_name,
email: rawEmail,
requested_at: now.toISOString(),
registration_url_id: registrationAccess.registrationUrl?.id ?? null,
client_ip: clientIp,
});
}
createAttempted = true;
user = await users.create(userRow);
} catch (error) {
if (!createAttempted) {
await withdrawSignupOfUncreatedAccount(instanceConfigRepository, {
userId,
registrationUrlUse,
pendingApproval: registrationAccess.pendingApproval,
});
}
throw error;
}
await users.upsertSettings(
UserSettings.getDefaultUserSettings({
userId,
@@ -401,20 +432,7 @@ export async function register(
}
if (rawEmail && emailEnabled) await maybeSendVerificationEmail(ctx, {user, email: rawEmail});
await users.createAuthorizedIp(userId, clientIp);
if (registrationAccess.registrationUrl) {
await instanceConfigRepository.recordRegistrationUrlUse(registrationAccess.registrationUrl.id, user.id.toString());
}
if (registrationAccess.pendingApproval) {
await instanceConfigRepository.addPendingRegistration({
user_id: user.id.toString(),
username: user.username,
discriminator: user.discriminator,
global_name: user.globalName,
email: rawEmail,
requested_at: now.toISOString(),
registration_url_id: registrationAccess.registrationUrl?.id ?? null,
client_ip: clientIp,
});
return {
registration_pending_approval: true,
user_id: user.id.toString(),
@@ -469,6 +487,38 @@ function shouldAttemptBootstrapAdminGrant(
);
}
async function claimRegistrationUrlUse(
instanceConfigRepository: InstanceConfigRepository,
registrationUrl: InstanceRegistrationUrl | null,
userId: UserID,
): Promise<RegistrationUrlClaim | null> {
if (registrationUrl === null) return null;
const use = await instanceConfigRepository.claimRegistrationUrlUse(registrationUrl.id, userId.toString());
if (use === null) {
throw new RegistrationUrlInvalidError();
}
return use;
}
async function withdrawSignupOfUncreatedAccount(
instanceConfigRepository: InstanceConfigRepository,
signup: {userId: UserID; registrationUrlUse: RegistrationUrlClaim | null; pendingApproval: boolean},
): Promise<void> {
try {
if (signup.registrationUrlUse !== null) {
await instanceConfigRepository.releaseRegistrationUrlUse(signup.registrationUrlUse);
}
if (signup.pendingApproval) {
await instanceConfigRepository.removePendingRegistration(signup.userId.toString());
}
} catch (error) {
Logger.warn(
{userId: signup.userId.toString(), registrationUrlId: signup.registrationUrlUse?.registration_url_id, error},
'[AuthRegistration] Failed to withdraw the registration URL use or pending approval of an account that was never created',
);
}
}
async function resolveRegistrationAccess(
instanceConfigRepository: InstanceConfigRepository,
registrationUrlCode: string | null | undefined,
+24 -13
View File
@@ -382,21 +382,8 @@ export class SsoService {
throw new RegistrationClosedError();
}
const pendingApproval = registrationConfig.mode === 'approval';
if (pendingApproval) {
await this.instanceConfigRepository.getPendingRegistrations();
}
const user = await this.provisionUserFromClaims(claims, config, {pendingApproval});
if (pendingApproval) {
await this.instanceConfigRepository.addPendingRegistration({
user_id: user.id.toString(),
username: user.username,
discriminator: user.discriminator,
global_name: user.globalName,
email: user.email,
requested_at: new Date().toISOString(),
registration_url_id: null,
client_ip: null,
});
throw new RegistrationPendingApprovalError();
}
return user;
@@ -537,8 +524,22 @@ export class SsoService {
version: 1,
} as const;
await this.claimSsoIdentity(userId, claims.sub, config);
let createAttempted = false;
let userCreated = false;
try {
if (options?.pendingApproval) {
await this.instanceConfigRepository.addPendingRegistration({
user_id: userId.toString(),
username,
discriminator: discriminatorResult.discriminator,
global_name: globalName,
email: userRow.email,
requested_at: now.toISOString(),
registration_url_id: null,
client_ip: null,
});
}
createAttempted = true;
const user = await users.create(userRow);
userCreated = true;
await users.upsertSettings(
@@ -557,6 +558,16 @@ export class SsoService {
await this.ssoIdentityRepository.releaseIdentity(config.providerId, claims.sub).catch((releaseError) => {
getLogger().error({releaseError}, 'Failed to release SSO identity after user provisioning failed');
});
if (options?.pendingApproval && !createAttempted) {
await this.instanceConfigRepository
.removePendingRegistration(userId.toString())
.catch((removeError: unknown) => {
getLogger().error(
{userId: userId.toString(), removeError},
'Failed to withdraw the pending approval of an SSO user that was never created',
);
});
}
}
throw error;
}
@@ -0,0 +1,436 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {
createAuthHarness,
createTestAccount,
createUniqueEmail,
createUniqueUsername,
enableSso,
setUserACLs,
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {
InstanceConfigRepository,
REGISTRATION_PENDING_APPROVAL_TRAIT,
} from '@app/api/instance/InstanceConfigRepository';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {UserRepository} from '@app/api/user/repositories/UserRepository';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const REGISTRATION_URLS_KEY = 'registration_urls';
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
interface RegistrationResponse {
user_id?: string;
token?: string;
registration_pending_approval?: true;
code?: string;
}
function registrationBody(prefix: string, registrationUrlCode?: string): Record<string, unknown> {
return {
email: createUniqueEmail(prefix),
username: createUniqueUsername(prefix),
global_name: 'Signup Race',
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
...(registrationUrlCode === undefined ? {} : {registration_url_code: registrationUrlCode}),
};
}
describe('signups racing on registration URLs and pending approvals', () => {
let harness: ApiTestHarness;
let admin: TestAccount;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness?.shutdown();
});
const register = (prefix: string, registrationUrlCode?: string) =>
createBuilderWithoutAuth<RegistrationResponse>(harness)
.post('/auth/register')
.body(registrationBody(prefix, registrationUrlCode))
.executeRaw();
const readAdminConfig = (): Promise<InstanceConfigResponse> =>
createBuilder<InstanceConfigResponse>(harness, admin.token).get('/admin/instance/config').execute();
const completeSso = async (prefix: string) => {
const start = await createBuilderWithoutAuth<{state: string}>(harness)
.post('/auth/sso/start')
.body({redirect_to: '/me'})
.execute();
return createBuilderWithoutAuth(harness)
.post('/auth/sso/complete')
.body({code: createUniqueEmail(prefix), state: start.state})
.executeRaw();
};
const failCreateAfterTheUserRowIsWritten = () => {
const create = UserRepository.prototype.create;
vi.spyOn(UserRepository.prototype, 'create').mockImplementationOnce(async function (
this: UserRepository,
row: UserRow,
) {
await create.call(this, row);
throw new Error('the user indexes could not be written after the user row');
});
};
const failAfterThePendingApprovalIsStored = () => {
const addPendingRegistration = InstanceConfigRepository.prototype.addPendingRegistration;
vi.spyOn(InstanceConfigRepository.prototype, 'addPendingRegistration').mockImplementationOnce(async function (
this: InstanceConfigRepository,
entry: Parameters<InstanceConfigRepository['addPendingRegistration']>[0],
) {
await addPendingRegistration.call(this, entry);
throw new Error('the pending approval could not be published');
});
};
const expectOnePendingAccount = async () => {
const pending = (await readAdminConfig()).registration.pending_registrations;
expect(pending).toHaveLength(1);
const account = await new UserRepository().findUnique(createUserID(BigInt(pending[0]!.user_id)));
expect(account?.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)).toBe(true);
};
it('never lets concurrent signups through a capped registration URL exceed max_uses', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const {code, registrationUrl} = await repository.createRegistrationUrl({
label: 'Capped',
createdByUserId: '1',
expiresAt: null,
maxUses: 2,
approvalRequired: false,
});
const attempts = await Promise.all(Array.from({length: 6}, (_, index) => register(`capped${index}`, code)));
const admitted = attempts.filter((attempt) => attempt.response.status === HTTP_STATUS.OK);
const refused = attempts.filter((attempt) => attempt.response.status !== HTTP_STATUS.OK);
expect(admitted).toHaveLength(2);
for (const attempt of refused) {
expect(attempt.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(attempt.json.code).toBe(APIErrorCodes.REGISTRATION_URL_INVALID);
}
const stored = (await readAdminConfig()).registration.urls.find((url) => url.id === registrationUrl.id);
expect(stored?.use_count).toBe(2);
expect(admitted.map((attempt) => attempt.json.user_id)).toContain(stored?.last_used_by_user_id);
});
it('admits exactly max_uses when 120 signups race through a registration URL capped at 40', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const {code, registrationUrl} = await repository.createRegistrationUrl({
label: 'Capped at 40',
createdByUserId: '1',
expiresAt: null,
maxUses: 40,
approvalRequired: false,
});
const registerUntilDecided = async (prefix: string) => {
for (let attempt = 0; attempt < 20; attempt += 1) {
const result = await register(`${prefix}r${attempt}`, code);
if (result.response.status !== HTTP_STATUS.SERVICE_UNAVAILABLE) return result;
}
throw new Error('a signup never reached a decision');
};
const attempts = await Promise.all(Array.from({length: 120}, (_, index) => registerUntilDecided(`surge${index}`)));
const admitted = attempts.filter((attempt) => attempt.response.status === HTTP_STATUS.OK);
expect(admitted).toHaveLength(40);
for (const attempt of attempts.filter((entry) => entry.response.status !== HTTP_STATUS.OK)) {
expect(attempt.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(attempt.json.code).toBe(APIErrorCodes.REGISTRATION_URL_INVALID);
}
const stored = (await readAdminConfig()).registration.urls.find((url) => url.id === registrationUrl.id);
expect(stored?.use_count).toBe(40);
});
it('counts every concurrent signup through an uncapped registration URL', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const {code, registrationUrl} = await repository.createRegistrationUrl({
label: 'Uncapped',
createdByUserId: '1',
expiresAt: null,
maxUses: null,
approvalRequired: false,
});
const attempts = await Promise.all(Array.from({length: 5}, (_, index) => register(`uncapped${index}`, code)));
expect(attempts.map((attempt) => attempt.response.status)).toEqual(Array(5).fill(HTTP_STATUS.OK));
const stored = (await readAdminConfig()).registration.urls.find((url) => url.id === registrationUrl.id);
expect(stored?.use_count).toBe(5);
});
it('gives the seat and the pending entry back when the signup failed before the account was created', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const {code, registrationUrl} = await repository.createRegistrationUrl({
label: 'Single use',
createdByUserId: '1',
expiresAt: null,
maxUses: 1,
approvalRequired: true,
});
failAfterThePendingApprovalIsStored();
const failed = await register('seatreleased', code);
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
const withdrawn = await readAdminConfig();
expect(withdrawn.registration.pending_registrations).toEqual([]);
expect(withdrawn.registration.urls.find((url) => url.id === registrationUrl.id)?.use_count).toBe(0);
const retried = await register('seatreleasedretry', code);
expect(retried.response.status).toBe(HTTP_STATUS.OK);
const stored = (await readAdminConfig()).registration.urls.find((url) => url.id === registrationUrl.id);
expect(stored).toMatchObject({use_count: 1, last_used_by_user_id: retried.json.user_id});
});
it('keeps the seat when the account create itself failed, because the row may still have landed', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const {code, registrationUrl} = await repository.createRegistrationUrl({
label: 'Single use',
createdByUserId: '1',
expiresAt: null,
maxUses: 1,
approvalRequired: false,
});
vi.spyOn(UserRepository.prototype, 'create').mockRejectedValueOnce(new Error('the user row write failed'));
const failed = await register('seatkeptoncreate', code);
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
const second = await register('seatkeptcreate2', code);
expect(second.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(second.json.code).toBe(APIErrorCodes.REGISTRATION_URL_INVALID);
const stored = (await readAdminConfig()).registration.urls.find((url) => url.id === registrationUrl.id);
expect(stored?.use_count).toBe(1);
});
it('keeps the seat of an account whose row was written before its creation failed', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const {code, registrationUrl} = await repository.createRegistrationUrl({
label: 'Single use',
createdByUserId: '1',
expiresAt: null,
maxUses: 1,
approvalRequired: false,
});
failCreateAfterTheUserRowIsWritten();
const failed = await register('seatkept', code);
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
const second = await register('seatkeptsecond', code);
expect(second.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(second.json.code).toBe(APIErrorCodes.REGISTRATION_URL_INVALID);
const stored = (await readAdminConfig()).registration.urls.find((url) => url.id === registrationUrl.id);
expect(stored?.use_count).toBe(1);
});
it('honours the use count and cap already stored on a registration URL', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const id = 'b3c4f0b2-8a6e-4c41-9f55-3f0c2a7d1e90';
await repository.setConfig(
REGISTRATION_URLS_KEY,
JSON.stringify([
{
id,
label: 'Issued earlier',
code_hash: createHash('sha256').update(id).digest('hex'),
created_by_user_id: '1400000000000000001',
created_at: '2026-09-01T00:00:00.000Z',
expires_at: null,
max_uses: 2,
use_count: 1,
revoked_at: null,
approval_required: false,
last_used_at: '2026-09-02T00:00:00.000Z',
last_used_by_user_id: '1400000000000000002',
},
]),
);
const before = (await readAdminConfig()).registration.urls.find((url) => url.id === id);
expect(before).toMatchObject({use_count: 1, max_uses: 2, last_used_by_user_id: '1400000000000000002'});
const first = await register('storedinvite', id);
expect(first.response.status).toBe(HTTP_STATUS.OK);
const second = await register('storedinviteagain', id);
expect(second.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(second.json.code).toBe(APIErrorCodes.REGISTRATION_URL_INVALID);
const after = (await readAdminConfig()).registration.urls.find((url) => url.id === id);
expect(after).toMatchObject({use_count: 2, max_uses: 2, last_used_by_user_id: first.json.user_id});
});
it('keeps every pending approval when approval-mode signups race', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const attempts = await Promise.all(Array.from({length: 5}, (_, index) => register(`pending${index}`)));
expect(attempts.map((attempt) => attempt.json.registration_pending_approval)).toEqual(Array(5).fill(true));
const pending = (await readAdminConfig()).registration.pending_registrations.map((entry) => entry.user_id);
expect(pending.toSorted()).toEqual(attempts.map((attempt) => attempt.json.user_id).toSorted());
});
it('lists an approval-mode account whose signup failed after the account was created', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
vi.spyOn(UserRepository.prototype, 'createAuthorizedIp').mockRejectedValueOnce(
new Error('the authorized IP write failed'),
);
const failed = await register('pendingstranded');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
await expectOnePendingAccount();
});
it('lists an approval-mode account whose row was written before its creation failed', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
failCreateAfterTheUserRowIsWritten();
const failed = await register('pendingrowwritten');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
await expectOnePendingAccount();
});
it('keeps the pending approval of an approval-mode signup whose account create failed', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
vi.spyOn(UserRepository.prototype, 'create').mockRejectedValueOnce(new Error('the user row write failed'));
const failed = await register('pendingkept');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toHaveLength(1);
});
it('lists no pending approval for an approval-mode signup that failed before the account was created', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
failAfterThePendingApprovalIsStored();
const failed = await register('pendingnever');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toEqual([]);
});
it('lists an SSO account provisioned in approval mode whose provisioning failed after the account was created', async () => {
await enableSso(harness, admin.token, {enforced: false});
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
vi.spyOn(UserRepository.prototype, 'upsertSettings').mockRejectedValueOnce(new Error('the settings write failed'));
const failed = await completeSso('ssopendingstranded');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
await expectOnePendingAccount();
});
it('lists an SSO account provisioned in approval mode whose row was written before its creation failed', async () => {
await enableSso(harness, admin.token, {enforced: false});
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
failCreateAfterTheUserRowIsWritten();
const failed = await completeSso('ssopendingrowwritten');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
await expectOnePendingAccount();
});
it('keeps the pending approval of an SSO signup in approval mode whose account create failed', async () => {
await enableSso(harness, admin.token, {enforced: false});
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
vi.spyOn(UserRepository.prototype, 'create').mockRejectedValueOnce(new Error('the user row write failed'));
const failed = await completeSso('ssopendingkept');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toHaveLength(1);
});
it('lists no pending approval for an SSO signup in approval mode that failed before the account was created', async () => {
await enableSso(harness, admin.token, {enforced: false});
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
failAfterThePendingApprovalIsStored();
const failed = await completeSso('ssopendingnever');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toEqual([]);
});
it('keeps a stored pending approval listed until an admin decides it', async () => {
const account = await createTestAccount(harness);
await getInstanceConfigRepository().setConfig(
REGISTRATION_PENDING_APPROVALS_KEY,
JSON.stringify([
{
user_id: account.userId,
username: 'stored_pending',
discriminator: 1,
global_name: null,
email: account.email,
requested_at: '2026-09-01T00:00:00.000Z',
registration_url_id: null,
client_ip: '127.0.0.1',
},
]),
);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const fresh = await register('pendingafter');
const listed = (await readAdminConfig()).registration.pending_registrations.map((entry) => entry.user_id);
expect(listed.toSorted()).toEqual([account.userId, fresh.json.user_id].toSorted());
const decided = await createBuilder<InstanceConfigResponse>(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${account.userId}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.OK)
.execute();
expect(decided.registration.pending_registrations.map((entry) => entry.user_id)).toEqual([fresh.json.user_id]);
expect(
JSON.parse(
(await getInstanceConfigRepository().getConfig(REGISTRATION_PENDING_APPROVALS_KEY)) ?? 'null',
) as Array<{user_id: string}>,
).toEqual([expect.objectContaining({user_id: fresh.json.user_id})]);
});
});
@@ -1,12 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {spawnSync} from 'node:child_process';
import {createHash} from 'node:crypto';
import {createServer} from 'node:net';
import type {CassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import type {PreparedQuery} from '@app/api/database/CassandraTypes';
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor';
import {
INSTANCE_CONFIG_REFRESH_CHANNEL,
INSTANCE_CONFIG_WRITE_ATTEMPTS,
InstanceConfigRepository,
InstanceConfigWriteConflictError,
type InstanceRegistrationConfig,
} from '@app/api/instance/InstanceConfigRepository';
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {
@@ -21,7 +30,13 @@ import {
DEFAULT_EXPERIMENT_DELIVERY_CONFIG,
type ExperimentDeliveryConfig,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {afterEach, describe, expect, it, vi} from 'vitest';
import {
getDefaultPostgresClient,
type IPostgresClient,
initPostgres,
shutdownPostgres,
} from '@pkgs/postgres/src/Client';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
const SCREEN_SHARE_DELIVERY_CONFIG_KEY = 'screen_share_delivery_config';
@@ -29,6 +44,12 @@ 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';
const REGISTRATION_CONFIG_KEY = 'registration_config';
const REGISTRATION_URLS_KEY = 'registration_urls';
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
const POSTGRES_KV_TABLE = 'kv_instance_config_races';
const POSTGRES_CONTAINER = `fluxer-instance-config-races-${process.pid.toString(36)}-${Date.now().toString(36)}`;
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
class CountingInMemoryCassandraQueryExecutor extends InMemoryCassandraQueryExecutor {
instanceConfigSelects = 0;
@@ -512,3 +533,466 @@ describe('InstanceConfigRepository', () => {
});
});
});
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (typeof address === 'string' || address === null) {
reject(new Error('no port'));
return;
}
server.close(() => resolve(address.port));
});
});
}
function describeConcurrentInstanceConfigWrites(prepareBase: () => Promise<CassandraQueryExecutorForTesting>): void {
const pods: Array<InstanceConfigRepository> = [];
let executor: InstanceConfigWriteRaceExecutor;
beforeEach(async () => {
executor = new InstanceConfigWriteRaceExecutor(await prepareBase());
setCassandraQueryExecutorForTesting(executor);
});
afterEach(async () => {
await Promise.all(pods.map((pod) => pod.shutdown()));
pods.length = 0;
});
function createPod(): InstanceConfigRepository {
const pod = new InstanceConfigRepository(new MockKVProvider());
pods.push(pod);
return pod;
}
async function readStoredRegistrationConfig(): Promise<unknown> {
const raw = await executor.readDirectly(REGISTRATION_CONFIG_KEY);
return raw === null ? null : JSON.parse(raw);
}
it('applies two concurrent patches on top of each other instead of dropping one', async () => {
const first = createPod();
const second = createPod();
await first.setRegistrationConfig({mode: 'open', admin_registration_urls_enabled: true});
await second.getRegistrationConfig();
executor.watch(REGISTRATION_CONFIG_KEY);
executor.pauseWritesUntil(2);
await Promise.all([
first.setRegistrationConfig({mode: 'closed'}),
second.setRegistrationConfig({admin_registration_urls_enabled: false}),
]);
expect(executor.events.filter((event) => event === 'write rejected')).toHaveLength(1);
expect(executor.events.filter((event) => event === 'write')).toHaveLength(2);
expect(await readStoredRegistrationConfig()).toEqual({mode: 'closed', admin_registration_urls_enabled: false});
});
it('lets one of two concurrent first writes create the config and applies the other on top', async () => {
const first = createPod();
const second = createPod();
await first.getRegistrationConfig();
await second.getRegistrationConfig();
executor.watch(REGISTRATION_CONFIG_KEY);
executor.pauseWritesUntil(2);
await Promise.all([
first.setRegistrationConfig({mode: 'closed'}),
second.setRegistrationConfig({admin_registration_urls_enabled: false}),
]);
expect(executor.events.filter((event) => event === 'write rejected')).toHaveLength(1);
expect(executor.events.filter((event) => event === 'write')).toHaveLength(2);
expect(await readStoredRegistrationConfig()).toEqual({mode: 'closed', admin_registration_urls_enabled: false});
});
it('re-reads the database, not its stale cache, when a concurrent write lands between its read and its write', async () => {
const stale = createPod();
const other = createPod();
await stale.setRegistrationConfig({mode: 'open', admin_registration_urls_enabled: true});
await stale.getRegistrationConfig();
await other.setRegistrationConfig({mode: 'approval'});
expect(await stale.getRegistrationConfig()).toEqual({mode: 'open', admin_registration_urls_enabled: true});
executor.watch(REGISTRATION_CONFIG_KEY);
let competed = false;
executor.competeBeforeEachWrite(async () => {
if (competed) return;
competed = true;
await executor.writeDirectly(
REGISTRATION_CONFIG_KEY,
JSON.stringify({mode: 'approval', admin_registration_urls_enabled: false}),
);
});
await stale.setRegistrationConfig({mode: 'closed'});
expect(executor.events).toEqual(['read', 'write rejected', 'read', 'write']);
expect(await readStoredRegistrationConfig()).toEqual({mode: 'closed', admin_registration_urls_enabled: false});
});
it('fails loudly and writes nothing once every attempt has lost the race', async () => {
const pod = createPod();
await pod.setRegistrationConfig({mode: 'open', admin_registration_urls_enabled: true});
executor.watch(REGISTRATION_CONFIG_KEY);
let competingWrites = 0;
executor.competeBeforeEachWrite(async () => {
competingWrites++;
await executor.writeDirectly(
REGISTRATION_CONFIG_KEY,
JSON.stringify({mode: 'approval', admin_registration_urls_enabled: competingWrites % 2 === 0}),
);
});
const write = pod.setRegistrationConfig({mode: 'closed'});
await expect(write).rejects.toBeInstanceOf(InstanceConfigWriteConflictError);
await expect(write).rejects.toMatchObject({
status: 409,
code: 'CONFLICT',
message: expect.stringContaining(REGISTRATION_CONFIG_KEY),
});
expect(executor.events.filter((event) => event === 'write rejected')).toHaveLength(INSTANCE_CONFIG_WRITE_ATTEMPTS);
expect(executor.events).not.toContain('write');
expect(await readStoredRegistrationConfig()).toEqual({
mode: 'approval',
admin_registration_urls_enabled: INSTANCE_CONFIG_WRITE_ATTEMPTS % 2 === 0,
});
});
it('keeps a pending registration another pod added while this pod held a stale list', async () => {
const first = createPod();
const second = createPod();
await first.getPendingRegistrations();
await second.getPendingRegistrations();
await first.addPendingRegistration(pendingRegistration('1400000000000000011'));
await second.addPendingRegistration(pendingRegistration('1400000000000000012'));
const listed = await createPod().getPendingRegistrations();
expect(listed.map((entry) => entry.user_id)).toEqual(['1400000000000000011', '1400000000000000012']);
});
it('keeps a pending registration another pod stored and removes it once decided', async () => {
const pod = createPod();
await executor.writeDirectly(
REGISTRATION_PENDING_APPROVALS_KEY,
JSON.stringify([pendingRegistration('1400000000000000021')]),
);
await pod.addPendingRegistration(pendingRegistration('1400000000000000022'));
expect((await createPod().getPendingRegistrations()).map((entry) => entry.user_id)).toEqual([
'1400000000000000021',
'1400000000000000022',
]);
await pod.removePendingRegistration('1400000000000000021');
await pod.removePendingRegistration('1400000000000000022');
expect(await createPod().getPendingRegistrations()).toEqual([]);
expect(await executor.readDirectly(REGISTRATION_PENDING_APPROVALS_KEY)).toBe('[]');
});
it('keeps a registration URL another pod created while this pod held a stale list', async () => {
const first = createPod();
const second = createPod();
await first.getRegistrationUrls();
await second.getRegistrationUrls();
const created = [
await first.createRegistrationUrl(registrationUrlParams(null)),
await second.createRegistrationUrl(registrationUrlParams(null)),
];
const listed = await createPod().getRegistrationUrlsForAdmin();
expect(listed.map((url) => url.id).toSorted()).toEqual(created.map((entry) => entry.registrationUrl.id).toSorted());
});
it('refuses a registration URL another pod revoked while this pod held a stale list', async () => {
const admin = createPod();
const signup = createPod();
const {code, registrationUrl} = await admin.createRegistrationUrl(registrationUrlParams(null));
expect(await signup.resolveRegistrationUrlCode(code)).not.toBeNull();
await admin.revokeRegistrationUrl(registrationUrl.id);
await expect(signup.resolveRegistrationUrlCode(code)).resolves.toBeNull();
await expect(signup.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000501')).resolves.toBeNull();
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed?.use_count).toBe(0);
});
it('admits a registration URL another pod created while this pod held a stale list', async () => {
const admin = createPod();
const signup = createPod();
await signup.getRegistrationUrls();
const {code, registrationUrl} = await admin.createRegistrationUrl(registrationUrlParams(1));
await expect(signup.resolveRegistrationUrlCode(code)).resolves.toMatchObject({
id: registrationUrl.id,
approval_required: false,
});
await expect(signup.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000601')).resolves.not.toBeNull();
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed?.use_count).toBe(1);
});
it('never seats more signups than max_uses when pods claim the same registration URL at once', async () => {
const pods = [createPod(), createPod(), createPod()];
const {code} = await pods[0]!.createRegistrationUrl(registrationUrlParams(2));
const registrationUrl = await pods[0]!.resolveRegistrationUrlCode(code);
if (registrationUrl === null) throw new Error('registration URL did not resolve');
const claims = await Promise.all(
Array.from({length: 6}, (_, index) =>
pods[index % pods.length]!.claimRegistrationUrlUse(registrationUrl.id, `14000000000000001${index}0`),
),
);
expect(claims.filter((claim) => claim !== null)).toHaveLength(2);
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed?.use_count).toBe(2);
await expect(createPod().resolveRegistrationUrlCode(code)).resolves.toBeNull();
});
it('refuses a claim whose retry finds the registration URL exhausted, rather than reporting the lost attempt', async () => {
const pod = createPod();
const {code, registrationUrl} = await pod.createRegistrationUrl(registrationUrlParams(1));
expect(await pod.resolveRegistrationUrlCode(code)).not.toBeNull();
executor.watch(REGISTRATION_URLS_KEY);
let competed = false;
executor.competeBeforeEachWrite(async () => {
if (competed) return;
competed = true;
const stored = JSON.parse((await executor.readDirectly(REGISTRATION_URLS_KEY)) ?? 'null') as Array<
Record<string, unknown>
>;
await executor.writeDirectly(
REGISTRATION_URLS_KEY,
JSON.stringify(
stored.map((entry) => ({
...entry,
use_count: 1,
last_used_at: '2026-09-20T00:00:00.000Z',
last_used_by_user_id: '1400000000000000901',
})),
),
);
});
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000902')).resolves.toBeNull();
expect(executor.events).toEqual(['read', 'write rejected', 'read']);
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed).toMatchObject({use_count: 1, last_used_by_user_id: '1400000000000000901'});
});
it('counts concurrent uses of an uncapped registration URL without ever refusing one', async () => {
const pods = [createPod(), createPod()];
const {code} = await pods[0]!.createRegistrationUrl(registrationUrlParams(null));
const registrationUrl = await pods[0]!.resolveRegistrationUrlCode(code);
if (registrationUrl === null) throw new Error('registration URL did not resolve');
const claims = await Promise.all(
Array.from({length: 5}, (_, index) =>
pods[index % pods.length]!.claimRegistrationUrlUse(registrationUrl.id, `14000000000000002${index}0`),
),
);
expect(claims.every((claim) => claim !== null)).toBe(true);
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed?.use_count).toBe(5);
});
it('frees a released seat for the next signup', async () => {
const pod = createPod();
const {code} = await pod.createRegistrationUrl(registrationUrlParams(1));
const registrationUrl = await pod.resolveRegistrationUrlCode(code);
if (registrationUrl === null) throw new Error('registration URL did not resolve');
const failedSignup = await pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000301');
if (failedSignup === null) throw new Error('the first claim was refused');
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000302')).resolves.toBeNull();
await pod.releaseRegistrationUrlUse(failedSignup);
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000303')).resolves.not.toBeNull();
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed).toMatchObject({use_count: 1, last_used_by_user_id: '1400000000000000303'});
});
it('enforces max_uses against the use count already stored in the blob', async () => {
const pod = createPod();
const id = 'b3c4f0b2-8a6e-4c41-9f55-3f0c2a7d1e91';
await executor.writeDirectly(
REGISTRATION_URLS_KEY,
JSON.stringify([
{
id,
label: 'Issued earlier',
code_hash: createHash('sha256').update(id).digest('hex'),
created_by_user_id: '1400000000000000001',
created_at: '2026-09-01T00:00:00.000Z',
expires_at: null,
max_uses: 3,
use_count: 2,
revoked_at: null,
approval_required: true,
last_used_at: '2026-09-02T00:00:00.000Z',
last_used_by_user_id: '1400000000000000002',
},
]),
);
expect(await pod.getRegistrationUrlsForAdmin()).toEqual([
expect.objectContaining({
id,
use_count: 2,
max_uses: 3,
approval_required: true,
last_used_at: '2026-09-02T00:00:00.000Z',
last_used_by_user_id: '1400000000000000002',
}),
]);
const registrationUrl = await pod.resolveRegistrationUrlCode(id);
if (registrationUrl === null) throw new Error('stored registration URL did not resolve');
expect(registrationUrl).toMatchObject({id, approval_required: true});
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000401')).resolves.not.toBeNull();
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000402')).resolves.toBeNull();
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed).toMatchObject({use_count: 3, max_uses: 3, last_used_by_user_id: '1400000000000000401'});
await expect(pod.resolveRegistrationUrlCode(id)).resolves.toBeNull();
expect(JSON.parse((await executor.readDirectly(REGISTRATION_URLS_KEY)) ?? 'null')[0]).toMatchObject({
use_count: 3,
max_uses: 3,
});
});
it('keeps an SSO field another pod changed while this pod held a stale snapshot', async () => {
const first = createPod();
const second = createPod();
await first.getSsoConfig();
await second.getSsoConfig();
await first.setSsoConfig({displayName: 'Set by the first pod'});
await second.setSsoConfig({clientId: 'set-by-the-second-pod'});
expect(await createPod().getSsoConfig()).toMatchObject({
displayName: 'Set by the first pod',
clientId: 'set-by-the-second-pod',
});
});
it('leaves an SSO row alone when another pod wrote it between this pod reading and writing it', async () => {
const pod = createPod();
await pod.getSsoConfig();
executor.watch('sso_enforced');
let competed = false;
executor.competeBeforeEachWrite(async () => {
if (competed) return;
competed = true;
await executor.writeDirectly('sso_enforced', 'true');
});
await pod.setSsoConfig({displayName: 'Only the display name'});
expect(await executor.readDirectly('sso_enforced')).toBe('true');
expect(await executor.readDirectly('sso_display_name')).toBe('Only the display name');
});
}
function pendingRegistration(userId: string) {
return {
user_id: userId,
username: `pending_${userId.slice(-3)}`,
discriminator: 1,
global_name: null,
email: `${userId}@example.com`,
requested_at: `2026-09-01T00:00:${userId.slice(-2)}.000Z`,
registration_url_id: null,
client_ip: '127.0.0.1',
};
}
function registrationUrlParams(maxUses: number | null) {
return {
label: maxUses === null ? 'Uncapped' : `Capped at ${maxUses}`,
createdByUserId: '1400000000000000001',
expiresAt: null,
maxUses,
approvalRequired: false,
};
}
describe('InstanceConfigRepository concurrent writes', () => {
describe('in memory', () => {
describeConcurrentInstanceConfigWrites(async () => new InMemoryCassandraQueryExecutor());
});
describe.skipIf(!dockerAvailable)('on postgres', () => {
let client: IPostgresClient;
beforeAll(async () => {
const port = await freePort();
startDockerContainer([
'run',
'-d',
'--name',
POSTGRES_CONTAINER,
'-e',
'POSTGRES_USER=fluxer',
'-e',
'POSTGRES_PASSWORD=fluxer',
'-e',
'POSTGRES_DB=fluxer',
'-p',
`127.0.0.1:${port}:5432`,
'postgres:16-alpine',
'-c',
'fsync=off',
]);
let ready = false;
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
await sleep(500);
const probe = spawnSync('docker', ['exec', POSTGRES_CONTAINER, 'pg_isready', '-U', 'fluxer', '-d', 'fluxer'], {
stdio: 'ignore',
});
if (probe.status !== 0) continue;
try {
await initPostgres({
url: `postgres://fluxer:[email protected]:${port}/fluxer`,
maxConnections: 4,
kvTable: POSTGRES_KV_TABLE,
});
await getDefaultPostgresClient().query('SELECT 1');
ready = true;
} catch {
await shutdownPostgres().catch(() => {});
}
}
if (!ready) throw new Error('postgres never came up');
client = getDefaultPostgresClient();
await ensurePostgresKvSchema(client);
}, 900_000);
afterAll(async () => {
setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor());
await shutdownPostgres().catch(() => {});
spawnSync('docker', ['rm', '-f', POSTGRES_CONTAINER], {stdio: 'ignore'});
});
describeConcurrentInstanceConfigWrites(async () => {
await client.query(`DELETE FROM ${POSTGRES_KV_TABLE}`);
return new PostgresKvQueryExecutor(client);
});
});
});
@@ -3,7 +3,8 @@
import crypto from 'node:crypto';
import {Config} from '@app/api/Config';
import type {APIConfig, BlueskyOAuthConfig, BlueskyOAuthKeyConfig} from '@app/api/config/APIConfig';
import {fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {executeConditional, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {Db, type PreparedQuery} from '@app/api/database/CassandraTypes';
import type {InstanceConfigurationRow} from '@app/api/database/types/InstanceConfigTypes';
import {
getDefaultDateOfBirthCollection,
@@ -17,6 +18,9 @@ import {resolveDeferredPhoneGateEnabled, setCachedDeferredPhoneGateEnabled} from
import {InstanceConfiguration} from '@app/api/Tables';
import {DEFAULT_DECAY_CONSTANTS, DEFAULT_RENEWAL_CONSTANTS} from '@app/api/utils/AttachmentDecay';
import {isJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
import {
InstanceConfigResponse,
@@ -28,6 +32,10 @@ import {
type GatewayRolloutConfig,
GatewayRolloutConfigSchema,
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {
type PushServiceDeliveryConfig,
PushServiceDeliveryConfigSchema,
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import {
type ScreenShareDeliveryConfig,
ScreenShareDeliveryConfigSchema,
@@ -59,6 +67,7 @@ import {z} from 'zod';
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
const SCREEN_SHARE_DELIVERY_CONFIG_KEY = 'screen_share_delivery_config';
const PUSH_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_config';
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
const REGISTRATION_CONFIG_KEY = 'registration_config';
const REGISTRATION_URLS_KEY = 'registration_urls';
@@ -72,6 +81,22 @@ 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';
export const INSTANCE_CONFIG_WRITE_ATTEMPTS = 5;
export class InstanceConfigWriteConflictError extends ConflictError {
constructor(key: string) {
super({
code: APIErrorCodes.CONFLICT,
message: `Instance config "${key}" changed concurrently on all ${INSTANCE_CONFIG_WRITE_ATTEMPTS} write attempts. Nothing was written. Retry the change.`,
});
this.name = 'InstanceConfigWriteConflictError';
}
}
interface StoredValueUpdate<T> {
value: string | null;
result: T;
}
export type InstanceRegistrationConfig = InstanceRegistration;
@@ -277,6 +302,11 @@ export interface InstanceRegistrationUrl extends RegistrationUrlResponse {
type InstanceRegistrationUrlPublic = RegistrationUrlResponse;
type InstancePendingRegistration = PendingRegistrationResponse;
export interface RegistrationUrlClaim {
registration_url_id: string;
user_id: string;
}
const DEFAULT_REGISTRATION_CONFIG: InstanceRegistrationConfig = {
mode: 'open',
admin_registration_urls_enabled: true,
@@ -345,6 +375,7 @@ type StoredConfigSection =
| 'gateway rollout'
| 'voice noise suppression'
| 'screen share delivery'
| 'push service delivery'
| 'experiment delivery'
| 'instance policy'
| 'integrations'
@@ -487,6 +518,10 @@ function parseStoredScreenShareDeliveryConfig(raw: string | null): ScreenShareDe
return parseStoredConfigOrDefault(ScreenShareDeliveryConfigSchema, raw, 'screen share delivery');
}
function parseStoredPushServiceDeliveryConfig(raw: string | null): PushServiceDeliveryConfig {
return parseStoredConfigOrDefault(PushServiceDeliveryConfigSchema, raw, 'push service delivery');
}
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery');
}
@@ -910,6 +945,75 @@ function parseStoredSsoAllowedEmailDomains(raw: string | undefined, log = false)
return Array.from(domains).slice(0, MAX_SSO_ALLOWED_DOMAINS);
}
function readStoredSsoConfig(
configs: ReadonlyMap<string, string>,
options?: {includeSecret?: boolean},
): InstanceSsoConfig {
const flags = readStoredSsoFlags(configs);
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 allowedDomains = parseStoredSsoAllowedEmailDomains(configs.get('sso_allowed_domains'));
const clientSecret = read('sso_client_secret');
return {
...flags,
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,
redirectUri: null,
};
}
interface SsoRowWrite {
key: string;
value: string | undefined;
unset: string;
}
function ssoRow<T>(key: string, value: T | undefined, current: T, format: (value: T) => string): SsoRowWrite {
return {key, value: value === undefined ? undefined : format(value), unset: format(current)};
}
function nextSsoRowValue(row: SsoRowWrite, raw: string | null): string | null {
const value = row.value ?? raw ?? row.unset;
return value === raw ? null : value;
}
function formatSsoBoolean(value: boolean): string {
return value ? 'true' : 'false';
}
function formatSsoString(value: string | null): string {
return value ?? '';
}
function formatSsoDomains(value: Array<string>): string {
return JSON.stringify(value);
}
function normalizeSsoAllowedEmailDomainsForWrite(domains: Array<string>, enabled: boolean): Array<string> {
try {
return normalizeSsoAllowedEmailDomains(domains);
} catch (error) {
if (enabled) {
throw error;
}
Logger.warn({error}, 'Clearing invalid SSO allowed domain config while SSO is disabled');
return [];
}
}
export class InstanceConfigRepository {
private readonly kvClient: IKVProvider | null;
private configCache: InstanceConfigCache;
@@ -993,6 +1097,57 @@ export class InstanceConfigRepository {
);
}
private async updateStoredConfig<T>(key: string, next: (raw: string | null) => T): Promise<T> {
const cache = this.configCache;
const {result} = await this.compareAndSetStoredValue(cache, key, (raw) => {
const config = next(raw);
return {value: JSON.stringify(config), result: config};
});
await this.publishRefresh(cache.sourceId);
return result;
}
private async compareAndSetStoredValue<T>(
cache: InstanceConfigCache,
key: string,
next: (raw: string | null) => StoredValueUpdate<T>,
): Promise<{result: T; written: boolean}> {
await cache.getSnapshot();
for (let attempt = 0; attempt < INSTANCE_CONFIG_WRITE_ATTEMPTS; attempt++) {
cache.assertActive();
const current = await this.fetchConfigForWrite(key);
cache.assertActive();
const {value, result} = next(current);
if (value === null) return {result, written: false};
if (await executeConditional(this.compareAndSetConfig(key, current, value))) {
cache.update(key, value);
return {result, written: true};
}
}
Logger.error(
{key, attempts: INSTANCE_CONFIG_WRITE_ATTEMPTS},
'Instance config write lost to a concurrent write on every attempt',
);
throw new InstanceConfigWriteConflictError(key);
}
private compareAndSetConfig(key: string, current: string | null, value: string): PreparedQuery {
const updatedAt = new Date();
if (current === null) {
return InstanceConfiguration.insertIfNotExists({key, value, updated_at: updatedAt});
}
return InstanceConfiguration.conditionalPatchByPk(
{key},
{value: Db.set(value), updated_at: Db.set(updatedAt)},
{value: current},
);
}
private async fetchConfigForWrite(key: string): Promise<string | null> {
const [row] = await fetchMany<InstanceConfigurationRow>(FETCH_CONFIG_QUERY, {key}, {consistency: 'serial'});
return row?.value ?? null;
}
private async fetchConfigFromDatabase(key: string): Promise<string | null> {
const row = await fetchOne<InstanceConfigurationRow>(FETCH_CONFIG_QUERY, {key});
return row?.value ?? null;
@@ -1015,6 +1170,7 @@ export class InstanceConfigRepository {
);
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
parseStoredScreenShareDeliveryConfig(snapshot.get(SCREEN_SHARE_DELIVERY_CONFIG_KEY) ?? null);
parseStoredPushServiceDeliveryConfig(snapshot.get(PUSH_SERVICE_DELIVERY_CONFIG_KEY) ?? null);
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
checkStoredConfig('registration', () =>
@@ -1082,8 +1238,12 @@ export class InstanceConfigRepository {
return parseStoredGatewayRolloutConfig(raw);
}
async setGatewayRolloutConfig(config: GatewayRolloutConfig): Promise<void> {
await this.setConfig(GATEWAY_ROLLOUT_CONFIG_KEY, JSON.stringify(decodeGatewayRolloutConfig(config)));
updateGatewayRolloutConfig(
update: (current: GatewayRolloutConfig) => GatewayRolloutConfig,
): Promise<GatewayRolloutConfig> {
return this.updateStoredConfig(GATEWAY_ROLLOUT_CONFIG_KEY, (raw) =>
decodeGatewayRolloutConfig(update(parseStoredGatewayRolloutConfig(raw))),
);
}
async getVoiceNoiseSuppressionConfig(): Promise<VoiceNoiseSuppressionConfig> {
@@ -1092,8 +1252,19 @@ export class InstanceConfigRepository {
}
async setVoiceNoiseSuppressionConfig(config: VoiceNoiseSuppressionConfig): Promise<void> {
const validated = validateStoredConfig(VoiceNoiseSuppressionConfigSchema, config, 'voice noise suppression');
await this.setConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY, JSON.stringify(validated));
await this.updateVoiceNoiseSuppressionConfig(() => config);
}
updateVoiceNoiseSuppressionConfig(
update: (current: VoiceNoiseSuppressionConfig) => VoiceNoiseSuppressionConfig,
): Promise<VoiceNoiseSuppressionConfig> {
return this.updateStoredConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY, (raw) =>
validateStoredConfig(
VoiceNoiseSuppressionConfigSchema,
update(parseStoredVoiceNoiseSuppressionConfig(raw)),
'voice noise suppression',
),
);
}
async getScreenShareDeliveryConfig(): Promise<ScreenShareDeliveryConfig> {
@@ -1102,8 +1273,36 @@ export class InstanceConfigRepository {
}
async setScreenShareDeliveryConfig(config: ScreenShareDeliveryConfig): Promise<void> {
const validated = validateStoredConfig(ScreenShareDeliveryConfigSchema, config, 'screen share delivery');
await this.setConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY, JSON.stringify(validated));
await this.updateScreenShareDeliveryConfig(() => config);
}
updateScreenShareDeliveryConfig(
update: (current: ScreenShareDeliveryConfig) => ScreenShareDeliveryConfig,
): Promise<ScreenShareDeliveryConfig> {
return this.updateStoredConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY, (raw) =>
validateStoredConfig(
ScreenShareDeliveryConfigSchema,
update(parseStoredScreenShareDeliveryConfig(raw)),
'screen share delivery',
),
);
}
async getPushServiceDeliveryConfig(): Promise<PushServiceDeliveryConfig> {
const raw = await this.getConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
return parseStoredPushServiceDeliveryConfig(raw);
}
updatePushServiceDeliveryConfig(
update: (current: PushServiceDeliveryConfig) => PushServiceDeliveryConfig,
): Promise<PushServiceDeliveryConfig> {
return this.updateStoredConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY, (raw) =>
validateStoredConfig(
PushServiceDeliveryConfigSchema,
update(parseStoredPushServiceDeliveryConfig(raw)),
'push service delivery',
),
);
}
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
@@ -1112,8 +1311,19 @@ export class InstanceConfigRepository {
}
async setExperimentDeliveryConfig(config: ExperimentDeliveryConfig): Promise<void> {
const validated = validateStoredConfig(ExperimentDeliveryConfigSchema, config, 'experiment delivery');
await this.setConfig(EXPERIMENT_DELIVERY_CONFIG_KEY, JSON.stringify(validated));
await this.updateExperimentDeliveryConfig(() => config);
}
updateExperimentDeliveryConfig(
update: (current: ExperimentDeliveryConfig) => ExperimentDeliveryConfig,
): Promise<ExperimentDeliveryConfig> {
return this.updateStoredConfig(EXPERIMENT_DELIVERY_CONFIG_KEY, (raw) =>
validateStoredConfig(
ExperimentDeliveryConfigSchema,
update(parseStoredExperimentDeliveryConfig(raw)),
'experiment delivery',
),
);
}
async readLimitConfigInputs(): Promise<LimitConfigInputs> {
@@ -1149,26 +1359,27 @@ export class InstanceConfigRepository {
legal?: Partial<InstanceAppPublicConfig['legal']>;
registration?: Partial<InstanceAppPublicConfig['registration']>;
}): Promise<InstanceAppPublicConfig> {
const current = await this.getAppPublicConfig();
const next = decodeAppPublicConfig({
branding: {
...current.branding,
...(config.branding ?? {}),
},
setup: {
...current.setup,
...(config.setup ?? {}),
},
legal: {
...current.legal,
...(config.legal ?? {}),
},
registration: {
...current.registration,
...(config.registration ?? {}),
},
const next = await this.updateStoredConfig(APP_PUBLIC_CONFIG_KEY, (raw) => {
const current = parseStoredAppPublicConfig(raw);
return decodeAppPublicConfig({
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;
}
@@ -1188,10 +1399,21 @@ export class InstanceConfigRepository {
return parseStoredInstancePolicyConfig(raw);
}
async setInstancePolicyConfig(config: Partial<InstancePolicyConfig>): Promise<InstancePolicyConfig> {
const current = await this.readStoredInstancePolicyConfig();
const next = decodeInstancePolicyConfig({...current, ...config});
await this.setConfig(INSTANCE_POLICY_CONFIG_KEY, JSON.stringify(next));
setInstancePolicyConfig(config: Partial<InstancePolicyConfig>): Promise<InstancePolicyConfig> {
return this.updateInstancePolicyConfig(() => config);
}
async updateInstancePolicyConfig(
plan: (current: InstancePolicyConfig) => Partial<InstancePolicyConfig>,
): Promise<InstancePolicyConfig> {
const cache = this.configCache;
const {result: next, written} = await this.compareAndSetStoredValue(cache, INSTANCE_POLICY_CONFIG_KEY, (raw) => {
const current = parseStoredInstancePolicyConfig(raw);
const patch = plan(current);
const config = decodeInstancePolicyConfig({...current, ...patch});
return {value: Object.keys(patch).length === 0 ? null : JSON.stringify(config), result: config};
});
if (written) await this.publishRefresh(cache.sourceId);
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(next));
return next;
}
@@ -1201,37 +1423,37 @@ export class InstanceConfigRepository {
return parseStoredInstanceIntegrationsConfig(raw);
}
async setInstanceIntegrationsConfig(config: InstanceIntegrationsConfigPatch): Promise<InstanceIntegrationsConfig> {
const current = await this.getInstanceIntegrationsConfig();
const next = decodeInstanceIntegrationsConfig({
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 ?? {}),
setInstanceIntegrationsConfig(config: InstanceIntegrationsConfigPatch): Promise<InstanceIntegrationsConfig> {
return this.updateStoredConfig(INSTANCE_INTEGRATIONS_CONFIG_KEY, (raw) => {
const current = parseStoredInstanceIntegrationsConfig(raw);
return decodeInstanceIntegrationsConfig({
gif: {
...current.gif,
...(config.gif ?? {}),
},
},
bluesky: {
...current.bluesky,
...(config.bluesky ?? {}),
keys: config.bluesky?.keys ?? current.bluesky.keys,
},
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> {
@@ -1239,16 +1461,16 @@ export class InstanceConfigRepository {
return parseStoredInstanceMediaConfig(raw);
}
async setInstanceMediaConfig(config: InstanceMediaConfigPatch): Promise<InstanceMediaConfig> {
const current = await this.getInstanceMediaConfig();
const next = decodeInstanceMediaConfig({
attachment_decay: {
...current.attachment_decay,
...(config.attachment_decay ?? {}),
},
setInstanceMediaConfig(config: InstanceMediaConfigPatch): Promise<InstanceMediaConfig> {
return this.updateStoredConfig(INSTANCE_MEDIA_CONFIG_KEY, (raw) => {
const current = parseStoredInstanceMediaConfig(raw);
return decodeInstanceMediaConfig({
attachment_decay: {
...current.attachment_decay,
...(config.attachment_decay ?? {}),
},
});
});
await this.setConfig(INSTANCE_MEDIA_CONFIG_KEY, JSON.stringify(next));
return next;
}
async getEffectiveAttachmentDecayConfig(): Promise<InstanceAttachmentDecayEffectiveConfig> {
@@ -1485,15 +1707,15 @@ export class InstanceConfigRepository {
return parseStoredRegistrationConfig(raw);
}
async setRegistrationConfig(config: Partial<InstanceRegistrationConfig>): Promise<InstanceRegistrationConfig> {
const current = await this.getRegistrationConfig();
const next = decodeRegistrationConfig({
mode: config.mode ?? current.mode,
admin_registration_urls_enabled:
config.admin_registration_urls_enabled ?? current.admin_registration_urls_enabled,
setRegistrationConfig(config: Partial<InstanceRegistrationConfig>): Promise<InstanceRegistrationConfig> {
return this.updateStoredConfig(REGISTRATION_CONFIG_KEY, (raw) => {
const current = parseStoredRegistrationConfig(raw);
return decodeRegistrationConfig({
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> {
@@ -1534,16 +1756,20 @@ export class InstanceConfigRepository {
last_used_at: null,
last_used_by_user_id: null,
};
const registrationUrls = await this.getRegistrationUrls();
await this.setRegistrationUrls([registrationUrl, ...registrationUrls]);
await this.updateStoredConfig(REGISTRATION_URLS_KEY, (raw) =>
validateStoredCollection(
StoredRegistrationUrlSchema,
[registrationUrl, ...parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs')],
'registration URLs',
),
);
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) =>
await this.updateStoredConfig(REGISTRATION_URLS_KEY, (raw) =>
parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs').map((registrationUrl) =>
registrationUrl.id === id && !registrationUrl.revoked_at
? {...registrationUrl, revoked_at: now}
: registrationUrl,
@@ -1556,9 +1782,8 @@ export class InstanceConfigRepository {
if (!normalizedCode) return null;
const hash = this.hashRegistrationUrlCode(normalizedCode);
const now = new Date();
const registrationUrls = await this.getRegistrationUrls();
return (
registrationUrls.find(
(await this.fetchRegistrationUrlDefinitions()).find(
(registrationUrl) =>
(registrationUrl.id === normalizedCode || registrationUrl.code_hash === hash) &&
isRegistrationUrlUsable(registrationUrl, now),
@@ -1566,21 +1791,68 @@ export class InstanceConfigRepository {
);
}
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 claimRegistrationUrlUse(registrationUrlId: string, userId: string): Promise<RegistrationUrlClaim | null> {
const cache = this.configCache;
let claimed: {result: RegistrationUrlClaim | null; written: boolean};
try {
claimed = await this.compareAndSetStoredValue<RegistrationUrlClaim | null>(
cache,
REGISTRATION_URLS_KEY,
(raw) => {
const registrationUrls = parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs');
const now = new Date();
const claimable = registrationUrls.find(
(registrationUrl) =>
registrationUrl.id === registrationUrlId && isRegistrationUrlUsable(registrationUrl, now),
);
if (!claimable) return {value: null, result: null};
const next = registrationUrls.map((registrationUrl) =>
registrationUrl === claimable
? {
...registrationUrl,
use_count: registrationUrl.use_count + 1,
last_used_at: now.toISOString(),
last_used_by_user_id: userId,
}
: registrationUrl,
);
return {
value: JSON.stringify(validateStoredCollection(StoredRegistrationUrlSchema, next, 'registration URLs')),
result: {registration_url_id: registrationUrlId, user_id: userId},
};
},
);
} catch (error) {
if (error instanceof InstanceConfigWriteConflictError) throw new ServiceUnavailableError();
throw error;
}
if (claimed.written) await this.publishRefresh(cache.sourceId);
return claimed.result;
}
async releaseRegistrationUrlUse(claim: RegistrationUrlClaim): Promise<void> {
const cache = this.configCache;
try {
const {written} = await this.compareAndSetStoredValue<null>(cache, REGISTRATION_URLS_KEY, (raw) => {
const registrationUrls = parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs');
const released = registrationUrls.find(
(registrationUrl) => registrationUrl.id === claim.registration_url_id && registrationUrl.use_count > 0,
);
if (!released) return {value: null, result: null};
const next = registrationUrls.map((registrationUrl) =>
registrationUrl === released
? {...registrationUrl, use_count: registrationUrl.use_count - 1}
: registrationUrl,
);
return {value: JSON.stringify(next), result: null};
});
if (written) await this.publishRefresh(cache.sourceId);
} catch (error) {
Logger.warn(
{registrationUrlId: claim.registration_url_id, userId: claim.user_id, error},
'Releasing a registration URL use failed',
);
}
}
async getPendingRegistrations(): Promise<Array<InstancePendingRegistration>> {
@@ -1591,104 +1863,82 @@ export class InstanceConfigRepository {
}
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.setPendingRegistrations(next);
await this.updateStoredConfig(REGISTRATION_PENDING_APPROVALS_KEY, (raw) =>
validateStoredCollection(
StoredPendingRegistrationSchema,
[
pendingRegistration,
...parseStoredCollection(StoredPendingRegistrationSchema, raw, 'pending registrations').filter(
(entry) => entry.user_id !== pendingRegistration.user_id,
),
],
'pending registrations',
),
);
}
async removePendingRegistration(userId: string): Promise<void> {
const pendingRegistrations = await this.getPendingRegistrations();
await this.setPendingRegistrations(pendingRegistrations.filter((entry) => entry.user_id !== userId));
await this.updateStoredConfig(REGISTRATION_PENDING_APPROVALS_KEY, (raw) =>
parseStoredCollection(StoredPendingRegistrationSchema, raw, 'pending registrations').filter(
(entry) => entry.user_id !== userId,
),
);
}
async getSsoConfig(options?: {includeSecret?: boolean}): Promise<InstanceSsoConfig> {
const configs = await this.getAllConfigs();
const flags = readStoredSsoFlags(configs);
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 allowedDomains = parseStoredSsoAllowedEmailDomains(configs.get('sso_allowed_domains'));
const clientSecret = read('sso_client_secret');
return {
...flags,
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,
redirectUri: null,
};
return readStoredSsoConfig(await this.getAllConfigs(), options);
}
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', ''],
const configs = await this.getAllConfigs();
const current = readStoredSsoConfig(configs, {includeSecret: true});
const enabled = config.enabled ?? current.enabled;
const allowedEmailDomains =
config.allowedEmailDomains === undefined
? undefined
: normalizeSsoAllowedEmailDomainsForWrite(config.allowedEmailDomains, enabled);
const rows: Array<SsoRowWrite> = [
ssoRow('sso_enabled', config.enabled, current.enabled, formatSsoBoolean),
ssoRow('sso_enforced', config.enforced, current.enforced, formatSsoBoolean),
ssoRow('sso_display_name', config.displayName, current.displayName, formatSsoString),
ssoRow('sso_issuer', config.issuer, current.issuer, formatSsoString),
ssoRow('sso_authorization_url', config.authorizationUrl, current.authorizationUrl, formatSsoString),
ssoRow('sso_token_url', config.tokenUrl, current.tokenUrl, formatSsoString),
ssoRow('sso_userinfo_url', config.userInfoUrl, current.userInfoUrl, formatSsoString),
ssoRow('sso_jwks_url', config.jwksUrl, current.jwksUrl, formatSsoString),
ssoRow('sso_client_id', config.clientId, current.clientId, formatSsoString),
ssoRow('sso_scope', config.scope, current.scope, formatSsoString),
ssoRow('sso_allowed_domains', allowedEmailDomains, current.allowedEmailDomains, formatSsoDomains),
ssoRow('sso_auto_provision', config.autoProvision, current.autoProvision, formatSsoBoolean),
ssoRow('sso_redirect_uri', undefined, null, formatSsoString),
];
if (config.clientSecret !== undefined) {
entries.push(['sso_client_secret', config.clientSecret ?? '']);
rows.push(ssoRow('sso_client_secret', config.clientSecret, current.clientSecret ?? null, formatSsoString));
}
await this.setConfigs(entries);
const cache = this.configCache;
const results = await Promise.allSettled(
rows
.filter((row) => row.value !== undefined || !configs.has(row.key))
.map((row) =>
this.compareAndSetStoredValue(cache, row.key, (raw) => ({value: nextSsoRowValue(row, raw), result: null})),
),
);
const errors: Array<unknown> = results.flatMap((result) => (result.status === 'rejected' ? [result.reason] : []));
if (results.some((result) => result.status === 'fulfilled' && result.value.written)) {
try {
await this.publishRefresh(cache.sourceId);
} catch (error) {
errors.push(error);
}
}
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, 'Failed to write or publish the SSO config');
return this.getSsoConfig({includeSecret: true});
}
private async setRegistrationUrls(registrationUrls: Array<InstanceRegistrationUrl>): Promise<void> {
const validated = validateStoredCollection(StoredRegistrationUrlSchema, registrationUrls, 'registration URLs');
await this.setConfig(REGISTRATION_URLS_KEY, JSON.stringify(validated));
}
private async setPendingRegistrations(pendingRegistrations: Array<InstancePendingRegistration>): Promise<void> {
const validated = validateStoredCollection(
StoredPendingRegistrationSchema,
pendingRegistrations,
'pending registrations',
);
await this.setConfig(REGISTRATION_PENDING_APPROVALS_KEY, JSON.stringify(validated));
private async fetchRegistrationUrlDefinitions(): Promise<Array<InstanceRegistrationUrl>> {
const raw = await this.fetchConfigFromDatabase(REGISTRATION_URLS_KEY);
return parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs');
}
private hashRegistrationUrlCode(code: string): string {
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {PushServiceDeliveryConfig} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
const textEncoder = new TextEncoder();
export const PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT = 'config.push.delivery';
interface PushServiceDeliveryConfigNatsMessage {
type: 'push_service_delivery_config';
config: PushServiceDeliveryConfig;
}
export class PushServiceDeliveryConfigPublisher {
constructor(private readonly connectionManager: INatsConnectionManager) {}
async publish(config: PushServiceDeliveryConfig): Promise<void> {
if (this.connectionManager.isClosed()) {
await this.connectionManager.connect();
}
const connection = this.connectionManager.getConnection();
const message: PushServiceDeliveryConfigNatsMessage = {
type: 'push_service_delivery_config',
config,
};
connection.publish(PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT, textEncoder.encode(JSON.stringify(message)));
await connection.flush();
}
}
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getKvMeta} from '@app/api/database/CassandraMetaRegistry';
import type {CassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import type {CassandraParams, KvQueryMeta, PreparedQuery} from '@app/api/database/CassandraTypes';
import type {InstanceConfigurationRow} from '@app/api/database/types/InstanceConfigTypes';
import {InstanceConfiguration} from '@app/api/Tables';
type InstanceConfigWriteEvent = 'read' | 'write' | 'write rejected';
interface WriteGate {
size: number;
paused: Array<() => void>;
}
const FETCH_ROW_QUERY = InstanceConfiguration.selectCql({
where: InstanceConfiguration.where.eq('key'),
limit: 1,
});
export class InstanceConfigWriteRaceExecutor implements CassandraQueryExecutorForTesting {
readonly events: Array<InstanceConfigWriteEvent> = [];
private watchedKey: string | null = null;
private gate: WriteGate | null = null;
private beforeEachWrite: (() => Promise<void>) | null = null;
constructor(private readonly base: CassandraQueryExecutorForTesting) {}
watch(key: string): void {
this.watchedKey = key;
this.events.length = 0;
}
pauseWritesUntil(size: number): void {
this.gate = {size, paused: []};
}
competeBeforeEachWrite(write: () => Promise<void>): void {
this.beforeEachWrite = write;
}
async writeDirectly(key: string, value: string): Promise<void> {
await this.base.executeQuery(InstanceConfiguration.upsertAll({key, value, updated_at: new Date()}));
}
async readDirectly(key: string): Promise<string | null> {
const [row] = await this.base.executeQuery<InstanceConfigurationRow>({cql: FETCH_ROW_QUERY, params: {key}});
return row?.value ?? null;
}
async executeQuery<T = Record<string, unknown>, P extends CassandraParams = CassandraParams>(
query: PreparedQuery<P>,
): Promise<Array<T>> {
const meta = query.kvMeta ?? getKvMeta(query.cql);
if (this.watchedKey === null || !this.isWatched(meta, query.params)) {
return this.base.executeQuery<T, P>(query);
}
if (meta?.action === 'select') {
this.events.push('read');
return this.base.executeQuery<T, P>(query);
}
await this.passGate();
await this.beforeEachWrite?.();
const rows = await this.base.executeQuery<T, P>(query);
const applied = (rows[0] as {'[applied]'?: unknown} | undefined)?.['[applied]'];
this.events.push(applied === false ? 'write rejected' : 'write');
return rows;
}
executeBatch(queries: Array<{query: string; params: object; meta?: KvQueryMeta}>, atomic?: boolean): Promise<void> {
return this.base.executeBatch(queries, atomic);
}
reset(): void {
this.base.reset?.();
this.watchedKey = null;
this.gate = null;
this.beforeEachWrite = null;
this.events.length = 0;
}
async shutdown(): Promise<void> {
await this.base.shutdown?.();
}
private isWatched(meta: KvQueryMeta | null | undefined, params: CassandraParams): boolean {
return meta?.table.name === InstanceConfiguration.name && params.key === this.watchedKey;
}
private async passGate(): Promise<void> {
const gate = this.gate;
if (gate === null) return;
await new Promise<void>((release) => {
gate.paused.push(release);
if (gate.paused.length < gate.size) return;
this.gate = null;
for (const release of gate.paused) release();
});
}
}
@@ -51,6 +51,7 @@ import {createUsersServiceClient} from '@app/api/infrastructure/UsersServiceClie
import {VirusScanService} from '@app/api/infrastructure/VirusScanService';
import {GatewayRolloutConfigPublisher} from '@app/api/instance/GatewayRolloutConfigPublisher';
import {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
import {InviteRepository} from '@app/api/invite/InviteRepository';
import {Logger} from '@app/api/Logger';
import {LimitConfigService} from '@app/api/limits/LimitConfigService';
@@ -155,6 +156,18 @@ export const getGatewayRolloutConfigPublisher = singleton(
}),
),
);
export const getPushServiceDeliveryConfigPublisher = singleton(
() =>
new PushServiceDeliveryConfigPublisher(
new NatsConnectionManager({
url: Config.nats.coreUrl,
token: Config.nats.authToken || undefined,
name: 'fluxer-api-push-service-delivery-config',
}),
),
);
export const getVisionarySlotRepository = singleton(() => new VisionarySlotRepository());
export const getCacheService: () => ICacheService = singleton(() => new KVCacheProvider({client: getKVClient()}));
export const getRateLimitService = singleton(() => new RateLimitService(getKVClient()));
+11 -5
View File
@@ -17030,7 +17030,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Registers a mobile push device token for APNs, Firebase Cloud Messaging, or UnifiedPush. UnifiedPush registrations include the endpoint URL plus Web Push encryption keys.",
"description": "Registers a mobile push device for APNs, Firebase Cloud Messaging, or UnifiedPush. A Web Push registration sends the endpoint URL with encryption_key and auth_secret. A raw registration sends the platform push token with no keys.",
"security": [{"sessionToken": []}],
"requestBody": {
"required": true,
@@ -22744,7 +22744,10 @@
"enum": ["android_fcm", "ios_apns", "android_unified_push"],
"type": "string"
},
"token": {"description": "The platform-specific push notification token to unregister", "type": "string"},
"token": {
"description": "The Web Push endpoint URL or raw platform push token used at registration",
"type": "string"
},
"app_id": {
"description": "Client app channel or bundle mapping identifier, such as stable, beta, or canary",
"type": "string"
@@ -22808,7 +22811,10 @@
"enum": ["android_fcm", "ios_apns", "android_unified_push"],
"type": "string"
},
"token": {"description": "The platform-specific push notification token or endpoint URL", "type": "string"},
"token": {
"description": "The Web Push endpoint URL when encryption keys are supplied, otherwise the raw platform push token",
"type": "string"
},
"user_agent": {"description": "The user agent string identifying the device", "type": "string"},
"app_id": {
"description": "Client app channel or bundle mapping identifier, such as stable, beta, or canary",
@@ -22825,11 +22831,11 @@
"type": "string"
},
"encryption_key": {
"description": "The P-256 ECDH public key for UnifiedPush encryption (base64url)",
"description": "The P-256 ECDH public key for Web Push encryption (base64url)",
"type": "string"
},
"auth_secret": {
"description": "The authentication secret for UnifiedPush encryption (base64url)",
"description": "The authentication secret for Web Push encryption (base64url)",
"type": "string"
}
},
+15
View File
@@ -97,6 +97,7 @@ import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import {UnauthorizedError} from '@fluxer/errors/src/domains/core/UnauthorizedError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {pushServiceDeliveryEnrols} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {VoiceStateResponse} from '@fluxer/schema/src/domains/gateway/GatewaySchemas';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
@@ -430,6 +431,13 @@ export class RpcService {
}),
};
case 'send_apns_push': {
const deliveryConfig = await this.instanceConfigRepository.getPushServiceDeliveryConfig();
if (pushServiceDeliveryEnrols(deliveryConfig, request.user_id.toString())) {
Logger.warn(
{userId: request.user_id.toString(), configVersion: deliveryConfig.config_version},
'push service delivery path mismatch',
);
}
const result = await sendApnsPush({
userId: request.user_id.toString(),
subscriptionId: request.subscription_id,
@@ -635,6 +643,13 @@ export class RpcService {
data: {config: rolloutConfig},
};
}
case 'get_push_service_delivery_config': {
const config = await this.instanceConfigRepository.getPushServiceDeliveryConfig();
return {
type: 'get_push_service_delivery_config',
data: {config},
};
}
default: {
const exhaustiveCheck: never = request;
throw new Error(
@@ -957,7 +957,7 @@ export function UserAccountController(app: HonoApp) {
security: ['bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Registers a mobile push device token for APNs, Firebase Cloud Messaging, or UnifiedPush. UnifiedPush registrations include the endpoint URL plus Web Push encryption keys.',
'Registers a mobile push device for APNs, Firebase Cloud Messaging, or UnifiedPush. A Web Push registration sends the endpoint URL with encryption_key and auth_secret. A raw registration sends the platform push token with no keys.',
}),
async (ctx) => {
const authSession = ctx.get('authSession');
@@ -3,6 +3,7 @@
import type {UserID} from '@app/api/BrandedTypes';
import {Db, type DbOp} from '@app/api/database/CassandraTypes';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {Logger} from '@app/api/Logger';
import {User} from '@app/api/models/User';
import {
UserDataRepository,
@@ -96,7 +97,12 @@ export class UserAccountRepository {
return updatedUser;
} catch (error) {
if (!dataCommitted && emailClaim) {
await this.emailOwnershipRepo.abortEmailClaim(emailClaim);
await this.emailOwnershipRepo.abortEmailClaim(emailClaim).catch((abortError: unknown) => {
Logger.warn(
{userId: userId.toString(), abortError},
'Failed to abort the email claim of a user write that did not commit',
);
});
}
throw error;
}
@@ -104,6 +104,27 @@ function assertPublicPushEndpoint(endpoint: string, fieldName: string): void {
}
}
function isPushEndpointUrl(token: string): boolean {
const normalized = token.trim().toLowerCase();
return normalized.startsWith('https://') || normalized.startsWith('http://');
}
function resolveMobileWebPushKeys(device: RegisterMobileDeviceRequest): {p256dh: string; auth: string} | null {
const p256dh = device.encryption_key;
const auth = device.auth_secret;
if (p256dh && auth) return {p256dh, auth};
if (p256dh || auth) {
throw InputValidationError.create(
p256dh ? 'auth_secret' : 'encryption_key',
'Web Push registrations require encryption_key and auth_secret',
);
}
if (isPushEndpointUrl(device.token)) {
throw InputValidationError.create('token', 'Endpoint URL registrations require encryption_key and auth_secret');
}
return null;
}
function normalizeMobileAppId(appId: string | undefined): string {
const normalized = appId?.trim();
return normalized && normalized.length > 0 ? normalized : DEFAULT_MOBILE_APP_ID;
@@ -400,7 +421,8 @@ export class UserContentService {
async registerMobileDevice(params: RegisterMobileDeviceParams): Promise<PushSubscription> {
const {userId, authSessionIdHash, device} = params;
if (device.platform === 'android_unified_push') {
const webPushKeys = resolveMobileWebPushKeys(device);
if (webPushKeys) {
assertPublicPushEndpoint(device.token, 'token');
}
const appId = normalizeMobileAppId(device.app_id);
@@ -411,8 +433,8 @@ export class UserContentService {
subscription_id: subscriptionId,
auth_session_id_hash: authSessionIdHash ?? null,
endpoint: device.token,
p256dh_key: device.platform === 'android_unified_push' ? (device.encryption_key ?? null) : null,
auth_key: device.platform === 'android_unified_push' ? (device.auth_secret ?? null) : null,
p256dh_key: webPushKeys?.p256dh ?? null,
auth_key: webPushKeys?.auth ?? null,
user_agent: device.user_agent ?? null,
platform: device.platform,
app_id: appId,
@@ -6,9 +6,12 @@ import {
loginAccount,
logoutSpecificSessions,
} from '@app/api/auth/tests/AuthTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import type {PushSubscription} from '@app/api/models/PushSubscription';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {PushSubscriptionRepository} from '@app/api/user/repositories/PushSubscriptionRepository';
import {
deleteMobileDevice,
deletePushSubscription,
@@ -21,6 +24,15 @@ import {
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {beforeEach, describe, expect, test} from 'vitest';
async function findStoredSubscription(userId: string, subscriptionId: string): Promise<PushSubscription> {
const subscriptions = await new PushSubscriptionRepository().listPushSubscriptions(createUserID(BigInt(userId)));
const subscription = subscriptions.find((entry) => entry.subscriptionId === subscriptionId);
if (!subscription) {
throw new Error(`Stored push subscription ${subscriptionId} not found`);
}
return subscription;
}
describe('Push Subscription Lifecycle', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
@@ -144,6 +156,155 @@ describe('Push Subscription Lifecycle', () => {
expect(mobileDevices.devices[0].device_id).toBe(registered.device_id);
expect(mobileDevices.devices[0].platform).toBe('android_unified_push');
});
test('APNs Web Push registration stores the endpoint and encryption keys', async () => {
const account = await createTestAccount(harness);
const endpoint = 'https://relay.example.com/apns/device-1';
const registered = await registerMobileDevice(harness, account.token, {
platform: 'ios_apns',
token: endpoint,
encryption_key: 'relay-p256dh-key',
auth_secret: 'relay-auth-secret',
app_id: 'stable',
});
const subscription = await findStoredSubscription(account.userId, registered.device_id);
expect(subscription.platform).toBe('ios_apns');
expect(subscription.endpoint).toBe(endpoint);
expect(subscription.p256dhKey).toBe('relay-p256dh-key');
expect(subscription.authKey).toBe('relay-auth-secret');
});
test('FCM Web Push registration stores the endpoint and encryption keys', async () => {
const account = await createTestAccount(harness);
const endpoint = 'https://relay.example.com/fcm/device-1';
const registered = await registerMobileDevice(harness, account.token, {
platform: 'android_fcm',
token: endpoint,
encryption_key: 'fcm-relay-p256dh-key',
auth_secret: 'fcm-relay-auth-secret',
});
const subscription = await findStoredSubscription(account.userId, registered.device_id);
expect(subscription.platform).toBe('android_fcm');
expect(subscription.endpoint).toBe(endpoint);
expect(subscription.p256dhKey).toBe('fcm-relay-p256dh-key');
expect(subscription.authKey).toBe('fcm-relay-auth-secret');
});
test('raw token registration stores the token without encryption keys', async () => {
const account = await createTestAccount(harness);
const registered = await registerMobileDevice(harness, account.token, {
platform: 'ios_apns',
token: '0123456789abcdef',
provider_environment: 'production',
});
const subscription = await findStoredSubscription(account.userId, registered.device_id);
expect(subscription.platform).toBe('ios_apns');
expect(subscription.endpoint).toBe('0123456789abcdef');
expect(subscription.p256dhKey).toBeNull();
expect(subscription.authKey).toBeNull();
});
test('Web Push and raw token registrations coexist for one platform', async () => {
const account = await createTestAccount(harness);
const rawDevice = await registerMobileDevice(harness, account.token, {
platform: 'android_fcm',
token: 'fcm-legacy-token',
});
const webPushDevice = await registerMobileDevice(harness, account.token, {
platform: 'android_fcm',
token: 'https://relay.example.com/fcm/device-2',
encryption_key: 'coexist-p256dh-key',
auth_secret: 'coexist-auth-secret',
});
expect(rawDevice.device_id).not.toBe(webPushDevice.device_id);
const rawSubscription = await findStoredSubscription(account.userId, rawDevice.device_id);
const webPushSubscription = await findStoredSubscription(account.userId, webPushDevice.device_id);
expect(rawSubscription.p256dhKey).toBeNull();
expect(rawSubscription.authKey).toBeNull();
expect(webPushSubscription.p256dhKey).toBe('coexist-p256dh-key');
expect(webPushSubscription.authKey).toBe('coexist-auth-secret');
const mobileDevices = await listMobileDevices(harness, account.token);
expect(mobileDevices.devices).toHaveLength(2);
});
test('endpoint registration without encryption keys is rejected', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/users/@me/mobile-devices')
.body({
platform: 'ios_apns',
token: 'https://relay.example.com/apns/no-keys',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('endpoint registration with only one encryption key is rejected', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/users/@me/mobile-devices')
.body({
platform: 'android_fcm',
token: 'https://relay.example.com/fcm/half-keys',
encryption_key: 'half-p256dh-key',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('raw token registration with encryption keys is rejected', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/users/@me/mobile-devices')
.body({
platform: 'ios_apns',
token: '0123456789abcdef',
encryption_key: 'raw-p256dh-key',
auth_secret: 'raw-auth-secret',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('Web Push registration rejects an endpoint that is not publicly routable', async () => {
const account = await createTestAccount(harness);
const response = await createBuilder(harness, account.token)
.post('/users/@me/mobile-devices')
.body({
platform: 'ios_apns',
token: 'https://127.0.0.1/apns/device',
encryption_key: 'local-p256dh-key',
auth_secret: 'local-auth-secret',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
expect(JSON.stringify(response)).toContain('URL_NOT_PUBLICLY_ROUTABLE');
});
test('unregister removes a Web Push mobile registration', async () => {
const account = await createTestAccount(harness);
const endpoint = 'https://relay.example.com/apns/unregister';
await registerMobileDevice(harness, account.token, {
platform: 'ios_apns',
token: endpoint,
encryption_key: 'unregister-p256dh-key',
auth_secret: 'unregister-auth-secret',
app_id: 'stable',
provider_environment: 'production',
});
await unregisterMobileDevice(harness, account.token, {
platform: 'ios_apns',
token: endpoint,
app_id: 'stable',
provider_environment: 'production',
});
const mobileDevices = await listMobileDevices(harness, account.token);
expect(mobileDevices.devices).toHaveLength(0);
});
test('mobile Web Push registrations stay out of the web push subscription list', async () => {
const account = await createTestAccount(harness);
await registerMobileDevice(harness, account.token, {
platform: 'android_fcm',
token: 'https://relay.example.com/fcm/separate',
encryption_key: 'separate-p256dh-key',
auth_secret: 'separate-auth-secret',
});
const webSubscriptions = await listPushSubscriptions(harness, account.token);
const mobileDevices = await listMobileDevices(harness, account.token);
expect(webSubscriptions.subscriptions).toHaveLength(0);
expect(mobileDevices.devices).toHaveLength(1);
});
test('list subscriptions returns multiple subscriptions', async () => {
const account = await createTestAccount(harness);
const first = await subscribePush(harness, account.token, 'https://push.example.com/multi-1');