feat(web): prepare the fluxer.com domain migration (#2952)

This commit is contained in:
Hampus
2026-09-25 13:43:34 +02:00
committed by GitHub
parent e62ae77643
commit 6730a242db
223 changed files with 13412 additions and 2669 deletions
@@ -11,6 +11,10 @@ import {ADMIN_ACL_COUNT, AdminAclType} from '@fluxer/schema/src/domains/admin/Ad
import {AdminArchiveResponseSchema} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
import {GuildAdminResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
import {UserAdminResponseSchema} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {
DomainMigrationConfigResponse,
DomainMigrationConfigUpdateRequest,
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {
GatewayRolloutConfigResponse,
GatewayRolloutConfigUpdateRequest,
@@ -649,6 +653,7 @@ export const InstanceConfigResponse = z.object({
gateway_rollout: GatewayRolloutConfigResponse,
voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
push_service_delivery: PushServiceDeliveryConfigResponse,
domain_migration: DomainMigrationConfigResponse,
experiment_delivery: ExperimentDeliveryConfigResponse,
registration: InstanceRegistrationResponse,
self_hosted: z.boolean(),
@@ -686,6 +691,7 @@ export const InstanceConfigUpdateRequest = z.object({
gateway_rollout: GatewayRolloutConfigUpdateRequest.nullish(),
voice_noise_suppression: VoiceNoiseSuppressionConfigUpdateRequest.nullish(),
push_service_delivery: PushServiceDeliveryConfigUpdateRequest.nullish(),
domain_migration: DomainMigrationConfigUpdateRequest.nullish(),
experiment_delivery: ExperimentDeliveryConfigUpdateRequest.nullish(),
registration: z
.object({
@@ -0,0 +1,234 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
DEFAULT_DOMAIN_MIGRATION_CONFIG,
type DomainMigrationConfig,
DomainMigrationConfigSchema,
DomainMigrationConfigUpdateRequest,
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
resolveDomainMigrationAssignment,
toDomainMigrationDiscovery,
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {describe, expect, test} from 'vitest';
const TARGETED_USER_ID = '1000000000000000001';
const OTHER_USER_ID = '1000000000000000002';
function createConfig(overrides: Partial<DomainMigrationConfig> = {}): DomainMigrationConfig {
return {
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
included_user_ids: [],
excluded_user_ids: [],
...overrides,
};
}
function syntheticUserIds(count: number): Array<string> {
const ids: Array<string> = [];
for (let index = 0; index < count; index++) {
ids.push((1400000000000000000n + BigInt(index)).toString());
}
return ids;
}
function targetedUserIds(config: DomainMigrationConfig, userIds: ReadonlyArray<string>): Set<string> {
const targeted = new Set<string>();
for (const userId of userIds) {
if (resolveDomainMigrationAssignment(config, userId).enabled) {
targeted.add(userId);
}
}
return targeted;
}
describe('domain migration configuration', () => {
test('derives defaults from the schema with independently owned arrays', () => {
const first = DomainMigrationConfigSchema.parse({});
const second = DomainMigrationConfigSchema.parse({});
expect(first).toEqual(DEFAULT_DOMAIN_MIGRATION_CONFIG);
first.included_user_ids.push(TARGETED_USER_ID);
first.excluded_user_ids.push(OTHER_USER_ID);
expect(second).toEqual(DEFAULT_DOMAIN_MIGRATION_CONFIG);
});
test('defaults to disabled with an empty rollout', () => {
expect(DEFAULT_DOMAIN_MIGRATION_CONFIG.enabled).toBe(false);
expect(DEFAULT_DOMAIN_MIGRATION_CONFIG.rollout_basis_points).toBe(0);
expect(DEFAULT_DOMAIN_MIGRATION_CONFIG.anonymous_rollout_basis_points).toBe(0);
expect(DEFAULT_DOMAIN_MIGRATION_CONFIG.rollout_salt).toBe('domain-migration-v1');
expect(DEFAULT_DOMAIN_MIGRATION_CONFIG.standalone_forwarding).toBe(false);
});
test('fills standalone forwarding in for configs stored before it existed', () => {
const stored = DomainMigrationConfigSchema.parse({enabled: true, config_version: 4, rollout_basis_points: 100});
expect(stored.standalone_forwarding).toBe(false);
});
test.each([{}, {enabled: false}, {enabled: undefined}, {rollout_basis_points: 2500}, {standalone_forwarding: true}])(
'keeps partial updates free of configuration defaults: %j',
(patch) => {
expect(DomainMigrationConfigUpdateRequest.parse(patch)).toEqual(patch);
},
);
test('does not accept a client-provided configuration version', () => {
expect(DomainMigrationConfigUpdateRequest.parse({config_version: 12})).toEqual({});
});
test.each([
{rollout_basis_points: -1},
{rollout_basis_points: 10001},
{anonymous_rollout_basis_points: -1},
{anonymous_rollout_basis_points: 10001},
{anonymous_rollout_basis_points: 1.5},
{rollout_salt: ' '},
{rollout_salt: 'domain-migration-\u00e9'},
{included_user_ids: ['not-an-id']},
{excluded_user_ids: ['not-an-id']},
{standalone_forwarding: 'yes'},
])('applies the same validation to stored configuration and updates: %j', (value) => {
expect(DomainMigrationConfigSchema.safeParse(value).success).toBe(false);
expect(DomainMigrationConfigUpdateRequest.safeParse(value).success).toBe(false);
});
test('rejects more than a thousand targeted user ids', () => {
const ids = syntheticUserIds(1001);
expect(DomainMigrationConfigSchema.safeParse({included_user_ids: ids}).success).toBe(false);
expect(DomainMigrationConfigSchema.safeParse({included_user_ids: ids.slice(0, 1000)}).success).toBe(true);
});
});
describe('resolveDomainMigrationAssignment', () => {
test('the inert assignment is disabled', () => {
expect(INERT_DOMAIN_MIGRATION_ASSIGNMENT.enabled).toBe(false);
});
test('returns the inert assignment when the master switch is off', () => {
const config = createConfig({
enabled: false,
rollout_basis_points: 10000,
included_user_ids: [TARGETED_USER_ID],
});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID)).toEqual(INERT_DOMAIN_MIGRATION_ASSIGNMENT);
});
test('returns the inert assignment for the default config', () => {
expect(resolveDomainMigrationAssignment(createConfig(), TARGETED_USER_ID)).toEqual(
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
);
});
test('never hands back the shared inert object', () => {
const assignment = resolveDomainMigrationAssignment(createConfig(), TARGETED_USER_ID);
expect(assignment).not.toBe(INERT_DOMAIN_MIGRATION_ASSIGNMENT);
});
test('denylist beats allowlist', () => {
const config = createConfig({
enabled: true,
included_user_ids: [TARGETED_USER_ID],
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID).enabled).toBe(false);
});
test('denylist beats the bucket', () => {
const config = createConfig({
enabled: true,
rollout_basis_points: 10000,
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID).enabled).toBe(false);
expect(resolveDomainMigrationAssignment(config, OTHER_USER_ID).enabled).toBe(true);
});
test('allowlist beats the bucket', () => {
const config = createConfig({
enabled: true,
rollout_basis_points: 0,
included_user_ids: [TARGETED_USER_ID],
});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID).enabled).toBe(true);
expect(resolveDomainMigrationAssignment(config, OTHER_USER_ID).enabled).toBe(false);
});
test.each([
{basisPoints: 0, enabled: false},
{basisPoints: 10000, enabled: true},
])('a rollout of $basisPoints basis points targets $enabled', ({basisPoints, enabled}) => {
const config = createConfig({enabled: true, rollout_basis_points: basisPoints});
for (const userId of syntheticUserIds(200)) {
expect(resolveDomainMigrationAssignment(config, userId).enabled).toBe(enabled);
}
});
test('the bucket boundary is exclusive at the low end and inclusive one point above', () => {
const salt = DEFAULT_DOMAIN_MIGRATION_CONFIG.rollout_salt;
const bucket = experimentBucket(TARGETED_USER_ID, salt);
expect(
resolveDomainMigrationAssignment(createConfig({enabled: true, rollout_basis_points: bucket}), TARGETED_USER_ID)
.enabled,
).toBe(false);
expect(
resolveDomainMigrationAssignment(
createConfig({enabled: true, rollout_basis_points: bucket + 1}),
TARGETED_USER_ID,
).enabled,
).toBe(true);
});
test('raising the rollout basis points only ever adds users', () => {
const userIds = syntheticUserIds(2000);
const atOneThousand = targetedUserIds(createConfig({enabled: true, rollout_basis_points: 1000}), userIds);
const atTwoThousand = targetedUserIds(createConfig({enabled: true, rollout_basis_points: 2000}), userIds);
expect(atOneThousand.size).toBeGreaterThan(0);
for (const userId of atOneThousand) {
expect(atTwoThousand.has(userId)).toBe(true);
}
expect(atTwoThousand.size).toBeGreaterThan(atOneThousand.size);
});
test('the targeted set follows the salt', () => {
const userIds = syntheticUserIds(2000);
const first = targetedUserIds(
createConfig({enabled: true, rollout_basis_points: 5000, rollout_salt: 'domain-migration-v1'}),
userIds,
);
const second = targetedUserIds(
createConfig({enabled: true, rollout_basis_points: 5000, rollout_salt: 'domain-migration-v2'}),
userIds,
);
expect(first).not.toEqual(second);
});
});
describe('toDomainMigrationDiscovery', () => {
test('projects only the public fields of the config', () => {
const config = createConfig({
enabled: true,
config_version: 3,
rollout_basis_points: 500,
rollout_salt: 'domain-migration-v2',
anonymous_rollout_basis_points: 250,
included_user_ids: [TARGETED_USER_ID],
excluded_user_ids: [OTHER_USER_ID],
standalone_forwarding: true,
});
expect(toDomainMigrationDiscovery(config)).toEqual({
enabled: true,
anonymous_rollout_basis_points: 250,
rollout_salt: 'domain-migration-v2',
standalone_forwarding: true,
});
});
test('reports the kill switch for the default config', () => {
expect(toDomainMigrationDiscovery(DEFAULT_DOMAIN_MIGRATION_CONFIG)).toEqual({
enabled: false,
anonymous_rollout_basis_points: 0,
rollout_salt: 'domain-migration-v1',
standalone_forwarding: false,
});
});
});
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {z} from 'zod';
const DOMAIN_MIGRATION_ROLLOUT_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION;
const DOMAIN_MIGRATION_MAX_TARGETED_USERS = 1000;
const DEFAULT_DOMAIN_MIGRATION_SALT = 'domain-migration-v1';
const DOMAIN_MIGRATION_SALT_PATTERN = /^[\x20-\x7e]+$/u;
const DomainMigrationTargetIdSchema = z.string().regex(/^\d{1,20}$/u);
const DomainMigrationTargetedUserIdsSchema = z
.array(DomainMigrationTargetIdSchema)
.max(DOMAIN_MIGRATION_MAX_TARGETED_USERS);
const domainMigrationConfigFields = {
enabled: z.boolean(),
config_version: z.number().int().min(0),
rollout_basis_points: z.number().int().min(0).max(DOMAIN_MIGRATION_ROLLOUT_BASIS_POINTS_MAX),
rollout_salt: z.string().trim().min(1).max(64).regex(DOMAIN_MIGRATION_SALT_PATTERN),
included_user_ids: DomainMigrationTargetedUserIdsSchema,
excluded_user_ids: DomainMigrationTargetedUserIdsSchema,
anonymous_rollout_basis_points: z.number().int().min(0).max(DOMAIN_MIGRATION_ROLLOUT_BASIS_POINTS_MAX),
standalone_forwarding: z.boolean(),
};
export const DomainMigrationConfigSchema = z.object({
enabled: domainMigrationConfigFields.enabled.default(false),
config_version: domainMigrationConfigFields.config_version.default(0),
rollout_basis_points: domainMigrationConfigFields.rollout_basis_points.default(0),
rollout_salt: domainMigrationConfigFields.rollout_salt.default(DEFAULT_DOMAIN_MIGRATION_SALT),
included_user_ids: domainMigrationConfigFields.included_user_ids.default([]),
excluded_user_ids: domainMigrationConfigFields.excluded_user_ids.default([]),
anonymous_rollout_basis_points: domainMigrationConfigFields.anonymous_rollout_basis_points.default(0),
standalone_forwarding: domainMigrationConfigFields.standalone_forwarding.default(false),
});
export type DomainMigrationConfig = z.infer<typeof DomainMigrationConfigSchema>;
export const DEFAULT_DOMAIN_MIGRATION_CONFIG: DomainMigrationConfig = DomainMigrationConfigSchema.parse({});
export const DomainMigrationConfigUpdateRequest = z
.object(domainMigrationConfigFields)
.omit({config_version: true})
.partial();
export type DomainMigrationConfigUpdateRequest = z.infer<typeof DomainMigrationConfigUpdateRequest>;
export const DomainMigrationConfigResponse = DomainMigrationConfigSchema;
export type DomainMigrationConfigResponse = z.infer<typeof DomainMigrationConfigResponse>;
export const DomainMigrationAssignmentResponse = z.object({
enabled: domainMigrationConfigFields.enabled,
});
export type DomainMigrationAssignmentResponse = z.infer<typeof DomainMigrationAssignmentResponse>;
export const INERT_DOMAIN_MIGRATION_ASSIGNMENT: DomainMigrationAssignmentResponse = {
enabled: false,
};
export function resolveDomainMigrationAssignment(
config: DomainMigrationConfig,
userId: string,
): DomainMigrationAssignmentResponse {
if (!config.enabled) return {...INERT_DOMAIN_MIGRATION_ASSIGNMENT};
if (config.excluded_user_ids.includes(userId)) return {...INERT_DOMAIN_MIGRATION_ASSIGNMENT};
if (config.included_user_ids.includes(userId)) return {enabled: true};
return {enabled: experimentBucket(userId, config.rollout_salt) < config.rollout_basis_points};
}
export const DomainMigrationDiscoveryResponse = z.object({
enabled: domainMigrationConfigFields.enabled.describe('Whether the domain migration is switched on'),
anonymous_rollout_basis_points: domainMigrationConfigFields.anonymous_rollout_basis_points.describe(
'Share of logged-out devices, in basis points, that move to the new domain',
),
rollout_salt: domainMigrationConfigFields.rollout_salt.describe('Salt used to bucket devices and users'),
standalone_forwarding: domainMigrationConfigFields.standalone_forwarding.describe(
'Whether installed desktop web apps forward to the new domain after moving their session',
),
});
export type DomainMigrationDiscoveryResponse = z.infer<typeof DomainMigrationDiscoveryResponse>;
export function toDomainMigrationDiscovery(config: DomainMigrationConfig): DomainMigrationDiscoveryResponse {
return {
enabled: config.enabled,
anonymous_rollout_basis_points: config.anonymous_rollout_basis_points,
rollout_salt: config.rollout_salt,
standalone_forwarding: config.standalone_forwarding,
};
}
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {z} from 'zod';
export const ORIGIN_HANDOFF_MAX_PAYLOAD_LENGTH = 8 * 1024 * 1024;
const Base64UrlPattern = /^[A-Za-z0-9_-]+$/u;
export const OriginHandoffCreateRequest = z.object({
nonce_hash: z
.string()
.regex(/^[0-9a-f]{64}$/u)
.describe('Lowercase hex SHA-256 digest of the nonce the receiving origin holds'),
payload: z
.string()
.min(1)
.max(ORIGIN_HANDOFF_MAX_PAYLOAD_LENGTH)
.regex(Base64UrlPattern)
.describe('Encrypted client state encoded as base64url'),
});
export type OriginHandoffCreateRequest = z.infer<typeof OriginHandoffCreateRequest>;
export const OriginHandoffCreateResponse = z.object({
handoff_id: z.string().describe('Single-use identifier the receiving origin redeems'),
});
export type OriginHandoffCreateResponse = z.infer<typeof OriginHandoffCreateResponse>;
export const OriginHandoffRedeemRequest = z.object({
handoff_id: z
.string()
.regex(/^[A-Za-z0-9_-]{43}$/u)
.describe('Identifier returned when the handoff was created'),
nonce: z
.string()
.min(16)
.max(256)
.regex(Base64UrlPattern)
.describe('Nonce whose SHA-256 digest was sent when the handoff was created'),
});
export type OriginHandoffRedeemRequest = z.infer<typeof OriginHandoffRedeemRequest>;
export const OriginHandoffRedeemResponse = z.object({
payload: z.string().describe('Encrypted client state encoded as base64url'),
});
export type OriginHandoffRedeemResponse = z.infer<typeof OriginHandoffRedeemResponse>;
@@ -1,5 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
DomainMigrationAssignmentResponse,
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
VoiceNoiseSuppressionAssignmentResponse,
@@ -42,6 +46,7 @@ export type ExperimentDeliveryConfigResponse = z.infer<typeof ExperimentDelivery
const ExperimentAssignmentsSchema = z.object({
voice_noise_suppression: VoiceNoiseSuppressionAssignmentResponse.optional(),
domain_migration: DomainMigrationAssignmentResponse.optional(),
});
export const ExperimentAssignmentsResponse = z.object({
@@ -63,3 +68,9 @@ export function readVoiceNoiseSuppressionAssignment(
): VoiceNoiseSuppressionAssignmentResponse {
return response.assignments.voice_noise_suppression ?? INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT;
}
export function readDomainMigrationAssignment(
response: ExperimentAssignmentsResponse,
): DomainMigrationAssignmentResponse {
return response.assignments.domain_migration ?? INERT_DOMAIN_MIGRATION_ASSIGNMENT;
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {DomainMigrationDiscoveryResponse} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {SsoStatusResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {createNamedStringLiteralUnion} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
@@ -187,6 +188,9 @@ export const WellKnownFluxerResponse = z.object({
limits: LimitConfigResponse.describe('Limit configuration with rules and trait definitions'),
push: InstancePushSchema,
app_public: InstanceAppPublicSchema.describe('Public application configuration for client-side features'),
domain_migration: DomainMigrationDiscoveryResponse.optional().describe(
'Web domain migration switch and anonymous rollout, only acted on by official instance clients',
),
});
export type WellKnownFluxerResponse = z.infer<typeof WellKnownFluxerResponse>;
@@ -615,6 +615,7 @@ export const PushSubscribeRequest = z.object({
})
.describe('Encryption keys for the push subscription'),
user_agent: createStringType(1, 1024).optional().describe('The user agent string identifying the client'),
installed_app: z.boolean().optional().describe('Whether the client runs in an installed web app window'),
});
export type PushSubscribeRequest = z.infer<typeof PushSubscribeRequest>;
@@ -629,6 +630,7 @@ export const PushRotateRequest = z.object({
})
.describe('Encryption keys for the new push subscription'),
user_agent: createStringType(1, 1024).optional().describe('The user agent string identifying the client'),
installed_app: z.boolean().optional().describe('Whether the client runs in an installed web app window'),
});
export type PushRotateRequest = z.infer<typeof PushRotateRequest>;