mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(push): relay notifications as encrypted web push (#2906)
This commit is contained in:
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user